feat: permanent device pairing for cross-network sharing
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
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:
parent
2e3408e8d7
commit
d6f7a2374b
@ -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) {
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
71
web/src/components/DevicePairingPanel.tsx
Normal file
71
web/src/components/DevicePairingPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
37
web/src/pages/Pair.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user