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:
@@ -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 {
|
||||
|
||||
106
pwa/src/App.tsx
106
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<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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user