feat: push notifications + background transfer alerts
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m1s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m1s
- Web Push API for offline device notifications - Custom service worker with push event handling - Local notifications for background tab transfers - VAPID keys in K8s config - Persistent deviceId per device
This commit is contained in:
parent
d8d747276a
commit
fd249abbf1
@ -6,6 +6,9 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
BASE_URL: "https://anydrop.arthurbarre.fr"
|
BASE_URL: "https://anydrop.arthurbarre.fr"
|
||||||
|
VAPID_PUBLIC_KEY: "BCta0SNLmjBFfizMInnBhEQvVZlMbbaM-qw1a-p3JeQykCyy00GRGkDAKMDA5nv5UfokwJ30HRGoA6buJjWwKcE"
|
||||||
|
VAPID_PRIVATE_KEY: "gbmrcm9Tuz4JgoHophO-jUbam8rV9YgjImYcWvoE0w0"
|
||||||
|
VAPID_SUBJECT: "mailto:arthurbarre.js@gmail.com"
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
|
|||||||
132
package-lock.json
generated
132
package-lock.json
generated
@ -2767,6 +2767,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@ -2811,6 +2821,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
@ -3373,6 +3392,12 @@
|
|||||||
"ieee754": "^1.2.1"
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@ -4007,6 +4032,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||||
@ -4870,6 +4904,15 @@
|
|||||||
"minimalistic-crypto-utils": "^1.0.1"
|
"minimalistic-crypto-utils": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/https-browserify": {
|
"node_modules/https-browserify": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
|
||||||
@ -4877,6 +4920,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/idb": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
@ -5544,6 +5600,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@ -5714,7 +5791,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/minimalistic-crypto-utils": {
|
"node_modules/minimalistic-crypto-utils": {
|
||||||
@ -5740,6 +5816,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
@ -7008,6 +7093,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@ -8768,6 +8859,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/web-push/node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/web-push/node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||||
@ -9341,10 +9469,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anydrop/shared": "*",
|
"@anydrop/shared": "*",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.18.1"
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.19.4"
|
"tsx": "^4.19.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anydrop/shared": "*",
|
"@anydrop/shared": "*",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.18.1"
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.19.4"
|
"tsx": "^4.19.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,12 @@ import {
|
|||||||
type DeviceType,
|
type DeviceType,
|
||||||
} from "@anydrop/shared";
|
} from "@anydrop/shared";
|
||||||
import { RoomManager, type Client } from "./rooms.js";
|
import { RoomManager, type Client } from "./rooms.js";
|
||||||
|
import {
|
||||||
|
initPush,
|
||||||
|
getVapidPublicKey,
|
||||||
|
storeSubscription,
|
||||||
|
notifyOfflineDevices,
|
||||||
|
} from "./push.js";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||||
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
||||||
@ -17,21 +23,18 @@ const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
|||||||
const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
|
const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
|
||||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||||
const RATE_LIMIT_MESSAGES = 100;
|
const RATE_LIMIT_MESSAGES = 100;
|
||||||
const MAX_AVATAR_SIZE = 100_000; // 100KB max for avatar data URLs
|
const MAX_AVATAR_SIZE = 100_000;
|
||||||
|
|
||||||
// Track connection rate per IP
|
|
||||||
const connectionCounts = new Map<string, { count: number; windowStart: number }>();
|
const connectionCounts = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
function hashIP(ip: string): string {
|
function hashIP(ip: string): string {
|
||||||
return createHash("sha256").update(ip).digest("hex").slice(0, 16);
|
return createHash("sha256").update(ip).digest("hex").slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip ::ffff: prefix from IPv4-mapped IPv6 addresses */
|
|
||||||
function normalizeIP(ip: string): string {
|
function normalizeIP(ip: string): string {
|
||||||
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if an IPv4 address is private (RFC 1918) or loopback */
|
|
||||||
function isPrivateIP(ip: string): boolean {
|
function isPrivateIP(ip: string): boolean {
|
||||||
return (
|
return (
|
||||||
ip.startsWith("10.") ||
|
ip.startsWith("10.") ||
|
||||||
@ -45,7 +48,6 @@ function isLoopback(ip: string): boolean {
|
|||||||
return ip === "::1" || ip === "127.0.0.1" || ip.startsWith("127.");
|
return ip === "::1" || ip === "127.0.0.1" || ip.startsWith("127.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detect the server's own LAN subnet at startup (e.g. "192.168.1") */
|
|
||||||
function detectLanSubnet(): string | null {
|
function detectLanSubnet(): string | null {
|
||||||
const nets = networkInterfaces();
|
const nets = networkInterfaces();
|
||||||
for (const ifaces of Object.values(nets)) {
|
for (const ifaces of Object.values(nets)) {
|
||||||
@ -63,6 +65,9 @@ function detectLanSubnet(): string | null {
|
|||||||
const serverLanSubnet = detectLanSubnet();
|
const serverLanSubnet = detectLanSubnet();
|
||||||
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
|
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
|
||||||
|
|
||||||
|
// Initialize Web Push
|
||||||
|
initPush();
|
||||||
|
|
||||||
function getLanGroupKey(rawIP: string): string {
|
function getLanGroupKey(rawIP: string): string {
|
||||||
const ip = normalizeIP(rawIP);
|
const ip = normalizeIP(rawIP);
|
||||||
if (isLoopback(ip) || ip === "unknown") {
|
if (isLoopback(ip) || ip === "unknown") {
|
||||||
@ -112,19 +117,16 @@ function checkMessageRate(client: Client): boolean {
|
|||||||
return client.messageCount <= RATE_LIMIT_MESSAGES;
|
return client.messageCount <= RATE_LIMIT_MESSAGES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sanitize client-provided display name */
|
|
||||||
function sanitizeName(name: unknown): string {
|
function sanitizeName(name: unknown): string {
|
||||||
if (typeof name !== "string") return "Appareil";
|
if (typeof name !== "string") return "Appareil";
|
||||||
return name.trim().slice(0, 30) || "Appareil";
|
return name.trim().slice(0, 30) || "Appareil";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate device type */
|
|
||||||
function validateDeviceType(dt: unknown): DeviceType {
|
function validateDeviceType(dt: unknown): DeviceType {
|
||||||
const valid: DeviceType[] = ["phone", "tablet", "laptop", "desktop"];
|
const valid: DeviceType[] = ["phone", "tablet", "laptop", "desktop"];
|
||||||
return typeof dt === "string" && valid.includes(dt as DeviceType) ? (dt as DeviceType) : "laptop";
|
return typeof dt === "string" && valid.includes(dt as DeviceType) ? (dt as DeviceType) : "laptop";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate avatar (must be a small data URL or undefined) */
|
|
||||||
function validateAvatar(avatar: unknown): string | undefined {
|
function validateAvatar(avatar: unknown): string | undefined {
|
||||||
if (typeof avatar !== "string") return undefined;
|
if (typeof avatar !== "string") return undefined;
|
||||||
if (!avatar.startsWith("data:image/")) return undefined;
|
if (!avatar.startsWith("data:image/")) return undefined;
|
||||||
@ -132,7 +134,7 @@ function validateAvatar(avatar: unknown): string | undefined {
|
|||||||
return avatar;
|
return avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HTTP server (health check) ──
|
// ── HTTP server ──
|
||||||
|
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
if (req.url === "/health") {
|
if (req.url === "/health") {
|
||||||
@ -164,10 +166,10 @@ wss.on("connection", (ws, req) => {
|
|||||||
const peerId = nanoid(8);
|
const peerId = nanoid(8);
|
||||||
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
|
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
|
||||||
|
|
||||||
// Client object created with placeholder name — filled in on "hello"
|
|
||||||
const client: Client = {
|
const client: Client = {
|
||||||
ws,
|
ws,
|
||||||
peerId,
|
peerId,
|
||||||
|
deviceId: null,
|
||||||
displayName: "Appareil",
|
displayName: "Appareil",
|
||||||
deviceType: "laptop",
|
deviceType: "laptop",
|
||||||
ip,
|
ip,
|
||||||
@ -202,6 +204,9 @@ wss.on("connection", (ws, req) => {
|
|||||||
case "signal":
|
case "signal":
|
||||||
handleSignal(client, msg.to, msg.data);
|
handleSignal(client, msg.to, msg.data);
|
||||||
break;
|
break;
|
||||||
|
case "subscribe-push":
|
||||||
|
handleSubscribePush(client, msg);
|
||||||
|
break;
|
||||||
case "leave":
|
case "leave":
|
||||||
handleLeave(client);
|
handleLeave(client);
|
||||||
break;
|
break;
|
||||||
@ -215,13 +220,13 @@ wss.on("connection", (ws, req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void {
|
function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void {
|
||||||
// Apply client-provided profile
|
|
||||||
client.displayName = sanitizeName(msg.deviceName);
|
client.displayName = sanitizeName(msg.deviceName);
|
||||||
client.deviceType = validateDeviceType(msg.deviceType);
|
client.deviceType = validateDeviceType(msg.deviceType);
|
||||||
client.avatar = validateAvatar(msg.avatar);
|
client.avatar = validateAvatar(msg.avatar);
|
||||||
|
client.deviceId = typeof msg.deviceId === "string" ? msg.deviceId : null;
|
||||||
|
|
||||||
// Join LAN room
|
const lanGroupKey = getLanGroupKey(client.ip);
|
||||||
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(lanGroupKey));
|
||||||
roomManager.addClientToRoom(lanRoom, client);
|
roomManager.addClientToRoom(lanRoom, client);
|
||||||
|
|
||||||
const peers: PeerInfo[] = [];
|
const peers: PeerInfo[] = [];
|
||||||
@ -233,7 +238,6 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
|
|||||||
deviceType: peer.deviceType,
|
deviceType: peer.deviceType,
|
||||||
avatar: peer.avatar,
|
avatar: peer.avatar,
|
||||||
});
|
});
|
||||||
// Notify existing peers
|
|
||||||
send(peer.ws, {
|
send(peer.ws, {
|
||||||
type: "peer-joined",
|
type: "peer-joined",
|
||||||
peerId: client.peerId,
|
peerId: client.peerId,
|
||||||
@ -280,7 +284,21 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
|
|||||||
peerId: client.peerId,
|
peerId: client.peerId,
|
||||||
roomId: client.lanRoomId,
|
roomId: client.lanRoomId,
|
||||||
peers,
|
peers,
|
||||||
|
vapidPublicKey: getVapidPublicKey(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify offline devices that have push subscriptions for this LAN group
|
||||||
|
const onlineDeviceIds = new Set<string>();
|
||||||
|
for (const peer of lanRoom.clients.values()) {
|
||||||
|
if (peer.deviceId) onlineDeviceIds.add(peer.deviceId);
|
||||||
|
}
|
||||||
|
notifyOfflineDevices(lanGroupKey, onlineDeviceIds, client.displayName, client.deviceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscribePush(client: Client, msg: ClientMessage & { type: "subscribe-push" }): void {
|
||||||
|
if (!msg.deviceId || !msg.subscription) return;
|
||||||
|
const lanGroupKey = getLanGroupKey(client.ip);
|
||||||
|
storeSubscription(msg.deviceId, client.displayName, msg.subscription as any, lanGroupKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreatePublicRoom(client: Client): void {
|
function handleCreatePublicRoom(client: Client): void {
|
||||||
|
|||||||
127
server/src/push.ts
Normal file
127
server/src/push.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
import type { PushPayload } from "@anydrop/shared";
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || "";
|
||||||
|
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || "";
|
||||||
|
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:contact@arthurbarre.fr";
|
||||||
|
|
||||||
|
// Subscription TTL: 24h (auto-clean stale entries)
|
||||||
|
const SUBSCRIPTION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface StoredSubscription {
|
||||||
|
deviceId: string;
|
||||||
|
displayName: string;
|
||||||
|
subscription: webpush.PushSubscription;
|
||||||
|
lanGroupKey: string;
|
||||||
|
storedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lanGroupKey → Map<deviceId, StoredSubscription>
|
||||||
|
const subscriptions = new Map<string, Map<string, StoredSubscription>>();
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
export function initPush(): boolean {
|
||||||
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
|
||||||
|
console.log("[push] VAPID keys not configured — push notifications disabled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||||
|
configured = true;
|
||||||
|
console.log("[push] Web Push configured");
|
||||||
|
|
||||||
|
// Clean stale subscriptions every 10 min
|
||||||
|
setInterval(cleanStale, 10 * 60 * 1000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVapidPublicKey(): string | undefined {
|
||||||
|
return configured ? VAPID_PUBLIC_KEY : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeSubscription(
|
||||||
|
deviceId: string,
|
||||||
|
displayName: string,
|
||||||
|
subscription: webpush.PushSubscription,
|
||||||
|
lanGroupKey: string,
|
||||||
|
): void {
|
||||||
|
if (!configured) return;
|
||||||
|
|
||||||
|
let group = subscriptions.get(lanGroupKey);
|
||||||
|
if (!group) {
|
||||||
|
group = new Map();
|
||||||
|
subscriptions.set(lanGroupKey, group);
|
||||||
|
}
|
||||||
|
group.set(deviceId, {
|
||||||
|
deviceId,
|
||||||
|
displayName,
|
||||||
|
subscription,
|
||||||
|
lanGroupKey,
|
||||||
|
storedAt: Date.now(),
|
||||||
|
});
|
||||||
|
console.log(`[push] stored subscription for ${displayName} (${deviceId}) in group ${lanGroupKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSubscription(deviceId: string, lanGroupKey: string): void {
|
||||||
|
subscriptions.get(lanGroupKey)?.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify offline devices in a LAN group that a peer is nearby.
|
||||||
|
* `onlineDeviceIds` = set of deviceIds currently connected in this room.
|
||||||
|
*/
|
||||||
|
export async function notifyOfflineDevices(
|
||||||
|
lanGroupKey: string,
|
||||||
|
onlineDeviceIds: Set<string>,
|
||||||
|
peerDisplayName: string,
|
||||||
|
peerDeviceType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!configured) return;
|
||||||
|
|
||||||
|
const group = subscriptions.get(lanGroupKey);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const payload: PushPayload = {
|
||||||
|
type: "peer-nearby",
|
||||||
|
displayName: peerDisplayName,
|
||||||
|
deviceType: peerDeviceType as any,
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
|
for (const [deviceId, stored] of group) {
|
||||||
|
// Skip devices that are currently online
|
||||||
|
if (onlineDeviceIds.has(deviceId)) continue;
|
||||||
|
|
||||||
|
// Skip stale subscriptions
|
||||||
|
if (Date.now() - stored.storedAt > SUBSCRIPTION_TTL_MS) {
|
||||||
|
group.delete(deviceId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(stored.subscription, body, { TTL: 60 });
|
||||||
|
console.log(`[push] notified ${stored.displayName} (${deviceId})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
// 404/410 = subscription expired/invalid
|
||||||
|
if (err.statusCode === 404 || err.statusCode === 410) {
|
||||||
|
group.delete(deviceId);
|
||||||
|
console.log(`[push] removed expired subscription ${deviceId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[push] failed to notify ${deviceId}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanStale(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, group] of subscriptions) {
|
||||||
|
for (const [deviceId, stored] of group) {
|
||||||
|
if (now - stored.storedAt > SUBSCRIPTION_TTL_MS) {
|
||||||
|
group.delete(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (group.size === 0) subscriptions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ const generateLongCode = customAlphabet(SHORT_CODE_ALPHABET, SHORT_CODE_MAX_LENG
|
|||||||
export interface Client {
|
export interface Client {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
peerId: string;
|
peerId: string;
|
||||||
|
deviceId: string | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
deviceType: DeviceType;
|
deviceType: DeviceType;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"root":["./src/index.ts","./src/rooms.ts"],"version":"5.8.3"}
|
{"root":["./src/index.ts","./src/push.ts","./src/rooms.ts"],"version":"5.8.3"}
|
||||||
@ -4,9 +4,10 @@ import type { DeviceType } from "./names.js";
|
|||||||
|
|
||||||
export interface HelloMessage {
|
export interface HelloMessage {
|
||||||
type: "hello";
|
type: "hello";
|
||||||
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceType: DeviceType;
|
deviceType: DeviceType;
|
||||||
avatar?: string; // base64 data URL (small, <50KB)
|
avatar?: string;
|
||||||
joinCode?: string;
|
joinCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,11 +25,19 @@ export interface LeaveMessage {
|
|||||||
type: "leave";
|
type: "leave";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Client sends its push subscription so the server can wake it when offline */
|
||||||
|
export interface SubscribePushMessage {
|
||||||
|
type: "subscribe-push";
|
||||||
|
deviceId: string;
|
||||||
|
subscription: PushSubscriptionJSON;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| HelloMessage
|
| HelloMessage
|
||||||
| CreatePublicRoomMessage
|
| CreatePublicRoomMessage
|
||||||
| SignalMessage
|
| SignalMessage
|
||||||
| LeaveMessage;
|
| LeaveMessage
|
||||||
|
| SubscribePushMessage;
|
||||||
|
|
||||||
// ── Server → Client messages ──
|
// ── Server → Client messages ──
|
||||||
|
|
||||||
@ -44,6 +53,7 @@ export interface WelcomeMessage {
|
|||||||
peerId: string;
|
peerId: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
peers: PeerInfo[];
|
peers: PeerInfo[];
|
||||||
|
vapidPublicKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicRoomCreatedMessage {
|
export interface PublicRoomCreatedMessage {
|
||||||
@ -127,6 +137,14 @@ export type DataChannelMessage =
|
|||||||
| TransferRequestMessage
|
| TransferRequestMessage
|
||||||
| TransferResponseMessage;
|
| TransferResponseMessage;
|
||||||
|
|
||||||
|
// ── Push notification payload ──
|
||||||
|
|
||||||
|
export interface PushPayload {
|
||||||
|
type: "peer-nearby";
|
||||||
|
displayName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
|
|
||||||
export const CHUNK_SIZE = 16 * 1024; // 16 KB
|
export const CHUNK_SIZE = 16 * 1024; // 16 KB
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -8,6 +8,7 @@ import {
|
|||||||
createTransferResponse,
|
createTransferResponse,
|
||||||
createFileReceiver,
|
createFileReceiver,
|
||||||
} from "../lib/fileTransfer";
|
} from "../lib/fileTransfer";
|
||||||
|
import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
|
||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
import { useProfileStore } from "../stores/useProfileStore";
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
|
|
||||||
@ -41,7 +42,6 @@ export function useSignaling(joinCode?: string) {
|
|||||||
setError,
|
setError,
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
// Initialize file receiver
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fileReceiverRef.current = createFileReceiver({
|
fileReceiverRef.current = createFileReceiver({
|
||||||
onData: () => {},
|
onData: () => {},
|
||||||
@ -109,11 +109,18 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}, [updateTransfer]);
|
}, [updateTransfer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const profile = useProfileStore.getState();
|
||||||
|
|
||||||
const handleMessage = (msg: ServerMessage) => {
|
const handleMessage = (msg: ServerMessage) => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "welcome":
|
case "welcome":
|
||||||
setConnection(msg.peerId, msg.roomId);
|
setConnection(msg.peerId, msg.roomId);
|
||||||
setPeers(msg.peers);
|
setPeers(msg.peers);
|
||||||
|
|
||||||
|
// Subscribe to push notifications if server provides VAPID key
|
||||||
|
if (msg.vapidPublicKey && signalingRef.current) {
|
||||||
|
setupPushNotifications(msg.vapidPublicKey, profile.deviceId, signalingRef.current);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "peer-joined":
|
case "peer-joined":
|
||||||
addPeer({
|
addPeer({
|
||||||
@ -122,6 +129,8 @@ export function useSignaling(joinCode?: string) {
|
|||||||
deviceType: msg.deviceType,
|
deviceType: msg.deviceType,
|
||||||
avatar: msg.avatar,
|
avatar: msg.avatar,
|
||||||
});
|
});
|
||||||
|
// Notify if tab is in background
|
||||||
|
showLocalNotification("AnyDrop", `${msg.displayName} est à proximité`);
|
||||||
break;
|
break;
|
||||||
case "peer-left":
|
case "peer-left":
|
||||||
removePeer(msg.peerId);
|
removePeer(msg.peerId);
|
||||||
@ -139,12 +148,10 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get profile from store
|
|
||||||
const profile = useProfileStore.getState();
|
|
||||||
|
|
||||||
const signaling = new SignalingClient(
|
const signaling = new SignalingClient(
|
||||||
handleMessage,
|
handleMessage,
|
||||||
{
|
{
|
||||||
|
deviceId: profile.deviceId,
|
||||||
deviceName: profile.deviceName,
|
deviceName: profile.deviceName,
|
||||||
deviceType: profile.deviceType,
|
deviceType: profile.deviceType,
|
||||||
avatar: profile.avatar || undefined,
|
avatar: profile.avatar || undefined,
|
||||||
@ -174,6 +181,13 @@ export function useSignaling(joinCode?: string) {
|
|||||||
files: msg.files,
|
files: msg.files,
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
});
|
});
|
||||||
|
// Notify in background
|
||||||
|
const fileCount = msg.files?.length || 0;
|
||||||
|
const label = fileCount > 1 ? `${fileCount} fichiers` : msg.files?.[0]?.name || "un fichier";
|
||||||
|
showLocalNotification(
|
||||||
|
"Transfert entrant",
|
||||||
|
`${peerInfo?.displayName || "Quelqu'un"} veut vous envoyer ${label}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "transfer-response") {
|
if (msg.type === "transfer-response") {
|
||||||
@ -189,6 +203,10 @@ export function useSignaling(joinCode?: string) {
|
|||||||
files: [],
|
files: [],
|
||||||
text: msg.content,
|
text: msg.content,
|
||||||
});
|
});
|
||||||
|
showLocalNotification(
|
||||||
|
"Texte reçu",
|
||||||
|
`${peerInfo?.displayName || "Quelqu'un"} vous a envoyé du texte`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
76
web/src/lib/notifications.ts
Normal file
76
web/src/lib/notifications.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import type { SignalingClient } from "./signaling";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permission and subscribe to Web Push.
|
||||||
|
* Sends the push subscription to the signaling server.
|
||||||
|
*/
|
||||||
|
export async function setupPushNotifications(
|
||||||
|
vapidPublicKey: string,
|
||||||
|
deviceId: string,
|
||||||
|
signaling: SignalingClient,
|
||||||
|
): Promise<void> {
|
||||||
|
// Check support
|
||||||
|
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
|
console.log("[push] Push notifications not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
console.log("[push] Notification permission denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
// Check for existing subscription
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
// Convert VAPID key from base64url to Uint8Array
|
||||||
|
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
signaling.send({
|
||||||
|
type: "subscribe-push",
|
||||||
|
deviceId,
|
||||||
|
subscription: subscription.toJSON() as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[push] Push subscription registered");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[push] Failed to subscribe:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a local notification (for when tab is in background but page is still loaded).
|
||||||
|
*/
|
||||||
|
export function showLocalNotification(title: string, body: string): void {
|
||||||
|
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||||
|
if (document.visibilityState === "visible") return; // Don't notify if tab is focused
|
||||||
|
|
||||||
|
new Notification(title, {
|
||||||
|
body,
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
tag: "anydrop-transfer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ClientMessage, ServerMessage, DeviceType } from "@anydrop/shared";
|
import type { ClientMessage, ServerMessage, DeviceType } from "@anydrop/shared";
|
||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceType: DeviceType;
|
deviceType: DeviceType;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
@ -31,6 +32,7 @@ export class SignalingClient {
|
|||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.send({
|
this.send({
|
||||||
type: "hello",
|
type: "hello",
|
||||||
|
deviceId: this.profile.deviceId,
|
||||||
deviceName: this.profile.deviceName,
|
deviceName: this.profile.deviceName,
|
||||||
deviceType: this.profile.deviceType,
|
deviceType: this.profile.deviceType,
|
||||||
avatar: this.profile.avatar,
|
avatar: this.profile.avatar,
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import type { DeviceType } from "@anydrop/shared";
|
|||||||
import { detectDeviceType, getDefaultDeviceName } from "@anydrop/shared";
|
import { detectDeviceType, getDefaultDeviceName } from "@anydrop/shared";
|
||||||
|
|
||||||
interface ProfileState {
|
interface ProfileState {
|
||||||
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceType: DeviceType;
|
deviceType: DeviceType;
|
||||||
avatar: string | null; // base64 data URL
|
avatar: string | null;
|
||||||
isSetUp: boolean; // true once user has confirmed their profile at least once
|
isSetUp: boolean;
|
||||||
|
|
||||||
setDeviceName: (name: string) => void;
|
setDeviceName: (name: string) => void;
|
||||||
setAvatar: (avatar: string | null) => void;
|
setAvatar: (avatar: string | null) => void;
|
||||||
@ -16,9 +17,14 @@ interface ProfileState {
|
|||||||
|
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
|
|
||||||
|
function generateDeviceId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
export const useProfileStore = create<ProfileState>()(
|
export const useProfileStore = create<ProfileState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
|
deviceId: generateDeviceId(),
|
||||||
deviceName: getDefaultDeviceName(ua),
|
deviceName: getDefaultDeviceName(ua),
|
||||||
deviceType: detectDeviceType(ua),
|
deviceType: detectDeviceType(ua),
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
|||||||
69
web/src/sw.ts
Normal file
69
web/src/sw.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
import { precacheAndRoute } from "workbox-precaching";
|
||||||
|
import { registerRoute, NavigationRoute } from "workbox-routing";
|
||||||
|
import { NetworkFirst } from "workbox-strategies";
|
||||||
|
import type { PushPayload } from "@anydrop/shared";
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Workbox injects the precache manifest here
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Navigation: always try network first (so deploys are visible immediately)
|
||||||
|
registerRoute(new NavigationRoute(new NetworkFirst()));
|
||||||
|
|
||||||
|
// Activate immediately, take control of all clients
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Push notifications ──
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
let payload: PushPayload;
|
||||||
|
try {
|
||||||
|
payload = event.data.json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "peer-nearby") {
|
||||||
|
const deviceEmoji = payload.deviceType === "phone" ? "📱" : payload.deviceType === "tablet" ? "📱" : "💻";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification("AnyDrop", {
|
||||||
|
body: `${deviceEmoji} ${payload.displayName} est à proximité`,
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
badge: "/icon-192.png",
|
||||||
|
tag: "peer-nearby",
|
||||||
|
data: { url: "/" },
|
||||||
|
} as NotificationOptions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on notification → open/focus AnyDrop
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const url = event.notification.data?.url || "/";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||||
|
// Focus existing tab if open
|
||||||
|
for (const client of clients) {
|
||||||
|
if (new URL(client.url).pathname === url && "focus" in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise open new tab
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -13,18 +13,13 @@ export default defineConfig({
|
|||||||
include: ["process", "events", "stream", "buffer", "util"],
|
include: ["process", "events", "stream", "buffer", "util"],
|
||||||
}),
|
}),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
strategies: "injectManifest",
|
||||||
|
srcDir: "src",
|
||||||
|
filename: "sw.ts",
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
includeAssets: ["favicon.svg"],
|
includeAssets: ["favicon.svg"],
|
||||||
workbox: {
|
injectManifest: {
|
||||||
skipWaiting: true,
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||||
clientsClaim: true,
|
|
||||||
navigateFallback: "index.html",
|
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: ({ request }) => request.mode === "navigate",
|
|
||||||
handler: "NetworkFirst",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "AnyDrop",
|
name: "AnyDrop",
|
||||||
@ -47,6 +42,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user