feat: transfer resume after page refresh
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
Transfers now survive page refreshes: - beforeunload warning prevents accidental refresh during transfer - Sender's files cached in IndexedDB before sending - Receiver's chunks persisted in IndexedDB as they arrive - On reconnect, receiver sends transfer-resume with bytes received - Sender resumes from offset, skipping already-sent data - Old transfer data auto-cleaned after 1 hour Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d6f7a2374b
commit
612222ccde
@ -141,12 +141,18 @@ export interface TransferResponseMessage {
|
|||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransferResumeMessage {
|
||||||
|
type: "transfer-resume";
|
||||||
|
files: { id: string; bytesReceived: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export type DataChannelMessage =
|
export type DataChannelMessage =
|
||||||
| FileMetaMessage
|
| FileMetaMessage
|
||||||
| FileEndMessage
|
| FileEndMessage
|
||||||
| TextMessage
|
| TextMessage
|
||||||
| TransferRequestMessage
|
| TransferRequestMessage
|
||||||
| TransferResponseMessage;
|
| TransferResponseMessage
|
||||||
|
| TransferResumeMessage;
|
||||||
|
|
||||||
// ── Push notification payload ──
|
// ── Push notification payload ──
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import type { ServerMessage, TransferRequestMessage, TransferResponseMessage } from "@anydrop/shared";
|
import type {
|
||||||
|
ServerMessage,
|
||||||
|
TransferRequestMessage,
|
||||||
|
TransferResponseMessage,
|
||||||
|
TransferResumeMessage,
|
||||||
|
} from "@anydrop/shared";
|
||||||
import { SignalingClient } from "../lib/signaling";
|
import { SignalingClient } from "../lib/signaling";
|
||||||
import { PeerManager } from "../lib/peerManager";
|
import { PeerManager } from "../lib/peerManager";
|
||||||
import {
|
import {
|
||||||
@ -10,6 +15,17 @@ import {
|
|||||||
} from "../lib/fileTransfer";
|
} from "../lib/fileTransfer";
|
||||||
import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
|
import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
|
||||||
import { detectLocalIP } from "../lib/localIP";
|
import { detectLocalIP } from "../lib/localIP";
|
||||||
|
import {
|
||||||
|
saveTransferMeta,
|
||||||
|
updateTransferBytes,
|
||||||
|
cacheSendFile,
|
||||||
|
getCachedSendFile,
|
||||||
|
getAllPendingTransfers,
|
||||||
|
getReceivedData,
|
||||||
|
appendReceivedChunk,
|
||||||
|
cleanupTransfer,
|
||||||
|
cleanupOldTransfers,
|
||||||
|
} from "../lib/transferStore";
|
||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
import { useProfileStore } from "../stores/useProfileStore";
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
|
|
||||||
@ -27,7 +43,7 @@ function downloadBlob(blob: Blob, fileName: string) {
|
|||||||
export function useSignaling(joinCode?: string) {
|
export function useSignaling(joinCode?: string) {
|
||||||
const signalingRef = useRef<SignalingClient | null>(null);
|
const signalingRef = useRef<SignalingClient | null>(null);
|
||||||
const peerManagerRef = useRef<PeerManager | null>(null);
|
const peerManagerRef = useRef<PeerManager | null>(null);
|
||||||
const pendingFilesRef = useRef<Map<string, { files: File[]; text?: string }>>(new Map());
|
const pendingFilesRef = useRef<Map<string, { files: File[] }>>(new Map());
|
||||||
const fileReceiverRef = useRef<ReturnType<typeof createFileReceiver> | null>(null);
|
const fileReceiverRef = useRef<ReturnType<typeof createFileReceiver> | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -43,16 +59,42 @@ export function useSignaling(joinCode?: string) {
|
|||||||
setError,
|
setError,
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
|
// ── beforeunload: warn during active transfers ──
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
const { transfers } = useStore.getState();
|
||||||
|
const active = transfers.some(
|
||||||
|
(t) => t.status === "transferring" || t.status === "pending",
|
||||||
|
);
|
||||||
|
if (active) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handler);
|
||||||
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Cleanup old transfers on mount ──
|
||||||
|
useEffect(() => {
|
||||||
|
cleanupOldTransfers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fileReceiverRef.current = createFileReceiver({
|
fileReceiverRef.current = createFileReceiver({
|
||||||
onData: () => {},
|
onData: () => {},
|
||||||
onComplete: (fileId, blob, fileName) => {
|
onComplete: (fileId, blob, fileName) => {
|
||||||
updateTransfer(fileId, { progress: 1, status: "done" });
|
updateTransfer(fileId, { progress: 1, status: "done" });
|
||||||
|
cleanupTransfer(fileId);
|
||||||
downloadBlob(blob, fileName);
|
downloadBlob(blob, fileName);
|
||||||
},
|
},
|
||||||
onProgress: (fileId, progress) => {
|
onProgress: (fileId, progress) => {
|
||||||
updateTransfer(fileId, { progress, status: "transferring" });
|
updateTransfer(fileId, { progress, status: "transferring" });
|
||||||
},
|
},
|
||||||
|
onChunkReceived: (fileId, chunk, totalReceived) => {
|
||||||
|
// Persist to IndexedDB for resume after refresh (fire-and-forget)
|
||||||
|
appendReceivedChunk(fileId, chunk).catch(() => {});
|
||||||
|
updateTransferBytes(fileId, totalReceived).catch(() => {});
|
||||||
|
},
|
||||||
onTransferRequest: (msg: TransferRequestMessage) => {
|
onTransferRequest: (msg: TransferRequestMessage) => {
|
||||||
setIncomingRequest({
|
setIncomingRequest({
|
||||||
peerId: "",
|
peerId: "",
|
||||||
@ -69,10 +111,15 @@ export function useSignaling(joinCode?: string) {
|
|||||||
for (const transfer of store.transfers) {
|
for (const transfer of store.transfers) {
|
||||||
if (transfer.direction === "send" && transfer.status === "pending") {
|
if (transfer.direction === "send" && transfer.status === "pending") {
|
||||||
updateTransfer(transfer.id, { status: "error" });
|
updateTransfer(transfer.id, { status: "error" });
|
||||||
|
cleanupTransfer(transfer.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onTransferResume: (msg: TransferResumeMessage) => {
|
||||||
|
// Peer is asking us to resume sending from an offset
|
||||||
|
handleResumeRequest(msg);
|
||||||
|
},
|
||||||
onText: (text) => {
|
onText: (text) => {
|
||||||
setIncomingRequest({
|
setIncomingRequest({
|
||||||
peerId: "",
|
peerId: "",
|
||||||
@ -84,6 +131,43 @@ export function useSignaling(joinCode?: string) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleResumeRequest = useCallback(
|
||||||
|
async (msg: TransferResumeMessage) => {
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) return;
|
||||||
|
|
||||||
|
for (const { id, bytesReceived } of msg.files) {
|
||||||
|
const cachedFile = await getCachedSendFile(id);
|
||||||
|
if (!cachedFile) continue;
|
||||||
|
|
||||||
|
// Find which peer to send to from the stored metadata
|
||||||
|
const meta = await import("../lib/transferStore").then((m) =>
|
||||||
|
m.getTransferMeta(id),
|
||||||
|
);
|
||||||
|
if (!meta) continue;
|
||||||
|
|
||||||
|
const peer = pm.getPeer(meta.peerId);
|
||||||
|
if (!peer) continue;
|
||||||
|
|
||||||
|
updateTransfer(id, {
|
||||||
|
progress: bytesReceived / meta.fileSize,
|
||||||
|
status: "transferring",
|
||||||
|
});
|
||||||
|
|
||||||
|
sendFile(peer, cachedFile, id, (progress) => {
|
||||||
|
updateTransfer(id, {
|
||||||
|
progress,
|
||||||
|
status: progress >= 1 ? "done" : "transferring",
|
||||||
|
});
|
||||||
|
if (progress >= 1) {
|
||||||
|
cleanupTransfer(id);
|
||||||
|
}
|
||||||
|
}, bytesReceived);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTransfer],
|
||||||
|
);
|
||||||
|
|
||||||
const startSending = useCallback(() => {
|
const startSending = useCallback(() => {
|
||||||
const store = useStore.getState();
|
const store = useStore.getState();
|
||||||
for (const [peerId, { files }] of pendingFilesRef.current) {
|
for (const [peerId, { files }] of pendingFilesRef.current) {
|
||||||
@ -94,7 +178,10 @@ export function useSignaling(joinCode?: string) {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const transfer = store.transfers.find(
|
const transfer = store.transfers.find(
|
||||||
(t) => t.direction === "send" && t.fileName === file.name && t.status === "pending",
|
(t) =>
|
||||||
|
t.direction === "send" &&
|
||||||
|
t.fileName === file.name &&
|
||||||
|
t.status === "pending",
|
||||||
);
|
);
|
||||||
if (transfer) {
|
if (transfer) {
|
||||||
sendFile(peer, file, transfer.id, (progress) => {
|
sendFile(peer, file, transfer.id, (progress) => {
|
||||||
@ -102,6 +189,9 @@ export function useSignaling(joinCode?: string) {
|
|||||||
progress,
|
progress,
|
||||||
status: progress >= 1 ? "done" : "transferring",
|
status: progress >= 1 ? "done" : "transferring",
|
||||||
});
|
});
|
||||||
|
if (progress >= 1) {
|
||||||
|
cleanupTransfer(transfer.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,10 +209,16 @@ export function useSignaling(joinCode?: string) {
|
|||||||
setConnection(msg.peerId, msg.roomId);
|
setConnection(msg.peerId, msg.roomId);
|
||||||
setPeers(msg.peers);
|
setPeers(msg.peers);
|
||||||
|
|
||||||
// Subscribe to push notifications if server provides VAPID key
|
|
||||||
if (msg.vapidPublicKey && signalingRef.current) {
|
if (msg.vapidPublicKey && signalingRef.current) {
|
||||||
setupPushNotifications(msg.vapidPublicKey, profile.deviceId, signalingRef.current);
|
setupPushNotifications(
|
||||||
|
msg.vapidPublicKey,
|
||||||
|
profile.deviceId,
|
||||||
|
signalingRef.current,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for pending transfers to resume
|
||||||
|
checkPendingResumes();
|
||||||
break;
|
break;
|
||||||
case "peer-joined":
|
case "peer-joined":
|
||||||
addPeer({
|
addPeer({
|
||||||
@ -131,8 +227,10 @@ export function useSignaling(joinCode?: string) {
|
|||||||
deviceType: msg.deviceType,
|
deviceType: msg.deviceType,
|
||||||
avatar: msg.avatar,
|
avatar: msg.avatar,
|
||||||
});
|
});
|
||||||
// Notify if tab is in background
|
showLocalNotification(
|
||||||
showLocalNotification("AnyDrop", `${msg.displayName} est à proximité`);
|
"AnyDrop",
|
||||||
|
`${msg.displayName} est à proximité`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "peer-left":
|
case "peer-left":
|
||||||
removePeer(msg.peerId);
|
removePeer(msg.peerId);
|
||||||
@ -150,6 +248,62 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkPendingResumes = async () => {
|
||||||
|
const pending = await getAllPendingTransfers();
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
for (const meta of pending) {
|
||||||
|
// Restore transfer in UI
|
||||||
|
addTransfer({
|
||||||
|
id: meta.id,
|
||||||
|
peerId: meta.peerId,
|
||||||
|
fileName: meta.fileName,
|
||||||
|
fileSize: meta.fileSize,
|
||||||
|
mime: meta.mime,
|
||||||
|
direction: meta.direction,
|
||||||
|
progress: meta.bytesTransferred / meta.fileSize,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (meta.direction === "receive") {
|
||||||
|
// We were receiving — need to tell the sender to resume
|
||||||
|
const existingBlob = await getReceivedData(meta.id);
|
||||||
|
const bytesReceived = existingBlob?.size ?? 0;
|
||||||
|
|
||||||
|
if (bytesReceived > 0) {
|
||||||
|
// Restore partial data in file receiver
|
||||||
|
fileReceiverRef.current?.restoreReceiving(
|
||||||
|
meta.id,
|
||||||
|
meta.fileName,
|
||||||
|
meta.fileSize,
|
||||||
|
meta.mime,
|
||||||
|
bytesReceived,
|
||||||
|
existingBlob!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we reconnect with this peer, send a resume request
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
if (pm) {
|
||||||
|
const sendResumeWhenReady = () => {
|
||||||
|
const peer = pm.getPeer(meta.peerId);
|
||||||
|
if (peer && (peer as any).connected) {
|
||||||
|
const resumeMsg: TransferResumeMessage = {
|
||||||
|
type: "transfer-resume",
|
||||||
|
files: [{ id: meta.id, bytesReceived }],
|
||||||
|
};
|
||||||
|
peer.send(JSON.stringify(resumeMsg));
|
||||||
|
} else {
|
||||||
|
setTimeout(sendResumeWhenReady, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Delay to let peer connection establish
|
||||||
|
setTimeout(sendResumeWhenReady, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const localIP = await detectLocalIP().catch(() => undefined);
|
const localIP = await detectLocalIP().catch(() => undefined);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -190,7 +344,10 @@ export function useSignaling(joinCode?: string) {
|
|||||||
text: msg.text,
|
text: msg.text,
|
||||||
});
|
});
|
||||||
const fileCount = msg.files?.length || 0;
|
const fileCount = msg.files?.length || 0;
|
||||||
const label = fileCount > 1 ? `${fileCount} fichiers` : msg.files?.[0]?.name || "un fichier";
|
const label =
|
||||||
|
fileCount > 1
|
||||||
|
? `${fileCount} fichiers`
|
||||||
|
: msg.files?.[0]?.name || "un fichier";
|
||||||
showLocalNotification(
|
showLocalNotification(
|
||||||
"Transfert entrant",
|
"Transfert entrant",
|
||||||
`${peerInfo?.displayName || "Quelqu'un"} veut vous envoyer ${label}`,
|
`${peerInfo?.displayName || "Quelqu'un"} veut vous envoyer ${label}`,
|
||||||
@ -203,6 +360,10 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === "transfer-resume") {
|
||||||
|
handleResumeRequest(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === "text") {
|
if (msg.type === "text") {
|
||||||
setIncomingRequest({
|
setIncomingRequest({
|
||||||
peerId,
|
peerId,
|
||||||
@ -242,48 +403,68 @@ export function useSignaling(joinCode?: string) {
|
|||||||
};
|
};
|
||||||
}, [joinCode]);
|
}, [joinCode]);
|
||||||
|
|
||||||
const sendFiles = useCallback((peerId: string, files: File[]) => {
|
const sendFiles = useCallback(
|
||||||
const pm = peerManagerRef.current;
|
async (peerId: string, files: File[]) => {
|
||||||
const signaling = signalingRef.current;
|
const pm = peerManagerRef.current;
|
||||||
if (!pm || !signaling) return;
|
const signaling = signalingRef.current;
|
||||||
|
if (!pm || !signaling) return;
|
||||||
|
|
||||||
let peer = pm.getPeer(peerId);
|
let peer = pm.getPeer(peerId);
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
peer = pm.createPeer(peerId, true);
|
peer = pm.createPeer(peerId, true);
|
||||||
}
|
|
||||||
|
|
||||||
const request = createTransferRequest(files);
|
|
||||||
|
|
||||||
for (const fileMeta of request.files) {
|
|
||||||
addTransfer({
|
|
||||||
id: fileMeta.id,
|
|
||||||
peerId,
|
|
||||||
fileName: fileMeta.name,
|
|
||||||
fileSize: fileMeta.size,
|
|
||||||
mime: fileMeta.mime,
|
|
||||||
direction: "send",
|
|
||||||
progress: 0,
|
|
||||||
status: "pending",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingFilesRef.current.set(peerId, { files });
|
|
||||||
|
|
||||||
const sendRequest = () => {
|
|
||||||
const p = pm.getPeer(peerId);
|
|
||||||
if (p && (p as any)._channel?.readyState === "open") {
|
|
||||||
p.send(JSON.stringify(request));
|
|
||||||
} else {
|
|
||||||
setTimeout(sendRequest, 100);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (peer && (peer as any).connected) {
|
const request = createTransferRequest(files);
|
||||||
sendRequest();
|
const store = useStore.getState();
|
||||||
} else {
|
const peerInfo = store.peers.find((p) => p.peerId === peerId);
|
||||||
peer.on("connect", sendRequest);
|
|
||||||
}
|
for (let i = 0; i < request.files.length; i++) {
|
||||||
}, [addTransfer]);
|
const fileMeta = request.files[i];
|
||||||
|
addTransfer({
|
||||||
|
id: fileMeta.id,
|
||||||
|
peerId,
|
||||||
|
fileName: fileMeta.name,
|
||||||
|
fileSize: fileMeta.size,
|
||||||
|
mime: fileMeta.mime,
|
||||||
|
direction: "send",
|
||||||
|
progress: 0,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist file and metadata for resume
|
||||||
|
await cacheSendFile(fileMeta.id, files[i]);
|
||||||
|
await saveTransferMeta({
|
||||||
|
id: fileMeta.id,
|
||||||
|
peerId,
|
||||||
|
peerDisplayName: peerInfo?.displayName || peerId,
|
||||||
|
fileName: fileMeta.name,
|
||||||
|
fileSize: fileMeta.size,
|
||||||
|
mime: fileMeta.mime,
|
||||||
|
direction: "send",
|
||||||
|
bytesTransferred: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFilesRef.current.set(peerId, { files });
|
||||||
|
|
||||||
|
const sendRequest = () => {
|
||||||
|
const p = pm.getPeer(peerId);
|
||||||
|
if (p && (p as any)._channel?.readyState === "open") {
|
||||||
|
p.send(JSON.stringify(request));
|
||||||
|
} else {
|
||||||
|
setTimeout(sendRequest, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (peer && (peer as any).connected) {
|
||||||
|
sendRequest();
|
||||||
|
} else {
|
||||||
|
peer.on("connect", sendRequest);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addTransfer],
|
||||||
|
);
|
||||||
|
|
||||||
const sendText = useCallback((peerId: string, text: string) => {
|
const sendText = useCallback((peerId: string, text: string) => {
|
||||||
const pm = peerManagerRef.current;
|
const pm = peerManagerRef.current;
|
||||||
@ -297,7 +478,9 @@ export function useSignaling(joinCode?: string) {
|
|||||||
const sendMsg = () => {
|
const sendMsg = () => {
|
||||||
const p = pm.getPeer(peerId);
|
const p = pm.getPeer(peerId);
|
||||||
if (p) {
|
if (p) {
|
||||||
p.send(JSON.stringify({ type: "text", id: `t-${Date.now()}`, content: text }));
|
p.send(
|
||||||
|
JSON.stringify({ type: "text", id: `t-${Date.now()}`, content: text }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -308,44 +491,64 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const acceptTransfer = useCallback((peerId: string) => {
|
const acceptTransfer = useCallback(
|
||||||
const pm = peerManagerRef.current;
|
async (peerId: string) => {
|
||||||
if (!pm) return;
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) return;
|
||||||
|
|
||||||
const peer = pm.getPeer(peerId);
|
const peer = pm.getPeer(peerId);
|
||||||
if (!peer) return;
|
if (!peer) return;
|
||||||
|
|
||||||
const store = useStore.getState();
|
const store = useStore.getState();
|
||||||
const request = store.incomingRequest;
|
const request = store.incomingRequest;
|
||||||
if (request) {
|
if (request) {
|
||||||
for (const file of request.files) {
|
const peerInfo = store.peers.find((p) => p.peerId === peerId);
|
||||||
addTransfer({
|
for (const file of request.files) {
|
||||||
id: file.id,
|
addTransfer({
|
||||||
peerId,
|
id: file.id,
|
||||||
fileName: file.name,
|
peerId,
|
||||||
fileSize: file.size,
|
fileName: file.name,
|
||||||
mime: file.mime,
|
fileSize: file.size,
|
||||||
direction: "receive",
|
mime: file.mime,
|
||||||
progress: 0,
|
direction: "receive",
|
||||||
status: "pending",
|
progress: 0,
|
||||||
});
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist receive metadata for resume
|
||||||
|
await saveTransferMeta({
|
||||||
|
id: file.id,
|
||||||
|
peerId,
|
||||||
|
peerDisplayName: peerInfo?.displayName || peerId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
mime: file.mime,
|
||||||
|
direction: "receive",
|
||||||
|
bytesTransferred: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
peer.send(JSON.stringify(createTransferResponse(true)));
|
peer.send(JSON.stringify(createTransferResponse(true)));
|
||||||
setIncomingRequest(null);
|
setIncomingRequest(null);
|
||||||
}, [addTransfer, setIncomingRequest]);
|
},
|
||||||
|
[addTransfer, setIncomingRequest],
|
||||||
|
);
|
||||||
|
|
||||||
const rejectTransfer = useCallback((peerId: string) => {
|
const rejectTransfer = useCallback(
|
||||||
const pm = peerManagerRef.current;
|
(peerId: string) => {
|
||||||
if (!pm) return;
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) return;
|
||||||
|
|
||||||
const peer = pm.getPeer(peerId);
|
const peer = pm.getPeer(peerId);
|
||||||
if (peer) {
|
if (peer) {
|
||||||
peer.send(JSON.stringify(createTransferResponse(false)));
|
peer.send(JSON.stringify(createTransferResponse(false)));
|
||||||
}
|
}
|
||||||
setIncomingRequest(null);
|
setIncomingRequest(null);
|
||||||
}, [setIncomingRequest]);
|
},
|
||||||
|
[setIncomingRequest],
|
||||||
|
);
|
||||||
|
|
||||||
const createPublicRoom = useCallback(() => {
|
const createPublicRoom = useCallback(() => {
|
||||||
signalingRef.current?.send({ type: "create-public-room" });
|
signalingRef.current?.send({ type: "create-public-room" });
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
type FileEndMessage,
|
type FileEndMessage,
|
||||||
type TransferRequestMessage,
|
type TransferRequestMessage,
|
||||||
type TransferResponseMessage,
|
type TransferResponseMessage,
|
||||||
|
type TransferResumeMessage,
|
||||||
type DataChannelMessage,
|
type DataChannelMessage,
|
||||||
} from "@anydrop/shared";
|
} from "@anydrop/shared";
|
||||||
|
|
||||||
@ -34,6 +35,7 @@ export async function sendFile(
|
|||||||
file: File,
|
file: File,
|
||||||
fileId: string,
|
fileId: string,
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
|
resumeOffset = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Send file metadata
|
// Send file metadata
|
||||||
const meta: FileMetaMessage = {
|
const meta: FileMetaMessage = {
|
||||||
@ -45,9 +47,10 @@ export async function sendFile(
|
|||||||
};
|
};
|
||||||
peer.send(JSON.stringify(meta));
|
peer.send(JSON.stringify(meta));
|
||||||
|
|
||||||
// Stream file in chunks
|
// If resuming, slice the file to skip already-sent data
|
||||||
let offset = 0;
|
const blob = resumeOffset > 0 ? file.slice(resumeOffset) : file;
|
||||||
const reader = file.stream().getReader();
|
let offset = resumeOffset;
|
||||||
|
const reader = blob.stream().getReader();
|
||||||
|
|
||||||
let buffer = new Uint8Array(0);
|
let buffer = new Uint8Array(0);
|
||||||
|
|
||||||
@ -55,18 +58,15 @@ export async function sendFile(
|
|||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
// Append to buffer
|
|
||||||
const newBuffer = new Uint8Array(buffer.length + value.length);
|
const newBuffer = new Uint8Array(buffer.length + value.length);
|
||||||
newBuffer.set(buffer);
|
newBuffer.set(buffer);
|
||||||
newBuffer.set(value, buffer.length);
|
newBuffer.set(value, buffer.length);
|
||||||
buffer = newBuffer;
|
buffer = newBuffer;
|
||||||
|
|
||||||
// Send complete chunks from buffer
|
|
||||||
while (buffer.length >= CHUNK_SIZE) {
|
while (buffer.length >= CHUNK_SIZE) {
|
||||||
const chunk = buffer.slice(0, CHUNK_SIZE);
|
const chunk = buffer.slice(0, CHUNK_SIZE);
|
||||||
buffer = buffer.slice(CHUNK_SIZE);
|
buffer = buffer.slice(CHUNK_SIZE);
|
||||||
|
|
||||||
// Backpressure: wait if buffer is full
|
|
||||||
await waitForDrain(peer);
|
await waitForDrain(peer);
|
||||||
|
|
||||||
peer.send(chunk);
|
peer.send(chunk);
|
||||||
@ -75,7 +75,6 @@ export async function sendFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send remaining bytes
|
|
||||||
if (buffer.length > 0) {
|
if (buffer.length > 0) {
|
||||||
await waitForDrain(peer);
|
await waitForDrain(peer);
|
||||||
peer.send(buffer);
|
peer.send(buffer);
|
||||||
@ -83,7 +82,6 @@ export async function sendFile(
|
|||||||
onProgress(offset / file.size);
|
onProgress(offset / file.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send end-of-file marker
|
|
||||||
const eof: FileEndMessage = { type: "file-end", id: fileId };
|
const eof: FileEndMessage = { type: "file-end", id: fileId };
|
||||||
peer.send(JSON.stringify(eof));
|
peer.send(JSON.stringify(eof));
|
||||||
onProgress(1);
|
onProgress(1);
|
||||||
@ -107,8 +105,10 @@ export interface FileReceiver {
|
|||||||
onData: (data: Uint8Array | string) => void;
|
onData: (data: Uint8Array | string) => void;
|
||||||
onComplete: (fileId: string, blob: Blob, fileName: string) => void;
|
onComplete: (fileId: string, blob: Blob, fileName: string) => void;
|
||||||
onProgress: (fileId: string, progress: number) => void;
|
onProgress: (fileId: string, progress: number) => void;
|
||||||
|
onChunkReceived?: (fileId: string, chunk: Uint8Array, totalReceived: number) => void;
|
||||||
onTransferRequest: (msg: TransferRequestMessage) => void;
|
onTransferRequest: (msg: TransferRequestMessage) => void;
|
||||||
onTransferResponse: (msg: TransferResponseMessage) => void;
|
onTransferResponse: (msg: TransferResponseMessage) => void;
|
||||||
|
onTransferResume: (msg: TransferResumeMessage) => void;
|
||||||
onText: (text: string) => void;
|
onText: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +120,6 @@ export function createFileReceiver(callbacks: FileReceiver) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
handleData(data: Uint8Array | string) {
|
handleData(data: Uint8Array | string) {
|
||||||
// Try to parse as JSON control message
|
|
||||||
if (typeof data === "string") {
|
if (typeof data === "string") {
|
||||||
try {
|
try {
|
||||||
const msg: DataChannelMessage = JSON.parse(data);
|
const msg: DataChannelMessage = JSON.parse(data);
|
||||||
@ -152,6 +151,9 @@ export function createFileReceiver(callbacks: FileReceiver) {
|
|||||||
case "transfer-response":
|
case "transfer-response":
|
||||||
callbacks.onTransferResponse(msg);
|
callbacks.onTransferResponse(msg);
|
||||||
break;
|
break;
|
||||||
|
case "transfer-resume":
|
||||||
|
callbacks.onTransferResume(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not JSON, ignore
|
// Not JSON, ignore
|
||||||
@ -159,15 +161,40 @@ export function createFileReceiver(callbacks: FileReceiver) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary data = file chunk
|
// Binary data = file chunk — route to first active receiving file
|
||||||
// Find which file we're receiving (first active one)
|
|
||||||
for (const [id, file] of receiving) {
|
for (const [id, file] of receiving) {
|
||||||
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||||
file.chunks.push(chunk);
|
file.chunks.push(chunk);
|
||||||
file.received += chunk.length;
|
file.received += chunk.length;
|
||||||
callbacks.onProgress(id, file.received / file.size);
|
callbacks.onProgress(id, file.received / file.size);
|
||||||
|
// Persist chunk for resume (fire-and-forget)
|
||||||
|
callbacks.onChunkReceived?.(id, chunk, file.received);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Restore a partially received file (from IndexedDB after refresh) */
|
||||||
|
restoreReceiving(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
size: number,
|
||||||
|
mime: string,
|
||||||
|
receivedBytes: number,
|
||||||
|
existingBlob: Blob,
|
||||||
|
) {
|
||||||
|
receiving.set(id, {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
mime,
|
||||||
|
chunks: [new Uint8Array(0)], // placeholder, actual data is in existingBlob
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
193
web/src/lib/transferStore.ts
Normal file
193
web/src/lib/transferStore.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB persistence for transfer state + data.
|
||||||
|
* Allows transfers to resume after page refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DB_NAME = "anydrop-transfers";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_META = "meta";
|
||||||
|
const STORE_CHUNKS = "chunks";
|
||||||
|
const STORE_FILES = "files";
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function getDB(): Promise<IDBDatabase> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_META)) {
|
||||||
|
db.createObjectStore(STORE_META, { keyPath: "id" });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(STORE_CHUNKS)) {
|
||||||
|
// chunks keyed by transferId, stored as one growing blob per transfer
|
||||||
|
db.createObjectStore(STORE_CHUNKS, { keyPath: "transferId" });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(STORE_FILES)) {
|
||||||
|
// sender's files cached for resume
|
||||||
|
db.createObjectStore(STORE_FILES, { keyPath: "transferId" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tx(
|
||||||
|
storeName: string,
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
): Promise<IDBObjectStore> {
|
||||||
|
return getDB().then(
|
||||||
|
(db) => db.transaction(storeName, mode).objectStore(storeName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transfer metadata ──
|
||||||
|
|
||||||
|
export interface TransferMeta {
|
||||||
|
id: string;
|
||||||
|
peerId: string;
|
||||||
|
peerDisplayName: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mime: string;
|
||||||
|
direction: "send" | "receive";
|
||||||
|
bytesTransferred: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTransferMeta(meta: TransferMeta): Promise<void> {
|
||||||
|
const store = await tx(STORE_META, "readwrite");
|
||||||
|
await reqToPromise(store.put(meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTransferMeta(
|
||||||
|
id: string,
|
||||||
|
): Promise<TransferMeta | null> {
|
||||||
|
const store = await tx(STORE_META, "readonly");
|
||||||
|
return (await reqToPromise(store.get(id))) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllPendingTransfers(): Promise<TransferMeta[]> {
|
||||||
|
const store = await tx(STORE_META, "readonly");
|
||||||
|
const all: TransferMeta[] = await reqToPromise(store.getAll());
|
||||||
|
return all.filter(
|
||||||
|
(m) => m.bytesTransferred < m.fileSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTransferBytes(
|
||||||
|
id: string,
|
||||||
|
bytesTransferred: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const store = await tx(STORE_META, "readwrite");
|
||||||
|
const meta: TransferMeta | undefined = await reqToPromise(store.get(id));
|
||||||
|
if (meta) {
|
||||||
|
meta.bytesTransferred = bytesTransferred;
|
||||||
|
await reqToPromise(store.put(meta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTransferMeta(id: string): Promise<void> {
|
||||||
|
const store = await tx(STORE_META, "readwrite");
|
||||||
|
await reqToPromise(store.delete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Received chunks (receiver side) ──
|
||||||
|
|
||||||
|
export async function saveReceivedData(
|
||||||
|
transferId: string,
|
||||||
|
blob: Blob,
|
||||||
|
): Promise<void> {
|
||||||
|
const store = await tx(STORE_CHUNKS, "readwrite");
|
||||||
|
await reqToPromise(store.put({ transferId, blob }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReceivedData(
|
||||||
|
transferId: string,
|
||||||
|
): Promise<Blob | null> {
|
||||||
|
const store = await tx(STORE_CHUNKS, "readonly");
|
||||||
|
const result = await reqToPromise(store.get(transferId));
|
||||||
|
return result?.blob ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendReceivedChunk(
|
||||||
|
transferId: string,
|
||||||
|
chunk: Uint8Array,
|
||||||
|
): Promise<number> {
|
||||||
|
const store = await tx(STORE_CHUNKS, "readwrite");
|
||||||
|
const existing = await reqToPromise(store.get(transferId));
|
||||||
|
const oldBlob: Blob = existing?.blob ?? new Blob();
|
||||||
|
const newBlob = new Blob([oldBlob, chunk]);
|
||||||
|
await reqToPromise(store.put({ transferId, blob: newBlob }));
|
||||||
|
return newBlob.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReceivedData(transferId: string): Promise<void> {
|
||||||
|
const store = await tx(STORE_CHUNKS, "readwrite");
|
||||||
|
await reqToPromise(store.delete(transferId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sender file cache ──
|
||||||
|
|
||||||
|
export async function cacheSendFile(
|
||||||
|
transferId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<void> {
|
||||||
|
const store = await tx(STORE_FILES, "readwrite");
|
||||||
|
// Store as blob with metadata
|
||||||
|
await reqToPromise(
|
||||||
|
store.put({
|
||||||
|
transferId,
|
||||||
|
blob: file,
|
||||||
|
name: file.name,
|
||||||
|
mime: file.type,
|
||||||
|
size: file.size,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedSendFile(
|
||||||
|
transferId: string,
|
||||||
|
): Promise<File | null> {
|
||||||
|
const store = await tx(STORE_FILES, "readonly");
|
||||||
|
const result = await reqToPromise(store.get(transferId));
|
||||||
|
if (!result) return null;
|
||||||
|
return new File([result.blob], result.name, { type: result.mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCachedSendFile(transferId: string): Promise<void> {
|
||||||
|
const store = await tx(STORE_FILES, "readwrite");
|
||||||
|
await reqToPromise(store.delete(transferId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──
|
||||||
|
|
||||||
|
export async function cleanupTransfer(transferId: string): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
deleteTransferMeta(transferId),
|
||||||
|
deleteReceivedData(transferId),
|
||||||
|
deleteCachedSendFile(transferId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupOldTransfers(maxAgeMs = 3600_000): Promise<void> {
|
||||||
|
const store = await tx(STORE_META, "readonly");
|
||||||
|
const all: TransferMeta[] = await reqToPromise(store.getAll());
|
||||||
|
const cutoff = Date.now() - maxAgeMs;
|
||||||
|
for (const meta of all) {
|
||||||
|
if (meta.createdAt < cutoff) {
|
||||||
|
await cleanupTransfer(meta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user