From 9565687351eb009d100a4a02894b534415541098 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 09:13:12 +0700 Subject: [PATCH 1/3] docs(file-transfer): P2P file transfer + WebRTC signaling design + plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-08-01-p2p-file-transfer.md | 33 ++++ .../2026-08-01-p2p-file-transfer-design.md | 153 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-p2p-file-transfer.md create mode 100644 docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md diff --git a/docs/superpowers/plans/2026-08-01-p2p-file-transfer.md b/docs/superpowers/plans/2026-08-01-p2p-file-transfer.md new file mode 100644 index 0000000..e997819 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-p2p-file-transfer.md @@ -0,0 +1,33 @@ +# P2P File Transfer — Implementation Plan + +Spec: `docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md`. TDD on pure libs; +server + browser wiring smoke-tested on staging before production. + +## Task 1 — `src/tools/webrtc/signal.lib.ts` (pure) + tests +`SignalRole`, `SignalMessage`, `makeRoomId`, `roomLink`, `roomIdFromHash`, `parseSignal`. + +## Task 2 — `src/tools/webrtc/file-transfer.lib.ts` (pure) + tests +`CHUNK_SIZE`, `TransferMeta`, `chunkCount`, `chunkRange`, `formatBytes`, `percent`, +`encodeMeta`/`decodeMeta`. + +## Task 3 — Server signaling +- `worker/signal-room.js` — `SignalRoom` Durable Object (hibernation WS, 2-peer relay). +- `worker/index.js` — route `/api/signal/*`, re-export `SignalRoom`. +- `wrangler.jsonc` — DO binding + `new_sqlite_classes` migration (top level + staging env). + +## Task 4 — Client transport (browser, smoke) +- `signal-client.ts` (WebSocket wrapper), `peer.ts` (RTCPeerConnection + STUN + negotiation). + +## Task 5 — `useFileTransfer` hook (light tests on pure transitions) +State machine + data-channel send/receive with backpressure; resource cleanup. + +## Task 6 — Island `FileTransfer.tsx` +Ack gate → sender (room link)/receiver (from `#hash`) → connect → transfer + progress → +download. + +## Task 7 — Register + verify +Registry (`file-transfer`, Files, `Send`, beta). `vitest`/`lint`/`build` green. + +## Task 8 — Ship dev → staging → verify DO → promote prod +PR to develop; after staging deploy, run a Node 2-WebSocket signaling smoke test against +`goodwebtools-staging.workers.dev`; only then promote to main. Verify live URL. diff --git a/docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md b/docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md new file mode 100644 index 0000000..30ecac7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-p2p-file-transfer-design.md @@ -0,0 +1,153 @@ +# P2P File Transfer + WebRTC Signaling — Design + +**Date:** 2026-08-01 +**Tool:** Files → P2P File Transfer (`/tools/file-transfer`) — NEW +**Type:** New tool + first server-side component (Durable Object signaling) +**Icon:** `Send` (lucide-react) +**Category:** Files + +This is item 6 of the batch, built **first** because it is the simpler of the two WebRTC +tools (data channel only). It establishes the **shared signaling + peer-connection +infrastructure** that the Video Call (item 5) will reuse. + +## Problem + +Users want to send a file directly to another person's device (like relay.rishishah.in), +peer-to-peer, without the file passing through a server. Browsers can do this with WebRTC +data channels, but two peers must first exchange a small connection handshake — which needs +a **signaling** rendezvous. + +## Goal + +- **File bytes travel peer-to-peer** (WebRTC data channel) and never touch our server. +- A tiny **signaling server** (Cloudflare Durable Object on the existing Worker) relays only + the ~2KB SDP/ICE handshake between the two peers in a room. +- **Mandatory ack before connecting:** the user must acknowledge that connecting uses our + signaling server to introduce the two devices, before any WebSocket opens. +- **STUN-only** (public STUN servers). No TURN → if both peers are behind strict/symmetric + NAT, show a clear "couldn't connect on this network" message. + +## Architecture + +``` +Sender browser ──WS──┐ ┌──WS── Receiver browser + ▼ ▼ + Cloudflare Worker (worker/index.js) + │ /api/signal/ (WebSocket upgrade) + ▼ + Durable Object SignalRoom (relays offer/answer/ICE only) + +Sender ───────────── WebRTC DataChannel (file bytes, P2P) ───────────── Receiver +``` + +### Server (Cloudflare Worker + Durable Object) + +- **`worker/signal-room.js`** — `export class SignalRoom extends DurableObject` + (from `cloudflare:workers`), WebSocket **Hibernation API**: + - `fetch(request)`: on `Upgrade: websocket`, create a `WebSocketPair`, `ctx.acceptWebSocket(server)`. + - Room capacity **2**. If already 2 sockets → `server.send({type:'full'})` then + `close(4001,'full')`. + - First socket → role `host`; second → role `guest`. Send each a + `{type:'welcome', role}` message; when the guest joins, also send the host + `{type:'peer-joined'}` so it starts the WebRTC offer. + - `webSocketMessage(ws, msg)`: **relay** — forward `msg` verbatim to every *other* socket + in `ctx.getWebSockets()`. (This carries `offer` / `answer` / `ice`.) + - `webSocketClose(ws, ...)`: notify the remaining peer `{type:'peer-left'}`. +- **`worker/index.js`** — add, before the `/models/` branch: + ```js + if (url.pathname.startsWith('/api/signal/')) { + if (request.headers.get('Upgrade') !== 'websocket') + return new Response('Expected WebSocket', { status: 426 }); + const roomId = url.pathname.slice('/api/signal/'.length); + if (!/^[a-z0-9]{6,32}$/.test(roomId)) return new Response('Bad room', { status: 400 }); + return env.SIGNAL.getByName(roomId).fetch(request); + } + ``` + Re-export the class: `export { SignalRoom } from './signal-room.js';` +- **`wrangler.jsonc`** — add to BOTH top level and `env.staging` (named envs don't inherit): + ```jsonc + "durable_objects": { "bindings": [ { "name": "SIGNAL", "class_name": "SignalRoom" } ] }, + "migrations": [ { "tag": "v1", "new_sqlite_classes": ["SignalRoom"] } ] + ``` + `new_sqlite_classes` = the free-tier SQLite-backed Durable Object (no extra cost). + +### Client libraries (`src/tools/webrtc/`) + +- **`signal.lib.ts`** (pure, tested): + - `type SignalRole = 'host' | 'guest'` + - `type SignalMessage` — welcome / peer-joined / peer-left / full / offer / answer / ice. + - `makeRoomId(): string` — 10 lowercase-alnum chars from `crypto.getRandomValues`. + - `roomLink(origin: string, roomId: string): string` → `${origin}/tools/file-transfer#${roomId}`. + - `roomIdFromHash(hash: string): string | null` — parse `#` (validates charset). + - `parseSignal(raw: string): SignalMessage | null` — safe JSON parse + shape guard. +- **`file-transfer.lib.ts`** (pure, tested): + - `CHUNK_SIZE = 16 * 1024`. + - `interface TransferMeta { name: string; size: number; mime: string }` + - `chunkCount(size, chunkSize?): number`; `chunkRange(index, size, chunkSize?): [start, end]`. + - `formatBytes(n): string`; `percent(done, total): number`. + - `encodeMeta(meta)/decodeMeta(raw)` for the JSON control message that precedes the bytes. +- **`signal-client.ts`** (browser, smoke): thin `WebSocket` wrapper — connect, `send(msg)`, + `onMessage`, `onClose`; JSON via `signal.lib`. +- **`peer.ts`** (browser, smoke): `createPeer({ initiator, onState, onChannel, sendSignal })` + wrapping `RTCPeerConnection` with public STUN + (`stun:stun.l.google.com:19302`, `stun:stun.cloudflare.com:3478`). Perfect-negotiation-lite: + host creates the data channel + offer on `peer-joined`; guest answers. ICE candidates + flow through `sendSignal`. Exposes `applySignal(msg)`. + +### Hook (`src/hooks/useFileTransfer.ts`, browser, light tests for pure transitions) + +Orchestrates signal-client + peer + data-channel transfer. State machine: +`idle → connecting → waiting-for-peer → connected → transferring → done | error`. +- Sender: on `connected`, user picks a file → send `TransferMeta` (JSON) then chunks with + **backpressure** (`bufferedAmountLowThreshold`, pause when `bufferedAmount` high) → + progress → `done`. +- Receiver: read `TransferMeta`, accumulate `ArrayBuffer` chunks until `size` reached → + assemble `Blob` → expose for download. +- Releases the peer, data channel, and socket on unmount / reset. + +### Island (`src/islands/files/FileTransfer.tsx`, thin) + +1. **Ack gate (required):** first render shows a notice — + > "To connect two devices, GoodWebTools uses a small signaling server to exchange + > connection details (~2KB). Your files transfer **directly, peer-to-peer**, and never + > pass through our server. Connections are best-effort and may fail on very restrictive + > networks." + with a **Continue** button. Nothing connects until Continue is clicked. +2. **Role:** if the URL has `#` → **receiver** (auto-join after ack). Else → + **sender**: generate a room, show the **shareable link** + Copy button + "waiting for the + other device…". +3. On `connected`: sender gets a Dropzone/file picker; on send, a progress bar (bytes + + %). Receiver shows the incoming filename/size + progress, then a Download button + (`downloadService`). +4. Connection failures (ICE failed / peer-left / full room) → `Alert` with the specific + reason. +5. Uses `ProgressBar` (determinate) for transfer progress; plain text for "connecting". + +## Testing + +- `signal.lib.test.ts` — `makeRoomId` (length/charset, two calls differ); `roomLink`; + `roomIdFromHash` (valid `#abc123`, rejects junk/empty); `parseSignal` (valid types, + rejects malformed JSON and unknown shapes). +- `file-transfer.lib.test.ts` — `chunkCount` (exact multiple + remainder + zero); + `chunkRange` (first/middle/last clamps to size); `percent` (0/partial/100, clamps); + `formatBytes`; `encodeMeta`/`decodeMeta` round-trip + rejects bad input. +- `useFileTransfer.test.ts` — reducer/state-transition helper unit-tested with fakes where + practical (mock `RTCPeerConnection`/`WebSocket` minimally); the full media path is + **smoke on staging**. +- **Server/DO + real connection:** verified on **staging** by a Node script that opens two + WebSockets to `/api/signal/` and asserts the relay (welcome/peer-joined/echo of an + offer). This exercises the Durable Object end-to-end before promoting to production. + +## Deploy note (important) + +The Durable Object + migration deploy via the existing **Workers Builds** integration +(wrangler.jsonc). First deploy to **develop → staging** and run the signaling smoke test +against `goodwebtools-staging.workers.dev` BEFORE promoting to production. If the account +can't create DOs, the deploy fails cleanly (no user-facing harm) — surface it and stop. + +## Out of scope (this item) + +- Video call (item 5 — reuses `signal.lib`, `signal-client`, `peer`). +- TURN relay (STUN-only for now). +- Multiple files per transfer / folders (one file at a time; can send another after). +- Resumable transfers, end-to-end encryption beyond WebRTC's built-in DTLS. From 02b8e1b75484695a72bacf3be46b8e42a1c9d893 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 09:21:47 +0700 Subject: [PATCH 2/3] feat(file-transfer): P2P file transfer via WebRTC + DO signaling New Files tool (/tools/file-transfer): send a file directly to another device, peer-to-peer. File bytes travel over a WebRTC data channel and never touch our server; a tiny Durable Object (SignalRoom, free SQLite tier) on the existing Worker relays only the ~2KB SDP/ICE handshake at /api/signal/. - Mandatory ack-before-connect gate. - STUN-only (public STUN); clear failure message on restrictive networks. - Shareable room link; sender picks a file, receiver downloads. Backpressure via bufferedAmountLowThreshold. Robust to either peer connecting first (WebRTC initiator decoupled from app send/receive role). Pure tested logic: signal.lib (room ids, link, message parsing), file-transfer .lib (chunking, meta, progress). Browser transport (signal-client, peer), useFileTransfer hook, thin island. Worker+DO bundle validated via wrangler dry-run. Spec+plan under docs/superpowers/. Shared infra reused by the upcoming video call (item 5). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useFileTransfer.ts | 181 +++++++++++++++++++++ src/islands/files/FileTransfer.tsx | 126 ++++++++++++++ src/registry/tools.ts | 13 +- src/tools/webrtc/file-transfer.lib.test.ts | 61 +++++++ src/tools/webrtc/file-transfer.lib.ts | 66 ++++++++ src/tools/webrtc/peer.ts | 81 +++++++++ src/tools/webrtc/signal-client.ts | 37 +++++ src/tools/webrtc/signal.lib.test.ts | 51 ++++++ src/tools/webrtc/signal.lib.ts | 71 ++++++++ worker/index.js | 17 ++ worker/signal-room.js | 60 +++++++ wrangler.jsonc | 20 +++ 12 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useFileTransfer.ts create mode 100644 src/islands/files/FileTransfer.tsx create mode 100644 src/tools/webrtc/file-transfer.lib.test.ts create mode 100644 src/tools/webrtc/file-transfer.lib.ts create mode 100644 src/tools/webrtc/peer.ts create mode 100644 src/tools/webrtc/signal-client.ts create mode 100644 src/tools/webrtc/signal.lib.test.ts create mode 100644 src/tools/webrtc/signal.lib.ts create mode 100644 worker/signal-room.js diff --git a/src/hooks/useFileTransfer.ts b/src/hooks/useFileTransfer.ts new file mode 100644 index 0000000..8948d1d --- /dev/null +++ b/src/hooks/useFileTransfer.ts @@ -0,0 +1,181 @@ +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 { + CHUNK_SIZE, + chunkCount, + chunkRange, + percent, + encodeMeta, + decodeMeta, + type TransferMeta, +} from '@/tools/webrtc/file-transfer.lib'; + +export type TransferStatus = + | 'idle' + | 'connecting' + | 'waiting' + | 'connected' + | 'transferring' + | 'done' + | 'error'; + +export type TransferMode = 'send' | 'receive'; + +const HIGH_WATER = 8 * 1024 * 1024; // pause sending above this bufferedAmount + +export function useFileTransfer() { + const signalRef = useRef(null); + const peerRef = useRef(null); + const channelRef = useRef(null); + const modeRef = useRef('send'); + const recvRef = useRef<{ meta: TransferMeta | null; chunks: ArrayBuffer[]; received: number }>({ + meta: null, + chunks: [], + received: 0, + }); + + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [progress, setProgress] = useState(0); + const [incoming, setIncoming] = useState<{ name: string; size: number } | null>(null); + const [receivedBlob, setReceivedBlob] = useState(null); + + const cleanup = useCallback(() => { + channelRef.current?.close(); + peerRef.current?.close(); + signalRef.current?.close(); + channelRef.current = null; + peerRef.current = null; + signalRef.current = null; + recvRef.current = { meta: null, chunks: [], received: 0 }; + }, []); + + const handleRecv = useCallback((data: string | ArrayBuffer) => { + const r = recvRef.current; + if (typeof data === 'string') { + const meta = decodeMeta(data); + if (meta) { + r.meta = meta; + r.chunks = []; + r.received = 0; + setIncoming({ name: meta.name, size: meta.size }); + setStatus('transferring'); + setProgress(0); + } + return; + } + if (!r.meta) return; + r.chunks.push(data); + r.received += data.byteLength; + setProgress(percent(r.received, r.meta.size)); + if (r.received >= r.meta.size) { + setReceivedBlob(new Blob(r.chunks, { type: r.meta.mime || 'application/octet-stream' })); + setStatus('done'); + } + }, []); + + const wireChannel = useCallback((channel: RTCDataChannel) => { + channel.binaryType = 'arraybuffer'; + channelRef.current = channel; + if (modeRef.current === 'receive') { + channel.onmessage = e => handleRecv(e.data as string | ArrayBuffer); + } + channel.onopen = () => setStatus('connected'); + channel.onclose = () => { /* transfer completion is driven by byte count */ }; + // A channel arriving via ondatachannel can already be open, so onopen won't fire. + if (channel.readyState === 'open') setStatus('connected'); + }, [handleRecv]); + + const setupPeer = useCallback((initiator: boolean) => { + if (peerRef.current || !signalRef.current) return; + const signal = signalRef.current; + peerRef.current = createPeer({ + initiator, + 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'); + } + }, + onChannel: wireChannel, + }); + }, [wireChannel]); + + const connect = useCallback((mode: TransferMode, roomId: string) => { + cleanup(); + modeRef.current = mode; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + + signalRef.current = connectSignal(roomId, { + onMessage: msg => { + switch (msg.type) { + case 'welcome': + if (msg.role === 'guest') setupPeer(false); + else setStatus('waiting'); + break; + case 'peer-joined': + setupPeer(true); // host starts the offer + break; + case 'peer-left': + setError('The other device disconnected.'); + setStatus('error'); + break; + case 'full': + setError('This transfer room is already full.'); + setStatus('error'); + break; + case 'offer': + case 'answer': + case 'ice': + peerRef.current?.applySignal(msg); + break; + } + }, + onError: () => { setError('Signaling connection failed.'); setStatus('error'); }, + }); + }, [cleanup, setupPeer]); + + 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'); + }, []); + + const reset = useCallback(() => { + cleanup(); + setStatus('idle'); + setError(''); + setProgress(0); + setIncoming(null); + setReceivedBlob(null); + }, [cleanup]); + + useEffect(() => () => cleanup(), [cleanup]); + + return { status, error, progress, incoming, receivedBlob, connect, sendFile, reset, CHUNK_SIZE }; +} diff --git a/src/islands/files/FileTransfer.tsx b/src/islands/files/FileTransfer.tsx new file mode 100644 index 0000000..f91a01d --- /dev/null +++ b/src/islands/files/FileTransfer.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from 'react'; +import { Send, Download, ShieldCheck } 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 { makeRoomId, roomLink, roomIdFromHash } from '@/tools/webrtc/signal.lib'; +import { formatBytes } from '@/tools/webrtc/file-transfer.lib'; + +export default function FileTransfer() { + const t = useFileTransfer(); + const [acked, setAcked] = useState(false); + const [mode, setMode] = useState('send'); + const [roomId, setRoomId] = useState(''); + const [link, setLink] = useState(''); + const sentName = useRef(''); + + // Decide role from the URL hash: a room id in the hash means we're receiving. + useEffect(() => { + const fromHash = roomIdFromHash(window.location.hash); + if (fromHash) { + setMode('receive'); + setRoomId(fromHash); + } else { + const id = makeRoomId(); + setMode('send'); + setRoomId(id); + setLink(roomLink(window.location.origin, id)); + } + }, []); + + const start = () => { + setAcked(true); + t.connect(mode, roomId); + }; + + const onDrop = (files: File[]) => { + const f = files[0]; + if (f) { sentName.current = f.name; t.sendFile(f); } + }; + + const downloadReceived = () => { + if (t.receivedBlob && t.incoming) downloadService.download(t.receivedBlob, t.incoming.name); + }; + + // --- Ack gate: nothing connects until the user agrees. --- + if (!acked) { + return ( +
+
+

+ 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). +

+ +
+
+ ); + } + + const statusLabel: Record = { + connecting: 'Connecting…', + waiting: 'Waiting for the other device to join…', + connected: mode === 'send' ? 'Connected — choose a file to send.' : 'Connected — waiting for a file…', + transferring: 'Transferring…', + done: mode === 'send' ? 'Sent!' : 'Received!', + }; + + return ( +
+ {/* Sender: shareable link */} + {mode === 'send' && (t.status === 'connecting' || t.status === 'waiting') && ( +
+

Share this link with the other device

+
+ {link} + +
+
+ )} + + {t.status !== 'error' && statusLabel[t.status] && ( +

{statusLabel[t.status]}

+ )} + {t.error && {t.error}} + + {/* Sender: file picker once connected */} + {mode === 'send' && (t.status === 'connected' || t.status === 'done') && ( + +
+

+ Drop a file to send +

+

or click to browse · sent directly to the other device

+
+
+ )} + + {/* Transfer progress */} + {(t.status === 'transferring' || (t.status === 'done' && mode === 'send')) && ( + + )} + + {/* Receiver: incoming file + download */} + {mode === 'receive' && t.incoming && ( +
+

{t.incoming.name} · {formatBytes(t.incoming.size)}

+ {t.status === 'transferring' && } + {t.status === 'done' && t.receivedBlob && ( + + )} +
+ )} +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index e766efa..f890d34 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -553,6 +553,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/image/ImageScrub'), status: 'stable' }, + { + id: 'file-transfer', + name: 'P2P File Transfer', + category: 'Files', + route: '/tools/file-transfer', + keywords: ['file', 'transfer', 'send', 'share', 'p2p', 'peer to peer', 'webrtc', 'direct', 'device to device'], + icon: Send, + summary: 'Send a file directly to another device, peer-to-peer', + load: () => import('@/islands/files/FileTransfer'), + status: 'beta' + }, { id: 'file-crypt', name: 'File Encrypt / Decrypt', diff --git a/src/tools/webrtc/file-transfer.lib.test.ts b/src/tools/webrtc/file-transfer.lib.test.ts new file mode 100644 index 0000000..c90f25d --- /dev/null +++ b/src/tools/webrtc/file-transfer.lib.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { + CHUNK_SIZE, + chunkCount, + chunkRange, + formatBytes, + percent, + encodeMeta, + decodeMeta, +} from './file-transfer.lib'; + +describe('chunkCount', () => { + it('handles exact multiples, remainders and zero', () => { + expect(chunkCount(0)).toBe(0); + expect(chunkCount(CHUNK_SIZE)).toBe(1); + expect(chunkCount(CHUNK_SIZE + 1)).toBe(2); + expect(chunkCount(CHUNK_SIZE * 3)).toBe(3); + }); + it('supports a custom chunk size', () => { + expect(chunkCount(10, 4)).toBe(3); + }); +}); + +describe('chunkRange', () => { + it('returns [start,end] clamped to the file size', () => { + expect(chunkRange(0, 10, 4)).toEqual([0, 4]); + expect(chunkRange(1, 10, 4)).toEqual([4, 8]); + expect(chunkRange(2, 10, 4)).toEqual([8, 10]); // last chunk clamps + }); +}); + +describe('percent', () => { + it('computes a clamped 0..100 integer', () => { + expect(percent(0, 100)).toBe(0); + expect(percent(50, 100)).toBe(50); + expect(percent(100, 100)).toBe(100); + expect(percent(5, 0)).toBe(0); // avoid divide-by-zero + expect(percent(200, 100)).toBe(100); // clamp + }); +}); + +describe('formatBytes', () => { + it('formats B / KB / MB', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); + +describe('encodeMeta / decodeMeta', () => { + it('round-trips a transfer meta message', () => { + const meta = { name: 'a.png', size: 1234, mime: 'image/png' }; + const decoded = decodeMeta(encodeMeta(meta)); + expect(decoded).toEqual(meta); + }); + it('rejects malformed control messages', () => { + expect(decodeMeta('not json')).toBeNull(); + expect(decodeMeta(JSON.stringify({ kind: 'nope' }))).toBeNull(); + expect(decodeMeta(JSON.stringify({ kind: 'meta', name: 'x' }))).toBeNull(); // missing size + }); +}); diff --git a/src/tools/webrtc/file-transfer.lib.ts b/src/tools/webrtc/file-transfer.lib.ts new file mode 100644 index 0000000..7db94f0 --- /dev/null +++ b/src/tools/webrtc/file-transfer.lib.ts @@ -0,0 +1,66 @@ +/** + * Pure helpers for the P2P file-transfer data-channel protocol. The wire format is: + * 1. one JSON control message: { kind: 'meta', name, size, mime } + * 2. then `chunkCount(size)` binary ArrayBuffer chunks of CHUNK_SIZE bytes. + */ + +export const CHUNK_SIZE = 16 * 1024; // 16 KB — safe for RTCDataChannel + +export interface TransferMeta { + name: string; + size: number; + mime: string; +} + +/** Number of chunks a file of `size` bytes splits into. */ +export function chunkCount(size: number, chunkSize: number = CHUNK_SIZE): number { + if (size <= 0) return 0; + return Math.ceil(size / chunkSize); +} + +/** Byte range [start, end) for chunk `index`, clamped to `size`. */ +export function chunkRange(index: number, size: number, chunkSize: number = CHUNK_SIZE): [number, number] { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, size); + return [start, end]; +} + +/** Clamped 0..100 integer progress. */ +export function percent(done: number, total: number): number { + if (total <= 0) return 0; + return Math.min(100, Math.max(0, Math.round((done / total) * 100))); +} + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; + +/** Human-readable byte size. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < UNITS.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${UNITS[unit]}`; +} + +/** Encode the leading control message. */ +export function encodeMeta(meta: TransferMeta): string { + return JSON.stringify({ kind: 'meta', name: meta.name, size: meta.size, mime: meta.mime }); +} + +/** Decode + validate the leading control message. Returns null if invalid. */ +export function decodeMeta(raw: string): TransferMeta | null { + let obj: unknown; + try { + obj = JSON.parse(raw); + } catch { + return null; + } + if (!obj || typeof obj !== 'object') return null; + const m = obj as Record; + if (m.kind !== 'meta') return null; + if (typeof m.name !== 'string' || typeof m.size !== 'number' || typeof m.mime !== 'string') return null; + return { name: m.name, size: m.size, mime: m.mime }; +} diff --git a/src/tools/webrtc/peer.ts b/src/tools/webrtc/peer.ts new file mode 100644 index 0000000..e7942a2 --- /dev/null +++ b/src/tools/webrtc/peer.ts @@ -0,0 +1,81 @@ +import type { SignalMessage } from './signal.lib'; + +/** Public STUN only (no TURN). Connections may fail behind strict/symmetric NAT. */ +const ICE_SERVERS: RTCIceServer[] = [ + { urls: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'] }, +]; + +export interface CreatePeerOptions { + initiator: boolean; + sendSignal: (msg: SignalMessage) => void; + onState?: (state: RTCPeerConnectionState) => void; + onChannel?: (channel: RTCDataChannel) => void; +} + +export interface PeerHandles { + pc: RTCPeerConnection; + applySignal(msg: SignalMessage): Promise; + close(): void; +} + +/** + * Wrap an RTCPeerConnection with the offer/answer/ICE plumbing routed through the + * signaling channel. The initiator creates the data channel and offer; the other + * side answers. ICE candidates that arrive before the remote description are queued. + */ +export function createPeer(opts: CreatePeerOptions): PeerHandles { + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + const pendingIce: RTCIceCandidateInit[] = []; + + pc.onicecandidate = e => { + if (e.candidate) opts.sendSignal({ type: 'ice', candidate: e.candidate.toJSON() }); + }; + pc.onconnectionstatechange = () => opts.onState?.(pc.connectionState); + + if (opts.initiator) { + const channel = pc.createDataChannel('data', { ordered: true }); + opts.onChannel?.(channel); + pc.onnegotiationneeded = async () => { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + opts.sendSignal({ type: 'offer', sdp: pc.localDescription }); + } catch { /* surfaced via connection state */ } + }; + } else { + pc.ondatachannel = e => opts.onChannel?.(e.channel); + } + + async function flushIce() { + while (pendingIce.length) { + const c = pendingIce.shift()!; + try { await pc.addIceCandidate(c); } catch { /* ignore */ } + } + } + + async function applySignal(msg: SignalMessage): Promise { + if (msg.type === 'offer') { + await pc.setRemoteDescription(msg.sdp as RTCSessionDescriptionInit); + await flushIce(); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + opts.sendSignal({ type: 'answer', sdp: pc.localDescription }); + } else if (msg.type === 'answer') { + await pc.setRemoteDescription(msg.sdp as RTCSessionDescriptionInit); + await flushIce(); + } else if (msg.type === 'ice') { + const candidate = msg.candidate as RTCIceCandidateInit; + if (pc.remoteDescription) { + try { await pc.addIceCandidate(candidate); } catch { /* ignore */ } + } else { + pendingIce.push(candidate); + } + } + } + + return { + pc, + applySignal, + close() { try { pc.close(); } catch { /* ignore */ } }, + }; +} diff --git a/src/tools/webrtc/signal-client.ts b/src/tools/webrtc/signal-client.ts new file mode 100644 index 0000000..7a3e77a --- /dev/null +++ b/src/tools/webrtc/signal-client.ts @@ -0,0 +1,37 @@ +import { parseSignal, type SignalMessage } from './signal.lib'; + +export interface SignalClient { + send(msg: SignalMessage): void; + close(): void; +} + +export interface SignalHandlers { + onMessage: (msg: SignalMessage) => void; + onOpen?: () => void; + onClose?: () => void; + onError?: () => void; +} + +/** Open a WebSocket to the signaling room and relay parsed messages. */ +export function connectSignal(roomId: string, handlers: SignalHandlers): SignalClient { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/api/signal/${roomId}`); + + ws.onopen = () => handlers.onOpen?.(); + ws.onmessage = e => { + if (typeof e.data !== 'string') return; + const msg = parseSignal(e.data); + if (msg) handlers.onMessage(msg); + }; + ws.onclose = () => handlers.onClose?.(); + ws.onerror = () => handlers.onError?.(); + + return { + send(msg: SignalMessage) { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); + }, + close() { + try { ws.close(); } catch { /* ignore */ } + }, + }; +} diff --git a/src/tools/webrtc/signal.lib.test.ts b/src/tools/webrtc/signal.lib.test.ts new file mode 100644 index 0000000..dc0c1b9 --- /dev/null +++ b/src/tools/webrtc/signal.lib.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { makeRoomId, roomLink, roomIdFromHash, parseSignal } from './signal.lib'; + +describe('makeRoomId', () => { + it('produces a 10-char lowercase-alnum id', () => { + const id = makeRoomId(); + expect(id).toMatch(/^[a-z0-9]{10}$/); + }); + it('is different across calls', () => { + expect(makeRoomId()).not.toBe(makeRoomId()); + }); +}); + +describe('roomLink', () => { + it('builds a same-origin file-transfer link with the id in the hash', () => { + expect(roomLink('https://goodwebtools.com', 'abc123xyz0')).toBe( + 'https://goodwebtools.com/tools/file-transfer#abc123xyz0', + ); + }); +}); + +describe('roomIdFromHash', () => { + it('extracts a valid id from the hash', () => { + expect(roomIdFromHash('#abc123xyz0')).toBe('abc123xyz0'); + expect(roomIdFromHash('abc123xyz0')).toBe('abc123xyz0'); + }); + it('rejects empty, junk, or out-of-charset hashes', () => { + expect(roomIdFromHash('')).toBeNull(); + expect(roomIdFromHash('#')).toBeNull(); + expect(roomIdFromHash('#UPPER!!')).toBeNull(); + expect(roomIdFromHash('#a')).toBeNull(); // too short + }); +}); + +describe('parseSignal', () => { + it('accepts known message shapes', () => { + expect(parseSignal(JSON.stringify({ type: 'welcome', role: 'host' }))).toEqual({ + type: 'welcome', + role: 'host', + }); + expect(parseSignal(JSON.stringify({ type: 'peer-joined' }))?.type).toBe('peer-joined'); + const ice = parseSignal(JSON.stringify({ type: 'ice', candidate: { c: 1 } })); + expect(ice?.type).toBe('ice'); + }); + it('rejects malformed JSON and unknown shapes', () => { + expect(parseSignal('not json')).toBeNull(); + expect(parseSignal(JSON.stringify({ type: 'nope' }))).toBeNull(); + expect(parseSignal(JSON.stringify({ foo: 'bar' }))).toBeNull(); + expect(parseSignal(JSON.stringify({ type: 'welcome', role: 'bogus' }))).toBeNull(); + }); +}); diff --git a/src/tools/webrtc/signal.lib.ts b/src/tools/webrtc/signal.lib.ts new file mode 100644 index 0000000..5379cb0 --- /dev/null +++ b/src/tools/webrtc/signal.lib.ts @@ -0,0 +1,71 @@ +/** + * Pure signaling protocol shared by the P2P tools (file transfer, video call). + * Only tiny JSON control messages flow through the signaling server — never media + * or file bytes. + */ + +export type SignalRole = 'host' | 'guest'; + +export type SignalMessage = + | { type: 'welcome'; role: SignalRole } + | { type: 'peer-joined' } + | { type: 'peer-left' } + | { type: 'full' } + | { type: 'offer'; sdp: unknown } + | { type: 'answer'; sdp: unknown } + | { type: 'ice'; candidate: unknown }; + +const ROOM_ID_RE = /^[a-z0-9]{6,32}$/; +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +/** A 10-char lowercase-alnum room id from a CSPRNG. */ +export function makeRoomId(): string { + const bytes = new Uint8Array(10); + crypto.getRandomValues(bytes); + let out = ''; + for (const b of bytes) out += ID_ALPHABET[b % ID_ALPHABET.length]; + return out; +} + +/** Same-origin shareable link for a file-transfer room. */ +export function roomLink(origin: string, roomId: string): string { + return `${origin}/tools/file-transfer#${roomId}`; +} + +/** Extract a valid room id from a URL hash (with or without the leading '#'). */ +export function roomIdFromHash(hash: string): string | null { + const id = hash.startsWith('#') ? hash.slice(1) : hash; + return ROOM_ID_RE.test(id) ? id : null; +} + +function isRole(v: unknown): v is SignalRole { + return v === 'host' || v === 'guest'; +} + +/** Safe parse + shape-guard a raw signaling message. Returns null if invalid. */ +export function parseSignal(raw: string): SignalMessage | null { + let obj: unknown; + try { + obj = JSON.parse(raw); + } catch { + return null; + } + if (!obj || typeof obj !== 'object') return null; + const m = obj as Record; + switch (m.type) { + case 'welcome': + return isRole(m.role) ? { type: 'welcome', role: m.role } : null; + case 'peer-joined': + case 'peer-left': + case 'full': + return { type: m.type }; + case 'offer': + return 'sdp' in m ? { type: 'offer', sdp: m.sdp } : null; + case 'answer': + return 'sdp' in m ? { type: 'answer', sdp: m.sdp } : null; + case 'ice': + return 'candidate' in m ? { type: 'ice', candidate: m.candidate } : null; + default: + return null; + } +} diff --git a/worker/index.js b/worker/index.js index 59599ed..fdc4231 100644 --- a/worker/index.js +++ b/worker/index.js @@ -6,11 +6,28 @@ * to the static Astro build via the ASSETS binding. Cloudflare only invokes * this Worker for requests that don't match a prebuilt static asset, so static * pages are still served directly. + * + * It also hosts a tiny WebRTC signaling rendezvous at /api/signal/ (a + * Durable Object), used by the P2P tools to exchange the ~2KB SDP/ICE handshake. + * Media and file bytes travel peer-to-peer and never reach this Worker. */ +export { SignalRoom } from './signal-room.js'; + export default { async fetch(request, env) { const url = new URL(request.url); + if (url.pathname.startsWith('/api/signal/')) { + if (request.headers.get('Upgrade') !== 'websocket') { + return new Response('Expected WebSocket', { status: 426 }); + } + const roomId = url.pathname.slice('/api/signal/'.length); + if (!/^[a-z0-9]{6,32}$/.test(roomId)) { + return new Response('Bad room id', { status: 400 }); + } + return env.SIGNAL.getByName(roomId).fetch(request); + } + if (url.pathname.startsWith('/models/')) { if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response('Method not allowed', { status: 405 }); diff --git a/worker/signal-room.js b/worker/signal-room.js new file mode 100644 index 0000000..e055d61 --- /dev/null +++ b/worker/signal-room.js @@ -0,0 +1,60 @@ +import { DurableObject } from 'cloudflare:workers'; + +/** + * SignalRoom — a 2-peer WebRTC signaling rendezvous. + * + * It is a dumb relay: it forwards the small SDP/ICE handshake messages between the + * (at most) two peers in a room. Media and file bytes never pass through here — they + * travel peer-to-peer over WebRTC once the handshake completes. + * + * Uses the WebSocket Hibernation API so the object costs nothing while idle. + */ +export class SignalRoom extends DurableObject { + async fetch(request) { + if (request.headers.get('Upgrade') !== 'websocket') { + return new Response('Expected WebSocket', { status: 426 }); + } + + const existing = this.ctx.getWebSockets(); + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Room is limited to two peers. + if (existing.length >= 2) { + this.ctx.acceptWebSocket(server); + server.send(JSON.stringify({ type: 'full' })); + server.close(4001, 'full'); + return new Response(null, { status: 101, webSocket: client }); + } + + const role = existing.length === 0 ? 'host' : 'guest'; + this.ctx.acceptWebSocket(server); + server.serializeAttachment({ role }); + server.send(JSON.stringify({ type: 'welcome', role })); + + // When the guest arrives, tell the host so it starts the WebRTC offer. + if (role === 'guest') { + for (const ws of existing) { + try { ws.send(JSON.stringify({ type: 'peer-joined' })); } catch { /* ignore */ } + } + } + + return new Response(null, { status: 101, webSocket: client }); + } + + /** Relay every message verbatim to the other peer(s). */ + async webSocketMessage(ws, message) { + for (const conn of this.ctx.getWebSockets()) { + if (conn === ws) continue; + try { conn.send(message); } catch { /* ignore */ } + } + } + + async webSocketClose(ws, code, reason) { + for (const conn of this.ctx.getWebSockets()) { + if (conn === ws) continue; + try { conn.send(JSON.stringify({ type: 'peer-left' })); } catch { /* ignore */ } + } + try { ws.close(code, reason); } catch { /* ignore */ } + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 2caaeb2..3496e68 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -19,6 +19,18 @@ { "binding": "MODELS", "bucket_name": "goodwebtools-models" } ], + // WebRTC signaling rendezvous for the P2P tools (file transfer, video call). + // SQLite-backed Durable Object = free tier. Relays only the tiny SDP/ICE + // handshake; media/files travel peer-to-peer. + "durable_objects": { + "bindings": [ + { "name": "SIGNAL", "class_name": "SignalRoom" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["SignalRoom"] } + ], + // Workers observability, declared here so deploys stay in sync with the // dashboard-configured settings (otherwise each deploy can reset them). // Named environments don't inherit this, so it's repeated under `staging`. @@ -53,6 +65,14 @@ "r2_buckets": [ { "binding": "MODELS", "bucket_name": "goodwebtools-models-staging" } ], + "durable_objects": { + "bindings": [ + { "name": "SIGNAL", "class_name": "SignalRoom" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["SignalRoom"] } + ], "observability": { "enabled": false, "head_sampling_rate": 1, From 6c49b6b5528093a41b57a04a70a571286fa995bc Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 09:40:54 +0700 Subject: [PATCH 3/3] 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 +
+ + +
+
+ )} +