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);
|
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 {
|
.btn-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -200,9 +239,8 @@
|
|||||||
|
|
||||||
.known-row {
|
.known-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -212,6 +250,14 @@
|
|||||||
|
|
||||||
.known-row:hover { border-color: var(--accent-border); }
|
.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 {
|
.known-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -238,12 +284,16 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.known-actions {
|
.known-secondary {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.known-connect {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
|
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.qr-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 { FlitSession, 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, type TrustedPeer } from './pairing/keyring'
|
import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring'
|
||||||
import { consumeSharedFiles, registerServiceWorker } from './share-target'
|
import { consumeSharedFiles, registerServiceWorker } from './share-target'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -39,7 +39,11 @@ function App() {
|
|||||||
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
|
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
|
||||||
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 [addIdInput, setAddIdInput] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
const sessionRef = useRef<FlitSession | null>(null)
|
const sessionRef = useRef<FlitSession | null>(null)
|
||||||
|
const verifiedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerServiceWorker()
|
registerServiceWorker()
|
||||||
@@ -48,6 +52,10 @@ function App() {
|
|||||||
if (files.length) void sendFiles(files)
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -59,9 +67,16 @@ function App() {
|
|||||||
|
|
||||||
function peerEvents() {
|
function peerEvents() {
|
||||||
return {
|
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),
|
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),
|
fileProgress: (e: FileProgressEvent) => setProgress(e),
|
||||||
fileRecv: (e: FileRecvEvent) => {
|
fileRecv: (e: FileRecvEvent) => {
|
||||||
setReceived((r) => [...r, e])
|
setReceived((r) => [...r, e])
|
||||||
@@ -162,6 +177,57 @@ function App() {
|
|||||||
setTrusted(listTrusted())
|
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 (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<h1>flit<span className="dot">.</span></h1>
|
<h1>flit<span className="dot">.</span></h1>
|
||||||
@@ -190,15 +256,22 @@ function App() {
|
|||||||
: <ul className="known-list">
|
: <ul className="known-list">
|
||||||
{trusted.map(p => (
|
{trusted.map(p => (
|
||||||
<li key={p.id} className="known-row">
|
<li key={p.id} className="known-row">
|
||||||
|
<div className="known-top">
|
||||||
<div className="known-info">
|
<div className="known-info">
|
||||||
<span className="known-label">{p.label}</span>
|
<span className="known-label">{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-actions">
|
<div className="known-secondary">
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => connectToKnown(p)}>Connect</button>
|
<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" onClick={() => renameKnown(p)}>Rename</button>
|
||||||
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
|
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary known-connect" onClick={() => connectToKnown(p)}>Connect</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -218,6 +291,26 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +344,10 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
{peerId && <span className="peer-id">{peerId.slice(0, 16)}…</span>}
|
{peerId && <span className="peer-id">{peerId.slice(0, 16)}…</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="btn-row">
|
||||||
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
|
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
|
||||||
|
<button className="btn btn-danger" onClick={disconnect}>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="dropzone">
|
<label className="dropzone">
|
||||||
Tap to choose file(s) to send
|
Tap to choose file(s) to send
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface TrustedPeer {
|
|||||||
id: string // hex Ed25519 pubkey
|
id: string // hex Ed25519 pubkey
|
||||||
label: string
|
label: string
|
||||||
addedAt: number
|
addedAt: number
|
||||||
|
autoAccept?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = 'flit_keyring'
|
const KEY = 'flit_keyring'
|
||||||
@@ -19,6 +20,11 @@ export function addTrusted(id: string, label: string): void {
|
|||||||
localStorage.setItem(KEY, JSON.stringify(peers))
|
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 {
|
export function removeTrusted(id: string): void {
|
||||||
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
|
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 {
|
export function isTrusted(id: string): boolean {
|
||||||
return listTrusted().some(p => p.id === id)
|
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