feat: pairing via short code instead of QR only
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s
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:
parent
f23cb7bffc
commit
d8721b6d0c
@ -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;
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 }),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user