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:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Copy to .env and fill in. .env is gitignored.
|
||||||
|
# Source before running the CLI: source .env
|
||||||
|
|
||||||
|
# Required for `flit send` (flit recv reads the anchor URL from the invite)
|
||||||
|
FLIT_SIGNAL_URL=wss://your-anchor.example.com/ws
|
||||||
|
|
||||||
|
# Optional — TURN relay for cross-network transfers (mobile data ↔ home WiFi)
|
||||||
|
FLIT_TURN_URL=turn:your-anchor.example.com:3478
|
||||||
|
|
||||||
|
# Optional — coturn use-auth-secret shared secret, only needed if your anchor
|
||||||
|
# doesn't expose GET /turn-credentials (native binary, so secret doesn't reach
|
||||||
|
# the browser)
|
||||||
|
FLIT_TURN_SECRET=
|
||||||
38
.gitea/workflows/build.yml
Normal file
38
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Build PWA
|
||||||
|
run: |
|
||||||
|
cd pwa
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Package release artifact
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
tar -C pwa/dist -czf dist/flit-pwa.tar.gz .
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
files: dist/*
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,2 +1,12 @@
|
|||||||
cli/flit
|
cli/flit
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Host-specific deploy scripts (contain server addresses / SSH targets)
|
||||||
|
deploy-pwa.sh
|
||||||
|
serve-pwa.sh
|
||||||
|
|
||||||
|
# Runtime config — contains anchor URL; copy from example and fill in
|
||||||
|
pwa/public/config.js
|
||||||
|
|
||||||
|
# Local env — CLI anchor/TURN config; copy from .env.example
|
||||||
|
.env
|
||||||
|
|||||||
285
README.md
Normal file
285
README.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# flit
|
||||||
|
|
||||||
|
Self-hosted, ephemeral, end-to-end encrypted file transfer between your own devices. Think AirDrop — but cross-platform, self-hosted, and accessible from any browser.
|
||||||
|
|
||||||
|
> Self-hosted — deploy your own instance, see [Self-hosting](#self-hosting) below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
flit solves a specific, annoying problem: getting a file from one of your devices to another right now, with no friction and nothing left behind on a server.
|
||||||
|
|
||||||
|
It is deliberately not a replacement for [Zipline](https://github.com/diced/zipline) or similar tools — those are great for "upload once, share via link." flit is for point-to-point transfers between devices you own: desktop to phone, homelab box to laptop, anything to anything.
|
||||||
|
|
||||||
|
- No accounts. No upload limits. No files touch a server disk.
|
||||||
|
- Works across networks (home WiFi ↔ mobile data) via a self-hosted TURN relay.
|
||||||
|
- Encrypted end-to-end with keys that never leave your devices.
|
||||||
|
- Remembered devices reconnect directly — no QR re-scan, no invite link.
|
||||||
|
- Multi-file send: select multiple files at once, each offered and accepted independently.
|
||||||
|
- Auto-downloads on the receiver side — no "click to save" step.
|
||||||
|
- In-app QR scanner (Chrome/Android) — scan the sender's QR without leaving flit.
|
||||||
|
- Android share sheet integration — "Share → flit" works from Photos, Files, any app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Device A ──┐ ┌── Device B
|
||||||
|
│ WebSocket signaling │
|
||||||
|
└──► Anchor (VPS) ◄───────┘
|
||||||
|
│
|
||||||
|
│ SDP/ICE only — no file data
|
||||||
|
▼
|
||||||
|
Direct WebRTC P2P ──────────────────────── (preferred)
|
||||||
|
TURN relay (coturn) ─────────────────────── (fallback, still E2E encrypted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Three components:
|
||||||
|
|
||||||
|
**Signaling anchor** — a lightweight Go WebSocket server (reused from [waste-go](../waste-go)). It brokers the WebRTC handshake: peers join a room by hashed name, exchange SDP offers/answers and ICE candidates through the anchor, then the anchor steps out of the way. No file data ever passes through it.
|
||||||
|
|
||||||
|
**TURN relay** — a coturn instance used as a fallback when direct peer-to-peer ICE fails (common across mobile networks and CGNAT). Data is end-to-end encrypted before entering the relay — coturn sees only ciphertext.
|
||||||
|
|
||||||
|
**Clients** — a PWA (primary) and a Go CLI (headless/homelab).
|
||||||
|
|
||||||
|
### Security model
|
||||||
|
|
||||||
|
Each device generates an **Ed25519 keypair** on first run. The hex-encoded public key is the device's permanent identity (`id`). This identity is stored in `localStorage` (PWA) or `~/.flit/` (CLI) and never leaves the device.
|
||||||
|
|
||||||
|
Pairing uses [yaw/2.1](PROTOCOL.md) forward-secret signaling, trimmed for flit's 1:1 ephemeral use case:
|
||||||
|
|
||||||
|
1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys — providing forward secrecy for the signaling channel.
|
||||||
|
2. Once the WebRTC data channel opens, each side sends a **`hello`** message containing their identity and a signature over a known prefix. This binds the WebRTC DTLS fingerprint to the Ed25519 identity, confirming "the device on the other end is who the QR said it was."
|
||||||
|
3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity-confirmed binding from step 2.
|
||||||
|
|
||||||
|
The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name.
|
||||||
|
|
||||||
|
### Pairing flows
|
||||||
|
|
||||||
|
**Ephemeral (QR)** — for first-time pairing or one-off transfers to unrecognised devices:
|
||||||
|
|
||||||
|
1. Sender opens flit → "Invite new" → "Show QR to pair." A 128-bit random room code is generated and encoded in a `flit:` invite string, displayed as a QR.
|
||||||
|
2. Receiver scans the QR using the in-app scanner ("Scan QR") or pastes the `flit:` string manually → "Join."
|
||||||
|
3. Both peers connect to the anchor under the hashed room name, complete the E2E handshake, and exchange files.
|
||||||
|
4. After connecting, either side can tap "Remember this device" and assign a nickname ("home", "phone") to the peer's identity.
|
||||||
|
|
||||||
|
**Trusted device (direct reconnect)** — for devices that have been previously paired:
|
||||||
|
|
||||||
|
1. flit opens to the "Known devices" screen by default (falls back to "Invite new" if none exist yet).
|
||||||
|
2. Tap **Connect** next to a remembered device — no QR, no invite string.
|
||||||
|
3. Both sides independently compute the same deterministic room name from their two peer IDs (`flit-pair:<sorted(idA, idB)>` in `pwa/src/pairing/ephemeral.ts`), join it pinned to each other's identity, and the handshake completes automatically.
|
||||||
|
|
||||||
|
The room is pinned to the trusted peer's identity — any other device that somehow joins the same derived room is silently ignored. Trusted peers can be renamed or forgotten at any time from the Known devices list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clients
|
||||||
|
|
||||||
|
### PWA
|
||||||
|
|
||||||
|
The primary client. Runs in any Chromium-based browser and is installable as a PWA on Android home screen. Once installed, it registers as a **Web Share Target** — "Share → flit" in any Android app's share sheet will open flit with the file pre-loaded, ready to send after pairing.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Known devices tab (direct reconnect) and Invite new tab (QR / paste)
|
||||||
|
- In-app QR scanner via native `BarcodeDetector` API (no library, Chrome/Android)
|
||||||
|
- Multi-file send with per-file accept/reject on the receiver, "Accept all" when multiple arrive simultaneously
|
||||||
|
- Real-time send and receive progress bars
|
||||||
|
- Auto-download on receipt — files save immediately without a manual tap
|
||||||
|
- Trusted peer nicknames, rename, forget
|
||||||
|
|
||||||
|
**Built with:** React, TypeScript, Vite, libsodium-wrappers (crypto), WebRTC (native browser API).
|
||||||
|
|
||||||
|
### CLI (`cli/`)
|
||||||
|
|
||||||
|
For headless machines (homelab boxes, servers) that can't run a browser.
|
||||||
|
|
||||||
|
```
|
||||||
|
flit send path/to/file.tar.gz # print QR + invite, wait for peer, send
|
||||||
|
flit recv "flit:eyJhbmNob3..." # join from invite string, accept file
|
||||||
|
flit daemon # persistent receiver for trusted peers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Built with:** Go, pion/webrtc, libsodium (via nhooyr.io/websocket + internal crypto).
|
||||||
|
|
||||||
|
### Daemon mode
|
||||||
|
|
||||||
|
`flit daemon` is an always-on receiver — it stays connected to a list of trusted peers and automatically accepts any file they send, saving to a configured directory. Useful for a homelab node that acts as a drop-point: send from phone or laptop, file appears on the home box without any interaction.
|
||||||
|
|
||||||
|
Config lives at `~/.flit/daemon.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
signal_url = "wss://your-anchor.example.com/ws"
|
||||||
|
turn_url = "turn:your-anchor.example.com:3478" # optional
|
||||||
|
turn_secret = "" # optional
|
||||||
|
download_dir = "~/flit-inbox"
|
||||||
|
|
||||||
|
[[peers]]
|
||||||
|
id = "aabbccddeeff..." # hex Ed25519 pubkey — from PWA Known devices list
|
||||||
|
label = "phone" # or from "peer connected" line in flit send output
|
||||||
|
|
||||||
|
[[peers]]
|
||||||
|
id = "112233445566..."
|
||||||
|
label = "laptop"
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `cli/daemon.toml.example` to `~/.flit/daemon.toml` to get started. The daemon runs one goroutine per peer, reconnects automatically with exponential backoff (2s → 30s cap) if the peer drops or the anchor restarts, and logs all activity to stdout — suitable for a tmux session or systemd unit.
|
||||||
|
|
||||||
|
**Topology note:** the daemon is just a third peer with its own identity. If you run it on a home box and trust it from both your phone and laptop, all three devices can reach each other directly — phone→laptop still works without the home box involved. The daemon adds an always-on rendezvous point, not a relay.
|
||||||
|
|
||||||
|
**Bootstrapping:** to get a peer's `id`, pair once via QR (`flit send` / PWA "Invite new") and copy the hex ID from the "peer connected" log line, or read it from the PWA's Known devices list after tapping "Remember this device."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-hosting
|
||||||
|
|
||||||
|
flit has no server of its own. The PWA is a static file bundle served by `npx serve`. For signaling and TURN relay it requires a running [waste-go](https://repo.explewd.com/explewd/waste-go) anchor.
|
||||||
|
|
||||||
|
### Deploy scripts
|
||||||
|
|
||||||
|
The deploy scripts contain your SSH target and are gitignored — create them locally from these templates:
|
||||||
|
|
||||||
|
**`deploy-pwa.sh`** — build and rsync `pwa/dist/` to the host:
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="user@your-host.example.com"
|
||||||
|
REMOTE_DIR="~/flit-www"
|
||||||
|
|
||||||
|
echo "→ building PWA…"
|
||||||
|
"$(dirname "$0")/build-pwa.sh"
|
||||||
|
|
||||||
|
echo "→ syncing to $HOST:$REMOTE_DIR"
|
||||||
|
rsync -azv --delete \
|
||||||
|
--exclude='config.js' \
|
||||||
|
pwa/dist/ "$HOST:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
echo "✓ done"
|
||||||
|
```
|
||||||
|
|
||||||
|
**`serve-pwa.sh`** — (re)start `npx serve` on the host:
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="user@your-host.example.com"
|
||||||
|
REMOTE_DIR="~/flit-www"
|
||||||
|
REMOTE_LOG="~/flit-www.log"
|
||||||
|
REMOTE_PID="~/flit-www.pid"
|
||||||
|
PORT=3002 # pick a free port; point your reverse proxy here
|
||||||
|
|
||||||
|
ssh "$HOST" bash <<EOF
|
||||||
|
if [ -f $REMOTE_PID ]; then
|
||||||
|
kill \$(cat $REMOTE_PID) 2>/dev/null || true
|
||||||
|
rm -f $REMOTE_PID
|
||||||
|
fi
|
||||||
|
echo "[\$(date)] starting" >> $REMOTE_LOG
|
||||||
|
nohup npx serve -s $REMOTE_DIR -l $PORT >> $REMOTE_LOG 2>&1 &
|
||||||
|
echo \$! > $REMOTE_PID
|
||||||
|
echo "→ started (pid \$(cat $REMOTE_PID))"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.js` is gitignored so host-specific config survives redeployment (`rsync --exclude='config.js'`). On a fresh host, seed it once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp pwa/public/config.js.example pwa/public/config.js
|
||||||
|
# fill in your anchor URL, then:
|
||||||
|
scp pwa/public/config.js user@your-host.example.com:~/flit-www/config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.js` sets the anchor and TURN URLs at runtime:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Your reverse proxy should forward `flit.<domain>` → `localhost:<PORT>` (the port chosen above).
|
||||||
|
|
||||||
|
### CI / releases
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` builds the PWA on `v*` tag push and publishes `flit-pwa.tar.gz` as a Gitea release artifact. Requires a `RELEASE_TOKEN` secret in the repo settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pwa
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server at localhost:5173
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build # output to pwa/dist/
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
export FLIT_SIGNAL_URL=wss://your-anchor.example.com/ws
|
||||||
|
export FLIT_TURN_URL=turn:your-anchor.example.com:3478 # optional
|
||||||
|
export FLIT_TURN_SECRET=your-coturn-secret # optional
|
||||||
|
cd cli
|
||||||
|
go run ./cmd/flit send path/to/file
|
||||||
|
```
|
||||||
|
|
||||||
|
The PWA reads its anchor URL from `public/config.js` at runtime. The CLI reads `FLIT_SIGNAL_URL` from the environment. Both are gitignored/unset by default — you supply your own anchor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
flit speaks a trimmed subset of [yaw/2.1](PROTOCOL.md). Dropped vs. waste-go: chat, presence, multi-peer mesh, file browsing. Kept: Ed25519 identity, forward-secret `ekey` handshake, `hello` verification, `file-offer`/`file-accept`/`file-cancel`, chunked binary DataChannel transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Scaffolding
|
||||||
|
- Repo scaffold: PWA (React/TS/Vite) + Go CLI skeleton
|
||||||
|
- Go CLI: `flit send` / `flit recv`, Ed25519 identity in `~/.flit/`, terminal QR output
|
||||||
|
- PWA: ephemeral QR pairing, yaw/2.1 signaling, WebRTC file transfer, Web Share Target manifest
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- `build-pwa.sh`, `deploy-pwa.sh`, `serve-pwa.sh` — mirrors waste-go's deploy pattern
|
||||||
|
- Static files served via `npx serve` on port 3002 of the VPS, fronted by Nginx Proxy Manager
|
||||||
|
- `.gitea/workflows/build.yml` — builds PWA on `v*` tag, publishes `flit-pwa.tar.gz` as a Gitea release artifact
|
||||||
|
- `pwa/public/config.js` runtime config excluded from deploys (host-specific override pattern)
|
||||||
|
|
||||||
|
### Core pairing and transfer
|
||||||
|
- Fixed silent failure when `window.FLIT_CONFIG` missing (error banner, async error surfacing)
|
||||||
|
- RTCPeerConnectionState surfaced in UI (`checking` / `connected` / `failed`) — previously stuck silently on "Connecting…"
|
||||||
|
- Sender-side progress events and progress bar (fix: synchronous chunk loop blocked React renders — added `setTimeout(0)` yield per chunk)
|
||||||
|
- `peerId` now set at connection time, not only on first file offer — fixed "Remember this device" silently doing nothing
|
||||||
|
|
||||||
|
### Trusted devices / known peer reconnect
|
||||||
|
- Persistent keyring (`pwa/src/pairing/keyring.ts`) — Ed25519 peer IDs stored in `localStorage` with user-assigned nicknames
|
||||||
|
- `pairRoomName(idA, idB)` — deterministic shared room derived from sorted peer IDs; both sides compute independently, no QR needed
|
||||||
|
- Known devices tab: default view when trusted peers exist; Connect / Rename / Forget per device
|
||||||
|
- Nickname prompt on "Remember this device" (previously auto-used key prefix)
|
||||||
|
- `connected` event now carries `peerId` so the keyring button works immediately after handshake, not only after a file offer
|
||||||
|
|
||||||
|
### Multi-file and UX
|
||||||
|
- Multi-file receive: offer queue (previously a single slot — second offer replaced first); "Accept all (N)" button when multiple arrive simultaneously
|
||||||
|
- Auto-download on receipt via programmatic `<a>.click()` — no manual tap required
|
||||||
|
- In-app QR scanner using native `BarcodeDetector` API (no library); camera overlay with cancel; graceful fallback message on unsupported browsers
|
||||||
|
- Received files list retained below the auto-download as a fallback link
|
||||||
|
|
||||||
|
### Visual design
|
||||||
|
- Full restyle to terminal aesthetic matching waste-go's visual identity: `#080808` bg, `#00e87a` green accent, JetBrains Mono throughout
|
||||||
|
- `manifest.json`: fixed missing PNG icon references (replaced with SVG), corrected `theme_color` from blue → green
|
||||||
|
- Apple PWA meta tags added (`apple-mobile-web-app-capable`, status bar style, title)
|
||||||
|
- Progress bars with green glow, `> flit.` monospace header, `@ nickname` device labels, `// comment` style hints
|
||||||
|
|
||||||
|
### CLI daemon
|
||||||
|
- `flit daemon` subcommand — persistent receiver for trusted peers, reads `~/.flit/daemon.toml`
|
||||||
|
- TOML config: `signal_url`, `turn_url`, `turn_secret`, `download_dir`, `[[peers]]` list
|
||||||
|
- One goroutine per peer, exponential backoff reconnect (2s → 30s)
|
||||||
|
- `OnDisconnected` callback added to `transport.Session` + WebRTC connection state wired to reset peer slot on failure
|
||||||
|
- `transport.PairRoomName(a, b)` exported (mirrors PWA's `pairRoomName`) — deterministic room from sorted peer IDs
|
||||||
|
- `cli/daemon.toml.example` committed; `~/.flit/daemon.toml` stays local
|
||||||
7
build-pwa.sh
Executable file
7
build-pwa.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-pwa.sh — build the PWA into pwa/dist/
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/pwa"
|
||||||
|
npm ci --silent
|
||||||
|
npm run build
|
||||||
|
echo "→ pwa/dist/ ready"
|
||||||
189
cli/cmd/flit/daemon.go
Normal file
189
cli/cmd/flit/daemon.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
|
"flit/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// daemonConfig is read from ~/.flit/daemon.toml.
|
||||||
|
type daemonConfig struct {
|
||||||
|
SignalURL string `toml:"signal_url"`
|
||||||
|
TurnURL string `toml:"turn_url"`
|
||||||
|
TurnSecret string `toml:"turn_secret"`
|
||||||
|
DownloadDir string `toml:"download_dir"`
|
||||||
|
Peers []peerConfig `toml:"peers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerConfig struct {
|
||||||
|
ID string `toml:"id"`
|
||||||
|
Label string `toml:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func daemonConfigPath() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".flit", "daemon.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDaemonConfig() (*daemonConfig, error) {
|
||||||
|
path := daemonConfigPath()
|
||||||
|
var cfg daemonConfig
|
||||||
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if cfg.SignalURL == "" {
|
||||||
|
return nil, fmt.Errorf("%s: signal_url is required", path)
|
||||||
|
}
|
||||||
|
if cfg.DownloadDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfg.DownloadDir = filepath.Join(home, "flit-inbox")
|
||||||
|
}
|
||||||
|
cfg.DownloadDir = expandHome(cfg.DownloadDir)
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHome(path string) string {
|
||||||
|
if !strings.HasPrefix(path, "~/") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemon(ctx context.Context) {
|
||||||
|
cfg, err := loadDaemonConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit daemon:", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "create %s — see daemon.toml.example\n", daemonConfigPath())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.DownloadDir, 0755); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit daemon: cannot create download_dir:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
tc := transport.Config{
|
||||||
|
SignalURL: cfg.SignalURL,
|
||||||
|
TurnURL: cfg.TurnURL,
|
||||||
|
TurnSecret: cfg.TurnSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get own identity so we can compute pair rooms.
|
||||||
|
probe, err := transport.NewSession(dataDir(), tc)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit daemon: identity error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
myID := probe.PeerID()
|
||||||
|
|
||||||
|
if len(cfg.Peers) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit daemon: no peers configured in daemon.toml — nothing to do")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("flit daemon starting — id %s…", myID[:16])
|
||||||
|
log.Printf(" download dir : %s", cfg.DownloadDir)
|
||||||
|
log.Printf(" anchor : %s", cfg.SignalURL)
|
||||||
|
log.Printf(" peers : %d", len(cfg.Peers))
|
||||||
|
for _, p := range cfg.Peers {
|
||||||
|
log.Printf(" @ %s (%s…)", p.Label, p.ID[:min16(p.ID)])
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, p := range cfg.Peers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(peer peerConfig) {
|
||||||
|
defer wg.Done()
|
||||||
|
runPeerLoop(ctx, tc, myID, peer, cfg.DownloadDir)
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
log.Println("flit daemon stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPeerLoop maintains a persistent connection to one trusted peer,
|
||||||
|
// reconnecting with exponential backoff whenever the connection drops.
|
||||||
|
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
|
||||||
|
room := transport.PairRoomName(myID, peer.ID)
|
||||||
|
backoff := 2 * time.Second
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s] connecting…", peer.Label)
|
||||||
|
disconnected := make(chan struct{})
|
||||||
|
|
||||||
|
sess, err := transport.NewSession(dataDir(), tc)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] session error: %v", peer.Label, err)
|
||||||
|
} else {
|
||||||
|
sess.OnConnected = func(verified bool) {
|
||||||
|
status := "unverified"
|
||||||
|
if verified {
|
||||||
|
status = "verified"
|
||||||
|
}
|
||||||
|
log.Printf("[%s] connected (%s)", peer.Label, status)
|
||||||
|
backoff = 2 * time.Second // reset on successful connect
|
||||||
|
}
|
||||||
|
sess.OnDisconnected = func() {
|
||||||
|
select {
|
||||||
|
case <-disconnected:
|
||||||
|
default:
|
||||||
|
close(disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sess.OnFileOffer = func(offer transport.FileOffer) {
|
||||||
|
log.Printf("[%s] ← %s (%.1f KB) — accepting", peer.Label, offer.Name, float64(offer.Size)/1024)
|
||||||
|
sess.AcceptOffer(offer, downloadDir)
|
||||||
|
}
|
||||||
|
sess.OnFileProgress = func(_ string, received, total int64) {
|
||||||
|
pct := int(float64(received) / float64(total) * 100)
|
||||||
|
fmt.Printf("\r[%s] receiving… %d%%", peer.Label, pct)
|
||||||
|
}
|
||||||
|
sess.OnFileDone = func(name, path string) {
|
||||||
|
fmt.Println() // clear progress line
|
||||||
|
log.Printf("[%s] ✓ saved %s → %s", peer.Label, name, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sess.Join(ctx, room, peer.ID); err != nil {
|
||||||
|
log.Printf("[%s] join error: %v", peer.Label, err)
|
||||||
|
} else {
|
||||||
|
// Wait until disconnected or context cancelled.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-disconnected:
|
||||||
|
log.Printf("[%s] disconnected", peer.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 30*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min16(s string) int {
|
||||||
|
if len(s) < 16 {
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
return 16
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
//
|
//
|
||||||
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
|
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
|
||||||
// flit recv <invite|code> join a room from an invite string, accept the offered file
|
// flit recv <invite|code> join a room from an invite string, accept the offered file
|
||||||
|
// flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,8 +20,11 @@ import (
|
|||||||
"flit/internal/transport"
|
"flit/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultSignalURL = "wss://waste.dev.xplwd.com/ws"
|
// No built-in defaults — set FLIT_SIGNAL_URL (required) and optionally
|
||||||
const defaultTurnURL = "turn:waste.dev.xplwd.com:3478"
|
// FLIT_TURN_URL in the environment, or use a received invite which carries
|
||||||
|
// the anchor URL directly.
|
||||||
|
const defaultSignalURL = ""
|
||||||
|
const defaultTurnURL = ""
|
||||||
|
|
||||||
// invite mirrors pwa/src/pairing/ephemeral.ts's "flit:" invite format —
|
// invite mirrors pwa/src/pairing/ephemeral.ts's "flit:" invite format —
|
||||||
// keep them in sync.
|
// keep them in sync.
|
||||||
@@ -67,11 +71,19 @@ func dataDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cfg() transport.Config {
|
func cfg() transport.Config {
|
||||||
c := transport.Config{SignalURL: defaultSignalURL, TurnURL: defaultTurnURL}
|
signalURL := defaultSignalURL
|
||||||
// TODO: same stopgap as the PWA — TurnSecret should come from a
|
if v := os.Getenv("FLIT_SIGNAL_URL"); v != "" {
|
||||||
// server-minted credential, not a local secret, once that flow is
|
signalURL = v
|
||||||
// wired for native clients too. For now leave TurnSecret unset; flit
|
}
|
||||||
// falls back to STUN-only (works whenever direct P2P is reachable).
|
turnURL := defaultTurnURL
|
||||||
|
if v := os.Getenv("FLIT_TURN_URL"); v != "" {
|
||||||
|
turnURL = v
|
||||||
|
}
|
||||||
|
c := transport.Config{SignalURL: signalURL, TurnURL: turnURL}
|
||||||
|
// TurnSecret: server-minted credentials are preferred (anchor's
|
||||||
|
// GET /turn-credentials). Set FLIT_TURN_SECRET only if your anchor
|
||||||
|
// doesn't expose that endpoint and you're comfortable with the secret
|
||||||
|
// living in the environment on a native binary.
|
||||||
if s := os.Getenv("FLIT_TURN_SECRET"); s != "" {
|
if s := os.Getenv("FLIT_TURN_SECRET"); s != "" {
|
||||||
c.TurnSecret = s
|
c.TurnSecret = s
|
||||||
}
|
}
|
||||||
@@ -79,8 +91,8 @@ func cfg() transport.Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Println("usage: flit send <path> | flit recv <invite>")
|
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +101,21 @@ func main() {
|
|||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "send":
|
case "send":
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Println("usage: flit send <path>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
send(ctx, os.Args[2])
|
send(ctx, os.Args[2])
|
||||||
case "recv":
|
case "recv":
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Println("usage: flit recv <invite>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
recv(ctx, os.Args[2])
|
recv(ctx, os.Args[2])
|
||||||
|
case "daemon":
|
||||||
|
runDaemon(ctx)
|
||||||
default:
|
default:
|
||||||
fmt.Println("usage: flit send <path> | flit recv <invite>")
|
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +127,10 @@ func send(ctx context.Context, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := cfg()
|
c := cfg()
|
||||||
|
if c.SignalURL == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit: FLIT_SIGNAL_URL is not set — export it to point at your anchor (e.g. wss://anchor.example.com/ws)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
sess, err := transport.NewSession(dataDir(), c)
|
sess, err := transport.NewSession(dataDir(), c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||||
|
|||||||
26
cli/daemon.toml.example
Normal file
26
cli/daemon.toml.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# flit daemon config — copy to ~/.flit/daemon.toml and fill in.
|
||||||
|
#
|
||||||
|
# Run with: flit daemon
|
||||||
|
# Files land in download_dir as peers send them.
|
||||||
|
|
||||||
|
# Required — WebSocket URL of your waste-go anchor
|
||||||
|
signal_url = "wss://your-anchor.example.com/ws"
|
||||||
|
|
||||||
|
# Optional — TURN relay for cross-network transfers
|
||||||
|
turn_url = "turn:your-anchor.example.com:3478"
|
||||||
|
turn_secret = "" # coturn use-auth-secret; leave empty to use STUN only
|
||||||
|
|
||||||
|
# Directory where received files are saved (~ is expanded)
|
||||||
|
download_dir = "~/flit-inbox"
|
||||||
|
|
||||||
|
# Trusted peers — add one [[peers]] block per device.
|
||||||
|
# Get a peer's id by pairing once via `flit send` / PWA and reading
|
||||||
|
# the "peer connected" log line, or from the PWA's Known devices list.
|
||||||
|
|
||||||
|
[[peers]]
|
||||||
|
id = "aabbccddeeff..." # hex Ed25519 pubkey
|
||||||
|
label = "phone"
|
||||||
|
|
||||||
|
[[peers]]
|
||||||
|
id = "112233445566..."
|
||||||
|
label = "laptop"
|
||||||
@@ -11,6 +11,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/google/uuid v1.3.1 // indirect
|
github.com/google/uuid v1.3.1 // indirect
|
||||||
github.com/pion/datachannel v1.5.8 // indirect
|
github.com/pion/datachannel v1.5.8 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ func NetHash(name string) string {
|
|||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PairRoomName derives a stable room name for two known peers — mirrors
|
||||||
|
// pairRoomName() in pwa/src/pairing/ephemeral.ts. Both sides compute it
|
||||||
|
// independently from their stored peer IDs, so no QR/invite is needed.
|
||||||
|
func PairRoomName(a, b string) string {
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
return "flit-pair:" + a + ":" + b
|
||||||
|
}
|
||||||
|
|
||||||
func iceServers(cfg Config) []webrtc.ICEServer {
|
func iceServers(cfg Config) []webrtc.ICEServer {
|
||||||
servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
|
servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
|
||||||
if cfg.TurnURL != "" && cfg.TurnSecret != "" {
|
if cfg.TurnURL != "" && cfg.TurnSecret != "" {
|
||||||
@@ -149,6 +159,7 @@ type Session struct {
|
|||||||
pendingRecv *recvState
|
pendingRecv *recvState
|
||||||
|
|
||||||
OnConnected func(verified bool)
|
OnConnected func(verified bool)
|
||||||
|
OnDisconnected func()
|
||||||
OnFileOffer func(FileOffer)
|
OnFileOffer func(FileOffer)
|
||||||
OnFileProgress func(xid string, received, total int64)
|
OnFileProgress func(xid string, received, total int64)
|
||||||
OnFileDone func(name string, path string)
|
OnFileDone func(name string, path string)
|
||||||
@@ -229,6 +240,21 @@ func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
if state == webrtc.PeerConnectionStateFailed ||
|
||||||
|
state == webrtc.PeerConnectionStateDisconnected ||
|
||||||
|
state == webrtc.PeerConnectionStateClosed {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pc = nil
|
||||||
|
s.ekeySent = false
|
||||||
|
s.offered = false
|
||||||
|
s.peerEPK = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.OnDisconnected != nil {
|
||||||
|
s.OnDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
s.sendEkey()
|
s.sendEkey()
|
||||||
offerByOrder := s.identity.PeerID() < peerID
|
offerByOrder := s.identity.PeerID() < peerID
|
||||||
|
|||||||
@@ -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)
|
```bash
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
npm install
|
||||||
|
npm run dev # dev server at localhost:5173
|
||||||
## React Compiler
|
npm run build # output to dist/
|
||||||
|
npm run lint
|
||||||
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.
|
`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.
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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="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="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" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>flit</title>
|
<title>flit</title>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
|
|||||||
@@ -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',
|
|
||||||
}
|
|
||||||
12
pwa/public/config.js.example
Normal file
12
pwa/public/config.js.example
Normal 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',
|
||||||
|
}
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
"description": "Ephemeral, end-to-end encrypted file transfer between your own devices.",
|
"description": "Ephemeral, end-to-end encrypted file transfer between your own devices.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0d1117",
|
"background_color": "#080808",
|
||||||
"theme_color": "#1f6feb",
|
"theme_color": "#00e87a",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/share-target",
|
"action": "/share-target",
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
268
pwa/src/App.tsx
268
pwa/src/App.tsx
@@ -1,25 +1,44 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FlitConfig } from './transport/flit'
|
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
|
||||||
import { createInvite, decodeInvite } from './pairing/ephemeral'
|
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
|
||||||
import { addTrusted, listTrusted } from './pairing/keyring'
|
import { QrScanner } from './pairing/QrScanner'
|
||||||
|
import { addTrusted, listTrusted, removeTrusted, type TrustedPeer } from './pairing/keyring'
|
||||||
import { consumeSharedFiles, registerServiceWorker } from './share-target'
|
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 {
|
declare global {
|
||||||
interface Window { FLIT_CONFIG?: FlitConfig }
|
interface Window { FLIT_CONFIG?: FlitConfig }
|
||||||
}
|
}
|
||||||
|
|
||||||
type Phase = 'idle' | 'hosting' | 'joining' | 'connected'
|
type Phase = 'idle' | 'hosting' | 'joining' | 'connected'
|
||||||
|
type Mode = 'known' | 'invite'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('idle')
|
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 [qrDataUrl, setQrDataUrl] = useState<string>('')
|
||||||
const [inviteInput, setInviteInput] = useState('')
|
const [inviteInput, setInviteInput] = useState('')
|
||||||
const [verified, setVerified] = useState(false)
|
const [verified, setVerified] = useState(false)
|
||||||
const [peerId, setPeerId] = useState('')
|
const [peerId, setPeerId] = useState('')
|
||||||
const [offer, setOffer] = useState<FileOfferEvent | null>(null)
|
const [offers, setOffers] = useState<FileOfferEvent[]>([])
|
||||||
const [progress, setProgress] = useState<FileProgressEvent | null>(null)
|
const [progress, setProgress] = useState<FileProgressEvent | null>(null)
|
||||||
const [received, setReceived] = useState<FileRecvEvent[]>([])
|
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)
|
const sessionRef = useRef<FlitSession | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,31 +59,66 @@ function App() {
|
|||||||
|
|
||||||
function peerEvents() {
|
function peerEvents() {
|
||||||
return {
|
return {
|
||||||
connected: (v: boolean) => { setVerified(v); setPhase('connected') },
|
connected: (v: boolean, pid: string) => { setVerified(v); setPeerId(pid); setPhase('connected') },
|
||||||
status: () => {},
|
status: (s: RTCPeerConnectionState) => setConnState(s),
|
||||||
fileOffer: (e: FileOfferEvent) => { setOffer(e); setPeerId(e.peer) },
|
fileOffer: (e: FileOfferEvent) => { setOffers((o) => [...o, e]); setPeerId(e.peer) },
|
||||||
fileProgress: (e: FileProgressEvent) => setProgress(e),
|
fileProgress: (e: FileProgressEvent) => setProgress(e),
|
||||||
fileRecv: (e: FileRecvEvent) => { setReceived((r) => [...r, e]); setProgress(null) },
|
fileRecv: (e: FileRecvEvent) => {
|
||||||
fileCancelled: () => setOffer(null),
|
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() {
|
async function startHost() {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
const { invite, room } = createInvite(cfg().signalURL)
|
const { invite, room } = createInvite(cfg().signalURL)
|
||||||
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
|
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
|
||||||
setPhase('hosting')
|
setPhase('hosting')
|
||||||
const session = await FlitSession.init(cfg())
|
const session = await FlitSession.init(cfg())
|
||||||
sessionRef.current = session
|
sessionRef.current = session
|
||||||
await session.join(room, peerEvents())
|
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) {
|
async function joinFromInvite(raw: string) {
|
||||||
|
setError(null)
|
||||||
const inv = decodeInvite(raw)
|
const inv = decodeInvite(raw)
|
||||||
if (!inv) { alert('Not a valid flit invite'); return }
|
if (!inv) { setError('Not a valid flit invite'); return }
|
||||||
setPhase('joining')
|
setPhase('joining')
|
||||||
|
try {
|
||||||
const session = await FlitSession.init(cfg())
|
const session = await FlitSession.init(cfg())
|
||||||
sessionRef.current = session
|
sessionRef.current = session
|
||||||
await session.join(inv.room, peerEvents())
|
await session.join(inv.room, peerEvents())
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e instanceof Error ? e.message : e))
|
||||||
|
setPhase('idle')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendFiles(files: File[]) {
|
async function sendFiles(files: File[]) {
|
||||||
@@ -73,90 +127,206 @@ function App() {
|
|||||||
for (const f of files) session.sendFile(f)
|
for (const f of files) session.sendFile(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
function acceptOffer() {
|
function acceptOffer(o: FileOfferEvent) {
|
||||||
if (!offer) return
|
sessionRef.current?.acceptOffer(o.xid, o.name, o.size)
|
||||||
sessionRef.current?.acceptOffer(offer.xid, offer.name, offer.size)
|
setOffers((prev) => prev.filter(f => f.xid !== o.xid))
|
||||||
setOffer(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectOffer() {
|
function rejectOffer(o: FileOfferEvent) {
|
||||||
if (!offer) return
|
sessionRef.current?.rejectOffer(o.xid)
|
||||||
sessionRef.current?.rejectOffer(offer.xid)
|
setOffers((prev) => prev.filter(f => f.xid !== o.xid))
|
||||||
setOffer(null)
|
}
|
||||||
|
|
||||||
|
function acceptAllOffers() {
|
||||||
|
offers.forEach(o => sessionRef.current?.acceptOffer(o.xid, o.name, o.size))
|
||||||
|
setOffers([])
|
||||||
}
|
}
|
||||||
|
|
||||||
function trustCurrentPeer() {
|
function trustCurrentPeer() {
|
||||||
if (!peerId) return
|
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 (
|
return (
|
||||||
<main style={{ maxWidth: 480, margin: '0 auto', padding: 16, fontFamily: 'system-ui' }}>
|
<main className="app">
|
||||||
<h1>flit</h1>
|
<h1>flit<span className="dot">.</span></h1>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">⚠️ {error}</div>}
|
||||||
|
|
||||||
{phase === 'idle' && (
|
{phase === 'idle' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="stack">
|
||||||
<button onClick={startHost}>Send a file (show QR)</button>
|
<div className="mode-toggle">
|
||||||
<div>
|
<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>
|
||||||
|
|
||||||
|
{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
|
<input
|
||||||
placeholder="paste flit: invite, or scan"
|
className="text-input"
|
||||||
|
placeholder="paste flit: invite"
|
||||||
value={inviteInput}
|
value={inviteInput}
|
||||||
onChange={(e) => setInviteInput(e.target.value)}
|
onChange={(e) => setInviteInput(e.target.value)}
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
<button onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'hosting' && qrDataUrl && (
|
{phase === 'hosting' && qrDataUrl && (
|
||||||
<div>
|
<div className="card qr-card">
|
||||||
<p>Scan this on the receiving device:</p>
|
<p className="qr-hint">Scan this on the receiving device</p>
|
||||||
<img src={qrDataUrl} alt="pairing QR" width={256} height={256} />
|
<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>
|
</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' && (
|
{phase === 'connected' && (
|
||||||
<div>
|
<div className="card stack">
|
||||||
<p>{verified ? '✅ Verified peer' : '⚠️ Unverified — connection not confirmed'} {peerId && `(${peerId.slice(0, 16)}…)`}</p>
|
<div className="peer-row">
|
||||||
<button onClick={trustCurrentPeer}>Remember this device</button>
|
<span className={`peer-badge ${verified ? 'verified' : 'unverified'}`}>
|
||||||
<div>
|
{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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
onChange={(e) => e.target.files && sendFiles(Array.from(e.target.files))}
|
onChange={(e) => e.target.files && sendFiles(Array.from(e.target.files))}
|
||||||
/>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
|
<progress className="bar" value={sendProgress.sent} max={sendProgress.total} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{offer && (
|
{sentFiles.length > 0 && (
|
||||||
<div style={{ border: '1px solid', padding: 8, marginTop: 12 }}>
|
<ul className="file-list">
|
||||||
<p>Incoming file: {offer.name} ({offer.size} bytes)</p>
|
{sentFiles.map((f, i) => (
|
||||||
<button onClick={acceptOffer}>Accept</button>
|
<li className="file-row" key={i}>✓ sent {f.name}</li>
|
||||||
<button onClick={rejectOffer}>Reject</button>
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{progress && (
|
{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 && (
|
{received.length > 0 && (
|
||||||
<ul>
|
<ul className="file-list">
|
||||||
{received.map((r, i) => (
|
{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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{scanning && (
|
||||||
|
<QrScanner
|
||||||
|
onDetect={(val) => { setScanning(false); void joinFromInvite(val) }}
|
||||||
|
onClose={() => setScanning(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,50 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
--bg: #080808;
|
||||||
--text-h: #08060d;
|
--bg-alt: #0e0e0e;
|
||||||
--bg: #fff;
|
--text: #c8c8c8;
|
||||||
--border: #e5e4e7;
|
--muted: #555;
|
||||||
--code-bg: #f4f3ec;
|
--heading: #f0f0f0;
|
||||||
--accent: #aa3bff;
|
--accent: #00e87a;
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
--accent-dim: rgba(0, 232, 122, 0.12);
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
--accent-border:rgba(0, 232, 122, 0.25);
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
--shadow:
|
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
|
||||||
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;
|
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
font: 16px/1.6 var(--sans);
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
font-synthesis: none;
|
color-scheme: dark;
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
: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 {
|
body { margin: 0; }
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
h1, h2, h3 {
|
||||||
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);
|
font-family: var(--mono);
|
||||||
display: inline-flex;
|
color: var(--heading);
|
||||||
border-radius: 4px;
|
line-height: 1.15;
|
||||||
color: var(--text-h);
|
margin: 0 0 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-size: 15px;
|
font-family: var(--mono);
|
||||||
line-height: 135%;
|
font-size: 13px;
|
||||||
padding: 4px 8px;
|
color: var(--accent);
|
||||||
background: var(--code-bg);
|
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 }
|
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 {
|
export function decodeInvite(s: string): FlitInvite | null {
|
||||||
const trimmed = s.trim()
|
const trimmed = s.trim()
|
||||||
if (!trimmed.startsWith(PREFIX)) return null
|
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 FileOfferEvent { peer: string; xid: string; name: string; size: number }
|
||||||
export interface FileProgressEvent { peer: string; xid: string; name: string; received: number; total: 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 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 = {
|
type PeerEvents = {
|
||||||
connected: (verified: boolean) => void
|
connected: (verified: boolean, peerId: string) => void
|
||||||
status: (state: RTCPeerConnectionState) => void
|
status: (state: RTCPeerConnectionState) => void
|
||||||
fileOffer: (e: FileOfferEvent) => void
|
fileOffer: (e: FileOfferEvent) => void
|
||||||
fileProgress: (e: FileProgressEvent) => void
|
fileProgress: (e: FileProgressEvent) => void
|
||||||
fileRecv: (e: FileRecvEvent) => void
|
fileRecv: (e: FileRecvEvent) => void
|
||||||
fileCancelled: (xid: string) => void
|
fileCancelled: (xid: string) => void
|
||||||
|
fileSendProgress: (e: FileSendProgressEvent) => void
|
||||||
|
fileSent: (e: FileSentEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PeerConn {
|
export class PeerConn {
|
||||||
@@ -391,7 +395,7 @@ export class PeerConn {
|
|||||||
|
|
||||||
if (m['type'] === 'hello') {
|
if (m['type'] === 'hello') {
|
||||||
this.verified = m['id'] === this.peerId && this.peerAuthed
|
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') {
|
} 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 })
|
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') {
|
} 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))
|
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
||||||
dc.send(buf.slice(offset, offset + CHUNK))
|
dc.send(buf.slice(offset, offset + CHUNK))
|
||||||
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()
|
dc.close()
|
||||||
|
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dc(obj: object) {
|
private _dc(obj: object) {
|
||||||
|
|||||||
Reference in New Issue
Block a user