Add PWA, CLI daemon, deploy tooling, and full documentation

PWA (pwa/):
- Ephemeral QR pairing and trusted device direct reconnect (no QR re-scan)
- Known devices tab as default; pairRoomName() derives deterministic room
- In-app QR scanner via native BarcodeDetector API
- Multi-file send/receive with offer queue and Accept all
- Auto-download on receipt, real-time send/receive progress bars
- Trusted peer nicknames, rename, forget
- Terminal aesthetic: #080808 bg, #00e87a accent, JetBrains Mono
- manifest.json corrected (SVG icon, theme_color, share_target)
- Apple PWA meta tags for home screen install

CLI (cli/):
- flit send / flit recv: ephemeral one-shot transfer with terminal QR
- flit daemon: persistent receiver for trusted peers from ~/.flit/daemon.toml
  - TOML config: signal_url, turn_url, download_dir, [[peers]]
  - One goroutine per peer, exponential backoff reconnect (2s→30s cap)
  - transport.PairRoomName() and Session.OnDisconnected added
- Anchor/TURN config via FLIT_SIGNAL_URL / FLIT_TURN_URL env vars (no hardcoded URLs)

Deploy:
- build-pwa.sh, deploy-pwa.sh / serve-pwa.sh templates in README (gitignored)
- .gitea/workflows/build.yml: PWA build on v* tag, Gitea release artifact
- pwa/public/config.js gitignored; config.js.example committed
- .env.example for CLI env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-01 15:34:53 +02:00
parent 5050ad5e79
commit 0b06500b9a
22 changed files with 1554 additions and 204 deletions

View File

@@ -1,32 +1,18 @@
# React + TypeScript + Vite
# flit — PWA
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
React/TypeScript/Vite frontend. See the [root README](../README.md) for the full project overview.
Currently, two official plugins are available:
## Development
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```bash
npm install
npm run dev # dev server at localhost:5173
npm run build # output to dist/
npm run lint
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
`public/config.js` sets the anchor and TURN URLs at runtime and is excluded from production deploys (so the host copy isn't overwritten). The repo's default points at the live anchor — fine for local dev.
## Deploy
See `../deploy-pwa.sh` and `../serve-pwa.sh` at the repo root.

View File

@@ -4,8 +4,14 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1f6feb" />
<meta name="theme-color" content="#00e87a" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="flit" />
<link rel="manifest" href="/manifest.json" />
<title>flit</title>
<script src="/config.js"></script>

View File

@@ -1,5 +0,0 @@
window.FLIT_CONFIG = {
signalURL: 'wss://waste.dev.xplwd.com/ws',
turnURL: 'turn:waste.dev.xplwd.com:3478',
turnCredentialsURL: 'https://waste.dev.xplwd.com/turn-credentials',
}

View File

@@ -0,0 +1,12 @@
// Copy this file to config.js and fill in your own anchor URL.
// config.js is gitignored — this example is what gets committed.
//
// signalURL — WebSocket URL of your waste-go anchor (/ws endpoint)
// turnURL — TURN relay (coturn), optional but needed across mobile networks
// turnCredentialsURL — anchor endpoint that mints short-lived TURN credentials
// (requires -turn-secret flag on the anchor; omit if no TURN)
window.FLIT_CONFIG = {
signalURL: 'wss://your-anchor.example.com/ws',
turnURL: 'turn:your-anchor.example.com:3478',
turnCredentialsURL: 'https://your-anchor.example.com/turn-credentials',
}

View File

@@ -4,11 +4,10 @@
"description": "Ephemeral, end-to-end encrypted file transfer between your own devices.",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#1f6feb",
"background_color": "#080808",
"theme_color": "#00e87a",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
],
"share_target": {
"action": "/share-target",

520
pwa/src/App.css Normal file
View File

@@ -0,0 +1,520 @@
.app {
width: 420px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
box-sizing: border-box;
}
/* ── Header ─────────────────────────────────────────────────────────────────── */
.app h1 {
font-family: var(--mono);
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
text-align: center;
margin: 0 0 32px;
color: var(--heading);
}
.app h1::before {
content: '> ';
color: var(--muted);
font-weight: 400;
}
.dot { color: var(--accent); }
/* ── Error banner ────────────────────────────────────────────────────────────── */
.error-banner {
font-family: var(--mono);
font-size: 13px;
color: #ff6b6b;
border: 1px solid rgba(255,107,107,0.25);
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 16px;
background: rgba(255,107,107,0.06);
}
/* ── Card ────────────────────────────────────────────────────────────────────── */
.card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
background: var(--bg-alt);
box-shadow: var(--shadow);
}
.stack {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Buttons ─────────────────────────────────────────────────────────────────── */
.btn {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.01em;
}
.btn:hover {
border-color: var(--accent-border);
color: var(--heading);
}
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
font-weight: 700;
}
.btn-primary:hover {
background: #00ff88;
border-color: #00ff88;
color: #000;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-danger {
color: #ff6b6b;
border-color: rgba(255,107,107,0.2);
}
.btn-danger:hover {
border-color: #ff6b6b;
background: rgba(255,107,107,0.08);
}
.btn-link {
background: none;
border: none;
padding: 0;
font-family: var(--mono);
font-size: inherit;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.btn-row {
display: flex;
gap: 8px;
}
.btn-row .btn { flex: 1; }
/* ── Inputs ──────────────────────────────────────────────────────────────────── */
.text-input {
font-family: var(--mono);
font-size: 13px;
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--heading);
}
.text-input::placeholder { color: var(--muted); }
.text-input:focus {
outline: none;
border-color: var(--accent-border);
}
.join-row {
display: flex;
gap: 8px;
}
/* ── Divider ─────────────────────────────────────────────────────────────────── */
.divider {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.05em;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ── Mode toggle ─────────────────────────────────────────────────────────────── */
.mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.mode-toggle .btn { flex: 1; }
/* ── Known devices list ──────────────────────────────────────────────────────── */
.empty-state {
text-align: center;
color: var(--muted);
font-size: 14px;
line-height: 1.7;
}
.known-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.known-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-alt);
transition: border-color 0.15s;
}
.known-row:hover { border-color: var(--accent-border); }
.known-info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.known-label {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
color: var(--heading);
}
.known-label::before {
content: '@ ';
color: var(--accent);
font-weight: 400;
}
.peer-id {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.known-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
.qr-card {
text-align: center;
}
.qr-hint {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
margin-bottom: 12px;
}
.qr-hint::before { content: '// '; }
.qr-card img {
width: 200px;
height: 200px;
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 16px;
image-rendering: pixelated;
}
/* ── Connecting / status ─────────────────────────────────────────────────────── */
.connecting-card {
text-align: center;
padding: 40px 20px;
}
.status-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(0,232,122,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Connected / peer info ───────────────────────────────────────────────────── */
.peer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.peer-badge {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid;
letter-spacing: 0.03em;
}
.peer-badge.verified {
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-dim);
}
.peer-badge.unverified {
color: #f59e0b;
border-color: rgba(245,158,11,0.25);
background: rgba(245,158,11,0.08);
}
/* ── Drop zone ───────────────────────────────────────────────────────────────── */
.dropzone {
position: relative;
border: 1px dashed var(--border);
border-radius: 8px;
padding: 24px 16px;
text-align: center;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone::before {
content: '+ ';
color: var(--accent);
}
.dropzone input[type='file'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.dropzone:hover {
border-color: var(--accent-border);
background: var(--accent-dim);
color: var(--heading);
}
/* ── Transfer progress ───────────────────────────────────────────────────────── */
.transfer { }
.transfer-row {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.pct { color: var(--accent); }
progress.bar {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border: none;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
progress.bar::-webkit-progress-bar {
background: var(--border);
border-radius: 999px;
}
progress.bar::-webkit-progress-value {
background: var(--accent);
border-radius: 999px;
transition: width 0.1s ease;
box-shadow: 0 0 8px rgba(0,232,122,0.5);
}
progress.bar::-moz-progress-bar {
background: var(--accent);
border-radius: 999px;
}
/* ── File lists ──────────────────────────────────────────────────────────────── */
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: var(--bg);
}
.file-row a {
color: var(--accent);
text-decoration: none;
}
.file-row a:hover { text-decoration: underline; }
.file-size {
color: var(--muted);
margin-left: auto;
font-size: 11px;
}
/* ── Incoming offer card ─────────────────────────────────────────────────────── */
.offer-card {
border-color: var(--accent-border);
background: linear-gradient(135deg, rgba(0,232,122,0.04) 0%, var(--bg-alt) 100%);
}
.offer-card .btn-row { margin-top: 4px; }
.offer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.offer-name {
font-family: var(--mono);
font-size: 13px;
color: var(--heading);
}
/* ── QR scanner overlay ──────────────────────────────────────────────────────── */
.scanner-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
backdrop-filter: blur(4px);
}
.scanner-card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: 100%;
max-width: 360px;
box-shadow: var(--shadow);
}
.scanner-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
font-family: var(--mono);
font-size: 13px;
color: var(--heading);
}
.scanner-video {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
aspect-ratio: 1;
object-fit: cover;
background: #000;
}
.scanner-error {
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 24px 0;
}

View File

@@ -1,25 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FlitConfig } from './transport/flit'
import { createInvite, decodeInvite } from './pairing/ephemeral'
import { addTrusted, listTrusted } from './pairing/keyring'
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 { consumeSharedFiles, registerServiceWorker } from './share-target'
import './App.css'
function humanSize(n: number): string {
if (n < 1024) return `${n} B`
const units = ['KB', 'MB', 'GB']
let v = n / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
declare global {
interface Window { FLIT_CONFIG?: FlitConfig }
}
type Phase = 'idle' | 'hosting' | 'joining' | 'connected'
type Mode = 'known' | 'invite'
function App() {
const [phase, setPhase] = useState<Phase>('idle')
const [mode, setMode] = useState<Mode>(listTrusted().length > 0 ? 'known' : 'invite')
const [trusted, setTrusted] = useState<TrustedPeer[]>(listTrusted())
const [qrDataUrl, setQrDataUrl] = useState<string>('')
const [inviteInput, setInviteInput] = useState('')
const [verified, setVerified] = useState(false)
const [peerId, setPeerId] = useState('')
const [offer, setOffer] = useState<FileOfferEvent | null>(null)
const [offers, setOffers] = useState<FileOfferEvent[]>([])
const [progress, setProgress] = useState<FileProgressEvent | null>(null)
const [received, setReceived] = useState<FileRecvEvent[]>([])
const [connState, setConnState] = useState<RTCPeerConnectionState | null>(null)
const [error, setError] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
const [scanning, setScanning] = useState(false)
const sessionRef = useRef<FlitSession | null>(null)
useEffect(() => {
@@ -40,31 +59,66 @@ function App() {
function peerEvents() {
return {
connected: (v: boolean) => { setVerified(v); setPhase('connected') },
status: () => {},
fileOffer: (e: FileOfferEvent) => { setOffer(e); setPeerId(e.peer) },
connected: (v: boolean, pid: string) => { setVerified(v); setPeerId(pid); setPhase('connected') },
status: (s: RTCPeerConnectionState) => setConnState(s),
fileOffer: (e: FileOfferEvent) => { setOffers((o) => [...o, e]); setPeerId(e.peer) },
fileProgress: (e: FileProgressEvent) => setProgress(e),
fileRecv: (e: FileRecvEvent) => { setReceived((r) => [...r, e]); setProgress(null) },
fileCancelled: () => setOffer(null),
fileRecv: (e: FileRecvEvent) => {
setReceived((r) => [...r, e])
setProgress(null)
const a = document.createElement('a')
a.href = e.url
a.download = e.name
a.click()
},
fileCancelled: (xid: string) => setOffers((o) => o.filter(f => f.xid !== xid)),
fileSendProgress: (e: FileSendProgressEvent) => setSendProgress(e),
fileSent: (e: FileSentEvent) => { setSentFiles((f) => [...f, e]); setSendProgress(null) },
}
}
async function startHost() {
const { invite, room } = createInvite(cfg().signalURL)
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
setPhase('hosting')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(room, peerEvents())
setError(null)
try {
const { invite, room } = createInvite(cfg().signalURL)
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
setPhase('hosting')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(room, peerEvents())
} catch (e) {
setError(String(e instanceof Error ? e.message : e))
setPhase('idle')
}
}
async function connectToKnown(peer: TrustedPeer) {
setError(null)
setPhase('joining')
try {
const session = await FlitSession.init(cfg())
sessionRef.current = session
const room = pairRoomName(session.identity.id, peer.id)
await session.join(room, peerEvents(), peer.id)
} catch (e) {
setError(String(e instanceof Error ? e.message : e))
setPhase('idle')
}
}
async function joinFromInvite(raw: string) {
setError(null)
const inv = decodeInvite(raw)
if (!inv) { alert('Not a valid flit invite'); return }
if (!inv) { setError('Not a valid flit invite'); return }
setPhase('joining')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(inv.room, peerEvents())
try {
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(inv.room, peerEvents())
} catch (e) {
setError(String(e instanceof Error ? e.message : e))
setPhase('idle')
}
}
async function sendFiles(files: File[]) {
@@ -73,90 +127,206 @@ function App() {
for (const f of files) session.sendFile(f)
}
function acceptOffer() {
if (!offer) return
sessionRef.current?.acceptOffer(offer.xid, offer.name, offer.size)
setOffer(null)
function acceptOffer(o: FileOfferEvent) {
sessionRef.current?.acceptOffer(o.xid, o.name, o.size)
setOffers((prev) => prev.filter(f => f.xid !== o.xid))
}
function rejectOffer() {
if (!offer) return
sessionRef.current?.rejectOffer(offer.xid)
setOffer(null)
function rejectOffer(o: FileOfferEvent) {
sessionRef.current?.rejectOffer(o.xid)
setOffers((prev) => prev.filter(f => f.xid !== o.xid))
}
function acceptAllOffers() {
offers.forEach(o => sessionRef.current?.acceptOffer(o.xid, o.name, o.size))
setOffers([])
}
function trustCurrentPeer() {
if (!peerId) return
addTrusted(peerId, peerId.slice(0, 8))
const label = window.prompt('Nickname for this device (e.g. "home", "phone")', peerId.slice(0, 8))
if (label === null) return
addTrusted(peerId, label.trim() || peerId.slice(0, 8))
setTrusted(listTrusted())
}
function renameKnown(peer: TrustedPeer) {
const label = window.prompt('Rename device', peer.label)
if (label === null) return
addTrusted(peer.id, label.trim() || peer.label)
setTrusted(listTrusted())
}
function forgetKnown(peer: TrustedPeer) {
removeTrusted(peer.id)
setTrusted(listTrusted())
}
return (
<main style={{ maxWidth: 480, margin: '0 auto', padding: 16, fontFamily: 'system-ui' }}>
<h1>flit</h1>
<main className="app">
<h1>flit<span className="dot">.</span></h1>
{error && <div className="error-banner"> {error}</div>}
{phase === 'idle' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button onClick={startHost}>Send a file (show QR)</button>
<div>
<input
placeholder="paste flit: invite, or scan"
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
style={{ width: '100%' }}
/>
<button onClick={() => joinFromInvite(inviteInput)}>Join</button>
<div className="stack">
<div className="mode-toggle">
<button
className={`btn ${mode === 'known' ? 'btn-primary' : ''}`}
onClick={() => setMode('known')}
>Known devices</button>
<button
className={`btn ${mode === 'invite' ? 'btn-primary' : ''}`}
onClick={() => setMode('invite')}
>Invite new</button>
</div>
<details>
<summary>Trusted devices ({listTrusted().length})</summary>
<ul>
{listTrusted().map((p) => <li key={p.id}>{p.label} {p.id.slice(0, 16)}</li>)}
</ul>
</details>
{mode === 'known' && (
trusted.length === 0
? <div className="card empty-state">
<p>No known devices yet.</p>
<p>Pair a new device via <button className="btn-link" onClick={() => setMode('invite')}>Invite new</button> and tap "Remember this device" after connecting.</p>
</div>
: <ul className="known-list">
{trusted.map(p => (
<li key={p.id} className="known-row">
<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>
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
</div>
</li>
))}
</ul>
)}
{mode === 'invite' && (
<div className="card stack">
<button className="btn btn-primary" onClick={startHost}>Show QR to pair</button>
<div className="divider">or join</div>
<button className="btn" onClick={() => setScanning(true)}>Scan QR</button>
<div className="join-row">
<input
className="text-input"
placeholder="paste flit: invite"
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
/>
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
</div>
</div>
)}
</div>
)}
{phase === 'hosting' && qrDataUrl && (
<div>
<p>Scan this on the receiving device:</p>
<img src={qrDataUrl} alt="pairing QR" width={256} height={256} />
<div className="card qr-card">
<p className="qr-hint">Scan this on the receiving device</p>
<img src={qrDataUrl} alt="pairing QR" width={220} height={220} />
<div className="status-row">
<span className="spinner" />
{connState ? `Peer status: ${connState}` : 'Waiting for peer…'}
</div>
</div>
)}
{phase === 'joining' && <p>Connecting</p>}
{phase === 'joining' && (
<div className="card connecting-card">
<div className="status-row">
<span className="spinner" />
Connecting {connState && `(${connState})`}
</div>
</div>
)}
{phase === 'connected' && (
<div>
<p>{verified ? '✅ Verified peer' : '⚠️ Unverified — connection not confirmed'} {peerId && `(${peerId.slice(0, 16)}…)`}</p>
<button onClick={trustCurrentPeer}>Remember this device</button>
<div>
<div className="card stack">
<div className="peer-row">
<span className={`peer-badge ${verified ? 'verified' : 'unverified'}`}>
{verified ? '✅ Verified peer' : '⚠️ Unverified peer'}
</span>
{peerId && <span className="peer-id">{peerId.slice(0, 16)}</span>}
</div>
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
<label className="dropzone">
Tap to choose file(s) to send
<input
type="file"
multiple
onChange={(e) => e.target.files && sendFiles(Array.from(e.target.files))}
/>
</div>
</label>
{sendProgress && (
<div className="transfer">
<div className="transfer-row">
<span>Sending {sendProgress.name}</span>
<span className="pct">{Math.round((sendProgress.sent / sendProgress.total) * 100)}%</span>
</div>
<progress className="bar" value={sendProgress.sent} max={sendProgress.total} />
</div>
)}
{sentFiles.length > 0 && (
<ul className="file-list">
{sentFiles.map((f, i) => (
<li className="file-row" key={i}> sent {f.name}</li>
))}
</ul>
)}
</div>
)}
{offer && (
<div style={{ border: '1px solid', padding: 8, marginTop: 12 }}>
<p>Incoming file: {offer.name} ({offer.size} bytes)</p>
<button onClick={acceptOffer}>Accept</button>
<button onClick={rejectOffer}>Reject</button>
{offers.length > 0 && (
<div className="card offer-card stack">
{offers.length > 1 && (
<div className="btn-row">
<button className="btn btn-primary" onClick={acceptAllOffers}>Accept all ({offers.length})</button>
</div>
)}
{offers.map(o => (
<div key={o.xid} className="offer-row">
<span className="offer-name"><strong>{o.name}</strong> <span className="file-size">{humanSize(o.size)}</span></span>
<div className="known-actions">
<button className="btn btn-primary btn-sm" onClick={() => acceptOffer(o)}>Accept</button>
<button className="btn btn-sm" onClick={() => rejectOffer(o)}>Reject</button>
</div>
</div>
))}
</div>
)}
{progress && (
<p>Receiving {progress.name}: {progress.received}/{progress.total}</p>
<div className="card transfer">
<div className="transfer-row">
<span>Receiving {progress.name}</span>
<span className="pct">{Math.round((progress.received / progress.total) * 100)}%</span>
</div>
<progress className="bar" value={progress.received} max={progress.total} />
</div>
)}
{received.length > 0 && (
<ul>
<ul className="file-list">
{received.map((r, i) => (
<li key={i}><a href={r.url} download={r.name}>{r.name}</a> ({r.size} bytes)</li>
<li className="file-row" key={i}>
<a href={r.url} download={r.name}>{r.name}</a>
<span className="file-size">{humanSize(r.size)}</span>
</li>
))}
</ul>
)}
{scanning && (
<QrScanner
onDetect={(val) => { setScanning(false); void joinFromInvite(val) }}
onClose={() => setScanning(false)}
/>
)}
</main>
)
}

View File

@@ -1,111 +1,50 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--bg: #080808;
--bg-alt: #0e0e0e;
--text: #c8c8c8;
--muted: #555;
--heading: #f0f0f0;
--accent: #00e87a;
--accent-dim: rgba(0, 232, 122, 0.12);
--accent-border:rgba(0, 232, 122, 0.25);
--border: rgba(255, 255, 255, 0.07);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
*, *::before, *::after { box-sizing: border-box; }
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body { margin: 0; }
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
h1, h2, h3 {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
color: var(--heading);
line-height: 1.15;
margin: 0 0 0.5em;
}
p { margin: 0; }
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
font-family: var(--mono);
font-size: 13px;
color: var(--accent);
background: var(--accent-dim);
padding: 1px 6px;
border-radius: 3px;
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
interface Props {
onDetect: (value: string) => void
onClose: () => void
}
declare class BarcodeDetector {
static getSupportedFormats(): Promise<string[]>
constructor(opts: { formats: string[] })
detect(source: ImageBitmapSource): Promise<{ rawValue: string }[]>
}
export function QrScanner({ onDetect, onClose }: Props) {
const videoRef = useRef<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let stream: MediaStream | null = null
let raf = 0
let done = false
async function start() {
if (!('BarcodeDetector' in window)) {
setError('QR scanning not supported in this browser — paste the invite instead.')
return
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
} catch {
setError('Camera access denied.')
return
}
const video = videoRef.current
if (!video || done) { stream.getTracks().forEach(t => t.stop()); return }
video.srcObject = stream
await video.play()
const detector = new BarcodeDetector({ formats: ['qr_code'] })
async function scan() {
if (done) return
try {
const results = await detector.detect(video!)
for (const r of results) {
if (r.rawValue.startsWith('flit:')) {
done = true
onDetect(r.rawValue)
return
}
}
} catch { /* ignore mid-frame errors */ }
raf = requestAnimationFrame(scan)
}
raf = requestAnimationFrame(scan)
}
void start()
return () => {
done = true
cancelAnimationFrame(raf)
stream?.getTracks().forEach(t => t.stop())
}
}, [onDetect])
return (
<div className="scanner-overlay">
<div className="scanner-card">
<div className="scanner-header">
<span>Scan flit QR</span>
<button className="btn-link" onClick={onClose}>Cancel</button>
</div>
{error
? <p className="scanner-error">{error}</p>
: <video ref={videoRef} className="scanner-video" muted playsInline />
}
</div>
</div>
)
}

View File

@@ -24,6 +24,13 @@ export function createInvite(anchorUrl: string): { invite: string; room: string
return { invite: PREFIX + b64, room }
}
// pairRoomName derives a stable room name for two known (trusted) peer ids —
// both sides compute the same string independently (order-independent), so
// reconnecting to a remembered device needs no QR/invite exchange.
export function pairRoomName(idA: string, idB: string): string {
return 'flit-pair:' + [idA, idB].sort().join(':')
}
export function decodeInvite(s: string): FlitInvite | null {
const trimmed = s.trim()
if (!trimmed.startsWith(PREFIX)) return null

View File

@@ -222,14 +222,18 @@ function gatherComplete(pc: RTCPeerConnection): Promise<void> {
export interface FileOfferEvent { peer: string; xid: string; name: string; size: number }
export interface FileProgressEvent { peer: string; xid: string; name: string; received: number; total: number }
export interface FileRecvEvent { peer: string; name: string; size: number; url: string }
export interface FileSendProgressEvent { peer: string; xid: string; name: string; sent: number; total: number }
export interface FileSentEvent { peer: string; xid: string; name: string }
type PeerEvents = {
connected: (verified: boolean) => void
connected: (verified: boolean, peerId: string) => void
status: (state: RTCPeerConnectionState) => void
fileOffer: (e: FileOfferEvent) => void
fileProgress: (e: FileProgressEvent) => void
fileRecv: (e: FileRecvEvent) => void
fileCancelled: (xid: string) => void
fileSendProgress: (e: FileSendProgressEvent) => void
fileSent: (e: FileSentEvent) => void
}
export class PeerConn {
@@ -391,7 +395,7 @@ export class PeerConn {
if (m['type'] === 'hello') {
this.verified = m['id'] === this.peerId && this.peerAuthed
this.events.connected?.(this.verified)
this.events.connected?.(this.verified, this.peerId)
} else if (m['type'] === 'file-offer') {
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
} else if (m['type'] === 'file-accept') {
@@ -436,8 +440,14 @@ export class PeerConn {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength })
// Yield to the event loop every chunk so the browser can paint
// progress — without this, small files finish in one synchronous
// tick and the UI never shows intermediate state.
await new Promise(r => setTimeout(r, 0))
}
dc.close()
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
}
private _dc(obj: object) {