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:
520
pwa/src/App.css
Normal file
520
pwa/src/App.css
Normal 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;
|
||||
}
|
||||
296
pwa/src/App.tsx
296
pwa/src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
83
pwa/src/pairing/QrScanner.tsx
Normal file
83
pwa/src/pairing/QrScanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user