feat: LAN detection fallback via WebRTC local IP
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s

iCloud Private Relay hides iPhones' real public IP, breaking
IP-based LAN grouping. Devices now detect their local IP
(192.168.x.x) via WebRTC ICE candidates and send it to the
server. The server groups devices by local subnet as a fallback
when public IPs differ.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-14 12:27:36 +02:00
parent d1c4b3d196
commit 99a182a831
8 changed files with 203 additions and 90 deletions

View File

@ -175,6 +175,7 @@ wss.on("connection", (ws, req) => {
displayName: "Appareil", displayName: "Appareil",
deviceType: "laptop", deviceType: "laptop",
ip, ip,
lanGroupKey: groupKey,
lanRoomId, lanRoomId,
publicRoomId: null, publicRoomId: null,
messageCount: 0, messageCount: 0,
@ -224,14 +225,48 @@ wss.on("connection", (ws, req) => {
}); });
}); });
function getLocalSubnet(localIP: unknown): string | null {
if (typeof localIP !== "string") return null;
const parts = localIP.split(".");
if (parts.length !== 4) return null;
if (!isPrivateIP(localIP)) return null;
return parts.slice(0, 3).join(".");
}
// Maps local subnet → LAN room hash, so devices on the same local subnet share a room
const subnetToRoomHash = new Map<string, string>();
function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void { function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void {
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; client.deviceId = typeof msg.deviceId === "string" ? msg.deviceId : null;
const lanGroupKey = getLanGroupKey(client.ip); const publicGroupKey = getLanGroupKey(client.ip);
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(lanGroupKey)); const localSubnet = getLocalSubnet(msg.localIP);
// Determine which room hash to use:
// - If the client provides a localIP subnet, check if another device on the same subnet
// is already in a room (possibly with a different public IP due to Private Relay / VPN).
// If so, join that room. Otherwise, register this subnet → room mapping.
// - Fall back to public IP grouping.
let roomHash: string;
if (localSubnet) {
const existingHash = subnetToRoomHash.get(localSubnet);
if (existingHash) {
roomHash = existingHash;
} else {
roomHash = hashIP(publicGroupKey);
subnetToRoomHash.set(localSubnet, roomHash);
}
console.log(`[hello] localIP=${msg.localIP} subnet=${localSubnet} → roomHash=${roomHash}`);
} else {
roomHash = hashIP(publicGroupKey);
}
const lanRoom = roomManager.getOrCreateLanRoom(roomHash);
client.lanRoomId = lanRoom.id;
client.lanGroupKey = localSubnet || publicGroupKey;
roomManager.addClientToRoom(lanRoom, client); roomManager.addClientToRoom(lanRoom, client);
const peers: PeerInfo[] = []; const peers: PeerInfo[] = [];
@ -291,7 +326,7 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
} }
// Add offline push-subscribed peers to the list (excluding self) // Add offline push-subscribed peers to the list (excluding self)
const offlineSubs = getOfflineSubscribers(lanGroupKey, onlineDeviceIds, client.deviceId ?? undefined); const offlineSubs = getOfflineSubscribers(client.lanGroupKey, onlineDeviceIds, client.deviceId ?? undefined);
for (const sub of offlineSubs) { for (const sub of offlineSubs) {
peers.push({ peers.push({
peerId: `offline:${sub.deviceId}`, peerId: `offline:${sub.deviceId}`,
@ -311,19 +346,17 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
}); });
// Notify offline devices via push (skip self) // Notify offline devices via push (skip self)
notifyOfflineDevices(lanGroupKey, onlineDeviceIds, client.deviceId, client.displayName, client.deviceType); notifyOfflineDevices(client.lanGroupKey, onlineDeviceIds, client.deviceId, client.displayName, client.deviceType);
} }
function handleSubscribePush(client: Client, msg: ClientMessage & { type: "subscribe-push" }): void { function handleSubscribePush(client: Client, msg: ClientMessage & { type: "subscribe-push" }): void {
if (!msg.deviceId || !msg.subscription) return; if (!msg.deviceId || !msg.subscription) return;
const lanGroupKey = getLanGroupKey(client.ip); storeSubscription(msg.deviceId, client.displayName, client.deviceType, msg.subscription as any, client.lanGroupKey);
storeSubscription(msg.deviceId, client.displayName, client.deviceType, msg.subscription as any, lanGroupKey);
} }
function handleWakePeer(client: Client, msg: ClientMessage & { type: "wake-peer" }): void { function handleWakePeer(client: Client, msg: ClientMessage & { type: "wake-peer" }): void {
if (!msg.deviceId) return; if (!msg.deviceId) return;
const lanGroupKey = getLanGroupKey(client.ip); wakeDevice(client.lanGroupKey, msg.deviceId, client.displayName, client.deviceType);
wakeDevice(lanGroupKey, msg.deviceId, client.displayName, client.deviceType);
} }
function handleCreatePublicRoom(client: Client): void { function handleCreatePublicRoom(client: Client): void {
@ -352,7 +385,8 @@ function handleSignal(client: Client, to: string, data: unknown): void {
} }
function handleLeave(client: Client): void { function handleLeave(client: Client): void {
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip))); const lanRoom = roomManager.getRoomById(client.lanRoomId);
if (!lanRoom) return;
roomManager.removeClientFromRoom(lanRoom, client.peerId); roomManager.removeClientFromRoom(lanRoom, client.peerId);
for (const peer of lanRoom.clients.values()) { for (const peer of lanRoom.clients.values()) {
send(peer.ws, { type: "peer-left", peerId: client.peerId }); send(peer.ws, { type: "peer-left", peerId: client.peerId });

View File

@ -20,6 +20,7 @@ export interface Client {
deviceType: DeviceType; deviceType: DeviceType;
avatar?: string; avatar?: string;
ip: string; ip: string;
lanGroupKey: string;
lanRoomId: string; lanRoomId: string;
publicRoomId: string | null; publicRoomId: string | null;
messageCount: number; messageCount: number;

View File

@ -8,6 +8,7 @@ export interface HelloMessage {
deviceName: string; deviceName: string;
deviceType: DeviceType; deviceType: DeviceType;
avatar?: string; avatar?: string;
localIP?: string; // e.g. "192.168.1.42" — used for LAN detection fallback
joinCode?: string; joinCode?: string;
} }

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@ import {
createFileReceiver, createFileReceiver,
} from "../lib/fileTransfer"; } from "../lib/fileTransfer";
import { setupPushNotifications, showLocalNotification } from "../lib/notifications"; import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
import { detectLocalIP } from "../lib/localIP";
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore"; import { useProfileStore } from "../stores/useProfileStore";
@ -109,6 +110,7 @@ export function useSignaling(joinCode?: string) {
}, [updateTransfer]); }, [updateTransfer]);
useEffect(() => { useEffect(() => {
let cancelled = false;
const profile = useProfileStore.getState(); const profile = useProfileStore.getState();
const handleMessage = (msg: ServerMessage) => { const handleMessage = (msg: ServerMessage) => {
@ -148,6 +150,10 @@ export function useSignaling(joinCode?: string) {
} }
}; };
const init = async () => {
const localIP = await detectLocalIP().catch(() => undefined);
if (cancelled) return;
const signaling = new SignalingClient( const signaling = new SignalingClient(
handleMessage, handleMessage,
{ {
@ -155,6 +161,7 @@ export function useSignaling(joinCode?: string) {
deviceName: profile.deviceName, deviceName: profile.deviceName,
deviceType: profile.deviceType, deviceType: profile.deviceType,
avatar: profile.avatar || undefined, avatar: profile.avatar || undefined,
localIP,
}, },
joinCode, joinCode,
); );
@ -181,7 +188,6 @@ 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 fileCount = msg.files?.length || 0;
const label = fileCount > 1 ? `${fileCount} fichiers` : msg.files?.[0]?.name || "un fichier"; const label = fileCount > 1 ? `${fileCount} fichiers` : msg.files?.[0]?.name || "un fichier";
showLocalNotification( showLocalNotification(
@ -223,10 +229,14 @@ export function useSignaling(joinCode?: string) {
signaling.connect(); signaling.connect();
setConnected(true); setConnected(true);
};
init();
return () => { return () => {
signaling.disconnect(); cancelled = true;
peerManager.closeAll(); signalingRef.current?.disconnect();
peerManagerRef.current?.closeAll();
setConnected(false); setConnected(false);
}; };
}, [joinCode]); }, [joinCode]);

52
web/src/lib/localIP.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* Detect the local LAN IP (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
* using WebRTC ICE candidate gathering.
*
* Returns the first private IPv4 found, or undefined if none detected
* (e.g. browser blocks mDNS candidates).
*/
export async function detectLocalIP(timeoutMs = 3000): Promise<string | undefined> {
return new Promise((resolve) => {
let resolved = false;
const done = (ip?: string) => {
if (resolved) return;
resolved = true;
pc.close();
clearTimeout(timer);
resolve(ip);
};
const timer = setTimeout(() => done(undefined), timeoutMs);
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
pc.createDataChannel("");
pc.onicecandidate = (event) => {
if (!event.candidate?.candidate) return;
const match = event.candidate.candidate.match(
/(?:srflx|host)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s/,
);
if (match) {
const ip = match[1];
if (isPrivateIPv4(ip)) {
done(ip);
}
}
};
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.catch(() => done(undefined));
});
}
function isPrivateIPv4(ip: string): boolean {
return (
ip.startsWith("192.168.") ||
ip.startsWith("10.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
);
}

View File

@ -5,6 +5,7 @@ export interface ProfileData {
deviceName: string; deviceName: string;
deviceType: DeviceType; deviceType: DeviceType;
avatar?: string; avatar?: string;
localIP?: string;
} }
export type SignalingHandler = (msg: ServerMessage) => void; export type SignalingHandler = (msg: ServerMessage) => void;
@ -36,6 +37,7 @@ export class SignalingClient {
deviceName: this.profile.deviceName, deviceName: this.profile.deviceName,
deviceType: this.profile.deviceType, deviceType: this.profile.deviceType,
avatar: this.profile.avatar, avatar: this.profile.avatar,
localIP: this.profile.localIP,
joinCode: this.joinCode, joinCode: this.joinCode,
}); });
}; };

View File

@ -1,16 +1,25 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { precacheAndRoute } from "workbox-precaching"; import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing"; import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies"; import { NetworkFirst } from "workbox-strategies";
import type { PushPayload } from "@anydrop/shared"; import type { PushPayload } from "@anydrop/shared";
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
// Clean up caches from old service worker (generateSW → injectManifest migration)
cleanupOutdatedCaches();
// Workbox injects the precache manifest here // Workbox injects the precache manifest here
precacheAndRoute(self.__WB_MANIFEST); precacheAndRoute(self.__WB_MANIFEST);
// Navigation: always try network first (so deploys are visible immediately) // Navigation: network first with precache fallback for offline
registerRoute(new NavigationRoute(new NetworkFirst())); registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: "navigations",
}),
),
);
// Activate immediately, take control of all clients // Activate immediately, take control of all clients
self.addEventListener("install", () => { self.addEventListener("install", () => {
@ -18,7 +27,16 @@ self.addEventListener("install", () => {
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); // Delete ALL old caches that aren't ours
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => !key.startsWith("workbox-") && key !== "navigations" && key !== "share-target")
.map((key) => caches.delete(key)),
);
}).then(() => self.clients.claim()),
);
}); });
// ── Share Target handler ── // ── Share Target handler ──
@ -36,15 +54,12 @@ self.addEventListener("fetch", (event) => {
const text = formData.get("text") as string | null; const text = formData.get("text") as string | null;
const sharedUrl = formData.get("url") as string | null; const sharedUrl = formData.get("url") as string | null;
// Build the shared text (combine text + url if both present)
let sharedText = ""; let sharedText = "";
if (text) sharedText += text; if (text) sharedText += text;
if (sharedUrl) sharedText += (sharedText ? "\n" : "") + sharedUrl; if (sharedUrl) sharedText += (sharedText ? "\n" : "") + sharedUrl;
// Stash files in Cache Storage so the page can retrieve them
if (files.length > 0) { if (files.length > 0) {
const cache = await caches.open("share-target"); const cache = await caches.open("share-target");
// Store each file as a cache entry keyed by index
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const response = new Response(files[i], { const response = new Response(files[i], {
headers: { headers: {
@ -54,7 +69,6 @@ self.addEventListener("fetch", (event) => {
}); });
await cache.put(`/share-target-file/${i}`, response); await cache.put(`/share-target-file/${i}`, response);
} }
// Store the count
await cache.put( await cache.put(
"/share-target-meta", "/share-target-meta",
new Response(JSON.stringify({ count: files.length, text: sharedText })), new Response(JSON.stringify({ count: files.length, text: sharedText })),
@ -67,7 +81,6 @@ self.addEventListener("fetch", (event) => {
); );
} }
// Redirect to the share page (GET) — the page will read from cache
return Response.redirect("/share", 303); return Response.redirect("/share", 303);
})(), })(),
); );