feat: pairing via short code instead of QR only
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s

iOS PWA (home screen) has separate localStorage from Safari, so
QR-based pairing breaks. Now pairing uses a 6-character code:
- Device A taps "Appairer" → shows a code like "A7K9XB"
- Device B taps "Appairer" → "Rejoindre" tab → enters the code
- Server resolves the code to the groupId, devices are linked

Codes expire after 5 minutes. Pairing is permanent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-14 13:04:58 +02:00
parent f23cb7bffc
commit d8721b6d0c
7 changed files with 211 additions and 44 deletions

View File

@ -136,6 +136,19 @@ function validateAvatar(avatar: unknown): string | undefined {
return avatar; return avatar;
} }
// ── Pair codes (temporary mapping: short code → groupId, 5 min TTL) ──
import { customAlphabet } from "nanoid";
const generatePairCode = customAlphabet("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", 6);
const pairCodes = new Map<string, { groupId: string; expiresAt: number }>();
setInterval(() => {
const now = Date.now();
for (const [code, entry] of pairCodes) {
if (now > entry.expiresAt) pairCodes.delete(code);
}
}, 60_000);
// ── HTTP server ── // ── HTTP server ──
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {
@ -215,6 +228,12 @@ wss.on("connection", (ws, req) => {
case "wake-peer": case "wake-peer":
handleWakePeer(client, msg); handleWakePeer(client, msg);
break; break;
case "create-pair-code":
handleCreatePairCode(client, msg);
break;
case "resolve-pair-code":
handleResolvePairCode(client, msg);
break;
case "leave": case "leave":
handleLeave(client); handleLeave(client);
break; break;
@ -414,6 +433,34 @@ function handleSignal(client: Client, to: string, data: unknown): void {
} }
} }
function handleCreatePairCode(client: Client, msg: ClientMessage & { type: "create-pair-code" }): void {
if (typeof msg.groupId !== "string" || msg.groupId.length === 0) return;
// Check if this groupId already has an active code
for (const [code, entry] of pairCodes) {
if (entry.groupId === msg.groupId && Date.now() < entry.expiresAt) {
send(client.ws, { type: "pair-code-created", code });
return;
}
}
const code = generatePairCode();
pairCodes.set(code, { groupId: msg.groupId, expiresAt: Date.now() + 5 * 60 * 1000 });
console.log(`[pair] created code ${code} for group ${msg.groupId.slice(0, 8)}...`);
send(client.ws, { type: "pair-code-created", code });
}
function handleResolvePairCode(client: Client, msg: ClientMessage & { type: "resolve-pair-code" }): void {
if (typeof msg.code !== "string") return;
const entry = pairCodes.get(msg.code.toUpperCase());
if (!entry || Date.now() > entry.expiresAt) {
send(client.ws, { type: "error", code: "pair-code-not-found", message: "Code d'appairage invalide ou expiré" });
return;
}
console.log(`[pair] resolved code ${msg.code} → group ${entry.groupId.slice(0, 8)}...`);
send(client.ws, { type: "pair-code-resolved", groupId: entry.groupId });
}
function handleLeave(client: Client): void { function handleLeave(client: Client): void {
const lanRoom = roomManager.getRoomById(client.lanRoomId); const lanRoom = roomManager.getRoomById(client.lanRoomId);
if (!lanRoom) return; if (!lanRoom) return;

View File

@ -40,13 +40,25 @@ export interface WakePeerMessage {
deviceId: string; deviceId: string;
} }
export interface CreatePairCodeMessage {
type: "create-pair-code";
groupId: string;
}
export interface ResolvePairCodeMessage {
type: "resolve-pair-code";
code: string;
}
export type ClientMessage = export type ClientMessage =
| HelloMessage | HelloMessage
| CreatePublicRoomMessage | CreatePublicRoomMessage
| SignalMessage | SignalMessage
| LeaveMessage | LeaveMessage
| SubscribePushMessage | SubscribePushMessage
| WakePeerMessage; | WakePeerMessage
| CreatePairCodeMessage
| ResolvePairCodeMessage;
// ── Server → Client messages ── // ── Server → Client messages ──
@ -93,7 +105,17 @@ export interface SignalRelayMessage {
data: unknown; data: unknown;
} }
export type ErrorCode = "room-not-found" | "room-expired" | "rate-limit"; export interface PairCodeCreatedMessage {
type: "pair-code-created";
code: string;
}
export interface PairCodeResolvedMessage {
type: "pair-code-resolved";
groupId: string;
}
export type ErrorCode = "room-not-found" | "room-expired" | "rate-limit" | "pair-code-not-found";
export interface ErrorMessage { export interface ErrorMessage {
type: "error"; type: "error";
@ -107,6 +129,8 @@ export type ServerMessage =
| PeerJoinedMessage | PeerJoinedMessage
| PeerLeftMessage | PeerLeftMessage
| SignalRelayMessage | SignalRelayMessage
| PairCodeCreatedMessage
| PairCodeResolvedMessage
| ErrorMessage; | ErrorMessage;
// ── Data channel messages (peer-to-peer) ── // ── Data channel messages (peer-to-peer) ──

File diff suppressed because one or more lines are too long

View File

@ -1,75 +1,142 @@
import { useState } from "react"; import { useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { useProfileStore } from "../stores/useProfileStore"; import { useProfileStore } from "../stores/useProfileStore";
import { useStore } from "../stores/useStore";
const BASE_URL = window.location.origin; interface DevicePairingPanelProps {
onRequestCode: (groupId: string) => void;
onResolveCode: (code: string) => void;
}
export default function DevicePairingPanel() { export default function DevicePairingPanel({
onRequestCode,
onResolveCode,
}: DevicePairingPanelProps) {
const { groupId, setGroupId } = useProfileStore(); const { groupId, setGroupId } = useProfileStore();
const [showQR, setShowQR] = useState(false); const pairCode = useStore((s) => s.pairCode);
const [showModal, setShowModal] = useState(false);
const [mode, setMode] = useState<"show" | "enter">("show");
const [inputCode, setInputCode] = useState("");
const ensureGroupId = (): string => { const handleShow = () => {
if (groupId) return groupId; let gid = groupId;
const newId = crypto.randomUUID(); if (!gid) {
setGroupId(newId); gid = crypto.randomUUID();
return newId; setGroupId(gid);
}
onRequestCode(gid);
setMode("show");
setShowModal(true);
}; };
const handleShowQR = () => { const handleEnter = () => {
ensureGroupId(); setMode("enter");
setShowQR(true); setInputCode("");
setShowModal(true);
}; };
const pairUrl = groupId ? `${BASE_URL}/pair?g=${groupId}` : ""; const handleSubmitCode = () => {
if (inputCode.length >= 6) {
onResolveCode(inputCode);
setShowModal(false);
}
};
return ( return (
<> <>
<button <button
onClick={handleShowQR} onClick={handleShow}
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3 className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3
border border-slate-700 hover:border-brand-500 rounded-xl border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50" bg-slate-900/30 hover:bg-slate-900/50"
> >
<span className="text-lg">📲</span> <span className="text-lg">📲</span>
<span className="text-xs font-medium text-center leading-tight"> <span className="text-xs font-medium text-center leading-tight">Appairer</span>
{groupId ? "Ajouter un appareil" : "Appairer"}
</span>
</button> </button>
{showQR && groupId && ( {showModal && (
<div <div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6" className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
onClick={() => setShowQR(false)} onClick={() => setShowModal(false)}
> >
<div <div
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full
text-center space-y-4" text-center space-y-5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="text-lg font-semibold text-white"> {/* Tab switcher */}
Appairer un appareil <div className="flex gap-2 bg-slate-800 rounded-xl p-1">
</h3> <button
onClick={() => { setMode("show"); if (!pairCode && groupId) onRequestCode(groupId); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
${mode === "show" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
>
Mon code
</button>
<button
onClick={() => setMode("enter")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
${mode === "enter" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
>
Rejoindre
</button>
</div>
{mode === "show" ? (
<>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Scannez ce QR code depuis votre autre appareil Entrez ce code sur votre autre appareil
</p> </p>
<div className="flex justify-center"> {pairCode ? (
<div className="bg-white p-3 rounded-xl"> <p className="text-4xl font-mono font-bold text-brand-400 tracking-[0.3em]">
<QRCodeSVG value={pairUrl} size={200} /> {pairCode}
</div> </p>
</div> ) : (
<p className="text-slate-500 text-sm">Génération du code...</p>
)}
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Appairage permanent vos appareils se verront toujours, Le code expire dans 5 minutes.
sur n'importe quel réseau. L'appairage est permanent.
</p>
</>
) : (
<>
<p className="text-sm text-slate-400">
Entrez le code affiché sur l'autre appareil
</p> </p>
<input
type="text"
value={inputCode}
onChange={(e) => setInputCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))}
placeholder="ABC123"
maxLength={6}
autoFocus
className="w-full px-4 py-3 bg-slate-800 border border-slate-600
rounded-xl text-white text-2xl text-center font-mono tracking-[0.3em]
placeholder:text-slate-700 focus:outline-none focus:border-brand-500"
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitCode();
}}
/>
<button <button
onClick={() => setShowQR(false)} onClick={handleSubmitCode}
className="px-6 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl disabled={inputCode.length < 6}
text-sm text-slate-300 transition-colors" className="w-full py-3 bg-brand-500 hover:bg-brand-400 disabled:opacity-30
disabled:cursor-not-allowed rounded-xl text-white font-medium
transition-colors"
>
Appairer
</button>
</>
)}
<button
onClick={() => setShowModal(false)}
className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
> >
Fermer Fermer
</button> </button>

View File

@ -242,6 +242,18 @@ export function useSignaling(joinCode?: string) {
case "public-room-created": case "public-room-created":
setPublicRoom(msg.code, msg.url, msg.expiresAt); setPublicRoom(msg.code, msg.url, msg.expiresAt);
break; break;
case "pair-code-created":
useStore.getState().setPairCode(msg.code);
break;
case "pair-code-resolved": {
// Store the groupId and reconnect to join the group room
const profileStore = useProfileStore.getState();
profileStore.setGroupId(msg.groupId);
// Reconnect so the hello message includes the new groupId
signalingRef.current?.disconnect();
setTimeout(() => signalingRef.current?.connect(), 500);
break;
}
case "error": case "error":
setError(msg.message); setError(msg.message);
break; break;
@ -558,6 +570,14 @@ export function useSignaling(joinCode?: string) {
signalingRef.current?.send({ type: "wake-peer", deviceId }); signalingRef.current?.send({ type: "wake-peer", deviceId });
}, []); }, []);
const requestPairCode = useCallback((groupId: string) => {
signalingRef.current?.send({ type: "create-pair-code", groupId });
}, []);
const resolvePairCode = useCallback((code: string) => {
signalingRef.current?.send({ type: "resolve-pair-code", code: code.toUpperCase() });
}, []);
return { return {
sendFiles, sendFiles,
sendText, sendText,
@ -565,5 +585,7 @@ export function useSignaling(joinCode?: string) {
rejectTransfer, rejectTransfer,
createPublicRoom, createPublicRoom,
wakePeer, wakePeer,
requestPairCode,
resolvePairCode,
}; };
} }

View File

@ -22,7 +22,7 @@ export default function Home() {
} }
function HomeConnected() { function HomeConnected() {
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, wakePeer } = const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, wakePeer, requestPairCode, resolvePairCode } =
useSignaling(); useSignaling();
const peers = useStore((s) => s.peers); const peers = useStore((s) => s.peers);
@ -130,7 +130,7 @@ function HomeConnected() {
{/* 2. Appairage + Lien public */} {/* 2. Appairage + Lien public */}
<section className="mb-4 flex gap-3"> <section className="mb-4 flex gap-3">
<DevicePairingPanel /> <DevicePairingPanel onRequestCode={requestPairCode} onResolveCode={resolvePairCode} />
<PublicRoomPanel onCreateRoom={createPublicRoom} /> <PublicRoomPanel onCreateRoom={createPublicRoom} />
</section> </section>

View File

@ -37,6 +37,9 @@ interface AppState {
transfers: TransferInfo[]; transfers: TransferInfo[];
incomingRequest: IncomingRequest | null; incomingRequest: IncomingRequest | null;
// Pairing
pairCode: string | null;
// UI // UI
showTextModal: boolean; showTextModal: boolean;
selectedPeerId: string | null; selectedPeerId: string | null;
@ -54,6 +57,7 @@ interface AppState {
updateTransfer: (id: string, updates: Partial<TransferInfo>) => void; updateTransfer: (id: string, updates: Partial<TransferInfo>) => void;
removeTransfer: (id: string) => void; removeTransfer: (id: string) => void;
setIncomingRequest: (request: IncomingRequest | null) => void; setIncomingRequest: (request: IncomingRequest | null) => void;
setPairCode: (code: string | null) => void;
setShowTextModal: (show: boolean) => void; setShowTextModal: (show: boolean) => void;
setSelectedPeerId: (peerId: string | null) => void; setSelectedPeerId: (peerId: string | null) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
@ -70,6 +74,7 @@ const initialState = {
publicRoomExpiresAt: null, publicRoomExpiresAt: null,
transfers: [], transfers: [],
incomingRequest: null, incomingRequest: null,
pairCode: null,
showTextModal: false, showTextModal: false,
selectedPeerId: null, selectedPeerId: null,
error: null, error: null,
@ -114,6 +119,8 @@ export const useStore = create<AppState>((set) => ({
setIncomingRequest: (request) => set({ incomingRequest: request }), setIncomingRequest: (request) => set({ incomingRequest: request }),
setPairCode: (code) => set({ pairCode: code }),
setShowTextModal: (show) => set({ showTextModal: show }), setShowTextModal: (show) => set({ showTextModal: show }),
setSelectedPeerId: (peerId) => set({ selectedPeerId: peerId }), setSelectedPeerId: (peerId) => set({ selectedPeerId: peerId }),