From 6c49b6b5528093a41b57a04a70a571286fa995bc Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 09:40:54 +0700 Subject: [PATCH] feat(webrtc): Network category + user-chosen ICE servers & serverless manual mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the server fully optional and user-chosen for the P2P tools: - New 'Network' category (cyan) with a note explaining the limited/optional server involvement; move P2P File Transfer into it. (Re-applies the category change whose earlier push didn't land before the #63 merge.) - Custom STUN/TURN: users can enter their own ICE servers (ice.lib parseIceConfig / effectiveIceServers, tested); defaults to public STUN. A user TURN server fixes strict-NAT connections. - Manual (serverless) signaling: copy-paste offer/answer codes (manual-sdp.lib base64 encode/decode, tested; manual.ts non-trickle connection) — NO signaling server used at all. Automatic link mode remains the default. - Settings persisted to localStorage; Advanced panel in the tool. peer.ts now accepts custom iceServers. useFileTransfer supports both signaling modes. Durable Object relay verified end-to-end via local wrangler dev (roles, peer-joined, offer/answer relay, room-capacity). 539 tests, lint clean, build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/ToolGrid.tsx | 5 +- src/hooks/useFileTransfer.ts | 78 +++++++- src/islands/files/FileTransfer.tsx | 241 +++++++++++++++++++++--- src/registry/categories.ts | 13 ++ src/registry/tools.ts | 2 +- src/tools/webrtc/ice.lib.test.ts | 43 +++++ src/tools/webrtc/ice.lib.ts | 55 ++++++ src/tools/webrtc/manual-sdp.lib.test.ts | 23 +++ src/tools/webrtc/manual-sdp.lib.ts | 47 +++++ src/tools/webrtc/manual.ts | 83 ++++++++ src/tools/webrtc/peer.ts | 10 +- src/types/tool.ts | 2 +- 12 files changed, 559 insertions(+), 43 deletions(-) create mode 100644 src/tools/webrtc/ice.lib.test.ts create mode 100644 src/tools/webrtc/ice.lib.ts create mode 100644 src/tools/webrtc/manual-sdp.lib.test.ts create mode 100644 src/tools/webrtc/manual-sdp.lib.ts create mode 100644 src/tools/webrtc/manual.ts diff --git a/src/components/ToolGrid.tsx b/src/components/ToolGrid.tsx index 75c9326..c67a027 100644 --- a/src/components/ToolGrid.tsx +++ b/src/components/ToolGrid.tsx @@ -1,5 +1,5 @@ import { tools } from '@/registry/tools'; -import { categories, categoryColors } from '@/registry/categories'; +import { categories, categoryColors, categoryNotes } from '@/registry/categories'; /** * Static tool grid grouped by category. Rendered without a client directive so @@ -25,6 +25,9 @@ export function ToolGrid() { ({categoryTools.filter(tool => !tool.desktopOnly).length}) + {categoryNotes[category] && ( +

{categoryNotes[category]}

+ )}
{categoryTools.map(tool => { const Icon = tool.icon; diff --git a/src/hooks/useFileTransfer.ts b/src/hooks/useFileTransfer.ts index 8948d1d..554252b 100644 --- a/src/hooks/useFileTransfer.ts +++ b/src/hooks/useFileTransfer.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { connectSignal, type SignalClient } from '@/tools/webrtc/signal-client'; import { createPeer, type PeerHandles } from '@/tools/webrtc/peer'; +import { createManualConnection, type ManualConnection } from '@/tools/webrtc/manual'; import { CHUNK_SIZE, chunkCount, @@ -27,8 +28,10 @@ const HIGH_WATER = 8 * 1024 * 1024; // pause sending above this bufferedAmount export function useFileTransfer() { const signalRef = useRef(null); const peerRef = useRef(null); + const manualRef = useRef(null); const channelRef = useRef(null); const modeRef = useRef('send'); + const iceRef = useRef(undefined); const recvRef = useRef<{ meta: TransferMeta | null; chunks: ArrayBuffer[]; received: number }>({ meta: null, chunks: [], @@ -44,13 +47,22 @@ export function useFileTransfer() { const cleanup = useCallback(() => { channelRef.current?.close(); peerRef.current?.close(); + manualRef.current?.close(); signalRef.current?.close(); channelRef.current = null; peerRef.current = null; + manualRef.current = null; signalRef.current = null; recvRef.current = { meta: null, chunks: [], received: 0 }; }, []); + const handleState = useCallback((state: RTCPeerConnectionState) => { + if (state === 'failed' || state === 'disconnected') { + setError('Could not connect — the network may be too restrictive (no relay server).'); + setStatus('error'); + } + }, []); + const handleRecv = useCallback((data: string | ArrayBuffer) => { const r = recvRef.current; if (typeof data === 'string') { @@ -92,20 +104,18 @@ export function useFileTransfer() { const signal = signalRef.current; peerRef.current = createPeer({ initiator, + iceServers: iceRef.current, sendSignal: msg => signal.send(msg), - onState: state => { - if (state === 'failed' || state === 'disconnected') { - setError('Could not connect — the network may be too restrictive (no relay server).'); - setStatus('error'); - } - }, + onState: handleState, onChannel: wireChannel, }); - }, [wireChannel]); + }, [wireChannel, handleState]); - const connect = useCallback((mode: TransferMode, roomId: string) => { + // --- Automatic signaling (via our server) --- + const connect = useCallback((mode: TransferMode, roomId: string, iceServers?: RTCIceServer[]) => { cleanup(); modeRef.current = mode; + iceRef.current = iceServers; setError(''); setProgress(0); setReceivedBlob(null); @@ -120,7 +130,7 @@ export function useFileTransfer() { else setStatus('waiting'); break; case 'peer-joined': - setupPeer(true); // host starts the offer + setupPeer(true); break; case 'peer-left': setError('The other device disconnected.'); @@ -141,6 +151,41 @@ export function useFileTransfer() { }); }, [cleanup, setupPeer]); + // --- Manual signaling (serverless copy-paste) --- + const manualCreateOffer = useCallback(async (iceServers?: RTCIceServer[]): Promise => { + cleanup(); + modeRef.current = 'send'; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + const conn = createManualConnection({ initiator: true, iceServers, onState: handleState, onChannel: wireChannel }); + manualRef.current = conn; + const code = await conn.createOfferCode(); + setStatus('waiting'); + return code; + }, [cleanup, wireChannel, handleState]); + + const manualAcceptAnswer = useCallback(async (answerCode: string): Promise => { + await manualRef.current?.acceptAnswer(answerCode); + }, []); + + const manualAcceptOffer = useCallback(async (offerCode: string, iceServers?: RTCIceServer[]): Promise => { + cleanup(); + modeRef.current = 'receive'; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + const conn = createManualConnection({ initiator: false, iceServers, onState: handleState, 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; @@ -177,5 +222,18 @@ export function useFileTransfer() { useEffect(() => () => cleanup(), [cleanup]); - return { status, error, progress, incoming, receivedBlob, connect, sendFile, reset, CHUNK_SIZE }; + return { + status, + error, + progress, + incoming, + receivedBlob, + connect, + manualCreateOffer, + manualAcceptAnswer, + manualAcceptOffer, + sendFile, + reset, + CHUNK_SIZE, + }; } diff --git a/src/islands/files/FileTransfer.tsx b/src/islands/files/FileTransfer.tsx index f91a01d..a22c04a 100644 --- a/src/islands/files/FileTransfer.tsx +++ b/src/islands/files/FileTransfer.tsx @@ -1,40 +1,82 @@ import { useEffect, useRef, useState } from 'react'; -import { Send, Download, ShieldCheck } from 'lucide-react'; +import { Send, Download, ShieldCheck, Settings } from 'lucide-react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { ProgressBar } from '@/components/ui/ProgressBar'; import { CopyButton } from '@/components/ui/CopyButton'; import { downloadService } from '@/services/download'; -import { useFileTransfer, type TransferMode } from '@/hooks/useFileTransfer'; +import { useFileTransfer } from '@/hooks/useFileTransfer'; import { makeRoomId, roomLink, roomIdFromHash } from '@/tools/webrtc/signal.lib'; import { formatBytes } from '@/tools/webrtc/file-transfer.lib'; +import { effectiveIceServers } from '@/tools/webrtc/ice.lib'; + +type Signaling = 'auto' | 'manual'; +type ManualRole = 'send' | 'receive'; + +const ICE_KEY = 'gwt.webrtc.ice'; +const SIGNALING_KEY = 'gwt.webrtc.signaling'; export default function FileTransfer() { const t = useFileTransfer(); const [acked, setAcked] = useState(false); - const [mode, setMode] = useState('send'); + const [signaling, setSignaling] = useState('auto'); + const [iceText, setIceText] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Auto mode + const [joining, setJoining] = useState(false); // arrived via a shared link → receiver const [roomId, setRoomId] = useState(''); const [link, setLink] = useState(''); + + // Manual mode + const [manualRole, setManualRole] = useState(null); + const [offerCode, setOfferCode] = useState(''); + const [answerCode, setAnswerCode] = useState(''); + const [pastedAnswer, setPastedAnswer] = useState(''); + const [pastedOffer, setPastedOffer] = useState(''); + const [manualBusy, setManualBusy] = useState(false); + const [manualErr, setManualErr] = useState(''); + const sentName = useRef(''); - // Decide role from the URL hash: a room id in the hash means we're receiving. + // Restore saved settings + decide role from the URL hash. useEffect(() => { + try { + setIceText(localStorage.getItem(ICE_KEY) ?? ''); + const savedSig = localStorage.getItem(SIGNALING_KEY); + if (savedSig === 'manual' || savedSig === 'auto') setSignaling(savedSig); + } catch { /* ignore */ } + const fromHash = roomIdFromHash(window.location.hash); if (fromHash) { - setMode('receive'); + setJoining(true); + setSignaling('auto'); // a shared link is always automatic signaling setRoomId(fromHash); } else { const id = makeRoomId(); - setMode('send'); setRoomId(id); setLink(roomLink(window.location.origin, id)); } }, []); + const persistIce = (text: string) => { + setIceText(text); + try { localStorage.setItem(ICE_KEY, text); } catch { /* ignore */ } + }; + const persistSignaling = (s: Signaling) => { + setSignaling(s); + try { localStorage.setItem(SIGNALING_KEY, s); } catch { /* ignore */ } + }; + + const ice = () => effectiveIceServers(iceText); + const start = () => { setAcked(true); - t.connect(mode, roomId); + if (signaling === 'auto') { + t.connect(joining ? 'receive' : 'send', roomId, ice()); + } + // manual: wait for the user to pick a role }; const onDrop = (files: File[]) => { @@ -46,7 +88,42 @@ export default function FileTransfer() { if (t.receivedBlob && t.incoming) downloadService.download(t.receivedBlob, t.incoming.name); }; - // --- Ack gate: nothing connects until the user agrees. --- + // Manual: sender + const chooseSend = async () => { + setManualRole('send'); + setManualErr(''); + setManualBusy(true); + try { + setOfferCode(await t.manualCreateOffer(ice())); + } catch (e) { + setManualErr(e instanceof Error ? e.message : 'Could not create the offer.'); + } finally { + setManualBusy(false); + } + }; + const submitAnswer = async () => { + setManualErr(''); + try { await t.manualAcceptAnswer(pastedAnswer.trim()); } + catch (e) { setManualErr(e instanceof Error ? e.message : 'Invalid answer code.'); } + }; + + // Manual: receiver + const chooseReceive = () => { setManualRole('receive'); setManualErr(''); }; + const submitOffer = async () => { + setManualErr(''); + setManualBusy(true); + try { + setAnswerCode(await t.manualAcceptOffer(pastedOffer.trim(), ice())); + } catch (e) { + setManualErr(e instanceof Error ? e.message : 'Invalid offer code.'); + } finally { + setManualBusy(false); + } + }; + + const isSending = signaling === 'auto' ? !joining : manualRole === 'send'; + + // ---------- Ack + settings gate ---------- if (!acked) { return (
@@ -55,11 +132,59 @@ export default function FileTransfer() { Before you connect

- To introduce your two devices, GoodWebTools uses a small signaling server to - exchange connection details (about 2 KB). Your files transfer directly, - peer-to-peer, and never pass through our server. Connections are best-effort and may - fail on very restrictive networks (no relay server is used). + {signaling === 'auto' ? ( + <>To introduce your two devices, GoodWebTools uses a small signaling server to + exchange connection details (about 2 KB). Your files transfer directly, + peer-to-peer, and never pass through our server. + ) : ( + <>Manual mode uses no server at all. You'll copy-paste a connection code to the + other person yourself. Files transfer directly, peer-to-peer. + )}{' '} + Connections are best-effort and may fail on restrictive networks unless you add your own TURN server.

+ + + + {showAdvanced && ( +
+ {!joining && ( +
+ Connection method +
+ + +
+
+ )} +