commit 5050ad5e79727a84fd1fc5b1258cb7b045c37216 Author: Fredrik Johansson Date: Tue Jun 30 19:23:04 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd39c6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +cli/flit +*.local diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..59bbc9a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,96 @@ +# flit + +Self-hosted, ephemeral, end-to-end encrypted file transfer between your own +devices — regardless of architecture. Think "AirDrop, but self-hosted and +cross-platform," built on the WebRTC/Ed25519 plumbing already proven out in +waste-go. + +## Why + +[Zipline](https://github.com/diced/zipline) already covers persistent, +link-based "upload once, share via URL" — good for sharing *to others* or +storing semi-long-term. This tool is different: ad hoc, point-to-point +transfer between *your own* devices (dev box → homelab box → laptop → +phone), without anything landing on a server's disk if it can be avoided. + +Two use cases to design for: +1. **Desktop ↔ desktop** — browser tab or small CLI on each end. +2. **Desktop ↔ mobile (Android)** — the share-sheet flow is the whole point. + +No iOS constraint — Android + desktop only. This drops a meaningful chunk of +complexity (no Safari/PWA quirks, no manual-code fallback needed for pairing). + +## How + +You can take a look at ../waste-go for inspiration, it does parts of this already. + +## Non-goals + +- No persistent storage, no accounts, no link sharing with third parties. +- Not a Zipline replacement — strictly "get a file from my hand to my other + hand." + + +## Architecture + +**Transport**: WebRTC data channels for the actual file bytes, P2P whenever +NAT traversal allows it. + +- A lightweight **signaling server** (self-hosted, homelab) exchanges + SDP/ICE candidates between peers. Stateless, ephemeral rooms, no file data + ever touches it. +- A **TURN relay fallback** (coturn, self-hosted) for when direct P2P fails + — common on mobile networks/CGNAT. Data stays E2E encrypted across the + relay, so it just shovels encrypted bytes. + +**Identity & security**: reuse the Ed25519 keypair model from waste-go. Each +device has a long-lived identity key; pairing two devices derives a session +key (X25519 ECDH) so transfers are E2E encrypted even across the TURN relay. +No accounts, no passwords. + +**Pairing UX**: QR code as the default everywhere, since there's no need to +support a browser that handles it poorly (no iOS). + +- Generate an ephemeral room code / QR code on the sending device. +- Scan it on the receiving device to join the WebRTC session. +- Once paired, drag-and-drop (desktop) or native share sheet (Android) to + send. + +**Clients**: + +- **PWA** — covers desktop browsers and Android. Installable on Android home + screen, registered as a **Web Share Target** so "Share → flit" works + directly from any app's OS share sheet (Photos, Files, browser downloads, + anything). This is the primary mobile flow, not a fallback. +- **Go CLI** — for headless machines (homelab boxes, servers without a GUI), + reusing waste-go's transport/identity code directly. `flit send file.tar.gz` + prints a code/QR for the other end to scan. + +## Build plan + +1. **Scaffold the repo.** Reuse waste-go's Ed25519 identity + WebRTC + handshake code as a starting library if cleanly extractable; otherwise + port the logic. +2. **Signaling server** — minimal Go service, WebSocket-based room exchange + (peer A creates room, gets code; peer B joins with code; server relays + SDP/ICE only, then gets out of the way). Deploy alongside Zipline in the + homelab. +3. **PWA client** — file picker + drag-drop, QR code generation/scanning + (camera access for scan), Web Share Target manifest entry, WebRTC data + channel transfer with progress UI. +4. **Go CLI** — thin wrapper sharing core logic with the + signaling/transport layer, for headless homelab boxes. +5. **TURN fallback** — coturn instance, only invoked when direct P2P ICE + candidates fail. +6. **Stretch**: chunked transfer + resume for large files over flaky mobile + connections; multi-file/folder zip-on-the-fly. + +## Open question + +Host the PWA under goonk.se (e.g. `/flit`, alongside Pitwall/Delve as a +public-facing sub-site with a project badge) or keep it standalone on a +homelab subdomain as a personal tool rather than a portfolio piece? This +decides whether the implementing work also touches the goonk.se repo's +project content collection. + + diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..778d203 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,49 @@ +# flit protocol + +flit speaks a trimmed subset of YAW/2.1 (forward-secret signaling) — see +`../waste-go/PROTOCOL.md` for the full spec. This file only notes where flit +diverges. + +## What's reused as-is + +- Ed25519 device identity; `id = hex(pubkey)`. +- Signaling: WebSocket join/challenge, sealed `to`/`from` relay (§5). +- yaw/2.1 ephemeral-key (`ekey`) forward-secret signaling, falling back to + static (yaw/2.0) seal if the peer doesn't send one (§6′, §6.1). +- The `hello` identity-confirm bind over DTLS fingerprints (§6). +- File transfer: `file-offer` / `file-accept` / `file-cancel`, chunked + 64 KiB binary DataChannel labeled `f:` (§9). + +## What's dropped + +flit has no chat, presence, multi-peer mesh, or file browsing — every +session is exactly one peer, ends after one transfer (or a cancel/close). +`chat`, `pm`, `reaction`, `peer_gossip`, `browse`/`get`/`files` message +types are not implemented. + +## What's flit-specific + +- **Room naming**: flit never uses a human-chosen network name. Rooms are + 16 random bytes (128 bits), hex-encoded, generated fresh per pairing — + `net = sha256("yaw2-net:" + random_hex)`. This is deliberately different + from yaw2's named-network convention: a flit room must not be guessable or + brute-forceable from the anchor's point of view, since pairing is the only + trust mechanism (no keyring-gated network name to fall back on for an + ephemeral session). +- **Invite encoding**: `flit:` where the JSON is + `{"anchor": "", "room": ""}`. Distinct from waste-go's + `waste:` invite format (which carries a network *name*, not a random room). + See `pwa/src/pairing/ephemeral.ts` and `cli/cmd/flit/main.go`. +- **Pairing trust**: two independent flows — + - *Ephemeral* (above): trust is "whoever shows up in this freshly + generated, never-reused room," confirmed by the `hello` signature. + - *Persistent keyring* (`pwa/src/pairing/keyring.ts`): optionally + remember a peer's `id` after a verified session, for repeat pairing + without re-scanning. Not yet wired into the join flow to pin sessions + to a specific id — currently advisory/UI-only. +- **TURN credentials**: short-lived, minted server-side by the anchor's + `GET /turn-credentials` (coturn `use-auth-secret` HMAC; see + `waste-go/cmd/anchor/main.go`). The PWA fetches from there. The CLI + computes the HMAC locally from `FLIT_TURN_SECRET` env (acceptable for a + native binary — unlike browser JS, it isn't visible to anyone who opens + the page source). diff --git a/cli/cmd/flit/main.go b/cli/cmd/flit/main.go new file mode 100644 index 0000000..48f7687 --- /dev/null +++ b/cli/cmd/flit/main.go @@ -0,0 +1,191 @@ +// flit — headless CLI for ephemeral, E2E-encrypted file transfer. +// +// flit send generate a one-shot room, print QR + invite, wait for a peer, send +// flit recv join a room from an invite string, accept the offered file +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/signal" + + "github.com/mdp/qrterminal/v3" + + "flit/internal/transport" +) + +const defaultSignalURL = "wss://waste.dev.xplwd.com/ws" +const defaultTurnURL = "turn:waste.dev.xplwd.com:3478" + +// invite mirrors pwa/src/pairing/ephemeral.ts's "flit:" invite format — +// keep them in sync. +type invite struct { + Anchor string `json:"anchor"` + Room string `json:"room"` +} + +func randomRoomName() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func encodeInvite(anchor, room string) string { + data, _ := json.Marshal(invite{Anchor: anchor, Room: room}) + return "flit:" + base64.RawURLEncoding.EncodeToString(data) +} + +func decodeInvite(s string) (*invite, error) { + const prefix = "flit:" + if len(s) < len(prefix) || s[:len(prefix)] != prefix { + return nil, fmt.Errorf("not a flit invite") + } + raw, err := base64.RawURLEncoding.DecodeString(s[len(prefix):]) + if err != nil { + return nil, err + } + var inv invite + if err := json.Unmarshal(raw, &inv); err != nil { + return nil, err + } + if inv.Anchor == "" || inv.Room == "" { + return nil, fmt.Errorf("invite missing anchor or room") + } + return &inv, nil +} + +func dataDir() string { + home, _ := os.UserHomeDir() + dir := home + "/.flit" + _ = os.MkdirAll(dir, 0700) + return dir +} + +func cfg() transport.Config { + c := transport.Config{SignalURL: defaultSignalURL, TurnURL: defaultTurnURL} + // TODO: same stopgap as the PWA — TurnSecret should come from a + // server-minted credential, not a local secret, once that flow is + // wired for native clients too. For now leave TurnSecret unset; flit + // falls back to STUN-only (works whenever direct P2P is reachable). + if s := os.Getenv("FLIT_TURN_SECRET"); s != "" { + c.TurnSecret = s + } + return c +} + +func main() { + if len(os.Args) < 3 { + fmt.Println("usage: flit send | flit recv ") + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + switch os.Args[1] { + case "send": + send(ctx, os.Args[2]) + case "recv": + recv(ctx, os.Args[2]) + default: + fmt.Println("usage: flit send | flit recv ") + os.Exit(1) + } +} + +func send(ctx context.Context, path string) { + if _, err := os.Stat(path); err != nil { + fmt.Fprintln(os.Stderr, "flit:", err) + os.Exit(1) + } + + c := cfg() + sess, err := transport.NewSession(dataDir(), c) + if err != nil { + fmt.Fprintln(os.Stderr, "flit:", err) + os.Exit(1) + } + + room := randomRoomName() + inv := encodeInvite(c.SignalURL, room) + fmt.Println("Scan this on the receiving device:") + qrterminal.GenerateHalfBlock(inv, qrterminal.L, os.Stdout) + fmt.Println(inv) + + done := make(chan struct{}) + sess.OnConnected = func(verified bool) { + status := "unverified" + if verified { + status = "verified" + } + fmt.Printf("peer connected (%s): %s\n", status, sess.PeerID()) + if err := sess.SendFile(path); err != nil { + fmt.Fprintln(os.Stderr, "flit: send failed:", err) + } else { + fmt.Println("transfer complete") + } + close(done) + } + + if err := sess.Join(ctx, room, ""); err != nil { + fmt.Fprintln(os.Stderr, "flit:", err) + os.Exit(1) + } + + select { + case <-done: + case <-ctx.Done(): + } +} + +func recv(ctx context.Context, inviteStr string) { + inv, err := decodeInvite(inviteStr) + if err != nil { + fmt.Fprintln(os.Stderr, "flit:", err) + os.Exit(1) + } + + c := cfg() + c.SignalURL = inv.Anchor + sess, err := transport.NewSession(dataDir(), c) + if err != nil { + fmt.Fprintln(os.Stderr, "flit:", err) + os.Exit(1) + } + + cwd, _ := os.Getwd() + done := make(chan struct{}) + sess.OnConnected = func(verified bool) { + status := "unverified" + if verified { + status = "verified" + } + fmt.Printf("peer connected (%s): %s — waiting for file offer\n", status, sess.PeerID()) + } + sess.OnFileOffer = func(offer transport.FileOffer) { + fmt.Printf("incoming file: %s (%d bytes) — accepting\n", offer.Name, offer.Size) + sess.AcceptOffer(offer, cwd) + } + sess.OnFileProgress = func(xid string, received, total int64) { + fmt.Printf("\rreceiving: %d/%d bytes", received, total) + } + sess.OnFileDone = func(name, path string) { + fmt.Printf("\nsaved %s\n", path) + close(done) + } + + if err := sess.Join(ctx, inv.Room, ""); err != nil { + fmt.Fprintln(os.Stderr, "flit:", err) + os.Exit(1) + } + + select { + case <-done: + case <-ctx.Done(): + } +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..c9d8eb4 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,39 @@ +module flit + +go 1.25.0 + +require ( + filippo.io/edwards25519 v1.2.0 + github.com/mdp/qrterminal/v3 v3.2.1 + github.com/pion/webrtc/v3 v3.3.6 + golang.org/x/crypto v0.53.0 + nhooyr.io/websocket v1.8.17 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/pion/datachannel v1.5.8 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect + github.com/pion/ice/v2 v2.3.38 // indirect + github.com/pion/interceptor v0.1.29 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/mdns v0.0.12 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtp v1.8.7 // indirect + github.com/pion/sctp v1.8.19 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v2 v2.0.20 // indirect + github.com/pion/stun v0.6.1 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pion/turn/v2 v2.1.6 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..5acd3ce --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,138 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= +github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA= +github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= +github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= +github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= +github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM= +github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8= +github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk= +github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= +github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= +github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= +github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE= +github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/cli/internal/crypto/crypto.go b/cli/internal/crypto/crypto.go new file mode 100644 index 0000000..75a4b75 --- /dev/null +++ b/cli/internal/crypto/crypto.go @@ -0,0 +1,186 @@ +// Package crypto implements flit's identity and signaling crypto: Ed25519 +// device identity, X25519 ECDH (static, derived from Ed25519; and ephemeral, +// yaw/2.1 forward-secret signaling), and the nacl/box signaling seal. +// Ported and trimmed from waste-go/internal/crypto — dropped chat/backup +// helpers not needed for 1:1 file transfer. +package crypto + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "filippo.io/edwards25519" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/nacl/box" +) + +var b64 = base64.RawURLEncoding + +// ── Identity ────────────────────────────────────────────────────────────────── + +type Identity struct { + privateKey ed25519.PrivateKey + PublicKey ed25519.PublicKey +} + +type identityFile struct { + PrivateKeyB64 string `json:"private_key"` +} + +// LoadOrCreate loads the identity from dataDir/identity.json, or generates a +// fresh keypair if none exists yet. +func LoadOrCreate(dataDir string) (*Identity, error) { + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("creating data dir: %w", err) + } + path := filepath.Join(dataDir, "identity.json") + + data, err := os.ReadFile(path) + if err == nil { + var f identityFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parsing identity file: %w", err) + } + privBytes, err := b64.DecodeString(f.PrivateKeyB64) + if err != nil { + return nil, fmt.Errorf("decoding private key: %w", err) + } + priv := ed25519.PrivateKey(privBytes) + return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("reading identity file: %w", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating keypair: %w", err) + } + f := identityFile{PrivateKeyB64: b64.EncodeToString(priv)} + raw, _ := json.MarshalIndent(f, "", " ") + if err := os.WriteFile(path, raw, 0600); err != nil { + return nil, fmt.Errorf("saving identity: %w", err) + } + return &Identity{privateKey: priv, PublicKey: pub}, nil +} + +// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key. +func (id *Identity) PeerID() string { return hex.EncodeToString(id.PublicKey) } + +func (id *Identity) Sign(data []byte) string { + return hex.EncodeToString(ed25519.Sign(id.privateKey, data)) +} + +func Verify(publicKeyHex string, data []byte, sigHex string) error { + pubBytes, err := hex.DecodeString(publicKeyHex) + if err != nil { + return fmt.Errorf("decoding public key: %w", err) + } + sigBytes, err := hex.DecodeString(sigHex) + if err != nil { + return fmt.Errorf("decoding signature: %w", err) + } + if !ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes) { + return errors.New("signature verification failed") + } + return nil +} + +// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity. +func (id *Identity) CurvePublicKey() *[32]byte { + edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey) + mont := edPoint.BytesMontgomery() + var out [32]byte + copy(out[:], mont) + return &out +} + +// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity. +func (id *Identity) CurvePrivateKey() *[32]byte { + h := sha512.Sum512(id.privateKey[:32]) + h[0] &= 248 + h[31] &= 127 + h[31] |= 64 + var out [32]byte + copy(out[:], h[:32]) + return &out +} + +// CurveFromPeerID derives the X25519 public key from a peer's hex Ed25519 id. +func CurveFromPeerID(idHex string) (*[32]byte, error) { + pubBytes, err := hex.DecodeString(idHex) + if err != nil || len(pubBytes) != 32 { + return nil, fmt.Errorf("invalid peer id %q", idHex) + } + edPoint, err := new(edwards25519.Point).SetBytes(pubBytes) + if err != nil { + return nil, fmt.Errorf("ed25519 point: %w", err) + } + mont := edPoint.BytesMontgomery() + var out [32]byte + copy(out[:], mont) + return &out, nil +} + +// ── Signaling seal (nacl/box, base64 std-padded) ────────────────────────────── + +func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string { + var nonce [24]byte + rand.Read(nonce[:]) //nolint:errcheck + ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv) + return base64.StdEncoding.EncodeToString(ct) +} + +func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(b64box) + if err != nil || len(raw) < 24 { + return nil, errors.New("invalid box") + } + var nonce [24]byte + copy(nonce[:], raw[:24]) + out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv) + if !ok { + return nil, errors.New("box open failed") + } + return out, nil +} + +// ── X25519 ephemeral keys (yaw/2.1 forward secrecy) ─────────────────────────── + +type EphemeralKey struct { + private [32]byte + public [32]byte +} + +func GenerateEphemeral() (*EphemeralKey, error) { + ek := &EphemeralKey{} + if _, err := rand.Read(ek.private[:]); err != nil { + return nil, fmt.Errorf("generating ephemeral key: %w", err) + } + ek.private[0] &= 248 + ek.private[31] &= 127 + ek.private[31] |= 64 + pub, err := curve25519.X25519(ek.private[:], curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("computing public key: %w", err) + } + copy(ek.public[:], pub) + return ek, nil +} + +func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public } +func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private } + +func (ek *EphemeralKey) Wipe() { + for i := range ek.private { + ek.private[i] = 0 + } +} diff --git a/cli/internal/transport/transport.go b/cli/internal/transport/transport.go new file mode 100644 index 0000000..079ce46 --- /dev/null +++ b/cli/internal/transport/transport.go @@ -0,0 +1,593 @@ +// Package transport implements flit's yaw/2.1 signaling + WebRTC session for +// 1:1 ephemeral file transfer, mirroring pwa/src/transport/flit.ts. Trimmed +// from waste-go: no chat, no multi-peer mesh — exactly one peer per session. +package transport + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/pion/webrtc/v3" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + + "flit/internal/crypto" +) + +const ( + bindPrefix = "yaw/2 bind" + ekeyPrefix = "yaw/2.1 ekey" + ekeyTimeout = 2 * time.Second + chunkSize = 64 * 1024 +) + +type Config struct { + SignalURL string + TurnURL string + // TurnSecret holds the coturn use-auth-secret shared secret. Safe to keep + // in a native config file (unlike the browser, this never ships to a + // client that can view-source it). + TurnSecret string +} + +func NetHash(name string) string { + h := sha256.Sum256([]byte("yaw2-net:" + name)) + return hex.EncodeToString(h[:]) +} + +func iceServers(cfg Config) []webrtc.ICEServer { + servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}} + if cfg.TurnURL != "" && cfg.TurnSecret != "" { + expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + ":flit" + mac := hmac.New(sha1.New, []byte(cfg.TurnSecret)) + mac.Write([]byte(expiry)) + cred := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + servers = append(servers, webrtc.ICEServer{ + URLs: []string{cfg.TurnURL}, Username: expiry, Credential: cred, + CredentialType: webrtc.ICECredentialTypePassword, + }) + } + return servers +} + +// ── Signaling ───────────────────────────────────────────────────────────────── + +type anchorMsg struct { + Type string `json:"type"` + Nonce string `json:"nonce,omitempty"` + ID string `json:"id,omitempty"` + Net string `json:"net,omitempty"` + Sig string `json:"sig,omitempty"` + Peers []string `json:"peers,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Box string `json:"box,omitempty"` +} + +type signaling struct { + conn *websocket.Conn + sendCh chan anchorMsg +} + +func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash string) (*signaling, []string, error) { + conn, _, err := websocket.Dial(ctx, url, nil) + if err != nil { + return nil, nil, fmt.Errorf("dial: %w", err) + } + s := &signaling{conn: conn, sendCh: make(chan anchorMsg, 64)} + go func() { + for msg := range s.sendCh { + if err := wsjson.Write(ctx, conn, msg); err != nil { + return + } + } + }() + + for { + var msg anchorMsg + if err := wsjson.Read(ctx, conn, &msg); err != nil { + return nil, nil, fmt.Errorf("read: %w", err) + } + if msg.Type == "challenge" { + nonceBytes, err := hex.DecodeString(msg.Nonce) + if err != nil { + return nil, nil, fmt.Errorf("bad challenge nonce: %w", err) + } + sig := id.Sign(append(nonceBytes, []byte(netHash)...)) + s.sendCh <- anchorMsg{Type: "join", ID: id.PeerID(), Net: netHash, Sig: sig} + } else if msg.Type == "joined" { + return s, msg.Peers, nil + } + } +} + +func (s *signaling) sendTo(to, box string) { + select { + case s.sendCh <- anchorMsg{Type: "to", To: to, Box: box}: + default: + } +} + +// ── Session ─────────────────────────────────────────────────────────────────── + +type FileOffer struct { + XID string + Name string + Size int64 +} + +// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one +// peer (optionally pinned to a specific id), exchange files. +type Session struct { + identity *crypto.Identity + cfg Config + + mu sync.Mutex + pc *webrtc.PeerConnection + dc *webrtc.DataChannel + peerID string + verified bool + esk, epk *[32]byte + peerEPK *[32]byte + ekeySent bool + offered bool + + sig *signaling + pendingRecv *recvState + + OnConnected func(verified bool) + OnFileOffer func(FileOffer) + OnFileProgress func(xid string, received, total int64) + OnFileDone func(name string, path string) +} + +func NewSession(dataDir string, cfg Config) (*Session, error) { + id, err := crypto.LoadOrCreate(dataDir) + if err != nil { + return nil, err + } + return &Session{identity: id, cfg: cfg}, nil +} + +func (s *Session) PeerID() string { return s.identity.PeerID() } + +// Join connects to the signaling server for roomName and waits for exactly +// one peer (or the pinned trustedPeerID) to complete the handshake. +func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error { + hash := NetHash(roomName) + sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, s.identity, hash) + if err != nil { + return err + } + s.sig = sig + + for _, pid := range present { + if trustedPeerID != "" && pid != trustedPeerID { + continue + } + if err := s.connectTo(ctx, pid); err != nil { + return err + } + break + } + + go func() { + for { + var msg anchorMsg + if err := wsjson.Read(ctx, sig.conn, &msg); err != nil { + return + } + switch msg.Type { + case "peer-join": + if trustedPeerID != "" && msg.ID != trustedPeerID { + continue + } + _ = s.connectTo(ctx, msg.ID) + case "from": + if trustedPeerID != "" && msg.From != trustedPeerID { + continue + } + s.onBox(msg.From, msg.Box) + } + } + }() + return nil +} + +func (s *Session) connectTo(ctx context.Context, peerID string) error { + s.mu.Lock() + if s.pc != nil { + s.mu.Unlock() + return nil + } + s.peerID = peerID + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)}) + if err != nil { + s.mu.Unlock() + return err + } + s.pc = pc + kp, err := crypto.GenerateEphemeral() + if err != nil { + s.mu.Unlock() + return err + } + s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw() + s.mu.Unlock() + + pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) }) + + s.sendEkey() + offerByOrder := s.identity.PeerID() < peerID + if offerByOrder { + go func() { + time.Sleep(ekeyTimeout) + s.maybeOffer() + }() + } + return nil +} + +func (s *Session) sendEkey() { + s.mu.Lock() + if s.ekeySent { + s.mu.Unlock() + return + } + s.ekeySent = true + epk := *s.epk + s.mu.Unlock() + + signed := append([]byte(ekeyPrefix), mustHex(s.identity.PeerID())...) + signed = append(signed, mustHex(s.peerID)...) + signed = append(signed, epk[:]...) + msg := map[string]string{ + "kind": "ekey", "v": "yaw/2.1", + "epk": hex.EncodeToString(epk[:]), + "sig": s.identity.Sign(signed), + } + s.sealAndSend(msg, false) +} + +func (s *Session) maybeOffer() { + s.mu.Lock() + if s.offered || s.pc == nil { + s.mu.Unlock() + return + } + s.offered = true + pc := s.pc + s.mu.Unlock() + + dc, err := pc.CreateDataChannel("yaw", nil) + if err != nil { + log.Printf("flit: create datachannel: %v", err) + return + } + s.wireDC(dc) + + offer, err := pc.CreateOffer(nil) + if err != nil { + return + } + if err := pc.SetLocalDescription(offer); err != nil { + return + } + <-gatherComplete(pc) + s.mu.Lock() + hasEPK := s.peerEPK != nil + s.mu.Unlock() + s.sealAndSend(map[string]string{"kind": "offer", "sdp": pc.LocalDescription().SDP}, hasEPK) +} + +func (s *Session) onBox(from, box string) { + plain, usedEph := s.openBox(box) + if plain == nil { + return + } + var obj map[string]any + if err := json.Unmarshal(plain, &obj); err != nil { + return + } + switch obj["kind"] { + case "ekey": + s.onEkey(obj) + case "offer": + s.mu.Lock() + pc := s.pc + s.mu.Unlock() + if pc == nil { + return + } + _ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: obj["sdp"].(string)}) + answer, err := pc.CreateAnswer(nil) + if err != nil { + return + } + if err := pc.SetLocalDescription(answer); err != nil { + return + } + <-gatherComplete(pc) + s.sealAndSend(map[string]string{"kind": "answer", "sdp": pc.LocalDescription().SDP}, usedEph) + case "answer": + s.mu.Lock() + pc := s.pc + s.mu.Unlock() + if pc == nil { + return + } + _ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: obj["sdp"].(string)}) + } +} + +func (s *Session) onEkey(obj map[string]any) { + s.mu.Lock() + if s.peerEPK != nil { + s.mu.Unlock() + return + } + epkHex, _ := obj["epk"].(string) + sigHex, _ := obj["sig"].(string) + epkRaw, err := hex.DecodeString(epkHex) + if err != nil || len(epkRaw) != 32 { + s.mu.Unlock() + return + } + signed := append([]byte(ekeyPrefix), mustHex(s.peerID)...) + signed = append(signed, mustHex(s.identity.PeerID())...) + signed = append(signed, epkRaw...) + if err := crypto.Verify(s.peerID, signed, sigHex); err != nil { + s.mu.Unlock() + return + } + var epk [32]byte + copy(epk[:], epkRaw) + s.peerEPK = &epk + s.mu.Unlock() + + s.sendEkey() + s.maybeOffer() +} + +func (s *Session) sealAndSend(obj map[string]string, preferEph bool) { + data, _ := json.Marshal(obj) + s.mu.Lock() + defer s.mu.Unlock() + var sealed string + if preferEph && s.peerEPK != nil { + sealed = crypto.SignalingBox(data, s.peerEPK, s.esk) + } else { + recip, err := crypto.CurveFromPeerID(s.peerID) + if err != nil { + return + } + sealed = crypto.SignalingBox(data, recip, s.identity.CurvePrivateKey()) + } + s.sig.sendTo(s.peerID, sealed) +} + +func (s *Session) openBox(boxB64 string) ([]byte, bool) { + s.mu.Lock() + peerEPK, esk := s.peerEPK, s.esk + peerID := s.peerID + s.mu.Unlock() + + if peerEPK != nil { + if pt, err := crypto.SignalingOpen(boxB64, peerEPK, esk); err == nil { + return pt, true + } + } + senderCurve, err := crypto.CurveFromPeerID(peerID) + if err != nil { + return nil, false + } + pt, err := crypto.SignalingOpen(boxB64, senderCurve, s.identity.CurvePrivateKey()) + if err != nil { + return nil, false + } + return pt, false +} + +// ── control channel + file transfer ─────────────────────────────────────────── + +func (s *Session) wireDC(dc *webrtc.DataChannel) { + if dc.Label() == "yaw" { + s.mu.Lock() + s.dc = dc + s.mu.Unlock() + dc.OnOpen(func() { s.sendHello() }) + dc.OnMessage(func(msg webrtc.DataChannelMessage) { s.onControl(msg.Data) }) + return + } + if strings.HasPrefix(dc.Label(), "f:") { + s.wireFileRecv(dc) + } +} + +func (s *Session) sendHello() { + s.controlSend(map[string]string{ + "type": "hello", "id": s.identity.PeerID(), + "sig": s.identity.Sign([]byte(bindPrefix)), + }) +} + +func (s *Session) onControl(data []byte) { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return + } + switch m["type"] { + case "hello": + s.mu.Lock() + s.verified = m["id"] == s.peerID + s.mu.Unlock() + if s.OnConnected != nil { + s.OnConnected(s.verified) + } + case "file-offer": + if s.OnFileOffer != nil { + size, _ := m["size"].(float64) + s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)}) + } + } +} + +func (s *Session) controlSend(obj map[string]string) { + s.mu.Lock() + dc := s.dc + s.mu.Unlock() + if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen { + return + } + data, _ := json.Marshal(obj) + _ = dc.SendText(string(data)) +} + +// SendFile offers and streams a file to the connected peer. +func (s *Session) SendFile(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return err + } + xid := fmt.Sprintf("push-%d", time.Now().UnixNano()) + name := st.Name() + + s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid}) + + s.mu.Lock() + pc := s.pc + s.mu.Unlock() + dc, err := pc.CreateDataChannel("f:"+xid, nil) + if err != nil { + return err + } + opened := make(chan struct{}) + dc.OnOpen(func() { close(opened) }) + select { + case <-opened: + case <-time.After(15 * time.Second): + return fmt.Errorf("timed out waiting for file-accept") + } + + buf := make([]byte, chunkSize) + for { + n, err := f.Read(buf) + if n > 0 { + for dc.BufferedAmount() > 1<<20 { + time.Sleep(10 * time.Millisecond) + } + if err := dc.Send(buf[:n]); err != nil { + return err + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + return dc.Close() +} + +// AcceptOffer accepts an incoming file-offer and writes the transfer to downloadDir. +func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) { + s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID}) + s.mu.Lock() + s.pendingRecv = &recvState{offer: offer, dir: downloadDir} + s.mu.Unlock() +} + +func (s *Session) RejectOffer(xid string) { + s.controlSend(map[string]string{"type": "file-cancel", "xid": xid}) +} + +type recvState struct { + offer FileOffer + dir string + have int64 + f *os.File +} + +func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { + xid := strings.TrimPrefix(dc.Label(), "f:") + s.mu.Lock() + rs := s.pendingRecv + s.pendingRecv = nil + s.mu.Unlock() + if rs == nil || rs.offer.XID != xid { + return + } + path := rs.dir + "/" + rs.offer.Name + f, err := os.Create(path) + if err != nil { + log.Printf("flit: create %s: %v", path, err) + return + } + rs.f = f + + dc.OnMessage(func(msg webrtc.DataChannelMessage) { + if _, err := f.Write(msg.Data); err != nil { + return + } + rs.have += int64(len(msg.Data)) + if s.OnFileProgress != nil { + s.OnFileProgress(xid, rs.have, rs.offer.Size) + } + }) + dc.OnClose(func() { + f.Close() + if s.OnFileDone != nil { + s.OnFileDone(rs.offer.Name, path) + } + }) +} + +func mustHex(s string) []byte { + b, _ := hex.DecodeString(s) + return b +} + +func gatherComplete(pc *webrtc.PeerConnection) <-chan struct{} { + ch := make(chan struct{}) + if pc.ICEGatheringState() == webrtc.ICEGatheringStateComplete { + close(ch) + return ch + } + pc.OnICEGatheringStateChange(func(s webrtc.ICEGathererState) { + if s == webrtc.ICEGathererStateComplete { + select { + case <-ch: + default: + close(ch) + } + } + }) + go func() { + time.Sleep(6 * time.Second) + select { + case <-ch: + default: + close(ch) + } + }() + return ch +} diff --git a/pwa/.gitignore b/pwa/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/pwa/.gitignore @@ -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? diff --git a/pwa/.oxlintrc.json b/pwa/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/pwa/.oxlintrc.json @@ -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 }] + } +} diff --git a/pwa/README.md b/pwa/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/pwa/README.md @@ -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. diff --git a/pwa/index.html b/pwa/index.html new file mode 100644 index 0000000..24884e1 --- /dev/null +++ b/pwa/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + flit + + + +
+ + + diff --git a/pwa/package-lock.json b/pwa/package-lock.json new file mode 100644 index 0000000..003d753 --- /dev/null +++ b/pwa/package-lock.json @@ -0,0 +1,1688 @@ +{ + "name": "pwa", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pwa", + "version": "0.0.0", + "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" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/libsodium-wrappers": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz", + "integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/libsodium": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.1.tgz", + "integrity": "sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/pwa/package.json b/pwa/package.json new file mode 100644 index 0000000..9b4fc91 --- /dev/null +++ b/pwa/package.json @@ -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" + } +} diff --git a/pwa/public/config.js b/pwa/public/config.js new file mode 100644 index 0000000..019c1b7 --- /dev/null +++ b/pwa/public/config.js @@ -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', +} diff --git a/pwa/public/favicon.svg b/pwa/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/pwa/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pwa/public/icons.svg b/pwa/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/pwa/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pwa/public/manifest.json b/pwa/public/manifest.json new file mode 100644 index 0000000..e7ae221 --- /dev/null +++ b/pwa/public/manifest.json @@ -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": ["*/*"] } + ] + } + } +} diff --git a/pwa/public/sw.js b/pwa/public/sw.js new file mode 100644 index 0000000..7450b8d --- /dev/null +++ b/pwa/public/sw.js @@ -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) +} diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx new file mode 100644 index 0000000..8977ad2 --- /dev/null +++ b/pwa/src/App.tsx @@ -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('idle') + const [qrDataUrl, setQrDataUrl] = useState('') + const [inviteInput, setInviteInput] = useState('') + const [verified, setVerified] = useState(false) + const [peerId, setPeerId] = useState('') + const [offer, setOffer] = useState(null) + const [progress, setProgress] = useState(null) + const [received, setReceived] = useState([]) + const sessionRef = useRef(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 ( +
+

flit

+ + {phase === 'idle' && ( +
+ +
+ setInviteInput(e.target.value)} + style={{ width: '100%' }} + /> + +
+
+ Trusted devices ({listTrusted().length}) +
    + {listTrusted().map((p) =>
  • {p.label} — {p.id.slice(0, 16)}…
  • )} +
+
+
+ )} + + {phase === 'hosting' && qrDataUrl && ( +
+

Scan this on the receiving device:

+ pairing QR +
+ )} + + {phase === 'joining' &&

Connecting…

} + + {phase === 'connected' && ( +
+

{verified ? '✅ Verified peer' : '⚠️ Unverified — connection not confirmed'} {peerId && `(${peerId.slice(0, 16)}…)`}

+ +
+ e.target.files && sendFiles(Array.from(e.target.files))} + /> +
+
+ )} + + {offer && ( +
+

Incoming file: {offer.name} ({offer.size} bytes)

+ + +
+ )} + + {progress && ( +

Receiving {progress.name}: {progress.received}/{progress.total}

+ )} + + {received.length > 0 && ( +
    + {received.map((r, i) => ( +
  • {r.name} ({r.size} bytes)
  • + ))} +
+ )} +
+ ) +} + +export default App diff --git a/pwa/src/index.css b/pwa/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/pwa/src/index.css @@ -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); +} diff --git a/pwa/src/main.tsx b/pwa/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/pwa/src/main.tsx @@ -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( + + + , +) diff --git a/pwa/src/pairing/ephemeral.ts b/pwa/src/pairing/ephemeral.ts new file mode 100644 index 0000000..bfb58f2 --- /dev/null +++ b/pwa/src/pairing/ephemeral.ts @@ -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 + } +} diff --git a/pwa/src/pairing/keyring.ts b/pwa/src/pairing/keyring.ts new file mode 100644 index 0000000..4ce2cb6 --- /dev/null +++ b/pwa/src/pairing/keyring.ts @@ -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) +} diff --git a/pwa/src/share-target/index.ts b/pwa/src/share-target/index.ts new file mode 100644 index 0000000..483d54b --- /dev/null +++ b/pwa/src/share-target/index.ts @@ -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 { + 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 */ }) + } +} diff --git a/pwa/src/transport/flit.ts b/pwa/src/transport/flit.ts new file mode 100644 index 0000000..7b7293b --- /dev/null +++ b/pwa/src/transport/flit.ts @@ -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 { + 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 { + 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 { + this._cbs = { onFrom, onJoin, onLeave, onReconnect } + return this._open(true) + } + + private _open(initial: boolean): Promise { + 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 + 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 { + 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 = {} + private _recvChannels: Record = {} + private _pushQueue: Map = new Map() + + private identity: Identity + private sig: Signaling + peerId: string + private events: Partial + + constructor( + identity: Identity, + sig: Signaling, + peerId: string, + events: Partial, + 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) { + 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 + 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 + 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(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 { + 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, trustedPeerId?: string): Promise { + 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, 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 + } +} diff --git a/pwa/tsconfig.app.json b/pwa/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/pwa/tsconfig.app.json @@ -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"] +} diff --git a/pwa/tsconfig.json b/pwa/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/pwa/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/pwa/tsconfig.node.json b/pwa/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/pwa/tsconfig.node.json @@ -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"] +} diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/pwa/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})