diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..f4b1a25 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png index 965f567..5989d6e 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png index 3c81ad2..16099b8 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..62e1c6e Binary files /dev/null and b/public/og.png differ diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs new file mode 100644 index 0000000..13906aa --- /dev/null +++ b/scripts/make-icons.mjs @@ -0,0 +1,30 @@ +// Generate the PWA / app icons with the GWT brand (was a black square). +// Maskable-safe: full-bleed violet background, "GWT" kept within the center safe +// zone. Run: node scripts/make-icons.mjs +import sharp from 'sharp'; +import { writeFileSync } from 'node:fs'; + +const VIOLET = '#7c3aed'; + +// fontFactor: text size as a fraction of the icon side. Smaller = more safe-zone +// padding (for maskable). Apple icons don't need a safe zone, so they run larger. +function iconSvg(size, fontFactor) { + const fs = Math.round(size * fontFactor); + const y = Math.round(size * 0.5 + fs * 0.35); // optical vertical centering + return ` + + GWT +`; +} + +const targets = [ + { file: 'icon-512.png', size: 512, font: 0.28 }, // maskable safe zone + { file: 'icon-192.png', size: 192, font: 0.28 }, // maskable safe zone + { file: 'apple-touch-icon.png', size: 180, font: 0.34 }, // iOS just rounds corners +]; + +for (const t of targets) { + const png = await sharp(Buffer.from(iconSvg(t.size, t.font))).png().toBuffer(); + writeFileSync(new URL(`../public/${t.file}`, import.meta.url), png); + console.log(`Wrote public/${t.file} (${t.size}x${t.size}, ${png.length} bytes)`); +} diff --git a/scripts/make-og-image.mjs b/scripts/make-og-image.mjs new file mode 100644 index 0000000..9ecedf3 --- /dev/null +++ b/scripts/make-og-image.mjs @@ -0,0 +1,18 @@ +// Generate the default social-share (Open Graph) image at public/og.png. +// 1200x630, opaque, brand-matched (brutalist cream + violet). Run: node scripts/make-og-image.mjs +import sharp from 'sharp'; +import { writeFileSync } from 'node:fs'; + +const svg = ` + + + + GWT + GoodWebTools + Privacy-first tools that run entirely in your browser. + No uploads · No tracking · 100% client-side +`; + +const png = await sharp(Buffer.from(svg)).png().toBuffer(); +writeFileSync(new URL('../public/og.png', import.meta.url), png); +console.log('Wrote public/og.png', png.length, 'bytes'); diff --git a/src/hooks/useFileTransfer.ts b/src/hooks/useFileTransfer.ts index 554252b..c2b8ea9 100644 --- a/src/hooks/useFileTransfer.ts +++ b/src/hooks/useFileTransfer.ts @@ -24,6 +24,7 @@ export type TransferStatus = export type TransferMode = 'send' | 'receive'; const HIGH_WATER = 8 * 1024 * 1024; // pause sending above this bufferedAmount +const RECONNECT_MS = 1500; export function useFileTransfer() { const signalRef = useRef(null); @@ -38,13 +39,28 @@ export function useFileTransfer() { received: 0, }); + // Auto-mode reconnection bookkeeping. + const reconnectRef = useRef<{ mode: TransferMode; roomId: string } | null>(null); + const reconnectTimerRef = 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 openSignalingRef = useRef<() => void>(() => {}); + 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 clearReconnect = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + const cleanup = useCallback(() => { + clearReconnect(); channelRef.current?.close(); peerRef.current?.close(); manualRef.current?.close(); @@ -57,9 +73,13 @@ export function useFileTransfer() { }, []); const handleState = useCallback((state: RTCPeerConnectionState) => { + if (state === 'connected') connectedRef.current = true; if (state === 'failed' || state === 'disconnected') { - setError('Could not connect — the network may be too restrictive (no relay server).'); - setStatus('error'); + // 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'); + } } }, []); @@ -93,10 +113,14 @@ export function useFileTransfer() { if (modeRef.current === 'receive') { channel.onmessage = e => handleRecv(e.data as string | ArrayBuffer); } - channel.onopen = () => setStatus('connected'); + const markOpen = () => { + connectedRef.current = true; + clearReconnect(); + setStatus('connected'); + }; + channel.onopen = markOpen; 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'); + if (channel.readyState === 'open') markOpen(); }, [handleRecv]); const setupPeer = useCallback((initiator: boolean) => { @@ -111,18 +135,27 @@ export function useFileTransfer() { }); }, [wireChannel, handleState]); - // --- 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); - setIncoming(null); - setStatus('connecting'); + const scheduleReconnect = useCallback(() => { + if (stoppedRef.current || connectedRef.current || !reconnectRef.current) return; + clearReconnect(); + if (typeof document !== 'undefined' && document.hidden) return; // wait for foreground + reconnectTimerRef.current = setTimeout(() => { + if (stoppedRef.current || connectedRef.current) return; + openSignalingRef.current(); + }, RECONNECT_MS); + }, []); + + // (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(roomId, { + signalRef.current = connectSignal(info.roomId, { onMessage: msg => { switch (msg.type) { case 'welcome': @@ -133,11 +166,12 @@ export function useFileTransfer() { setupPeer(true); break; case 'peer-left': - setError('The other device disconnected.'); - setStatus('error'); + // Only meaningful before the P2P channel is up; after, ignore. + if (!connectedRef.current) { peerRef.current?.close(); peerRef.current = null; setStatus('waiting'); } break; case 'full': - setError('This transfer room is already full.'); + stoppedRef.current = true; + setError('This transfer room is already full (two devices are connected).'); setStatus('error'); break; case 'offer': @@ -147,13 +181,46 @@ export function useFileTransfer() { break; } }, - onError: () => { setError('Signaling connection failed.'); setStatus('error'); }, + onClose: () => { if (!connectedRef.current && !stoppedRef.current) scheduleReconnect(); }, + onError: () => { if (!connectedRef.current && !stoppedRef.current) scheduleReconnect(); }, }); - }, [cleanup, setupPeer]); + }, [setupPeer, scheduleReconnect]); + + useEffect(() => { openSignalingRef.current = openSignaling; }, [openSignaling]); + + const connect = useCallback((mode: TransferMode, roomId: string, iceServers?: RTCIceServer[]) => { + cleanup(); + modeRef.current = mode; + iceRef.current = iceServers; + reconnectRef.current = { mode, roomId }; + stoppedRef.current = false; + connectedRef.current = false; + setError(''); + setProgress(0); + setReceivedBlob(null); + setIncoming(null); + setStatus('connecting'); + openSignaling(); + }, [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. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible' && reconnectRef.current && !connectedRef.current && !stoppedRef.current) { + scheduleReconnect(); + } + }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [scheduleReconnect]); // --- 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; + connectedRef.current = false; modeRef.current = 'send'; setError(''); setProgress(0); @@ -173,6 +240,9 @@ export function useFileTransfer() { const manualAcceptOffer = useCallback(async (offerCode: string, iceServers?: RTCIceServer[]): Promise => { cleanup(); + stoppedRef.current = true; + reconnectRef.current = null; + connectedRef.current = false; modeRef.current = 'receive'; setError(''); setProgress(0); @@ -212,6 +282,9 @@ export function useFileTransfer() { }, []); const reset = useCallback(() => { + stoppedRef.current = true; + reconnectRef.current = null; + connectedRef.current = false; cleanup(); setStatus('idle'); setError(''); @@ -220,7 +293,7 @@ export function useFileTransfer() { setReceivedBlob(null); }, [cleanup]); - useEffect(() => () => cleanup(), [cleanup]); + useEffect(() => () => { stoppedRef.current = true; cleanup(); }, [cleanup]); return { status, diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 60b241f..2515307 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -26,7 +26,7 @@ const { title, description = SITE_TAGLINE, keywords, - image = '/icon-512.png', + image = '/og.png', canonical, noindex = false, fullTitle, @@ -67,6 +67,9 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? + + + @@ -90,7 +93,7 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? - + diff --git a/src/tools/webrtc/signal-client.ts b/src/tools/webrtc/signal-client.ts index 7a3e77a..95d6d17 100644 --- a/src/tools/webrtc/signal-client.ts +++ b/src/tools/webrtc/signal-client.ts @@ -12,18 +12,30 @@ export interface SignalHandlers { onError?: () => void; } +const KEEPALIVE_MS = 25000; + /** 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?.(); + // Keepalive: the Durable Object auto-responds 'pong' (not relayed to the peer), + // keeping the connection alive through idle-timeout proxies. + let keepalive: ReturnType | null = null; + const stopKeepalive = () => { if (keepalive) { clearInterval(keepalive); keepalive = null; } }; + + ws.onopen = () => { + keepalive = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) ws.send('ping'); + }, KEEPALIVE_MS); + handlers.onOpen?.(); + }; ws.onmessage = e => { - if (typeof e.data !== 'string') return; + if (typeof e.data !== 'string' || e.data === 'pong') return; const msg = parseSignal(e.data); if (msg) handlers.onMessage(msg); }; - ws.onclose = () => handlers.onClose?.(); + ws.onclose = () => { stopKeepalive(); handlers.onClose?.(); }; ws.onerror = () => handlers.onError?.(); return { @@ -31,6 +43,7 @@ export function connectSignal(roomId: string, handlers: SignalHandlers): SignalC if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }, close() { + stopKeepalive(); try { ws.close(); } catch { /* ignore */ } }, }; diff --git a/worker/signal-room.js b/worker/signal-room.js index e055d61..0139328 100644 --- a/worker/signal-room.js +++ b/worker/signal-room.js @@ -10,6 +10,13 @@ import { DurableObject } from 'cloudflare:workers'; * Uses the WebSocket Hibernation API so the object costs nothing while idle. */ export class SignalRoom extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + // Auto-answer keepalive pings without waking the object, and without + // relaying them to the other peer. + this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong')); + } + async fetch(request) { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 });