From 488431eaec7d04660ca896a3ea611bdc82643eca Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Wed, 1 Jul 2026 17:07:11 +0200 Subject: [PATCH] Add per-peer auto-accept, disconnect, copy snip, and add-by-ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-accept toggle per trusted peer (keyring.autoAccept); only activates when peer is cryptographically verified — unverified connections always prompt regardless of setting - Disconnect button on connected screen returns to idle with full state reset - Copy snip: copies own peer ID as a flit-peer: string for sharing out-of-band (no QR needed for initial pairing) - Add peer by ID: paste a raw hex peer ID or flit-peer: snip in Invite new tab to add directly to keyring and connect without QR - Own peer ID always visible at bottom of idle screen - Restructured known-device row for mobile: secondary actions (Auto/Rename/Forget) on top line, Connect full-width below Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/App.css | 58 ++++++++++++++++-- pwa/src/App.tsx | 120 +++++++++++++++++++++++++++++++++---- pwa/src/pairing/keyring.ts | 10 ++++ 3 files changed, 172 insertions(+), 16 deletions(-) diff --git a/pwa/src/App.css b/pwa/src/App.css index 4b49578..867a614 100644 --- a/pwa/src/App.css +++ b/pwa/src/App.css @@ -106,6 +106,45 @@ background: rgba(255,107,107,0.08); } +.btn-accent { + color: var(--accent); + border-color: var(--accent-border); + background: var(--accent-dim); +} + +.btn-accent:hover { + background: rgba(0,232,122,0.2); + border-color: var(--accent); +} + +.my-id-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 8px; + margin-top: 8px; +} + +.my-id-label { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + flex-shrink: 0; +} + +.my-id-row .peer-id { + flex: 1; +} + +.my-id-row .btn { + flex-shrink: 0; +} + .btn-link { background: none; border: none; @@ -200,9 +239,8 @@ .known-row { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + flex-direction: column; + gap: 10px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 8px; @@ -212,6 +250,14 @@ .known-row:hover { border-color: var(--accent-border); } +.known-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + .known-info { display: flex; flex-direction: column; @@ -238,12 +284,16 @@ color: var(--muted); } -.known-actions { +.known-secondary { display: flex; gap: 6px; flex-shrink: 0; } +.known-connect { + width: 100%; +} + /* ── QR / hosting card ───────────────────────────────────────────────────────── */ .qr-card { diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index fe1fa3a..63b5a7f 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -3,7 +3,7 @@ import QRCode from 'qrcode' import { FlitSession, 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, type TrustedPeer } from './pairing/keyring' +import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring' import { consumeSharedFiles, registerServiceWorker } from './share-target' import './App.css' @@ -39,7 +39,11 @@ function App() { const [sendProgress, setSendProgress] = useState(null) const [sentFiles, setSentFiles] = useState([]) const [scanning, setScanning] = useState(false) + const [myId, setMyId] = useState(null) + const [addIdInput, setAddIdInput] = useState('') + const [copied, setCopied] = useState(false) const sessionRef = useRef(null) + const verifiedRef = useRef(false) useEffect(() => { registerServiceWorker() @@ -48,6 +52,10 @@ function App() { if (files.length) void sendFiles(files) }) } + // Load own identity eagerly so we can show/copy our peer ID + try { + FlitSession.init(cfg()).then(s => setMyId(s.identity.id)).catch(() => {}) + } catch { /* config not ready yet */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -59,9 +67,16 @@ function App() { function peerEvents() { return { - connected: (v: boolean, pid: string) => { setVerified(v); setPeerId(pid); setPhase('connected') }, + connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') }, status: (s: RTCPeerConnectionState) => setConnState(s), - fileOffer: (e: FileOfferEvent) => { setOffers((o) => [...o, e]); setPeerId(e.peer) }, + fileOffer: (e: FileOfferEvent) => { + setPeerId(e.peer) + if (verifiedRef.current && shouldAutoAccept(e.peer)) { + sessionRef.current?.acceptOffer(e.xid, e.name, e.size) + } else { + setOffers((o) => [...o, e]) + } + }, fileProgress: (e: FileProgressEvent) => setProgress(e), fileRecv: (e: FileRecvEvent) => { setReceived((r) => [...r, e]) @@ -162,6 +177,57 @@ function App() { setTrusted(listTrusted()) } + function toggleAutoAccept(peer: TrustedPeer) { + setAutoAccept(peer.id, !peer.autoAccept) + setTrusted(listTrusted()) + } + + function disconnect() { + sessionRef.current?.close() + sessionRef.current = null + setPhase('idle') + setVerified(false) + verifiedRef.current = false + setPeerId('') + setOffers([]) + setProgress(null) + setSendProgress(null) + setConnState(null) + } + + function addPeerById() { + const raw = addIdInput.trim() + if (!raw) return + // Accept bare hex ID or flit-peer: snip + let id = raw + let suggestedLabel = '' + if (raw.startsWith('flit-peer:')) { + try { + const b64 = raw.slice('flit-peer:'.length).replace(/-/g, '+').replace(/_/g, '/') + const parsed = JSON.parse(atob(b64)) as { id: string; label?: string } + id = parsed.id + suggestedLabel = parsed.label ?? '' + } catch { setError('Invalid flit-peer snip'); return } + } + if (!/^[0-9a-f]{64}$/i.test(id)) { setError('Not a valid peer ID (64 hex chars)'); return } + const label = window.prompt('Label for this device', suggestedLabel || id.slice(0, 8)) + if (label === null) return + addTrusted(id, label.trim() || id.slice(0, 8)) + setTrusted(listTrusted()) + setAddIdInput('') + setMode('known') + } + + function copyMyId() { + if (!myId) return + // Build a snip the other side can paste into "Add peer" + const snip = 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + navigator.clipboard.writeText(snip).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + return (

flit.

@@ -190,15 +256,22 @@ function App() { :
    {trusted.map(p => (
  • -
    - {p.label} - {p.id.slice(0, 16)}… -
    -
    - - - +
    +
    + {p.label} + {p.id.slice(0, 16)}… +
    +
    + + + +
    +
  • ))}
@@ -218,6 +291,26 @@ function App() { /> +
or add known peer
+
+ setAddIdInput(e.target.value)} + /> + +
+ + )} + + {myId && ( +
+ your ID + {myId.slice(0, 16)}… +
)} @@ -251,7 +344,10 @@ function App() { {peerId && {peerId.slice(0, 16)}…} - +
+ + +