From bd7879ce7bcb33634f848c34ee1f4c9b22040bfd Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Thu, 9 Jul 2026 15:06:48 +0200 Subject: [PATCH] Show online/offline status for known devices Uses the waste-go anchor's new EXT-009 presence_query to check each known device's reachability without a full connect attempt. Each pairing already has its own deterministic anchor room, so this is one short-lived query per device (auto-refreshed every 15s while the Known Devices tab is open), not a persistent connection held per pairing while idle. Co-Authored-By: Claude Sonnet 5 --- pwa/src/App.css | 13 +++++++++++++ pwa/src/App.tsx | 37 +++++++++++++++++++++++++++++++++++-- pwa/src/transport/flit.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/pwa/src/App.css b/pwa/src/App.css index 867a614..94623b8 100644 --- a/pwa/src/App.css +++ b/pwa/src/App.css @@ -278,6 +278,19 @@ font-weight: 400; } +.presence-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.presence-online { background: var(--accent); box-shadow: 0 0 4px var(--accent); } +.presence-offline { background: var(--muted); opacity: 0.5; } +.presence-unknown { background: var(--muted); opacity: 0.25; } + .peer-id { font-family: var(--mono); font-size: 11px; diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index c77c89b..d9daf30 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import QRCode from 'qrcode' -import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit' +import { FlitSession, queryPresence, netHash, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit' import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral' import { QrScanner } from './pairing/QrScanner' import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring' @@ -40,6 +40,7 @@ function App() { const [sentFiles, setSentFiles] = useState([]) const [scanning, setScanning] = useState(false) const [myId, setMyId] = useState(null) + const [presence, setPresence] = useState>({}) const [addIdInput, setAddIdInput] = useState('') const [copied, setCopied] = useState<'snip' | 'id' | null>(null) const sessionRef = useRef(null) @@ -65,6 +66,32 @@ function App() { return c } + // Presence check for known devices (waste-go EXT-009). Each pairing has its + // own deterministic anchor room, so this is one short-lived query per + // device — no persistent connection held while idle. + async function refreshPresence() { + if (!myId || trusted.length === 0) return + const signalURL = cfg().signalURL + const results = await Promise.all(trusted.map(async (p) => { + try { + const hash = await netHash(pairRoomName(myId, p.id)) + const online = await queryPresence(signalURL, hash, p.id) + return [p.id, online] as const + } catch { + return [p.id, null] as const + } + })) + setPresence(Object.fromEntries(results)) + } + + useEffect(() => { + if (mode !== 'known' || phase !== 'idle' || !myId || trusted.length === 0) return + void refreshPresence() + const iv = setInterval(() => void refreshPresence(), 15000) + return () => clearInterval(iv) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, phase, myId, trusted]) + function peerEvents() { return { connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') }, @@ -259,7 +286,13 @@ function App() {
  • - {p.label} + + + {p.label} + {p.id.slice(0, 16)}…
    diff --git a/pwa/src/transport/flit.ts b/pwa/src/transport/flit.ts index c39694f..b91d59f 100644 --- a/pwa/src/transport/flit.ts +++ b/pwa/src/transport/flit.ts @@ -79,6 +79,38 @@ export async function netHash(name: string): Promise { throw new Error('SHA-256 not available in this browser runtime') } +// queryPresence asks the anchor whether `id` is currently connected in +// network `net` (waste-go EXT-009). Opens a short-lived WS connection — +// no join, no identity signature required, since the query is anchor-local +// and scoped to a (net, id) pair the caller must already know. Resolves +// `null` on timeout/error (treat the same as "unknown", not "offline"). +export function queryPresence(url: string, net: string, id: string, timeoutMs = 4000): Promise { + return new Promise((resolve) => { + let settled = false + const ws = new WebSocket(url) + const finish = (result: boolean | null) => { + if (settled) return + settled = true + clearTimeout(timer) + try { ws.close() } catch { /* ignore */ } + resolve(result) + } + const timer = setTimeout(() => finish(null), timeoutMs) + ws.onopen = () => { + try { ws.send(JSON.stringify({ type: 'presence_query', net, id })) } catch { finish(null) } + } + ws.onmessage = (ev) => { + let m: Record + try { m = JSON.parse(ev.data) } catch { return } + if (m['type'] === 'presence' && m['id'] === id && m['net'] === net) { + finish(Boolean(m['online'])) + } + } + ws.onerror = () => finish(null) + ws.onclose = () => finish(null) + }) +} + // ── Identity ───────────────────────────────────────────────────────────────── export class Identity {