feat: LAN detection fallback via WebRTC local IP
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
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:
parent
d1c4b3d196
commit
99a182a831
@ -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 });
|
||||
|
||||
@ -20,6 +20,7 @@ export interface Client {
|
||||
deviceType: DeviceType;
|
||||
avatar?: string;
|
||||
ip: string;
|
||||
lanGroupKey: string;
|
||||
lanRoomId: string;
|
||||
publicRoomId: string | null;
|
||||
messageCount: number;
|
||||
|
||||
@ -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
@ -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
52
web/src/lib/localIP.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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);
|
||||
})(),
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user