Add per-peer auto-accept, disconnect, copy snip, and add-by-ID

- 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:<base64> 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 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-01 17:07:11 +02:00
parent 0b06500b9a
commit 488431eaec
3 changed files with 172 additions and 16 deletions

View File

@@ -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 {

View File

@@ -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<FileSendProgressEvent | null>(null)
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
const [scanning, setScanning] = useState(false)
const [myId, setMyId] = useState<string | null>(null)
const [addIdInput, setAddIdInput] = useState('')
const [copied, setCopied] = useState(false)
const sessionRef = useRef<FlitSession | null>(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:<base64> 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 (
<main className="app">
<h1>flit<span className="dot">.</span></h1>
@@ -190,15 +256,22 @@ function App() {
: <ul className="known-list">
{trusted.map(p => (
<li key={p.id} className="known-row">
<div className="known-top">
<div className="known-info">
<span className="known-label">{p.label}</span>
<span className="peer-id">{p.id.slice(0, 16)}</span>
</div>
<div className="known-actions">
<button className="btn btn-primary btn-sm" onClick={() => connectToKnown(p)}>Connect</button>
<div className="known-secondary">
<button
className={`btn btn-sm ${p.autoAccept ? 'btn-accent' : ''}`}
title="Auto-accept files from this device when verified"
onClick={() => toggleAutoAccept(p)}
>{p.autoAccept ? 'Auto ✓' : 'Auto'}</button>
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
</div>
</div>
<button className="btn btn-primary known-connect" onClick={() => connectToKnown(p)}>Connect</button>
</li>
))}
</ul>
@@ -218,6 +291,26 @@ function App() {
/>
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
</div>
<div className="divider">or add known peer</div>
<div className="join-row">
<input
className="text-input"
placeholder="peer ID or flit-peer: snip"
value={addIdInput}
onChange={(e) => setAddIdInput(e.target.value)}
/>
<button className="btn" onClick={addPeerById}>Add</button>
</div>
</div>
)}
{myId && (
<div className="my-id-row">
<span className="my-id-label">your ID</span>
<span className="peer-id">{myId.slice(0, 16)}</span>
<button className="btn btn-sm" onClick={copyMyId}>
{copied ? 'Copied ✓' : 'Copy snip'}
</button>
</div>
)}
</div>
@@ -251,7 +344,10 @@ function App() {
</span>
{peerId && <span className="peer-id">{peerId.slice(0, 16)}</span>}
</div>
<div className="btn-row">
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
<button className="btn btn-danger" onClick={disconnect}>Disconnect</button>
</div>
<label className="dropzone">
Tap to choose file(s) to send

View File

@@ -5,6 +5,7 @@ export interface TrustedPeer {
id: string // hex Ed25519 pubkey
label: string
addedAt: number
autoAccept?: boolean
}
const KEY = 'flit_keyring'
@@ -19,6 +20,11 @@ export function addTrusted(id: string, label: string): void {
localStorage.setItem(KEY, JSON.stringify(peers))
}
export function setAutoAccept(id: string, value: boolean): void {
const peers = listTrusted().map(p => p.id === id ? { ...p, autoAccept: value } : p)
localStorage.setItem(KEY, JSON.stringify(peers))
}
export function removeTrusted(id: string): void {
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
}
@@ -26,3 +32,7 @@ export function removeTrusted(id: string): void {
export function isTrusted(id: string): boolean {
return listTrusted().some(p => p.id === id)
}
export function shouldAutoAccept(id: string): boolean {
return listTrusted().some(p => p.id === id && p.autoAccept === true)
}