feat: permanent device pairing for cross-network sharing
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s

Adds a groupId-based pairing system so devices can always see
each other regardless of network. Scan a QR code once from the
other device, and they're permanently linked via a shared group
stored in localStorage. No account, no email — just one-time QR
scan like Bluetooth pairing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-14 12:39:21 +02:00
parent 2e3408e8d7
commit d6f7a2374b
11 changed files with 193 additions and 1 deletions

View File

@ -177,6 +177,8 @@ wss.on("connection", (ws, req) => {
ip, ip,
lanGroupKey: groupKey, lanGroupKey: groupKey,
lanRoomId, lanRoomId,
groupId: null,
groupRoomId: null,
publicRoomId: null, publicRoomId: null,
messageCount: 0, messageCount: 0,
messageWindowStart: Date.now(), messageWindowStart: Date.now(),
@ -289,6 +291,34 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
} }
console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`); console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`);
// Join device group room if paired
if (typeof msg.groupId === "string" && msg.groupId.length > 0 && msg.groupId.length <= 64) {
client.groupId = msg.groupId;
const groupRoom = roomManager.getOrCreateGroupRoom(msg.groupId);
client.groupRoomId = groupRoom.id;
roomManager.addClientToRoom(groupRoom, client);
for (const peer of groupRoom.clients.values()) {
if (peer.peerId !== client.peerId) {
if (!peers.find((p) => p.peerId === peer.peerId)) {
peers.push({
peerId: peer.peerId,
displayName: peer.displayName,
deviceType: peer.deviceType,
avatar: peer.avatar,
});
}
send(peer.ws, {
type: "peer-joined",
peerId: client.peerId,
displayName: client.displayName,
deviceType: client.deviceType,
avatar: client.avatar,
});
}
}
console.log(`[hello] peer=${client.peerId} joined group=${msg.groupId} peers_in_group=${groupRoom.clients.size}`);
}
// Join public room if code provided // Join public room if code provided
if (msg.joinCode) { if (msg.joinCode) {
const pubRoom = roomManager.getPublicRoomByCode(msg.joinCode); const pubRoom = roomManager.getPublicRoomByCode(msg.joinCode);
@ -392,6 +422,17 @@ function handleLeave(client: Client): void {
send(peer.ws, { type: "peer-left", peerId: client.peerId }); send(peer.ws, { type: "peer-left", peerId: client.peerId });
} }
if (client.groupRoomId) {
const groupRoom = roomManager.getRoomById(client.groupRoomId);
if (groupRoom) {
roomManager.removeClientFromRoom(groupRoom, client.peerId);
for (const peer of groupRoom.clients.values()) {
send(peer.ws, { type: "peer-left", peerId: client.peerId });
}
}
client.groupRoomId = null;
}
if (client.publicRoomId) { if (client.publicRoomId) {
const pubRoom = roomManager.getRoomById(client.publicRoomId); const pubRoom = roomManager.getRoomById(client.publicRoomId);
if (pubRoom) { if (pubRoom) {

View File

@ -22,6 +22,8 @@ export interface Client {
ip: string; ip: string;
lanGroupKey: string; lanGroupKey: string;
lanRoomId: string; lanRoomId: string;
groupId: string | null;
groupRoomId: string | null;
publicRoomId: string | null; publicRoomId: string | null;
messageCount: number; messageCount: number;
messageWindowStart: number; messageWindowStart: number;
@ -64,6 +66,25 @@ export class RoomManager {
return room; return room;
} }
getGroupRoomId(groupId: string): string {
return `group:${groupId}`;
}
getOrCreateGroupRoom(groupId: string): Room {
const id = this.getGroupRoomId(groupId);
let room = this.rooms.get(id);
if (!room) {
room = {
id,
type: "lan", // reuse type — it's just a persistent room
clients: new Map(),
lastActivity: Date.now(),
};
this.rooms.set(id, room);
}
return room;
}
createPublicRoom(): { code: string; roomId: string; expiresAt: number } { createPublicRoom(): { code: string; roomId: string; expiresAt: number } {
let code: string | null = null; let code: string | null = null;
for (let i = 0; i < SHORT_CODE_MAX_RETRIES; i++) { for (let i = 0; i < SHORT_CODE_MAX_RETRIES; i++) {
@ -141,6 +162,12 @@ export class RoomManager {
if (lanRoom?.clients.has(targetPeerId)) { if (lanRoom?.clients.has(targetPeerId)) {
return lanRoom.clients.get(targetPeerId)!; return lanRoom.clients.get(targetPeerId)!;
} }
if (client.groupRoomId) {
const groupRoom = this.rooms.get(client.groupRoomId);
if (groupRoom?.clients.has(targetPeerId)) {
return groupRoom.clients.get(targetPeerId)!;
}
}
if (client.publicRoomId) { if (client.publicRoomId) {
const pubRoom = this.rooms.get(client.publicRoomId); const pubRoom = this.rooms.get(client.publicRoomId);
if (pubRoom?.clients.has(targetPeerId)) { if (pubRoom?.clients.has(targetPeerId)) {

View File

@ -9,6 +9,7 @@ export interface HelloMessage {
deviceType: DeviceType; deviceType: DeviceType;
avatar?: string; avatar?: string;
localIP?: string; // e.g. "192.168.1.42" — used for LAN detection fallback localIP?: string; // e.g. "192.168.1.42" — used for LAN detection fallback
groupId?: string; // permanent device group for cross-network pairing
joinCode?: string; joinCode?: string;
} }

File diff suppressed because one or more lines are too long

View File

@ -2,12 +2,14 @@ import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home"; import Home from "./pages/Home";
import JoinRoom from "./pages/JoinRoom"; import JoinRoom from "./pages/JoinRoom";
import Share from "./pages/Share"; import Share from "./pages/Share";
import Pair from "./pages/Pair";
export default function App() { export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/share" element={<Share />} /> <Route path="/share" element={<Share />} />
<Route path="/pair" element={<Pair />} />
<Route path="/:code" element={<JoinRoom />} /> <Route path="/:code" element={<JoinRoom />} />
</Routes> </Routes>
); );

View File

@ -0,0 +1,71 @@
import { useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { useProfileStore } from "../stores/useProfileStore";
const BASE_URL = window.location.origin;
export default function DevicePairingPanel() {
const { groupId, setGroupId } = useProfileStore();
const [showQR, setShowQR] = useState(false);
const ensureGroupId = (): string => {
if (groupId) return groupId;
const newId = crypto.randomUUID();
setGroupId(newId);
return newId;
};
const handleShowQR = () => {
ensureGroupId();
setShowQR(true);
};
const pairUrl = groupId ? `${BASE_URL}/pair?g=${groupId}` : "";
if (showQR && groupId) {
return (
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6 text-center space-y-4">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Ajouter un appareil
</h3>
<p className="text-sm text-slate-300">
Scannez ce QR code depuis votre autre appareil
</p>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl">
<QRCodeSVG value={pairUrl} size={180} />
</div>
</div>
<p className="text-xs text-slate-500">
L'appairage est permanent vos appareils se verront toujours,
même sur des réseaux différents.
</p>
<button
onClick={() => setShowQR(false)}
className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Fermer
</button>
</div>
);
}
return (
<button
onClick={handleShowQR}
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="text-lg">📲</span>
<span className="text-sm font-medium">
{groupId ? "Ajouter un autre appareil" : "Appairer mes appareils"}
</span>
</button>
);
}

View File

@ -162,6 +162,7 @@ export function useSignaling(joinCode?: string) {
deviceType: profile.deviceType, deviceType: profile.deviceType,
avatar: profile.avatar || undefined, avatar: profile.avatar || undefined,
localIP, localIP,
groupId: profile.groupId || undefined,
}, },
joinCode, joinCode,
); );

View File

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

View File

@ -8,6 +8,7 @@ import TransferProgress from "../components/TransferProgress";
import TextShareModal from "../components/TextShareModal"; import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog"; import ReceiveDialog from "../components/ReceiveDialog";
import PublicRoomPanel from "../components/PublicRoomPanel"; import PublicRoomPanel from "../components/PublicRoomPanel";
import DevicePairingPanel from "../components/DevicePairingPanel";
import ProfileSetup from "../components/ProfileSetup"; import ProfileSetup from "../components/ProfileSetup";
export default function Home() { export default function Home() {
@ -153,6 +154,11 @@ function HomeConnected() {
<TransferProgress /> <TransferProgress />
</section> </section>
{/* Device pairing */}
<section className="mb-6">
<DevicePairingPanel />
</section>
{/* Public room */} {/* Public room */}
<section className="mb-6"> <section className="mb-6">
<PublicRoomPanel onCreateRoom={createPublicRoom} /> <PublicRoomPanel onCreateRoom={createPublicRoom} />

37
web/src/pages/Pair.tsx Normal file
View File

@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useProfileStore } from "../stores/useProfileStore";
export default function Pair() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { setGroupId, isSetUp } = useProfileStore();
const groupId = params.get("g");
useEffect(() => {
if (groupId) {
setGroupId(groupId);
// Small delay so user sees the confirmation
const t = setTimeout(() => navigate("/", { replace: true }), 1500);
return () => clearTimeout(t);
}
}, [groupId, setGroupId, navigate]);
if (!groupId) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
<p className="text-slate-400">Lien d'appairage invalide.</p>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex flex-col items-center justify-center gap-4">
<div className="text-5xl"></div>
<h1 className="text-xl font-bold text-white">Appareil appairé !</h1>
<p className="text-slate-400 text-sm">Vos appareils se verront automatiquement.</p>
<p className="text-slate-600 text-xs">Redirection...</p>
</div>
);
}

View File

@ -9,10 +9,12 @@ interface ProfileState {
deviceType: DeviceType; deviceType: DeviceType;
avatar: string | null; avatar: string | null;
isSetUp: boolean; isSetUp: boolean;
groupId: string | null;
setDeviceName: (name: string) => void; setDeviceName: (name: string) => void;
setAvatar: (avatar: string | null) => void; setAvatar: (avatar: string | null) => void;
setUp: () => void; setUp: () => void;
setGroupId: (groupId: string) => void;
} }
const ua = navigator.userAgent; const ua = navigator.userAgent;
@ -29,10 +31,12 @@ export const useProfileStore = create<ProfileState>()(
deviceType: detectDeviceType(ua), deviceType: detectDeviceType(ua),
avatar: null, avatar: null,
isSetUp: false, isSetUp: false,
groupId: null,
setDeviceName: (deviceName) => set({ deviceName: deviceName.trim() || getDefaultDeviceName(ua) }), setDeviceName: (deviceName) => set({ deviceName: deviceName.trim() || getDefaultDeviceName(ua) }),
setAvatar: (avatar) => set({ avatar }), setAvatar: (avatar) => set({ avatar }),
setUp: () => set({ isSetUp: true }), setUp: () => set({ isSetUp: true }),
setGroupId: (groupId) => set({ groupId }),
}), }),
{ {
name: "anydrop-profile", name: "anydrop-profile",