feat: transfer resume after page refresh
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:
ordinarthur 2026-04-14 12:45:05 +02:00
parent d6f7a2374b
commit 612222ccde
5 changed files with 522 additions and 93 deletions

View File

@ -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

View File

@ -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" });

View File

@ -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));
});
},
}; };
} }

View 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);
}
}
}