feat: push notifications + background transfer alerts
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:
ordinarthur 2026-04-14 12:03:43 +02:00
parent d8d747276a
commit fd249abbf1
15 changed files with 503 additions and 35 deletions

View File

@ -6,6 +6,9 @@ metadata:
data:
PORT: "3001"
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

132
package-lock.json generated
View File

@ -2767,6 +2767,16 @@
"dev": true,
"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": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -2811,6 +2821,15 @@
"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": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@ -3373,6 +3392,12 @@
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -4007,6 +4032,15 @@
"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": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@ -4870,6 +4904,15 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
@ -4877,6 +4920,19 @@
"dev": true,
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -5544,6 +5600,27 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -5714,7 +5791,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"dev": true,
"license": "ISC"
},
"node_modules/minimalistic-crypto-utils": {
@ -5740,6 +5816,15 @@
"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": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@ -7008,6 +7093,12 @@
"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": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -8768,6 +8859,43 @@
"dev": true,
"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": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@ -9341,10 +9469,12 @@
"dependencies": {
"@anydrop/shared": "*",
"nanoid": "^5.1.5",
"web-push": "^3.6.7",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"tsx": "^4.19.4"
}

View File

@ -13,10 +13,12 @@
"dependencies": {
"@anydrop/shared": "*",
"nanoid": "^5.1.5",
"web-push": "^3.6.7",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"tsx": "^4.19.4"
}

View File

@ -10,6 +10,12 @@ import {
type DeviceType,
} from "@anydrop/shared";
import { RoomManager, type Client } from "./rooms.js";
import {
initPush,
getVapidPublicKey,
storeSubscription,
notifyOfflineDevices,
} from "./push.js";
const PORT = parseInt(process.env.PORT || "3001", 10);
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_WINDOW_MS = 60_000;
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 }>();
function hashIP(ip: string): string {
return createHash("sha256").update(ip).digest("hex").slice(0, 16);
}
/** Strip ::ffff: prefix from IPv4-mapped IPv6 addresses */
function normalizeIP(ip: string): string {
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
}
/** Check if an IPv4 address is private (RFC 1918) or loopback */
function isPrivateIP(ip: string): boolean {
return (
ip.startsWith("10.") ||
@ -45,7 +48,6 @@ function isLoopback(ip: string): boolean {
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 {
const nets = networkInterfaces();
for (const ifaces of Object.values(nets)) {
@ -63,6 +65,9 @@ function detectLanSubnet(): string | null {
const serverLanSubnet = detectLanSubnet();
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
// Initialize Web Push
initPush();
function getLanGroupKey(rawIP: string): string {
const ip = normalizeIP(rawIP);
if (isLoopback(ip) || ip === "unknown") {
@ -112,19 +117,16 @@ function checkMessageRate(client: Client): boolean {
return client.messageCount <= RATE_LIMIT_MESSAGES;
}
/** Sanitize client-provided display name */
function sanitizeName(name: unknown): string {
if (typeof name !== "string") return "Appareil";
return name.trim().slice(0, 30) || "Appareil";
}
/** Validate device type */
function validateDeviceType(dt: unknown): DeviceType {
const valid: DeviceType[] = ["phone", "tablet", "laptop", "desktop"];
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 {
if (typeof avatar !== "string") return undefined;
if (!avatar.startsWith("data:image/")) return undefined;
@ -132,7 +134,7 @@ function validateAvatar(avatar: unknown): string | undefined {
return avatar;
}
// ── HTTP server (health check) ──
// ── HTTP server ──
const httpServer = createServer((req, res) => {
if (req.url === "/health") {
@ -164,10 +166,10 @@ wss.on("connection", (ws, req) => {
const peerId = nanoid(8);
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
// Client object created with placeholder name — filled in on "hello"
const client: Client = {
ws,
peerId,
deviceId: null,
displayName: "Appareil",
deviceType: "laptop",
ip,
@ -202,6 +204,9 @@ wss.on("connection", (ws, req) => {
case "signal":
handleSignal(client, msg.to, msg.data);
break;
case "subscribe-push":
handleSubscribePush(client, msg);
break;
case "leave":
handleLeave(client);
break;
@ -215,13 +220,13 @@ wss.on("connection", (ws, req) => {
});
function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void {
// Apply client-provided profile
client.displayName = sanitizeName(msg.deviceName);
client.deviceType = validateDeviceType(msg.deviceType);
client.avatar = validateAvatar(msg.avatar);
client.deviceId = typeof msg.deviceId === "string" ? msg.deviceId : null;
// Join LAN room
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
const lanGroupKey = getLanGroupKey(client.ip);
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(lanGroupKey));
roomManager.addClientToRoom(lanRoom, client);
const peers: PeerInfo[] = [];
@ -233,7 +238,6 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
deviceType: peer.deviceType,
avatar: peer.avatar,
});
// Notify existing peers
send(peer.ws, {
type: "peer-joined",
peerId: client.peerId,
@ -280,7 +284,21 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
peerId: client.peerId,
roomId: client.lanRoomId,
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 {

127
server/src/push.ts Normal file
View 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);
}
}

View File

@ -15,6 +15,7 @@ const generateLongCode = customAlphabet(SHORT_CODE_ALPHABET, SHORT_CODE_MAX_LENG
export interface Client {
ws: WebSocket;
peerId: string;
deviceId: string | null;
displayName: string;
deviceType: DeviceType;
avatar?: string;

View File

@ -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"}

View File

@ -4,9 +4,10 @@ import type { DeviceType } from "./names.js";
export interface HelloMessage {
type: "hello";
deviceId: string;
deviceName: string;
deviceType: DeviceType;
avatar?: string; // base64 data URL (small, <50KB)
avatar?: string;
joinCode?: string;
}
@ -24,11 +25,19 @@ export interface LeaveMessage {
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 =
| HelloMessage
| CreatePublicRoomMessage
| SignalMessage
| LeaveMessage;
| LeaveMessage
| SubscribePushMessage;
// ── Server → Client messages ──
@ -44,6 +53,7 @@ export interface WelcomeMessage {
peerId: string;
roomId: string;
peers: PeerInfo[];
vapidPublicKey?: string;
}
export interface PublicRoomCreatedMessage {
@ -127,6 +137,14 @@ export type DataChannelMessage =
| TransferRequestMessage
| TransferResponseMessage;
// ── Push notification payload ──
export interface PushPayload {
type: "peer-nearby";
displayName: string;
deviceType: DeviceType;
}
// ── Constants ──
export const CHUNK_SIZE = 16 * 1024; // 16 KB

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,7 @@ import {
createTransferResponse,
createFileReceiver,
} from "../lib/fileTransfer";
import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
@ -41,7 +42,6 @@ export function useSignaling(joinCode?: string) {
setError,
} = useStore();
// Initialize file receiver
useEffect(() => {
fileReceiverRef.current = createFileReceiver({
onData: () => {},
@ -109,11 +109,18 @@ export function useSignaling(joinCode?: string) {
}, [updateTransfer]);
useEffect(() => {
const profile = useProfileStore.getState();
const handleMessage = (msg: ServerMessage) => {
switch (msg.type) {
case "welcome":
setConnection(msg.peerId, msg.roomId);
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;
case "peer-joined":
addPeer({
@ -122,6 +129,8 @@ export function useSignaling(joinCode?: string) {
deviceType: msg.deviceType,
avatar: msg.avatar,
});
// Notify if tab is in background
showLocalNotification("AnyDrop", `${msg.displayName} est à proximité`);
break;
case "peer-left":
removePeer(msg.peerId);
@ -139,12 +148,10 @@ export function useSignaling(joinCode?: string) {
}
};
// Get profile from store
const profile = useProfileStore.getState();
const signaling = new SignalingClient(
handleMessage,
{
deviceId: profile.deviceId,
deviceName: profile.deviceName,
deviceType: profile.deviceType,
avatar: profile.avatar || undefined,
@ -174,6 +181,13 @@ export function useSignaling(joinCode?: string) {
files: msg.files,
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;
}
if (msg.type === "transfer-response") {
@ -189,6 +203,10 @@ export function useSignaling(joinCode?: string) {
files: [],
text: msg.content,
});
showLocalNotification(
"Texte reçu",
`${peerInfo?.displayName || "Quelqu'un"} vous a envoyé du texte`,
);
return;
}
} catch {

View 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;
}

View File

@ -1,6 +1,7 @@
import type { ClientMessage, ServerMessage, DeviceType } from "@anydrop/shared";
export interface ProfileData {
deviceId: string;
deviceName: string;
deviceType: DeviceType;
avatar?: string;
@ -31,6 +32,7 @@ export class SignalingClient {
this.ws.onopen = () => {
this.send({
type: "hello",
deviceId: this.profile.deviceId,
deviceName: this.profile.deviceName,
deviceType: this.profile.deviceType,
avatar: this.profile.avatar,

View File

@ -4,10 +4,11 @@ import type { DeviceType } from "@anydrop/shared";
import { detectDeviceType, getDefaultDeviceName } from "@anydrop/shared";
interface ProfileState {
deviceId: string;
deviceName: string;
deviceType: DeviceType;
avatar: string | null; // base64 data URL
isSetUp: boolean; // true once user has confirmed their profile at least once
avatar: string | null;
isSetUp: boolean;
setDeviceName: (name: string) => void;
setAvatar: (avatar: string | null) => void;
@ -16,9 +17,14 @@ interface ProfileState {
const ua = navigator.userAgent;
function generateDeviceId(): string {
return crypto.randomUUID();
}
export const useProfileStore = create<ProfileState>()(
persist(
(set) => ({
deviceId: generateDeviceId(),
deviceName: getDefaultDeviceName(ua),
deviceType: detectDeviceType(ua),
avatar: null,

69
web/src/sw.ts Normal file
View 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);
}),
);
});

View File

@ -13,18 +13,13 @@ export default defineConfig({
include: ["process", "events", "stream", "buffer", "util"],
}),
VitePWA({
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
registerType: "autoUpdate",
includeAssets: ["favicon.svg"],
workbox: {
skipWaiting: true,
clientsClaim: true,
navigateFallback: "index.html",
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === "navigate",
handler: "NetworkFirst",
},
],
injectManifest: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
},
manifest: {
name: "AnyDrop",
@ -47,6 +42,9 @@ export default defineConfig({
},
],
},
devOptions: {
enabled: false,
},
}),
],
server: {