Scaffold flit: PWA + Go CLI for ephemeral E2E file transfer

Ports the yaw/2.1 identity/signaling/WebRTC transport from waste-go to
both a browser PWA (with QR pairing and Web Share Target) and a headless
Go CLI, trimmed to 1:1 ephemeral file transfer only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-30 19:23:04 +02:00
commit 5050ad5e79
30 changed files with 4150 additions and 0 deletions

24
pwa/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
pwa/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
pwa/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@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 }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

17
pwa/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<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="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.json" />
<title>flit</title>
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1688
pwa/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
pwa/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"libsodium-wrappers": "^0.8.4",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/libsodium-wrappers": "^0.7.14",
"@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

5
pwa/public/config.js Normal file
View File

@@ -0,0 +1,5 @@
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',
}

1
pwa/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
pwa/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

23
pwa/public/manifest.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "flit",
"short_name": "flit",
"description": "Ephemeral, end-to-end encrypted file transfer between your own devices.",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#1f6feb",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{ "name": "files", "accept": ["*/*"] }
]
}
}
}

44
pwa/public/sw.js Normal file
View File

@@ -0,0 +1,44 @@
// Minimal service worker: only exists to catch the POST /share-target
// request from Android's share sheet (required for Web Share Target to
// work — the browser needs an installed SW controlling the scope). Stores
// the shared files in a temporary Cache entry, then redirects to a page
// that reads them and feeds them into the send flow.
const SHARE_CACHE = 'flit-share-v1'
self.addEventListener('install', (event) => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (event.request.method === 'POST' && url.pathname === '/share-target') {
event.respondWith(handleShareTarget(event))
}
})
async function handleShareTarget(event) {
const formData = await event.request.formData()
const files = formData.getAll('files')
const cache = await caches.open(SHARE_CACHE)
await cache.put('/shared-files', new Response(await packFiles(files)))
return Response.redirect('/?shared=1', 303)
}
async function packFiles(files) {
// Pack as a multipart-ish JSON manifest + blobs isn't trivial inside a SW;
// simplest robust approach: store each file as its own cache entry keyed
// by index, plus a manifest of names/types.
const cache = await caches.open(SHARE_CACHE)
const manifest = []
for (let i = 0; i < files.length; i++) {
const f = files[i]
manifest.push({ name: f.name, type: f.type, size: f.size, key: `/shared-file-${i}` })
await cache.put(`/shared-file-${i}`, new Response(f))
}
return JSON.stringify(manifest)
}

164
pwa/src/App.tsx Normal file
View File

@@ -0,0 +1,164 @@
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 { consumeSharedFiles, registerServiceWorker } from './share-target'
declare global {
interface Window { FLIT_CONFIG?: FlitConfig }
}
type Phase = 'idle' | 'hosting' | 'joining' | 'connected'
function App() {
const [phase, setPhase] = useState<Phase>('idle')
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 [progress, setProgress] = useState<FileProgressEvent | null>(null)
const [received, setReceived] = useState<FileRecvEvent[]>([])
const sessionRef = useRef<FlitSession | null>(null)
useEffect(() => {
registerServiceWorker()
if (new URLSearchParams(location.search).get('shared') === '1') {
consumeSharedFiles().then((files) => {
if (files.length) void sendFiles(files)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function cfg(): FlitConfig {
const c = window.FLIT_CONFIG
if (!c?.signalURL) throw new Error('FLIT_CONFIG missing — check public/config.js')
return c
}
function peerEvents() {
return {
connected: (v: boolean) => { setVerified(v); setPhase('connected') },
status: () => {},
fileOffer: (e: FileOfferEvent) => { setOffer(e); setPeerId(e.peer) },
fileProgress: (e: FileProgressEvent) => setProgress(e),
fileRecv: (e: FileRecvEvent) => { setReceived((r) => [...r, e]); setProgress(null) },
fileCancelled: () => setOffer(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())
}
async function joinFromInvite(raw: string) {
const inv = decodeInvite(raw)
if (!inv) { alert('Not a valid flit invite'); return }
setPhase('joining')
const session = await FlitSession.init(cfg())
sessionRef.current = session
await session.join(inv.room, peerEvents())
}
async function sendFiles(files: File[]) {
const session = sessionRef.current
if (!session) return
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 rejectOffer() {
if (!offer) return
sessionRef.current?.rejectOffer(offer.xid)
setOffer(null)
}
function trustCurrentPeer() {
if (!peerId) return
addTrusted(peerId, peerId.slice(0, 8))
}
return (
<main style={{ maxWidth: 480, margin: '0 auto', padding: 16, fontFamily: 'system-ui' }}>
<h1>flit</h1>
{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>
<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>
</div>
)}
{phase === 'hosting' && qrDataUrl && (
<div>
<p>Scan this on the receiving device:</p>
<img src={qrDataUrl} alt="pairing QR" width={256} height={256} />
</div>
)}
{phase === 'joining' && <p>Connecting</p>}
{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>
<input
type="file"
multiple
onChange={(e) => e.target.files && sendFiles(Array.from(e.target.files))}
/>
</div>
</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>
</div>
)}
{progress && (
<p>Receiving {progress.name}: {progress.received}/{progress.total}</p>
)}
{received.length > 0 && (
<ul>
{received.map((r, i) => (
<li key={i}><a href={r.url} download={r.name}>{r.name}</a> ({r.size} bytes)</li>
))}
</ul>
)}
</main>
)
}
export default App

111
pwa/src/index.css Normal file
View File

@@ -0,0 +1,111 @@
: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;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-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;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#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 {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

10
pwa/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,39 @@
// Ephemeral one-shot QR pairing: generate a random room name, encode it (plus
// the anchor URL) into a "flit:" invite string for a QR code. No persistent
// trust is established unless the user explicitly adds the peer afterward
// (see pairing/keyring.ts) — this is intentionally a *separate*, lower-trust
// flow from the persistent keyring.
const PREFIX = 'flit:'
export interface FlitInvite {
anchor: string
room: string
}
function randomRoomName(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
export function createInvite(anchorUrl: string): { invite: string; room: string } {
const room = randomRoomName()
const payload: FlitInvite = { anchor: anchorUrl, room }
const json = JSON.stringify(payload)
const b64 = btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return { invite: PREFIX + b64, room }
}
export function decodeInvite(s: string): FlitInvite | null {
const trimmed = s.trim()
if (!trimmed.startsWith(PREFIX)) return null
try {
const b64 = trimmed.slice(PREFIX.length).replace(/-/g, '+').replace(/_/g, '/')
const json = atob(b64)
const parsed = JSON.parse(json) as FlitInvite
if (!parsed.anchor || !parsed.room) return null
return parsed
} catch {
return null
}
}

View File

@@ -0,0 +1,28 @@
// Persistent trusted-peer list ("Add device" flow) — separate from the
// ephemeral one-shot QR room pairing in pairing/ephemeral.ts.
export interface TrustedPeer {
id: string // hex Ed25519 pubkey
label: string
addedAt: number
}
const KEY = 'flit_keyring'
export function listTrusted(): TrustedPeer[] {
try { return JSON.parse(localStorage.getItem(KEY) ?? '[]') } catch { return [] }
}
export function addTrusted(id: string, label: string): void {
const peers = listTrusted().filter(p => p.id !== id)
peers.push({ id, label, addedAt: Date.now() })
localStorage.setItem(KEY, JSON.stringify(peers))
}
export function removeTrusted(id: string): void {
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
}
export function isTrusted(id: string): boolean {
return listTrusted().some(p => p.id === id)
}

View File

@@ -0,0 +1,31 @@
// Reads files captured by public/sw.js's POST /share-target handler.
// Call once on app load when the URL has ?shared=1.
const SHARE_CACHE = 'flit-share-v1'
interface ManifestEntry { name: string; type: string; size: number; key: string }
export async function consumeSharedFiles(): Promise<File[]> {
if (!('caches' in window)) return []
const cache = await caches.open(SHARE_CACHE)
const manifestRes = await cache.match('/shared-files')
if (!manifestRes) return []
const manifest: ManifestEntry[] = await manifestRes.json()
const files: File[] = []
for (const entry of manifest) {
const res = await cache.match(entry.key)
if (!res) continue
const blob = await res.blob()
files.push(new File([blob], entry.name, { type: entry.type }))
await cache.delete(entry.key)
}
await cache.delete('/shared-files')
return files
}
export function registerServiceWorker(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => { /* PWA-only feature, fine to no-op */ })
}
}

515
pwa/src/transport/flit.ts Normal file
View File

@@ -0,0 +1,515 @@
// flit transport — yaw/2.1 signaling + WebRTC, trimmed from waste-go's
// web/src/adapter/browser.ts to 1:1 ephemeral pairing + file transfer only.
// Dropped vs. waste-go: chat, pm, reactions, file browsing/share lists,
// multi-network mesh. Kept: identity, ekey/offer/answer handshake, hello
// verification, file-offer/accept/chunked transfer.
import sodium from 'libsodium-wrappers'
const BIND_PREFIX = 'yaw/2 bind'
const EKEY_PREFIX = 'yaw/2.1 ekey'
const FS_TIMEOUT = 2000
const STUN = 'stun:stun.l.google.com:19302'
const CHUNK = 64 * 1024
// ── TURN ─────────────────────────────────────────────────────────────────────
//
// Credentials are minted server-side by the anchor's GET /turn-credentials
// (coturn use-auth-secret scheme; see waste-go/cmd/anchor/main.go). The raw
// shared secret never reaches the browser — only a short-lived
// {username,credential} pair.
export interface FlitConfig {
signalURL: string
turnURL?: string
turnCredentialsURL?: string
}
async function fetchTurnCredentials(url: string): Promise<{ username: string; credential: string } | null> {
try {
const res = await fetch(url)
if (!res.ok) return null
const data = await res.json()
if (!data.username || !data.credential) return null
return { username: data.username, credential: data.credential }
} catch {
return null
}
}
async function iceServers(cfg: FlitConfig): Promise<RTCIceServer[]> {
const servers: RTCIceServer[] = [{ urls: STUN }]
if (cfg.turnURL && cfg.turnCredentialsURL) {
const creds = await fetchTurnCredentials(cfg.turnCredentialsURL)
if (creds) servers.push({ urls: cfg.turnURL, username: creds.username, credential: creds.credential })
}
return servers
}
const enc = (s: string) => new TextEncoder().encode(s)
function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0
for (const a of arrs) { out.set(a, o); o += a.length }
return out
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
export async function netHash(name: string): Promise<string> {
const input = enc('yaw2-net:' + name)
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
return toHex(new Uint8Array(digest))
}
throw new Error('SHA-256 not available in this browser runtime')
}
// ── Identity ─────────────────────────────────────────────────────────────────
export class Identity {
pub: Uint8Array
priv: Uint8Array
id: string
curvePriv: Uint8Array
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
this.pub = kp.publicKey
this.priv = kp.privateKey
this.id = sodium.to_hex(this.pub)
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
}
// load() always persists — this is the long-lived device identity used
// both for the persistent keyring and to sign ephemeral sessions.
static load(): Identity {
const seedHex = localStorage.getItem('flit_seed')
let kp
if (seedHex) {
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
} else {
kp = sodium.crypto_sign_keypair()
localStorage.setItem('flit_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
}
return new Identity(kp)
}
sign(data: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(data, this.priv)
}
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
catch { return false }
}
seal(recipIdHex: string, plaintext: Uint8Array): string {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
}
open(senderIdHex: string, boxB64: string): Uint8Array | null {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
catch { return null }
}
get short(): string {
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
}
}
// ── Signaling ─────────────────────────────────────────────────────────────────
export class Signaling {
private ws: WebSocket | null = null
private _closed = false
private _backoff = 1000
private _cbs: {
onFrom?: (from: string, box: string) => void
onJoin?: (id: string) => void
onLeave?: (id: string) => void
onReconnect?: (peers: string[]) => void
} = {}
private url: string
private identity: Identity
private net: string
constructor(url: string, identity: Identity, net: string) {
this.url = url
this.identity = identity
this.net = net
}
connect(
onFrom: (from: string, box: string) => void,
onJoin: (id: string) => void,
onLeave: (id: string) => void,
onReconnect: (peers: string[]) => void,
): Promise<string[]> {
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
return this._open(true)
}
private _open(initial: boolean): Promise<string[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.url)
this.ws = ws
let joined = false
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
ws.onmessage = (ev) => {
let m: Record<string, unknown>
try { m = JSON.parse(ev.data) } catch { return }
if (m['type'] === 'challenge') {
const nonce = sodium.from_hex(m['nonce'] as string)
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
} else if (m['type'] === 'joined') {
joined = true; this._backoff = 1000
const peers = (m['peers'] as string[]) || []
if (initial) resolve(peers)
else this._cbs.onReconnect?.(peers)
} else if (m['type'] === 'from') {
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
} else if (m['type'] === 'peer-join') {
this._cbs.onJoin?.(m['id'] as string)
} else if (m['type'] === 'peer-leave') {
this._cbs.onLeave?.(m['id'] as string)
}
}
})
}
private _scheduleReconnect() {
if (this._closed) return
const delay = this._backoff
this._backoff = Math.min(this._backoff * 2, 30000)
setTimeout(() => { if (!this._closed) this._open(false).catch(() => {}) }, delay)
}
sendTo(toId: string, box: string) {
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { /* ignore */ }
}
close() { this._closed = true; this.ws?.close() }
}
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve()
return new Promise((res) => {
const check = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', check)
res()
}
}
pc.addEventListener('icegatheringstatechange', check)
setTimeout(res, 6000)
})
}
// ── Peer connection (yaw/2.1, file transfer only) ─────────────────────────────
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 }
type PeerEvents = {
connected: (verified: boolean) => void
status: (state: RTCPeerConnectionState) => void
fileOffer: (e: FileOfferEvent) => void
fileProgress: (e: FileProgressEvent) => void
fileRecv: (e: FileRecvEvent) => void
fileCancelled: (xid: string) => void
}
export class PeerConn {
pc: RTCPeerConnection
dc: RTCDataChannel | null = null
verified = false
peerAuthed = false
created = Date.now()
private _esk: Uint8Array
private _epk: Uint8Array
peer_epk: Uint8Array | null = null
private _ekeySent = false
private _offerPending = false
private _offered = false
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean }> = {}
private _recvChannels: Record<string, RTCDataChannel> = {}
private _pushQueue: Map<string, File> = new Map()
private identity: Identity
private sig: Signaling
peerId: string
private events: Partial<PeerEvents>
constructor(
identity: Identity,
sig: Signaling,
peerId: string,
events: Partial<PeerEvents>,
ice: RTCIceServer[],
) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this.events = events
this.pc = new RTCPeerConnection({ iceServers: ice })
const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey
this._epk = kp.publicKey
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
this.pc.onconnectionstatechange = () => this.events.status?.(this.pc.connectionState)
}
private _seal(obj: object, preferEph: boolean): string {
const data = enc(JSON.stringify(obj))
if (preferEph && this.peer_epk) {
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
}
return this.identity.seal(this.peerId, data)
}
private _open(box: string): [Uint8Array | null, boolean] {
if (this.peer_epk) {
try {
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
} catch { /* fall through to static */ }
}
return [this.identity.open(this.peerId, box), false]
}
private _sendEkey() {
if (this._ekeySent) return
this._ekeySent = true
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk)
const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) }
this.sig.sendTo(this.peerId, this._seal(msg, false))
}
private async _onEkey(obj: Record<string, unknown>) {
if (this.peer_epk) return
try {
const epkRaw = sodium.from_hex(obj['epk'] as string)
const sigBytes = sodium.from_hex(obj['sig'] as string)
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId), sodium.from_hex(this.identity.id), epkRaw)
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sigBytes)) return
this.peer_epk = epkRaw
} catch { return }
this._sendEkey()
if (this._offerPending) await this._doOffer()
}
async startOffer() {
this._sendEkey()
this._offerPending = true
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
if (this.peer_epk) await this._doOffer()
}
private async _doOffer() {
if (this._offered) return
this._offered = true; this._offerPending = false
this.dc = this.pc.createDataChannel('yaw')
this._wire(this.dc)
await this.pc.setLocalDescription(await this.pc.createOffer())
await gatherComplete(this.pc)
this.sig.sendTo(this.peerId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
}
async onBox(box: string) {
const [plain, usedEph] = this._open(box)
if (!plain) return
this.peerAuthed = true
let obj: Record<string, unknown>
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
if (obj['kind'] === 'ekey') {
await this._onEkey(obj)
} else if (obj['kind'] === 'offer') {
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
await this.pc.setLocalDescription(await this.pc.createAnswer())
await gatherComplete(this.pc)
this.sig.sendTo(this.peerId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
} else if (obj['kind'] === 'answer') {
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
}
}
private _wire(channel: RTCDataChannel) {
if (channel.label === 'yaw') {
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
return
}
if (channel.label.startsWith('f:')) {
const xid = channel.label.slice(2)
const rx = this._recv[xid]
if (!rx) return
channel.binaryType = 'arraybuffer'
channel.onmessage = (ev) => {
if (!(ev.data instanceof ArrayBuffer)) return
rx.buf.push(ev.data)
rx.have += ev.data.byteLength
this.events.fileProgress?.({ peer: this.peerId, xid, name: rx.name, received: rx.have, total: rx.size })
}
channel.onclose = () => {
if (rx.cancelled) { delete this._recv[xid]; return }
const blob = new Blob(rx.buf)
const url = URL.createObjectURL(blob)
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
delete this._recv[xid]
}
this._recvChannels[xid] = channel
}
}
private _sendHello() {
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) })
}
private _onControl(data: string) {
let m: Record<string, unknown>
try { m = JSON.parse(data) } catch { return }
if (m['type'] === 'hello') {
this.verified = m['id'] === this.peerId && this.peerAuthed
this.events.connected?.(this.verified)
} 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') {
const xid = m['xid'] as string
if (this._pushQueue.has(xid)) void this._stream(xid)
} else if (m['type'] === 'file-cancel') {
const xid = m['xid'] as string
if (this._recv[xid]) {
this._recv[xid].cancelled = true
this._recvChannels[xid]?.close()
delete this._recvChannels[xid]
this.events.fileCancelled?.(xid)
}
}
}
acceptOffer(xid: string, name: string, size: number) {
this._recv[xid] = { name, size, buf: [], have: 0 }
this._dc({ type: 'file-accept', xid })
}
rejectOffer(xid: string) {
this._dc({ type: 'file-cancel', xid })
}
sendFile(file: File) {
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file)
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
}
private async _stream(xid: string) {
const file = this._pushQueue.get(xid)
if (!file) return
this._pushQueue.delete(xid)
const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer'
await new Promise<void>(res => { dc.onopen = () => res() })
const buf = await file.arrayBuffer()
let offset = 0
while (offset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
}
dc.close()
}
private _dc(obj: object) {
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
}
close() { try { this.pc.close() } catch { /* ignore */ } }
}
// ── Session: one ephemeral or persistent pairing ──────────────────────────────
export class FlitSession {
identity: Identity
private sig: Signaling | null = null
private peer: PeerConn | null = null
networkId = ''
private cfg: FlitConfig
constructor(cfg: FlitConfig) {
this.cfg = cfg
this.identity = Identity.load()
}
static async init(cfg: FlitConfig): Promise<FlitSession> {
await sodium.ready
return new FlitSession(cfg)
}
// Join an ephemeral or named room by name; trustedPeerId, if given, is the
// only peer this session will complete a handshake with (others are
// ignored — defends against a third party joining a guessed room name).
async join(roomName: string, events: Partial<PeerEvents>, trustedPeerId?: string): Promise<void> {
const hash = await netHash(roomName)
this.networkId = hash
const sig = new Signaling(this.cfg.signalURL, this.identity, hash)
this.sig = sig
const ice = await iceServers(this.cfg)
const connectTo = async (pid: string) => {
if (pid === this.identity.id) return
if (trustedPeerId && pid !== trustedPeerId) return
if (this.peer) return
this.peer = new PeerConn(this.identity, sig, pid, events, ice)
if (this.identity.id < pid) await this.peer.startOffer()
}
const present = await sig.connect(
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) },
(pid) => { void connectTo(pid) },
() => { /* peer-leave: nothing to clean up for a single 1:1 session */ },
(peers) => { peers.forEach(pid => void connectTo(pid)) },
)
for (const pid of present) await connectTo(pid)
}
private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
if (from === this.identity.id) return
if (trustedPeerId && from !== trustedPeerId) return
if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice)
await this.peer.onBox(box)
}
sendFile(file: File) { this.peer?.sendFile(file) }
acceptOffer(xid: string, name: string, size: number) { this.peer?.acceptOffer(xid, name, size) }
rejectOffer(xid: string) { this.peer?.rejectOffer(xid) }
close() {
this.sig?.close()
this.peer?.close()
this.sig = null
this.peer = null
}
}

26
pwa/tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
pwa/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
pwa/tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
pwa/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})