fix: pairing flow + code review bugfixes + remove notifications
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
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:
parent
b407e6ce95
commit
dd37b49ce4
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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));
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, "/");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -81,6 +81,7 @@ function ShareConnected() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSent(true);
|
setSent(true);
|
||||||
|
setSelectedPeerId(null);
|
||||||
},
|
},
|
||||||
[shared, sent, sendFiles, sendText, setSelectedPeerId],
|
[shared, sent, sendFiles, sendText, setSelectedPeerId],
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user