diff --git a/src/hooks/useFileTransfer.ts b/src/hooks/useFileTransfer.ts index c2b8ea9..9e45a17 100644 --- a/src/hooks/useFileTransfer.ts +++ b/src/hooks/useFileTransfer.ts @@ -24,7 +24,8 @@ export type TransferStatus = export type TransferMode = 'send' | 'receive'; const HIGH_WATER = 8 * 1024 * 1024; // pause sending above this bufferedAmount -const RECONNECT_MS = 1500; +const RECONNECT_MS = 1200; +const DISCONNECT_GRACE_MS = 5000; // let a transient ICE 'disconnected' self-heal first export function useFileTransfer() { const signalRef = useRef(null); @@ -33,6 +34,7 @@ export function useFileTransfer() { const channelRef = useRef(null); const modeRef = useRef('send'); const iceRef = useRef(undefined); + const pendingFileRef = useRef(null); // selected before the channel is open const recvRef = useRef<{ meta: TransferMeta | null; chunks: ArrayBuffer[]; received: number }>({ meta: null, chunks: [], @@ -42,9 +44,12 @@ export function useFileTransfer() { // Auto-mode reconnection bookkeeping. const reconnectRef = useRef<{ mode: TransferMode; roomId: string } | null>(null); const reconnectTimerRef = useRef | null>(null); + const disconnectTimerRef = useRef | null>(null); const stoppedRef = useRef(false); // set once the room is full or on reset/unmount const connectedRef = useRef(false); // true once the P2P data channel is open + const failuresRef = useRef(0); // consecutive re-establish attempts (capped) const openSignalingRef = useRef<() => void>(() => {}); + const handleFailureRef = useRef<() => void>(() => {}); const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); @@ -53,14 +58,15 @@ export function useFileTransfer() { const [receivedBlob, setReceivedBlob] = useState(null); const clearReconnect = () => { - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } + }; + const clearDisconnect = () => { + if (disconnectTimerRef.current) { clearTimeout(disconnectTimerRef.current); disconnectTimerRef.current = null; } }; const cleanup = useCallback(() => { clearReconnect(); + clearDisconnect(); channelRef.current?.close(); peerRef.current?.close(); manualRef.current?.close(); @@ -72,17 +78,42 @@ export function useFileTransfer() { recvRef.current = { meta: null, chunks: [], received: 0 }; }, []); - const handleState = useCallback((state: RTCPeerConnectionState) => { - if (state === 'connected') connectedRef.current = true; - if (state === 'failed' || state === 'disconnected') { - // A P2P drop after we were connected is terminal; before, let signaling retry. - if (connectedRef.current) { - setError('The peer-to-peer connection dropped.'); - setStatus('error'); + const sendFile = useCallback(async (file: File) => { + const channel = channelRef.current; + if (!channel || channel.readyState !== 'open') return; + setStatus('transferring'); + setProgress(0); + channel.bufferedAmountLowThreshold = 1024 * 1024; + channel.send(encodeMeta({ name: file.name, size: file.size, mime: file.type || 'application/octet-stream' })); + + const total = chunkCount(file.size); + for (let i = 0; i < total; i++) { + const [start, end] = chunkRange(i, file.size); + const buf = await file.slice(start, end).arrayBuffer(); + channel.send(buf); + setProgress(percent(end, file.size)); + if (channel.bufferedAmount > HIGH_WATER) { + await new Promise(resolve => { + const onLow = () => { channel.removeEventListener('bufferedamountlow', onLow); resolve(); }; + channel.addEventListener('bufferedamountlow', onLow); + }); } } + setProgress(100); + setStatus('done'); }, []); + // Send a file the moment the channel is open (queued if picked earlier). + // pendingFileRef is cleared on send, so this can't double-send. + const flushPending = useCallback(() => { + const ch = channelRef.current; + if (modeRef.current === 'send' && pendingFileRef.current && ch && ch.readyState === 'open') { + const f = pendingFileRef.current; + pendingFileRef.current = null; + void sendFile(f); + } + }, [sendFile]); + const handleRecv = useCallback((data: string | ArrayBuffer) => { const r = recvRef.current; if (typeof data === 'string') { @@ -115,25 +146,45 @@ export function useFileTransfer() { } const markOpen = () => { connectedRef.current = true; + failuresRef.current = 0; clearReconnect(); - setStatus('connected'); + clearDisconnect(); + // Once P2P is up, signaling is no longer needed — close it to avoid churn. + signalRef.current?.close(); + signalRef.current = null; + setStatus(s => (s === 'transferring' || s === 'done' ? s : 'connected')); + flushPending(); }; channel.onopen = markOpen; - channel.onclose = () => { /* transfer completion is driven by byte count */ }; if (channel.readyState === 'open') markOpen(); - }, [handleRecv]); + }, [handleRecv, flushPending]); const setupPeer = useCallback((initiator: boolean) => { - if (peerRef.current || !signalRef.current) return; + // Replace any stale peer (e.g. re-negotiating after a drop). + peerRef.current?.close(); + peerRef.current = null; const signal = signalRef.current; + if (!signal) return; peerRef.current = createPeer({ initiator, iceServers: iceRef.current, sendSignal: msg => signal.send(msg), - onState: handleState, + onState: state => { + if (state === 'connected') { + connectedRef.current = true; + clearDisconnect(); + } else if (state === 'disconnected') { + // Transient (e.g. sender backgrounded to pick a file). Let it self-heal, + // then re-establish if it doesn't. + clearDisconnect(); + disconnectTimerRef.current = setTimeout(() => handleFailureRef.current(), DISCONNECT_GRACE_MS); + } else if (state === 'failed') { + handleFailureRef.current(); + } + }, onChannel: wireChannel, }); - }, [wireChannel, handleState]); + }, [wireChannel]); const scheduleReconnect = useCallback(() => { if (stoppedRef.current || connectedRef.current || !reconnectRef.current) return; @@ -145,29 +196,56 @@ export function useFileTransfer() { }, RECONNECT_MS); }, []); + // A hard drop: tear the peer down and re-establish through signaling (auto mode) + // or surface an error (manual mode has no server to renegotiate through). + const MAX_FAILURES = 4; + const handleFailure = useCallback(() => { + if (stoppedRef.current) return; + clearDisconnect(); + connectedRef.current = false; + peerRef.current?.close(); + peerRef.current = null; + failuresRef.current += 1; + if (!reconnectRef.current) { + // Manual mode: no server to renegotiate through. + setError('The connection was lost. Please start over.'); + setStatus('error'); + } else if (failuresRef.current > MAX_FAILURES) { + setError('Couldn’t keep a stable connection on this network. Try again, or add your own TURN server in Advanced settings.'); + setStatus('error'); + } else { + setStatus(s => (s === 'done' ? s : 'connecting')); + scheduleReconnect(); + } + }, [scheduleReconnect]); + useEffect(() => { handleFailureRef.current = handleFailure; }, [handleFailure]); + // (Re)open the signaling socket for the current auto-mode room. const openSignaling = useCallback(() => { const info = reconnectRef.current; if (!info || stoppedRef.current || connectedRef.current) return; - // Close only the stale socket/peer; keep transfer state. - peerRef.current?.close(); - peerRef.current = null; signalRef.current?.close(); signalRef.current = null; signalRef.current = connectSignal(info.roomId, { onMessage: msg => { + if (connectedRef.current) { + // Already connected over P2P — ignore signaling churn except a hard failure path. + if (msg.type === 'offer' || msg.type === 'answer' || msg.type === 'ice') peerRef.current?.applySignal(msg); + return; + } switch (msg.type) { case 'welcome': - if (msg.role === 'guest') setupPeer(false); - else setStatus('waiting'); + if (msg.role === 'guest') setupPeer(false); // will answer the host's offer + else setStatus('waiting'); // host waits for the guest to (re)join break; case 'peer-joined': - setupPeer(true); + setupPeer(true); // (re)start the offer for the peer that just joined break; case 'peer-left': - // Only meaningful before the P2P channel is up; after, ignore. - if (!connectedRef.current) { peerRef.current?.close(); peerRef.current = null; setStatus('waiting'); } + peerRef.current?.close(); + peerRef.current = null; + setStatus('waiting'); break; case 'full': stoppedRef.current = true; @@ -185,7 +263,6 @@ export function useFileTransfer() { onError: () => { if (!connectedRef.current && !stoppedRef.current) scheduleReconnect(); }, }); }, [setupPeer, scheduleReconnect]); - useEffect(() => { openSignalingRef.current = openSignaling; }, [openSignaling]); const connect = useCallback((mode: TransferMode, roomId: string, iceServers?: RTCIceServer[]) => { @@ -195,6 +272,7 @@ export function useFileTransfer() { reconnectRef.current = { mode, roomId }; stoppedRef.current = false; connectedRef.current = false; + failuresRef.current = 0; setError(''); setProgress(0); setReceivedBlob(null); @@ -204,7 +282,7 @@ export function useFileTransfer() { }, [cleanup, openSignaling]); // Reconnect signaling as soon as the tab returns to the foreground (e.g. after - // switching apps to share the link) — until the P2P channel is established. + // switching apps to share the link, or opening the file picker). useEffect(() => { const onVisible = () => { if (document.visibilityState === 'visible' && reconnectRef.current && !connectedRef.current && !stoppedRef.current) { @@ -215,11 +293,18 @@ export function useFileTransfer() { return () => document.removeEventListener('visibilitychange', onVisible); }, [scheduleReconnect]); + // Queue a file to send; sends immediately if already connected, else on connect. + const queueFile = useCallback((file: File) => { + pendingFileRef.current = file; + const ch = channelRef.current; + if (ch && ch.readyState === 'open') flushPending(); + }, [flushPending]); + // --- Manual signaling (serverless copy-paste) --- const manualCreateOffer = useCallback(async (iceServers?: RTCIceServer[]): Promise => { cleanup(); - stoppedRef.current = true; // no auto-reconnect in manual mode - reconnectRef.current = null; + stoppedRef.current = false; + reconnectRef.current = null; // no auto-reconnect in manual mode connectedRef.current = false; modeRef.current = 'send'; setError(''); @@ -227,12 +312,17 @@ export function useFileTransfer() { setReceivedBlob(null); setIncoming(null); setStatus('connecting'); - const conn = createManualConnection({ initiator: true, iceServers, onState: handleState, onChannel: wireChannel }); + const conn = createManualConnection({ + initiator: true, + iceServers, + onState: s => { if (s === 'connected') connectedRef.current = true; else if (s === 'failed') handleFailureRef.current(); }, + onChannel: wireChannel, + }); manualRef.current = conn; const code = await conn.createOfferCode(); setStatus('waiting'); return code; - }, [cleanup, wireChannel, handleState]); + }, [cleanup, wireChannel]); const manualAcceptAnswer = useCallback(async (answerCode: string): Promise => { await manualRef.current?.acceptAnswer(answerCode); @@ -240,7 +330,7 @@ export function useFileTransfer() { const manualAcceptOffer = useCallback(async (offerCode: string, iceServers?: RTCIceServer[]): Promise => { cleanup(); - stoppedRef.current = true; + stoppedRef.current = false; reconnectRef.current = null; connectedRef.current = false; modeRef.current = 'receive'; @@ -249,42 +339,23 @@ export function useFileTransfer() { setReceivedBlob(null); setIncoming(null); setStatus('connecting'); - const conn = createManualConnection({ initiator: false, iceServers, onState: handleState, onChannel: wireChannel }); + const conn = createManualConnection({ + initiator: false, + iceServers, + onState: s => { if (s === 'connected') connectedRef.current = true; else if (s === 'failed') handleFailureRef.current(); }, + onChannel: wireChannel, + }); manualRef.current = conn; const answer = await conn.acceptOfferReturnAnswer(offerCode); setStatus('waiting'); return answer; - }, [cleanup, wireChannel, handleState]); - - const sendFile = useCallback(async (file: File) => { - const channel = channelRef.current; - if (!channel || channel.readyState !== 'open') return; - setStatus('transferring'); - setProgress(0); - channel.bufferedAmountLowThreshold = 1024 * 1024; - channel.send(encodeMeta({ name: file.name, size: file.size, mime: file.type || 'application/octet-stream' })); - - const total = chunkCount(file.size); - for (let i = 0; i < total; i++) { - const [start, end] = chunkRange(i, file.size); - const buf = await file.slice(start, end).arrayBuffer(); - channel.send(buf); - setProgress(percent(end, file.size)); - if (channel.bufferedAmount > HIGH_WATER) { - await new Promise(resolve => { - const onLow = () => { channel.removeEventListener('bufferedamountlow', onLow); resolve(); }; - channel.addEventListener('bufferedamountlow', onLow); - }); - } - } - setProgress(100); - setStatus('done'); - }, []); + }, [cleanup, wireChannel]); const reset = useCallback(() => { stoppedRef.current = true; reconnectRef.current = null; connectedRef.current = false; + pendingFileRef.current = null; cleanup(); setStatus('idle'); setError(''); @@ -305,6 +376,7 @@ export function useFileTransfer() { manualCreateOffer, manualAcceptAnswer, manualAcceptOffer, + queueFile, sendFile, reset, CHUNK_SIZE, diff --git a/src/islands/files/FileTransfer.tsx b/src/islands/files/FileTransfer.tsx index a22c04a..94deab3 100644 --- a/src/islands/files/FileTransfer.tsx +++ b/src/islands/files/FileTransfer.tsx @@ -37,6 +37,7 @@ export default function FileTransfer() { const [pastedOffer, setPastedOffer] = useState(''); const [manualBusy, setManualBusy] = useState(false); const [manualErr, setManualErr] = useState(''); + const [queuedName, setQueuedName] = useState(''); const sentName = useRef(''); @@ -81,7 +82,7 @@ export default function FileTransfer() { const onDrop = (files: File[]) => { const f = files[0]; - if (f) { sentName.current = f.name; t.sendFile(f); } + if (f) { sentName.current = f.name; setQueuedName(f.name); t.queueFile(f); } }; const downloadReceived = () => { @@ -280,15 +281,24 @@ export default function FileTransfer() { )} {t.error && {t.error}} - {isSending && (t.status === 'connected' || t.status === 'done') && ( - -
-

- Drop a file to send -

-

or click to browse · sent directly to the other device

-
-
+ {isSending && (t.status === 'connecting' || t.status === 'waiting' || t.status === 'connected') && ( +
+ +
+

+ {queuedName ? 'Choose a different file' : 'Pick a file to send'} +

+

+ {t.status === 'connected' + ? 'Sent directly to the other device.' + : 'Pick it now — it sends automatically once the other device connects.'} +

+
+
+ {queuedName && (t.status === 'connecting' || t.status === 'waiting') && ( +

Ready to send: {queuedName} — waiting for the other device…

+ )} +
)} {(t.status === 'transferring' || (t.status === 'done' && isSending)) && (