fix: pairing flow + code review bugfixes + remove notifications
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s

Pairing:
- Always request fresh code when opening "Mon code" tab
- Clear stale pairCode on modal close/tab switch
- Show errors inline for invalid codes (don't close modal)
- Delete pair code after first use (server)
- Validate code length server-side

Notifications removed:
- Remove all showLocalNotification calls (peer-joined, transfer, text)
- Push setup now runs only once per session (no memory leak)

Other fixes:
- Clear pendingFilesRef on disconnect
- Set transfer status to "transferring" immediately on send start
- Select offline peer when tapping to wake
- Fix file receiver blob restoration race condition (await arrayBuffer)
- Clear selectedPeerId after share-target send
- Add returnValue to beforeunload handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-14 13:22:33 +02:00
parent b407e6ce95
commit dd37b49ce4
7 changed files with 75 additions and 73 deletions

View File

@ -451,12 +451,16 @@ function handleCreatePairCode(client: Client, msg: ClientMessage & { type: "crea
} }
function handleResolvePairCode(client: Client, msg: ClientMessage & { type: "resolve-pair-code" }): void { function handleResolvePairCode(client: Client, msg: ClientMessage & { type: "resolve-pair-code" }): void {
if (typeof msg.code !== "string") return; if (typeof msg.code !== "string" || msg.code.length !== 6) {
send(client.ws, { type: "error", code: "pair-code-not-found", message: "Code d'appairage invalide" });
return;
}
const entry = pairCodes.get(msg.code.toUpperCase()); const entry = pairCodes.get(msg.code.toUpperCase());
if (!entry || Date.now() > entry.expiresAt) { if (!entry || Date.now() > entry.expiresAt) {
send(client.ws, { type: "error", code: "pair-code-not-found", message: "Code d'appairage invalide ou expiré" }); send(client.ws, { type: "error", code: "pair-code-not-found", message: "Code d'appairage invalide ou expiré" });
return; return;
} }
pairCodes.delete(msg.code.toUpperCase());
console.log(`[pair] resolved code ${msg.code} → group ${entry.groupId.slice(0, 8)}...`); console.log(`[pair] resolved code ${msg.code} → group ${entry.groupId.slice(0, 8)}...`);
send(client.ws, { type: "pair-code-resolved", groupId: entry.groupId }); send(client.ws, { type: "pair-code-resolved", groupId: entry.groupId });
} }

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useProfileStore } from "../stores/useProfileStore"; import { useProfileStore } from "../stores/useProfileStore";
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
@ -13,38 +13,60 @@ export default function DevicePairingPanel({
}: DevicePairingPanelProps) { }: DevicePairingPanelProps) {
const { groupId, setGroupId } = useProfileStore(); const { groupId, setGroupId } = useProfileStore();
const pairCode = useStore((s) => s.pairCode); const pairCode = useStore((s) => s.pairCode);
const error = useStore((s) => s.error);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [mode, setMode] = useState<"show" | "enter">("show"); const [mode, setMode] = useState<"show" | "enter">("show");
const [inputCode, setInputCode] = useState(""); const [inputCode, setInputCode] = useState("");
const [pairError, setPairError] = useState<string | null>(null);
const handleShow = () => { // When modal opens in "show" mode, always request a fresh code
let gid = groupId; useEffect(() => {
if (!gid) { if (showModal && mode === "show") {
gid = crypto.randomUUID(); let gid = groupId;
setGroupId(gid); if (!gid) {
gid = crypto.randomUUID();
setGroupId(gid);
}
useStore.getState().setPairCode(null);
onRequestCode(gid);
} }
onRequestCode(gid); }, [showModal, mode]);
setMode("show");
setShowModal(true);
};
const handleEnter = () => { // Detect pair-code-not-found errors
setMode("enter"); useEffect(() => {
if (error && error.includes("appairage")) {
setPairError(error);
useStore.getState().setError(null);
}
}, [error]);
const handleClose = () => {
setShowModal(false);
useStore.getState().setPairCode(null);
setPairError(null);
setInputCode(""); setInputCode("");
setShowModal(true);
}; };
const handleSubmitCode = () => { const handleSubmitCode = () => {
if (inputCode.length >= 6) { if (inputCode.length >= 6) {
setPairError(null);
onResolveCode(inputCode); onResolveCode(inputCode);
setShowModal(false); // Don't close — wait for response or error
} }
}; };
// Auto-close on successful pairing (groupId changed after resolve)
const currentGroupId = useProfileStore((s) => s.groupId);
useEffect(() => {
if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) {
handleClose();
}
}, [currentGroupId]);
return ( return (
<> <>
<button <button
onClick={handleShow} onClick={() => { setMode("show"); setShowModal(true); }}
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
@ -57,7 +79,7 @@ export default function DevicePairingPanel({
{showModal && ( {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={() => setShowModal(false)} onClick={handleClose}
> >
<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
@ -67,14 +89,14 @@ export default function DevicePairingPanel({
{/* Tab switcher */} {/* Tab switcher */}
<div className="flex gap-2 bg-slate-800 rounded-xl p-1"> <div className="flex gap-2 bg-slate-800 rounded-xl p-1">
<button <button
onClick={() => { setMode("show"); if (!pairCode && groupId) onRequestCode(groupId); }} onClick={() => setMode("show")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors 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"}`} ${mode === "show" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
> >
Mon code Mon code
</button> </button>
<button <button
onClick={() => setMode("enter")} onClick={() => { setMode("enter"); setPairError(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors 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"}`} ${mode === "enter" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
> >
@ -93,7 +115,9 @@ export default function DevicePairingPanel({
{pairCode} {pairCode}
</p> </p>
) : ( ) : (
<p className="text-slate-500 text-sm">Génération du code...</p> <div className="flex justify-center py-2">
<div className="w-6 h-6 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
</div>
)} )}
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
@ -110,7 +134,10 @@ export default function DevicePairingPanel({
<input <input
type="text" type="text"
value={inputCode} value={inputCode}
onChange={(e) => setInputCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} onChange={(e) => {
setInputCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""));
setPairError(null);
}}
placeholder="ABC123" placeholder="ABC123"
maxLength={6} maxLength={6}
autoFocus autoFocus
@ -122,6 +149,10 @@ export default function DevicePairingPanel({
}} }}
/> />
{pairError && (
<p className="text-sm text-red-400">{pairError}</p>
)}
<button <button
onClick={handleSubmitCode} onClick={handleSubmitCode}
disabled={inputCode.length < 6} disabled={inputCode.length < 6}
@ -135,7 +166,7 @@ export default function DevicePairingPanel({
)} )}
<button <button
onClick={() => setShowModal(false)} onClick={handleClose}
className="text-sm text-slate-500 hover:text-slate-300 transition-colors" className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
> >
Fermer Fermer

View File

@ -13,7 +13,7 @@ import {
createTransferResponse, createTransferResponse,
createFileReceiver, createFileReceiver,
} from "../lib/fileTransfer"; } from "../lib/fileTransfer";
import { setupPushNotifications, showLocalNotification } from "../lib/notifications"; import { setupPushNotifications } from "../lib/notifications";
import { detectLocalIP } from "../lib/localIP"; import { detectLocalIP } from "../lib/localIP";
import { import {
saveTransferMeta, saveTransferMeta,
@ -68,6 +68,7 @@ export function useSignaling(joinCode?: string) {
); );
if (active) { if (active) {
e.preventDefault(); e.preventDefault();
e.returnValue = "Des transferts sont en cours";
} }
}; };
window.addEventListener("beforeunload", handler); window.addEventListener("beforeunload", handler);
@ -184,6 +185,7 @@ export function useSignaling(joinCode?: string) {
t.status === "pending", t.status === "pending",
); );
if (transfer) { if (transfer) {
updateTransfer(transfer.id, { status: "transferring" });
sendFile(peer, file, transfer.id, (progress) => { sendFile(peer, file, transfer.id, (progress) => {
updateTransfer(transfer.id, { updateTransfer(transfer.id, {
progress, progress,
@ -227,10 +229,6 @@ export function useSignaling(joinCode?: string) {
deviceType: msg.deviceType, deviceType: msg.deviceType,
avatar: msg.avatar, avatar: msg.avatar,
}); });
showLocalNotification(
"AnyDrop",
`${msg.displayName} est à proximité`,
);
break; break;
case "peer-left": case "peer-left":
removePeer(msg.peerId); removePeer(msg.peerId);
@ -355,15 +353,6 @@ export function useSignaling(joinCode?: string) {
files: msg.files, files: msg.files,
text: msg.text, text: msg.text,
}); });
const fileCount = msg.files?.length || 0;
const label =
fileCount > 1
? `${fileCount} fichiers`
: msg.files?.[0]?.name || "un fichier";
showLocalNotification(
"Transfert entrant",
`${peerInfo?.displayName || "Quelqu'un"} veut vous envoyer ${label}`,
);
return; return;
} }
if (msg.type === "transfer-response") { if (msg.type === "transfer-response") {
@ -383,10 +372,6 @@ export function useSignaling(joinCode?: string) {
files: [], files: [],
text: msg.content, text: msg.content,
}); });
showLocalNotification(
"Texte reçu",
`${peerInfo?.displayName || "Quelqu'un"} vous a envoyé du texte`,
);
return; return;
} }
} catch { } catch {
@ -411,6 +396,7 @@ export function useSignaling(joinCode?: string) {
cancelled = true; cancelled = true;
signalingRef.current?.disconnect(); signalingRef.current?.disconnect();
peerManagerRef.current?.closeAll(); peerManagerRef.current?.closeAll();
pendingFilesRef.current.clear();
setConnected(false); setConnected(false);
}; };
}, [joinCode]); }, [joinCode]);

View File

@ -175,7 +175,7 @@ export function createFileReceiver(callbacks: FileReceiver) {
}, },
/** Restore a partially received file (from IndexedDB after refresh) */ /** Restore a partially received file (from IndexedDB after refresh) */
restoreReceiving( async restoreReceiving(
id: string, id: string,
name: string, name: string,
size: number, size: number,
@ -183,19 +183,14 @@ export function createFileReceiver(callbacks: FileReceiver) {
receivedBytes: number, receivedBytes: number,
existingBlob: Blob, existingBlob: Blob,
) { ) {
const buf = await existingBlob.arrayBuffer();
receiving.set(id, { receiving.set(id, {
name, name,
size, size,
mime, mime,
chunks: [new Uint8Array(0)], // placeholder, actual data is in existingBlob chunks: [new Uint8Array(buf)],
received: receivedBytes, received: receivedBytes,
}); });
// Replace chunks array with the existing blob as first entry
const entry = receiving.get(id)!;
entry.chunks = [];
existingBlob.arrayBuffer().then((buf) => {
entry.chunks.push(new Uint8Array(buf));
});
}, },
}; };
} }

View File

@ -1,35 +1,35 @@
import type { SignalingClient } from "./signaling"; import type { SignalingClient } from "./signaling";
let pushSetupDone = false;
/** /**
* Request notification permission and subscribe to Web Push. * Request notification permission and subscribe to Web Push.
* Sends the push subscription to the signaling server. * Sends the push subscription to the signaling server.
* Only runs once per session.
*/ */
export async function setupPushNotifications( export async function setupPushNotifications(
vapidPublicKey: string, vapidPublicKey: string,
deviceId: string, deviceId: string,
signaling: SignalingClient, signaling: SignalingClient,
): Promise<void> { ): Promise<void> {
// Check support if (pushSetupDone) return;
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) { if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("[push] Push notifications not supported");
return; return;
} }
// Request permission
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
if (permission !== "granted") { if (permission !== "granted") {
console.log("[push] Notification permission denied");
return; return;
} }
pushSetupDone = true;
try { try {
const registration = await navigator.serviceWorker.ready; const registration = await navigator.serviceWorker.ready;
// Check for existing subscription
let subscription = await registration.pushManager.getSubscription(); let subscription = await registration.pushManager.getSubscription();
if (!subscription) { if (!subscription) {
// Convert VAPID key from base64url to Uint8Array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
@ -37,33 +37,17 @@ export async function setupPushNotifications(
}); });
} }
// Send subscription to server
signaling.send({ signaling.send({
type: "subscribe-push", type: "subscribe-push",
deviceId, deviceId,
subscription: subscription.toJSON() as any, subscription: subscription.toJSON() as any,
}); });
console.log("[push] Push subscription registered");
} catch (err) { } catch (err) {
console.error("[push] Failed to subscribe:", err); console.error("[push] Failed to subscribe:", err);
pushSetupDone = false;
} }
} }
/**
* Show a local notification (for when tab is in background but page is still loaded).
*/
export function showLocalNotification(title: string, body: string): void {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible") return; // Don't notify if tab is focused
new Notification(title, {
body,
icon: "/icon-192.png",
tag: "anydrop-transfer",
});
}
function urlBase64ToUint8Array(base64String: string): Uint8Array { function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");

View File

@ -43,8 +43,9 @@ function HomeConnected() {
// Check if this is an offline peer // Check if this is an offline peer
const peer = peers.find((p) => p.peerId === peerId); const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) { if (peer && peer.online === false && peer.deviceId) {
wakePeer(peer.deviceId); setSelectedPeerId(peerId);
setWakingDeviceId(peer.deviceId); setWakingDeviceId(peer.deviceId);
wakePeer(peer.deviceId);
return; return;
} }
setSelectedPeerId(selectedPeerId === peerId ? null : peerId); setSelectedPeerId(selectedPeerId === peerId ? null : peerId);

View File

@ -81,6 +81,7 @@ function ShareConnected() {
} }
setSent(true); setSent(true);
setSelectedPeerId(null);
}, },
[shared, sent, sendFiles, sendText, setSelectedPeerId], [shared, sent, sendFiles, sendText, setSelectedPeerId],
); );