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;
}
// ── 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 ──
const httpServer = createServer((req, res) => {
@ -215,6 +228,12 @@ wss.on("connection", (ws, req) => {
case "wake-peer":
handleWakePeer(client, msg);
break;
case "create-pair-code":
handleCreatePairCode(client, msg);
break;
case "resolve-pair-code":
handleResolvePairCode(client, msg);
break;
case "leave":
handleLeave(client);
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 {
const lanRoom = roomManager.getRoomById(client.lanRoomId);
if (!lanRoom) return;

View File

@ -40,13 +40,25 @@ export interface WakePeerMessage {
deviceId: string;
}
export interface CreatePairCodeMessage {
type: "create-pair-code";
groupId: string;
}
export interface ResolvePairCodeMessage {
type: "resolve-pair-code";
code: string;
}
export type ClientMessage =
| HelloMessage
| CreatePublicRoomMessage
| SignalMessage
| LeaveMessage
| SubscribePushMessage
| WakePeerMessage;
| WakePeerMessage
| CreatePairCodeMessage
| ResolvePairCodeMessage;
// ── Server → Client messages ──
@ -93,7 +105,17 @@ export interface SignalRelayMessage {
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 {
type: "error";
@ -107,6 +129,8 @@ export type ServerMessage =
| PeerJoinedMessage
| PeerLeftMessage
| SignalRelayMessage
| PairCodeCreatedMessage
| PairCodeResolvedMessage
| ErrorMessage;
// ── 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 { QRCodeSVG } from "qrcode.react";
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 [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 => {
if (groupId) return groupId;
const newId = crypto.randomUUID();
setGroupId(newId);
return newId;
const handleShow = () => {
let gid = groupId;
if (!gid) {
gid = crypto.randomUUID();
setGroupId(gid);
}
onRequestCode(gid);
setMode("show");
setShowModal(true);
};
const handleShowQR = () => {
ensureGroupId();
setShowQR(true);
const handleEnter = () => {
setMode("enter");
setInputCode("");
setShowModal(true);
};
const pairUrl = groupId ? `${BASE_URL}/pair?g=${groupId}` : "";
const handleSubmitCode = () => {
if (inputCode.length >= 6) {
onResolveCode(inputCode);
setShowModal(false);
}
};
return (
<>
<button
onClick={handleShowQR}
onClick={handleShow}
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
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-xs font-medium text-center leading-tight">
{groupId ? "Ajouter un appareil" : "Appairer"}
</span>
<span className="text-xs font-medium text-center leading-tight">Appairer</span>
</button>
{showQR && groupId && (
{showModal && (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
onClick={() => setShowQR(false)}
onClick={() => setShowModal(false)}
>
<div
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()}
>
<h3 className="text-lg font-semibold text-white">
Appairer un appareil
</h3>
<p className="text-sm text-slate-400">
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={200} />
</div>
{/* Tab switcher */}
<div className="flex gap-2 bg-slate-800 rounded-xl p-1">
<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>
<p className="text-xs text-slate-500">
Appairage permanent vos appareils se verront toujours,
sur n'importe quel réseau.
</p>
{mode === "show" ? (
<>
<p className="text-sm text-slate-400">
Entrez ce code sur votre autre appareil
</p>
{pairCode ? (
<p className="text-4xl font-mono font-bold text-brand-400 tracking-[0.3em]">
{pairCode}
</p>
) : (
<p className="text-slate-500 text-sm">Génération du code...</p>
)}
<p className="text-xs text-slate-500">
Le code expire dans 5 minutes.
L'appairage est permanent.
</p>
</>
) : (
<>
<p className="text-sm text-slate-400">
Entrez le code affiché sur l'autre appareil
</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
onClick={handleSubmitCode}
disabled={inputCode.length < 6}
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={() => setShowQR(false)}
className="px-6 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl
text-sm text-slate-300 transition-colors"
onClick={() => setShowModal(false)}
className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Fermer
</button>

View File

@ -242,6 +242,18 @@ export function useSignaling(joinCode?: string) {
case "public-room-created":
setPublicRoom(msg.code, msg.url, msg.expiresAt);
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":
setError(msg.message);
break;
@ -558,6 +570,14 @@ export function useSignaling(joinCode?: string) {
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 {
sendFiles,
sendText,
@ -565,5 +585,7 @@ export function useSignaling(joinCode?: string) {
rejectTransfer,
createPublicRoom,
wakePeer,
requestPairCode,
resolvePairCode,
};
}

View File

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

View File

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