Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions scripts/make-icons.mjs
Original file line number Diff line number Diff line change
@@ -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 `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" fill="${VIOLET}"/>
<text x="${size / 2}" y="${y}" font-family="Helvetica, Arial, sans-serif" font-size="${fs}" font-weight="800" fill="#ffffff" text-anchor="middle" letter-spacing="${Math.round(size * 0.006)}">GWT</text>
</svg>`;
}

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)`);
}
18 changes: 18 additions & 0 deletions scripts/make-og-image.mjs
Original file line number Diff line number Diff line change
@@ -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 = `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="630" fill="#fffdf5"/>
<rect x="24" y="24" width="1152" height="582" fill="none" stroke="#0a0a0a" stroke-width="10"/>
<rect x="88" y="96" width="156" height="156" fill="#7c3aed" stroke="#0a0a0a" stroke-width="8"/>
<text x="166" y="200" font-family="Helvetica, Arial, sans-serif" font-size="66" font-weight="700" fill="#ffffff" text-anchor="middle">GWT</text>
<text x="90" y="380" font-family="Helvetica, Arial, sans-serif" font-size="100" font-weight="800" fill="#0a0a0a">GoodWebTools</text>
<text x="94" y="452" font-family="Helvetica, Arial, sans-serif" font-size="38" font-weight="500" fill="#3a3a3a">Privacy-first tools that run entirely in your browser.</text>
<text x="94" y="524" font-family="Helvetica, Arial, sans-serif" font-size="32" font-weight="700" fill="#7c3aed">No uploads · No tracking · 100% client-side</text>
</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');
117 changes: 95 additions & 22 deletions src/hooks/useFileTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SignalClient | null>(null);
Expand All @@ -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<ReturnType<typeof setTimeout> | 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<TransferStatus>('idle');
const [error, setError] = useState('');
const [progress, setProgress] = useState(0);
const [incoming, setIncoming] = useState<{ name: string; size: number } | null>(null);
const [receivedBlob, setReceivedBlob] = useState<Blob | null>(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();
Expand All @@ -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');
}
}
}, []);

Expand Down Expand Up @@ -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) => {
Expand All @@ -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':
Expand All @@ -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':
Expand All @@ -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<string> => {
cleanup();
stoppedRef.current = true; // no auto-reconnect in manual mode
reconnectRef.current = null;
connectedRef.current = false;
modeRef.current = 'send';
setError('');
setProgress(0);
Expand All @@ -173,6 +240,9 @@ export function useFileTransfer() {

const manualAcceptOffer = useCallback(async (offerCode: string, iceServers?: RTCIceServer[]): Promise<string> => {
cleanup();
stoppedRef.current = true;
reconnectRef.current = null;
connectedRef.current = false;
modeRef.current = 'receive';
setError('');
setProgress(0);
Expand Down Expand Up @@ -212,6 +282,9 @@ export function useFileTransfer() {
}, []);

const reset = useCallback(() => {
stoppedRef.current = true;
reconnectRef.current = null;
connectedRef.current = false;
cleanup();
setStatus('idle');
setError('');
Expand All @@ -220,7 +293,7 @@ export function useFileTransfer() {
setReceivedBlob(null);
}, [cleanup]);

useEffect(() => () => cleanup(), [cleanup]);
useEffect(() => () => { stoppedRef.current = true; cleanup(); }, [cleanup]);

return {
status,
Expand Down
7 changes: 5 additions & 2 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const {
title,
description = SITE_TAGLINE,
keywords,
image = '/icon-512.png',
image = '/og.png',
canonical,
noindex = false,
fullTitle,
Expand Down Expand Up @@ -67,6 +67,9 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ?
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={pageTitle} />

<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
Expand All @@ -90,7 +93,7 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ?
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
Expand Down
19 changes: 16 additions & 3 deletions src/tools/webrtc/signal-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,38 @@ 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<typeof setInterval> | 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 {
send(msg: SignalMessage) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
},
close() {
stopKeepalive();
try { ws.close(); } catch { /* ignore */ }
},
};
Expand Down
7 changes: 7 additions & 0 deletions worker/signal-room.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading