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 ``;
+}
+
+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 = ``;
+
+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 });