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",
deviceType: "laptop",
ip,
lanGroupKey: groupKey,
lanRoomId,
publicRoomId: null,
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 {
client.displayName = sanitizeName(msg.deviceName);
client.deviceType = validateDeviceType(msg.deviceType);
client.avatar = validateAvatar(msg.avatar);
client.deviceId = typeof msg.deviceId === "string" ? msg.deviceId : null;
const lanGroupKey = getLanGroupKey(client.ip);
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(lanGroupKey));
const publicGroupKey = getLanGroupKey(client.ip);
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);
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)
const offlineSubs = getOfflineSubscribers(lanGroupKey, onlineDeviceIds, client.deviceId ?? undefined);
const offlineSubs = getOfflineSubscribers(client.lanGroupKey, onlineDeviceIds, client.deviceId ?? undefined);
for (const sub of offlineSubs) {
peers.push({
peerId: `offline:${sub.deviceId}`,
@ -311,19 +346,17 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
});
// 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 {
if (!msg.deviceId || !msg.subscription) return;
const lanGroupKey = getLanGroupKey(client.ip);
storeSubscription(msg.deviceId, client.displayName, client.deviceType, msg.subscription as any, lanGroupKey);
storeSubscription(msg.deviceId, client.displayName, client.deviceType, msg.subscription as any, client.lanGroupKey);
}
function handleWakePeer(client: Client, msg: ClientMessage & { type: "wake-peer" }): void {
if (!msg.deviceId) return;
const lanGroupKey = getLanGroupKey(client.ip);
wakeDevice(lanGroupKey, msg.deviceId, client.displayName, client.deviceType);
wakeDevice(client.lanGroupKey, msg.deviceId, client.displayName, client.deviceType);
}
function handleCreatePublicRoom(client: Client): void {
@ -352,7 +385,8 @@ function handleSignal(client: Client, to: string, data: unknown): 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);
for (const peer of lanRoom.clients.values()) {
send(peer.ws, { type: "peer-left", peerId: client.peerId });

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@ import {
createFileReceiver,
} from "../lib/fileTransfer";
import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
import { detectLocalIP } from "../lib/localIP";
import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
@ -109,6 +110,7 @@ export function useSignaling(joinCode?: string) {
}, [updateTransfer]);
useEffect(() => {
let cancelled = false;
const profile = useProfileStore.getState();
const handleMessage = (msg: ServerMessage) => {
@ -148,85 +150,93 @@ export function useSignaling(joinCode?: string) {
}
};
const signaling = new SignalingClient(
handleMessage,
{
deviceId: profile.deviceId,
deviceName: profile.deviceName,
deviceType: profile.deviceType,
avatar: profile.avatar || undefined,
},
joinCode,
);
signalingRef.current = signaling;
const init = async () => {
const localIP = await detectLocalIP().catch(() => undefined);
if (cancelled) return;
const peerManager = new PeerManager(signaling, {
onConnect: (peerId) => {
console.log(`P2P connected with ${peerId}`);
},
onClose: (peerId) => {
console.log(`P2P disconnected from ${peerId}`);
},
onData: (peerId, data) => {
const store = useStore.getState();
const peerInfo = store.peers.find((p) => p.peerId === peerId);
const signaling = new SignalingClient(
handleMessage,
{
deviceId: profile.deviceId,
deviceName: profile.deviceName,
deviceType: profile.deviceType,
avatar: profile.avatar || undefined,
localIP,
},
joinCode,
);
signalingRef.current = signaling;
if (typeof data === "string") {
try {
const msg = JSON.parse(data);
if (msg.type === "transfer-request") {
setIncomingRequest({
peerId,
displayName: peerInfo?.displayName || peerId,
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") {
if (msg.accepted) {
startSending();
const peerManager = new PeerManager(signaling, {
onConnect: (peerId) => {
console.log(`P2P connected with ${peerId}`);
},
onClose: (peerId) => {
console.log(`P2P disconnected from ${peerId}`);
},
onData: (peerId, data) => {
const store = useStore.getState();
const peerInfo = store.peers.find((p) => p.peerId === peerId);
if (typeof data === "string") {
try {
const msg = JSON.parse(data);
if (msg.type === "transfer-request") {
setIncomingRequest({
peerId,
displayName: peerInfo?.displayName || peerId,
files: msg.files,
text: msg.text,
});
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.accepted) {
startSending();
}
return;
}
if (msg.type === "text") {
setIncomingRequest({
peerId,
displayName: peerInfo?.displayName || peerId,
files: [],
text: msg.content,
});
showLocalNotification(
"Texte reçu",
`${peerInfo?.displayName || "Quelqu'un"} vous a envoyé du texte`,
);
return;
}
} catch {
// Not JSON
}
if (msg.type === "text") {
setIncomingRequest({
peerId,
displayName: peerInfo?.displayName || peerId,
files: [],
text: msg.content,
});
showLocalNotification(
"Texte reçu",
`${peerInfo?.displayName || "Quelqu'un"} vous a envoyé du texte`,
);
return;
}
} catch {
// Not JSON
}
}
fileReceiverRef.current?.handleData(data);
},
onError: (peerId, err) => {
console.error(`P2P error with ${peerId}:`, err);
},
});
peerManagerRef.current = peerManager;
fileReceiverRef.current?.handleData(data);
},
onError: (peerId, err) => {
console.error(`P2P error with ${peerId}:`, err);
},
});
peerManagerRef.current = peerManager;
signaling.connect();
setConnected(true);
signaling.connect();
setConnected(true);
};
init();
return () => {
signaling.disconnect();
peerManager.closeAll();
cancelled = true;
signalingRef.current?.disconnect();
peerManagerRef.current?.closeAll();
setConnected(false);
};
}, [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;
deviceType: DeviceType;
avatar?: string;
localIP?: string;
}
export type SignalingHandler = (msg: ServerMessage) => void;
@ -36,6 +37,7 @@ export class SignalingClient {
deviceName: this.profile.deviceName,
deviceType: this.profile.deviceType,
avatar: this.profile.avatar,
localIP: this.profile.localIP,
joinCode: this.joinCode,
});
};

View File

@ -1,16 +1,25 @@
/// <reference lib="webworker" />
import { precacheAndRoute } from "workbox-precaching";
import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import type { PushPayload } from "@anydrop/shared";
declare const self: ServiceWorkerGlobalScope;
// Clean up caches from old service worker (generateSW → injectManifest migration)
cleanupOutdatedCaches();
// 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()));
// Navigation: network first with precache fallback for offline
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: "navigations",
}),
),
);
// Activate immediately, take control of all clients
self.addEventListener("install", () => {
@ -18,7 +27,16 @@ self.addEventListener("install", () => {
});
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 ──
@ -36,15 +54,12 @@ self.addEventListener("fetch", (event) => {
const text = formData.get("text") as string | null;
const sharedUrl = formData.get("url") as string | null;
// Build the shared text (combine text + url if both present)
let sharedText = "";
if (text) sharedText += text;
if (sharedUrl) sharedText += (sharedText ? "\n" : "") + sharedUrl;
// Stash files in Cache Storage so the page can retrieve them
if (files.length > 0) {
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++) {
const response = new Response(files[i], {
headers: {
@ -54,7 +69,6 @@ self.addEventListener("fetch", (event) => {
});
await cache.put(`/share-target-file/${i}`, response);
}
// Store the count
await cache.put(
"/share-target-meta",
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);
})(),
);