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 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-09 15:06:48 +02:00
parent 6a3ad787d5
commit bd7879ce7b
3 changed files with 80 additions and 2 deletions

View File

@@ -278,6 +278,19 @@
font-weight: 400; 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 { .peer-id {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 11px;

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode' 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 { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
import { QrScanner } from './pairing/QrScanner' import { QrScanner } from './pairing/QrScanner'
import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring' import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring'
@@ -40,6 +40,7 @@ function App() {
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([]) const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
const [scanning, setScanning] = useState(false) const [scanning, setScanning] = useState(false)
const [myId, setMyId] = useState<string | null>(null) const [myId, setMyId] = useState<string | null>(null)
const [presence, setPresence] = useState<Record<string, boolean | null>>({})
const [addIdInput, setAddIdInput] = useState('') const [addIdInput, setAddIdInput] = useState('')
const [copied, setCopied] = useState<'snip' | 'id' | null>(null) const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
const sessionRef = useRef<FlitSession | null>(null) const sessionRef = useRef<FlitSession | null>(null)
@@ -65,6 +66,32 @@ function App() {
return c 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() { function peerEvents() {
return { return {
connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') }, connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') },
@@ -259,7 +286,13 @@ function App() {
<li key={p.id} className="known-row"> <li key={p.id} className="known-row">
<div className="known-top"> <div className="known-top">
<div className="known-info"> <div className="known-info">
<span className="known-label">{p.label}</span> <span className="known-label">
<span
className={`presence-dot ${presence[p.id] === true ? 'presence-online' : presence[p.id] === false ? 'presence-offline' : 'presence-unknown'}`}
title={presence[p.id] === true ? 'Online' : presence[p.id] === false ? 'Offline' : 'Unknown'}
/>
{p.label}
</span>
<span className="peer-id">{p.id.slice(0, 16)}</span> <span className="peer-id">{p.id.slice(0, 16)}</span>
</div> </div>
<div className="known-secondary"> <div className="known-secondary">

View File

@@ -79,6 +79,38 @@ export async function netHash(name: string): Promise<string> {
throw new Error('SHA-256 not available in this browser runtime') 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<boolean | null> {
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<string, unknown>
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 ───────────────────────────────────────────────────────────────── // ── Identity ─────────────────────────────────────────────────────────────────
export class Identity { export class Identity {