@@ -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
+
+
+
+
+
+ )}
+
+
+ )}
+
@@ -68,16 +193,78 @@ export default function FileTransfer() {
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…',
+ waiting: signaling === 'manual' ? 'Waiting to connect…' : 'Waiting for the other device to join…',
+ connected: isSending ? 'Connected — choose a file to send.' : 'Connected — waiting for a file…',
transferring: 'Transferring…',
- done: mode === 'send' ? 'Sent!' : 'Received!',
+ done: isSending ? 'Sent!' : 'Received!',
};
+ const codeBox = (value: string) => (
+
+
+ Files transfer directly between devices (peer-to-peer).{' '}
+ {signaling === 'manual'
+ ? 'Manual mode uses no server at all.'
+ : 'A minimal signaling server is used only to introduce the two devices (~2 KB handshake) — your files never pass through it.'}
+
);
}
diff --git a/src/registry/categories.ts b/src/registry/categories.ts
index f158285..1ac93df 100644
--- a/src/registry/categories.ts
+++ b/src/registry/categories.ts
@@ -7,6 +7,7 @@ export const categories: Category[] = [
'Files',
'Draw',
'Media',
+ 'Network',
'Playground'
];
@@ -17,5 +18,17 @@ export const categoryColors: Record = {
Files: 'bg-yellow-500',
Draw: 'bg-purple-500',
Media: 'bg-pink-500',
+ Network: 'bg-cyan-500',
Playground: 'bg-orange-500'
};
+
+/**
+ * Categories whose tools use a limited server-side component. Everything else on
+ * GoodWebTools runs fully client-side; Network tools additionally use a minimal
+ * signaling server to introduce two devices (only the ~2KB WebRTC handshake — no
+ * media or file bytes pass through it), and this can be disabled entirely with the
+ * manual (serverless) connection mode.
+ */
+export const categoryNotes: Partial> = {
+ Network: 'These tools connect two devices directly (peer-to-peer). By default a minimal signaling server only introduces the devices — your media and files never pass through it — and you can switch to a fully serverless manual mode or bring your own STUN/TURN servers.',
+};
diff --git a/src/registry/tools.ts b/src/registry/tools.ts
index f890d34..828c4b3 100644
--- a/src/registry/tools.ts
+++ b/src/registry/tools.ts
@@ -556,7 +556,7 @@ export const tools: ToolDef[] = [
{
id: 'file-transfer',
name: 'P2P File Transfer',
- category: 'Files',
+ category: 'Network',
route: '/tools/file-transfer',
keywords: ['file', 'transfer', 'send', 'share', 'p2p', 'peer to peer', 'webrtc', 'direct', 'device to device'],
icon: Send,
diff --git a/src/tools/webrtc/ice.lib.test.ts b/src/tools/webrtc/ice.lib.test.ts
new file mode 100644
index 0000000..330fac3
--- /dev/null
+++ b/src/tools/webrtc/ice.lib.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { DEFAULT_ICE_SERVERS, parseIceConfig, effectiveIceServers } from './ice.lib';
+
+describe('parseIceConfig', () => {
+ it('parses a STUN line', () => {
+ const { servers, invalid } = parseIceConfig('stun:stun.example.com:3478');
+ expect(servers).toEqual([{ urls: ['stun:stun.example.com:3478'] }]);
+ expect(invalid).toEqual([]);
+ });
+ it('parses a TURN line with username and credential', () => {
+ const { servers } = parseIceConfig('turn:turn.example.com:3478 alice s3cret');
+ expect(servers[0]).toEqual({
+ urls: ['turn:turn.example.com:3478'],
+ username: 'alice',
+ credential: 's3cret',
+ });
+ });
+ it('ignores blank lines and # comments', () => {
+ const { servers } = parseIceConfig('\n# my servers\nstun:a.com:3478\n\n');
+ expect(servers).toHaveLength(1);
+ });
+ it('collects invalid lines by scheme', () => {
+ const { servers, invalid } = parseIceConfig('http://nope\nstun:ok.com:3478');
+ expect(servers).toHaveLength(1);
+ expect(invalid).toEqual(['http://nope']);
+ });
+ it('supports comma-separated urls on one line', () => {
+ const { servers } = parseIceConfig('stun:a.com:3478,stun:b.com:3478');
+ expect(servers[0].urls).toEqual(['stun:a.com:3478', 'stun:b.com:3478']);
+ });
+});
+
+describe('effectiveIceServers', () => {
+ it('falls back to the public default when input is empty', () => {
+ expect(effectiveIceServers('')).toBe(DEFAULT_ICE_SERVERS);
+ expect(effectiveIceServers(' \n # only comment')).toBe(DEFAULT_ICE_SERVERS);
+ });
+ it('uses parsed servers when provided', () => {
+ const servers = effectiveIceServers('stun:my.com:3478');
+ expect(servers).not.toBe(DEFAULT_ICE_SERVERS);
+ expect(servers[0].urls).toEqual(['stun:my.com:3478']);
+ });
+});
diff --git a/src/tools/webrtc/ice.lib.ts b/src/tools/webrtc/ice.lib.ts
new file mode 100644
index 0000000..16a2567
--- /dev/null
+++ b/src/tools/webrtc/ice.lib.ts
@@ -0,0 +1,55 @@
+/**
+ * ICE (STUN/TURN) server configuration. Users can bring their own servers so they
+ * don't rely on the public defaults — a custom TURN server also fixes connections
+ * behind strict/symmetric NAT.
+ *
+ * Input format (one server per line; blank lines and `#` comments ignored):
+ * stun:stun.example.com:3478
+ * turn:turn.example.com:3478
+ * stun:a.com:3478,stun:b.com:3478 (comma-separated urls share one entry)
+ */
+
+export const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
+ { urls: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'] },
+];
+
+const SCHEME_RE = /^(stun|stuns|turn|turns):/i;
+
+export interface IceParseResult {
+ servers: RTCIceServer[];
+ invalid: string[];
+}
+
+/** Parse user ICE-server input into RTCIceServer entries, collecting invalid lines. */
+export function parseIceConfig(text: string): IceParseResult {
+ const servers: RTCIceServer[] = [];
+ const invalid: string[] = [];
+
+ for (const rawLine of text.split('\n')) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+
+ const tokens = line.split(/\s+/);
+ const urls = tokens[0].split(',').map(u => u.trim()).filter(Boolean);
+ if (urls.length === 0 || !urls.every(u => SCHEME_RE.test(u))) {
+ invalid.push(line);
+ continue;
+ }
+
+ const entry: RTCIceServer = { urls };
+ const isTurn = urls.some(u => /^turns?:/i.test(u));
+ if (isTurn && tokens[1]) {
+ entry.username = tokens[1];
+ if (tokens[2]) entry.credential = tokens[2];
+ }
+ servers.push(entry);
+ }
+
+ return { servers, invalid };
+}
+
+/** The ICE servers to actually use: parsed custom servers, or the public default. */
+export function effectiveIceServers(text: string): RTCIceServer[] {
+ const { servers } = parseIceConfig(text);
+ return servers.length > 0 ? servers : DEFAULT_ICE_SERVERS;
+}
diff --git a/src/tools/webrtc/manual-sdp.lib.test.ts b/src/tools/webrtc/manual-sdp.lib.test.ts
new file mode 100644
index 0000000..64a8df7
--- /dev/null
+++ b/src/tools/webrtc/manual-sdp.lib.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'vitest';
+import { encodeSdp, decodeSdp } from './manual-sdp.lib';
+
+describe('encodeSdp / decodeSdp', () => {
+ it('round-trips an offer description', () => {
+ const desc = { type: 'offer' as RTCSdpType, sdp: 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\n' };
+ const code = encodeSdp(desc);
+ expect(typeof code).toBe('string');
+ expect(code).not.toContain(' ');
+ expect(decodeSdp(code)).toEqual(desc);
+ });
+ it('handles unicode in the sdp safely', () => {
+ const desc = { type: 'answer' as RTCSdpType, sdp: 'naïve—café ☃' };
+ expect(decodeSdp(encodeSdp(desc))).toEqual(desc);
+ });
+ it('rejects malformed codes', () => {
+ expect(decodeSdp('')).toBeNull();
+ expect(decodeSdp('!!!not-base64!!!')).toBeNull();
+ expect(decodeSdp(btoa('{"type":"offer"}'))).toBeNull(); // missing sdp
+ expect(decodeSdp(btoa('not json'))).toBeNull();
+ expect(decodeSdp(btoa(JSON.stringify({ type: 'bogus', sdp: 'x' })))).toBeNull();
+ });
+});
diff --git a/src/tools/webrtc/manual-sdp.lib.ts b/src/tools/webrtc/manual-sdp.lib.ts
new file mode 100644
index 0000000..a3b8a37
--- /dev/null
+++ b/src/tools/webrtc/manual-sdp.lib.ts
@@ -0,0 +1,47 @@
+/**
+ * Encode/decode an SDP offer or answer as a compact, copy-pasteable code for the
+ * manual (serverless) signaling mode. Base64 keeps it on a single line and robust
+ * to being pasted through chat/email. UTF-8 safe.
+ */
+
+function toBase64(str: string): string {
+ // Encode UTF-8 → base64 without relying on Node Buffer.
+ const utf8 = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, h) =>
+ String.fromCharCode(parseInt(h, 16)),
+ );
+ return btoa(utf8);
+}
+
+function fromBase64(b64: string): string {
+ const binary = atob(b64);
+ return decodeURIComponent(
+ Array.from(binary, c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(''),
+ );
+}
+
+/** Serialize a session description to a single-line code. */
+export function encodeSdp(desc: RTCSessionDescriptionInit): string {
+ return toBase64(JSON.stringify({ type: desc.type, sdp: desc.sdp }));
+}
+
+/** Parse + validate a pasted code back into a session description, or null. */
+export function decodeSdp(code: string): RTCSessionDescriptionInit | null {
+ const trimmed = code.trim();
+ if (!trimmed) return null;
+ let json: string;
+ try {
+ json = fromBase64(trimmed);
+ } catch {
+ return null;
+ }
+ let obj: unknown;
+ try {
+ obj = JSON.parse(json);
+ } catch {
+ return null;
+ }
+ if (!obj || typeof obj !== 'object') return null;
+ const m = obj as Record;
+ if ((m.type !== 'offer' && m.type !== 'answer') || typeof m.sdp !== 'string') return null;
+ return { type: m.type, sdp: m.sdp };
+}
diff --git a/src/tools/webrtc/manual.ts b/src/tools/webrtc/manual.ts
new file mode 100644
index 0000000..f762ba1
--- /dev/null
+++ b/src/tools/webrtc/manual.ts
@@ -0,0 +1,83 @@
+import { DEFAULT_ICE_SERVERS } from './ice.lib';
+import { encodeSdp, decodeSdp } from './manual-sdp.lib';
+
+/**
+ * Serverless WebRTC connection: the two peers copy-paste an offer code and an
+ * answer code to each other through any channel (chat, email). No signaling
+ * server is involved. ICE is gathered fully before producing each code
+ * ("non-trickle") so all candidates travel inside the code.
+ */
+
+export interface ManualOptions {
+ initiator: boolean;
+ iceServers?: RTCIceServer[];
+ onState?: (state: RTCPeerConnectionState) => void;
+ onChannel?: (channel: RTCDataChannel) => void;
+}
+
+export interface ManualConnection {
+ pc: RTCPeerConnection;
+ /** Sender: produce the offer code to share. */
+ createOfferCode(): Promise;
+ /** Receiver: accept the sender's offer code, return the answer code to share back. */
+ acceptOfferReturnAnswer(offerCode: string): Promise;
+ /** Sender: accept the receiver's answer code to complete the connection. */
+ acceptAnswer(answerCode: string): Promise;
+ close(): void;
+}
+
+/** Resolve once ICE gathering completes (or after a timeout, to avoid stalling). */
+function gatherComplete(pc: RTCPeerConnection, timeoutMs = 4000): Promise {
+ if (pc.iceGatheringState === 'complete') return Promise.resolve();
+ return new Promise(resolve => {
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ pc.removeEventListener('icegatheringstatechange', check);
+ resolve();
+ };
+ const check = () => { if (pc.iceGatheringState === 'complete') finish(); };
+ pc.addEventListener('icegatheringstatechange', check);
+ setTimeout(finish, timeoutMs);
+ });
+}
+
+export function createManualConnection(opts: ManualOptions): ManualConnection {
+ const pc = new RTCPeerConnection({ iceServers: opts.iceServers ?? DEFAULT_ICE_SERVERS });
+ pc.onconnectionstatechange = () => opts.onState?.(pc.connectionState);
+
+ if (opts.initiator) {
+ const channel = pc.createDataChannel('data', { ordered: true });
+ opts.onChannel?.(channel);
+ } else {
+ pc.ondatachannel = e => opts.onChannel?.(e.channel);
+ }
+
+ return {
+ pc,
+ async createOfferCode() {
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ await gatherComplete(pc);
+ return encodeSdp(pc.localDescription!);
+ },
+ async acceptOfferReturnAnswer(offerCode: string) {
+ const offer = decodeSdp(offerCode);
+ if (!offer || offer.type !== 'offer') throw new Error('That doesn’t look like a valid offer code.');
+ await pc.setRemoteDescription(offer);
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ await gatherComplete(pc);
+ return encodeSdp(pc.localDescription!);
+ },
+ async acceptAnswer(answerCode: string) {
+ const answer = decodeSdp(answerCode);
+ if (!answer || answer.type !== 'answer') throw new Error('That doesn’t look like a valid answer code.');
+ await pc.setRemoteDescription(answer);
+ },
+ close() {
+ try { pc.close(); } catch { /* ignore */ }
+ },
+ };
+}
diff --git a/src/tools/webrtc/peer.ts b/src/tools/webrtc/peer.ts
index e7942a2..f66f836 100644
--- a/src/tools/webrtc/peer.ts
+++ b/src/tools/webrtc/peer.ts
@@ -1,15 +1,13 @@
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'] },
-];
+import { DEFAULT_ICE_SERVERS } from './ice.lib';
export interface CreatePeerOptions {
initiator: boolean;
sendSignal: (msg: SignalMessage) => void;
onState?: (state: RTCPeerConnectionState) => void;
onChannel?: (channel: RTCDataChannel) => void;
+ /** Custom ICE servers; defaults to the public STUN set. */
+ iceServers?: RTCIceServer[];
}
export interface PeerHandles {
@@ -24,7 +22,7 @@ export interface PeerHandles {
* 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 pc = new RTCPeerConnection({ iceServers: opts.iceServers ?? DEFAULT_ICE_SERVERS });
const pendingIce: RTCIceCandidateInit[] = [];
pc.onicecandidate = e => {
diff --git a/src/types/tool.ts b/src/types/tool.ts
index 243ca0a..9cd8f8f 100644
--- a/src/types/tool.ts
+++ b/src/types/tool.ts
@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react';
-export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media' | 'Playground';
+export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media' | 'Network' | 'Playground';
export interface AssetRef {
url: string;