From 0789cf88401d4ac7ef29107abc9bc7c702909d65 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sun, 28 Jun 2026 21:38:28 +0200 Subject: [PATCH] feat: download dirs, file transfer resume, Wails desktop app, PWA, CI Daemon: - Per-network download directories (-download-dir flag, set_download_dir IPC) - File transfer resume after disconnection: .tmp.meta sidecars survive interruption; resume_offset in file-accept lets sender seek and continue - set_download_dir IPC command; download_dir reported in state_snapshot Protocol: - PeerMessage.ResumeOffset (EXT-006) for file transfer resume - IpcMessage.ShareNetworks json tag changed from "networks" to "network_ids" to fix duplicate json tag collision with Networks []NetworkInfo Desktop app (cmd/app): - Wails v2 shell embedding daemon logic directly (no subprocess) - System tray on Linux/Windows via getlantern/systray; macOS hides to Dock - OS notifications for message_received and file_complete via Wails events - notray build tag for headless/CI builds without GTK tray headers - build-app.sh: builds web frontend, copies dist, runs wails build Web / PWA: - manifest.json + Apple touch icon meta tags for mobile "Add to Home Screen" - PNG icons (192px, 512px, 180px) generated from SVG - Wails EventsOn("notify") hook in App.tsx for native OS notifications CI: - .gitea/workflows/build.yml: server binaries cross-compiled for 5 platforms, desktop app for Linux amd64, release artifacts published on v* tags Docs: - README: download dir, file transfer resume, desktop app, PWA, CI sections - EXTENSIONS.md: EXT-004 daemon mode marked shipped; EXT-006 resume added - FUTURE.md: roadmap updated Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 120 ++++++++++++++++++++ .gitignore | 2 + EXTENSIONS.md | 59 +++++++++- FUTURE.md | 11 +- README.md | 82 +++++++++++++- build-app.sh | 30 +++++ cmd/app/app.go | 130 ++++++++++++++++++++++ cmd/app/appicon.png | Bin 0 -> 3791 bytes cmd/app/frontend/dist/.gitkeep | 0 cmd/app/main.go | 66 +++++++++++ cmd/app/tray.go | 35 ++++++ cmd/app/tray_darwin.go | 14 +++ cmd/app/tray_stub.go | 8 ++ cmd/app/wails.json | 12 ++ cmd/daemon/main.go | 2 + go.mod | 39 ++++++- go.sum | 85 +++++++++++++++ internal/ipc/ipc.go | 16 +++ internal/mesh/peer.go | 2 +- internal/mesh/transfer.go | 188 ++++++++++++++++++++++++++------ internal/netmgr/manager.go | 27 ++++- internal/proto/proto.go | 14 ++- web/index.html | 9 +- web/package.json | 4 +- web/public/apple-touch-icon.png | Bin 0 -> 68715 bytes web/public/icon-192.png | Bin 0 -> 78381 bytes web/public/icon-512.png | Bin 0 -> 466908 bytes web/public/manifest.json | 23 ++++ web/src/App.tsx | 31 ++++++ 29 files changed, 951 insertions(+), 58 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100755 build-app.sh create mode 100644 cmd/app/app.go create mode 100644 cmd/app/appicon.png create mode 100644 cmd/app/frontend/dist/.gitkeep create mode 100644 cmd/app/main.go create mode 100644 cmd/app/tray.go create mode 100644 cmd/app/tray_darwin.go create mode 100644 cmd/app/tray_stub.go create mode 100644 cmd/app/wails.json create mode 100644 web/public/apple-touch-icon.png create mode 100644 web/public/icon-192.png create mode 100644 web/public/icon-512.png create mode 100644 web/public/manifest.json diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..926365e --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,120 @@ +name: Build + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + # ── Server binaries (no CGo, cross-compile freely) ─────────────────────────── + + server: + name: Server binaries + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build daemon + anchor + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}" + [ "${{ matrix.goos }}" = "windows" ] && EXT=".exe" || EXT="" + go build -trimpath -ldflags="-s -w" -o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon + go build -trimpath -ldflags="-s -w" -o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor + + - uses: actions/upload-artifact@v4 + with: + name: server-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/ + + # ── Desktop app (Wails, requires CGo + webview libs) ───────────────────────── + # Runs only on Linux amd64 with the default runner. + # For macOS/Windows desktop builds, add self-hosted runners with those platforms + # and duplicate this job (adjusting the runs-on and platform deps). + + desktop-linux: + name: Desktop app (Linux amd64) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Install platform dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.0-dev \ + libayatana-appindicator3-dev + + - name: Build frontend + run: | + cd web + npm ci + npm run build + cp -r dist ../cmd/app/frontend/dist + + - name: Build desktop app + run: | + mkdir -p dist + cd cmd/app + wails build -trimpath -ldflags="-s -w" -o ../../dist/waste-linux-amd64 + + - uses: actions/upload-artifact@v4 + with: + name: desktop-linux-amd64 + path: dist/waste-linux-amd64 + + # ── Release: collect all artifacts and publish ──────────────────────────────── + + release: + name: Publish release + needs: [server, desktop-linux] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + merge-multiple: true + + - name: Create release + uses: https://gitea.com/actions/gitea-release-action@main + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: artifacts/* + prerelease: ${{ contains(github.ref_name, '-') }} diff --git a/.gitignore b/.gitignore index 016fc90..85f164a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ deploy-web.sh deploy-daemon.sh serve-web.sh web/public/config.js +cmd/app/frontend/dist/* +!cmd/app/frontend/dist/.gitkeep diff --git a/EXTENSIONS.md b/EXTENSIONS.md index 2167b07..31292d3 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -117,8 +117,8 @@ proper signed invite (if the network enforces it) to be accepted by peers. ### New IPC commands ```jsonc -{"type":"add_share","path":"/home/alice/Music"} // global -{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // scoped +{"type":"add_share","path":"/home/alice/Music"} // global +{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // scoped {"type":"remove_share","path":"/home/alice/Music"} {"type":"list_shares"} ``` @@ -148,7 +148,7 @@ entries from all applicable share roots, with relative `path` fields ## EXT-004 — TURN Relay (browser mode) -**Status:** implemented (browser mode); pending (daemon mode) +**Status:** implemented (browser mode + daemon mode) **Affects:** ICE server configuration only, no wire changes The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret` @@ -171,3 +171,56 @@ this field continue to use `name` for display and download requests. `MsgFileListReq` / `get` requests use `path` as the lookup key when present, falling back to `name` for backward compat with peers that don't send `path`. + +--- + +## EXT-006 — File Transfer Resume + +**Status:** implemented (daemon mode) +**Affects:** `file-accept` wire message (additive field) + +### Motivation + +A transfer interrupted mid-stream (peer disconnect, network drop) can be +continued from where it left off on the next offer of the same file, avoiding +a full re-download. + +### Protocol change + +`file-accept` gains one optional field: + +```json +{ "type": "file-accept", "xid": "...", "resume_offset": 65536 } +``` + +`resume_offset` is the number of bytes the receiver already has on disk. +When non-zero, the sender seeks to that byte position before streaming. +Peers that don't understand this field ignore it and send from the start — +the receiver detects this by comparing incoming data to expected offset and +will still verify the final SHA-256, but the partial bytes from the interrupted +session will be overwritten (YAW/2-only interop degrades gracefully to a full +re-download, not corruption). + +### Receiver behaviour + +1. On `file-offer`, the receiver scans its download directory for a `.tmp.meta` + sidecar whose `sha256` matches the offer. +2. If found, the corresponding `.tmp` file's size is the resume offset. This is + sent back in `file-accept`. +3. On DC open, the receiver opens the existing `.tmp` in append mode and + re-hashes its existing bytes to restore the SHA-256 state. +4. On DC close with all bytes received, SHA-256 is verified. Success → sidecar + removed, file renamed to final path. Hash mismatch → both files removed. +5. On DC close with fewer bytes than expected (interrupted again) → both files + kept for the next resume attempt. + +### Sidecar format + +Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar: + +```json +{ "name": "archive.zip", "sha256": "abc...", "from": "", "size": 1048576 } +``` + +The sidecar is written when the transfer starts and removed on completion or +corruption. Interrupted transfers keep the sidecar indefinitely. diff --git a/FUTURE.md b/FUTURE.md index 1a9d514..e45b8dc 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -91,10 +91,10 @@ DM rooms (`dm:`) appear automatically in both interfaces when messages a - Live progress bar per active transfer - Push (📎) sends directly to a peer without them needing to share a folder -**Not yet done:** resume after disconnection, daemon-side download directory. +**Not yet done:** daemon-side resume UX (IPC event to surface resumable transfers to the UI on reconnect). ### Native UI -Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications. +Web frontend (React, already built) + [Wails v2](https://wails.io) shell for native packaging. Wails is Go-native — no Rust toolchain required. The daemon runs embedded in the same process; the webview connects to the existing WebSocket IPC at `ws://127.0.0.1:17338`. Scaffolded in `cmd/app/`; build with `./build-app.sh`. Remaining work: system tray, OS notifications. --- @@ -128,8 +128,11 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC | ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) | | ✅ shipped | TUI room creation + daemon-side room persistence | | ✅ shipped | Unread room indicators in TUI (`*` prefix) | -| next | File transfer resume after disconnection | -| future | Native UI (React + Tauri) | +| ✅ shipped | Per-network download directories (`-download-dir` flag + `set_download_dir` IPC) | +| ✅ shipped | File transfer resume after disconnection | +| ✅ shipped | PWA manifest — installable via "Add to Home Screen" on iOS and Android | +| ✅ shipped | Native desktop app (Wails 2) — system tray (Linux/Windows), OS notifications, single binary | +| ✅ shipped | Gitea Actions CI — server binaries (all platforms via cross-compile) + desktop app (Linux amd64) | --- diff --git a/README.md b/README.md index a1a877c..a12468a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ A user visits your domain, enters their name and a network name, and joins. Invi **Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person. +**Mobile:** the web UI ships a PWA manifest (`manifest.json`) so iOS and Android users can pin it to their home screen via "Add to Home Screen" in Safari or Chrome. It runs as a standalone app with no browser chrome. No app store, no native install required. + **Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it). ### Daemon mode (for users running the daemon locally) @@ -215,8 +217,80 @@ Hover over a peer in the sidebar to reveal action buttons. Click **⊞** to requ Hover over a peer and click **📎** to open a file picker. The selected file is pushed immediately to that peer — they don't need to be sharing anything. The recipient's browser auto-downloads the file on arrival. +### Transfer resume (daemon mode) + +When a file transfer is interrupted mid-stream — peer disconnects, network drops — the partially-received data is kept on disk. A `.meta` sidecar is written alongside each in-progress `.tmp` file recording the file name, size, and SHA-256. + +When the peer reconnects and re-offers the same file, the daemon finds the matching partial by SHA-256, sends back a `file-accept` with a non-zero `resume_offset`, and the sender seeks to that byte position before streaming. The receiver appends to the existing file and verifies the full SHA-256 at the end. + +Corrupted transfers (all bytes received but hash mismatch) are removed. Interrupted transfers are kept indefinitely until either successfully completed or the `.tmp`/`.meta` files are manually deleted. + > In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it. +### Daemon download directory + +Received files are saved into a per-network subdirectory so networks stay isolated. By default this is inside the data directory: + +``` +~/.waste/downloads-/ +``` + +Pass `-download-dir` at startup to use a different base: + +```bash +go run ./cmd/daemon -download-dir ~/Downloads ... +``` + +Files then land in `~/Downloads/downloads-/`. The path can also be changed at runtime via the `set_download_dir` IPC command (takes effect for the next incoming transfer on that network). The current path is reported in every `state_snapshot` event under `networks[].download_dir`. + +--- + +## Desktop app (Wails) + +`cmd/app/` is a [Wails v2](https://wails.io) shell that packages the React frontend and the daemon logic into a single native binary for Windows, macOS, and Linux. No Rust, no Electron — just Go + the OS webview. + +The daemon runs embedded in the same process. The webview connects to `ws://127.0.0.1:17338` exactly as it does in browser-daemon mode, so the frontend code is unchanged. Identity and stores use the OS config directory (`~/Library/Application Support/waste` on macOS, `~/.config/waste` on Linux, `%APPDATA%\waste` on Windows). + +### Prerequisites + +- Go 1.24+ +- Node.js 20+ +- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest` +- Linux platform deps: `sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev` + +### Build + +```bash +# Production build → dist/waste-linux-amd64 (or waste.exe / waste.app) +./build-app.sh + +# Dev mode (hot-reload; start Vite in another terminal: cd web && npm run dev) +./build-app.sh dev + +# Build without system tray (no libayatana-appindicator3-dev needed) +CGO_ENABLED=1 WAILS_TAGS=notray ./build-app.sh +``` + +`build-app.sh` builds the React frontend, copies `web/dist/` into `cmd/app/frontend/dist/` for embedding, then runs `wails build`. The result is a single self-contained binary. + +### What the desktop app provides + +- **Single binary** — daemon logic is embedded, no subprocess to manage +- **System tray** (Linux/Windows) — hide to tray, reopen from tray menu, Quit +- **macOS** — hides to Dock on window close; full menu-bar tray is future work +- **OS notifications** — native popup on new message or completed file transfer (uses the browser `Notification` API via Wails events; the app will request permission on first notification) +- **Data directory** — OS-appropriate config dir (`~/.config/waste`, `~/Library/Application Support/waste`, `%APPDATA%\waste`) + +### CI / automated builds + +`.gitea/workflows/build.yml` runs on every `v*` tag push: + +- **Server binaries** (daemon + anchor): cross-compiled for Linux amd64/arm64, macOS amd64/arm64, Windows amd64 — no CGo, no special runner needed +- **Desktop app**: Linux amd64 on the default runner (installs GTK + WebKit + appindicator deps automatically) +- **Releases**: all artifacts attached to the Gitea release + +To add macOS or Windows desktop builds, add self-hosted Gitea runners on those platforms and mirror the `desktop-linux` job. + --- ## Local development @@ -338,18 +412,20 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338). {"type":"get_file_list"} {"type":"get_file_list","peer_id":"<64-hex>"} {"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} -{"type":"add_share","path":"/home/alice/Music"} // global share -{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped +{"type":"add_share","path":"/home/alice/Music"} // global share +{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped {"type":"remove_share","path":"/home/alice/Music"} {"type":"list_shares"} {"type":"create_room","room":"dev"} +{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network +{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"} {"type":"export_identity","passphrase":"..."} {"type":"import_identity","passphrase":"...","backup":"..."} ``` **Events:** ```jsonc -{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>"} +{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","networks":[{"network_id":"...","network_name":"friends","share_dir":"...","download_dir":"..."}]} {"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}} {"type":"session_ready","peer_id":"<64-hex>","nick":"bob"} {"type":"peer_disconnected","peer_id":"<64-hex>"} diff --git a/build-app.sh b/build-app.sh new file mode 100755 index 0000000..7852eb0 --- /dev/null +++ b/build-app.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build the waste desktop app (Wails + embedded React frontend). +# +# Prerequisites: +# go install github.com/wailsapp/wails/v2/cmd/wails@latest +# Node.js 20+ +# +# Usage: +# ./build-app.sh # production build → bin/waste (or bin/waste.exe) +# ./build-app.sh dev # dev mode (hot-reload; requires Vite dev server) +set -euo pipefail + +MODE="${1:-build}" + +echo "==> Building web frontend..." +(cd web && npm install --silent && npm run build) + +echo "==> Copying frontend dist into app package..." +rm -rf cmd/app/frontend/dist +cp -r web/dist cmd/app/frontend/dist + +if [ "$MODE" = "dev" ]; then + echo "==> Starting Wails dev mode (start 'cd web && npm run dev' in another terminal)..." + (cd cmd/app && wails dev) +else + echo "==> Running Wails build..." + mkdir -p bin + (cd cmd/app && wails build -o "$(pwd)/../../bin/waste") + echo "==> Done: bin/waste" +fi diff --git a/cmd/app/app.go b/cmd/app/app.go new file mode 100644 index 0000000..0b79dfd --- /dev/null +++ b/cmd/app/app.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "log" + "os" + "path/filepath" + + "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/waste-go/internal/crypto" + "github.com/waste-go/internal/ipc" + "github.com/waste-go/internal/netmgr" + "github.com/waste-go/internal/proto" +) + +const wsPort = 17338 + +// App is the Wails application backend. +// It embeds the daemon directly — no subprocess needed. +// The React frontend connects to the daemon's WebSocket IPC at ws://127.0.0.1:17338, +// exactly as it does in browser-daemon mode. +type App struct { + ctx context.Context + mgr *netmgr.Manager +} + +func newApp() *App { + return &App{} +} + +// startup is called when the Wails window is ready. It initialises the daemon, +// starts the WebSocket IPC listener, sets up the system tray, and begins +// forwarding message/file events to the webview as OS notifications. +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + + dir := dataDir() + id, err := crypto.LoadOrCreate(dir, "") + if err != nil { + log.Printf("app: identity: %v", err) + runtime.MessageDialog(ctx, runtime.MessageDialogOptions{ + Type: runtime.ErrorDialog, + Title: "waste — startup error", + Message: "Failed to load identity: " + err.Error(), + }) + return + } + log.Printf("app: peer id: %s alias: %s", id.PeerID().Short(), id.Alias) + + a.mgr = netmgr.New(netmgr.Config{ + MasterIdentity: id, + StoreDir: dir, + }) + + // Forward daemon events to the webview and generate OS notifications. + go a.watchEvents() + + // Start the WebSocket IPC server; the webview connects here (daemon mode). + go func() { + if err := ipc.RunWS(a.mgr, wsPort); err != nil { + log.Printf("app: IPC: %v", err) + } + }() + + log.Printf("app: WebSocket IPC listening on 127.0.0.1:%d", wsPort) + + // System tray (Linux/Windows; no-op on macOS — see tray_darwin.go). + a.startTray() +} + +// shutdown is called when the Wails app exits. +func (a *App) shutdown(ctx context.Context) { + if a.mgr != nil { + a.mgr.LeaveAll() + } +} + +// watchEvents subscribes to all daemon events and emits OS notifications for +// incoming messages and completed file transfers. The "notify" event is received +// by the frontend via Wails EventsOn and displayed using the browser Notification API. +func (a *App) watchEvents() { + if a.mgr == nil { + return + } + events := a.mgr.Subscribe() + defer a.mgr.Unsubscribe(events) + + for evt := range events { + switch evt.Type { + case proto.EvtMessageReceived: + if evt.Message == nil { + continue + } + // Don't notify for messages sent by the local peer. + if a.mgr.MasterIdentity() != nil && evt.Message.From == a.mgr.MasterIdentity().PeerID() { + continue + } + runtime.EventsEmit(a.ctx, "notify", map[string]string{ + "title": "waste — new message", + "body": evt.Message.Text, + }) + + case proto.EvtFileComplete: + runtime.EventsEmit(a.ctx, "notify", map[string]string{ + "title": "waste — file received", + "body": filepath.Base(evt.Path), + }) + } + } +} + +// dataDir returns the OS-appropriate config directory for identity and stores. +// macOS: ~/Library/Application Support/waste +// Linux: ~/.config/waste +// Windows: %APPDATA%\waste +func dataDir() string { + base, err := os.UserConfigDir() + if err != nil { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".waste") + } + return ".waste" + } + dir := filepath.Join(base, "waste") + if err := os.MkdirAll(dir, 0o700); err != nil { + log.Printf("app: mkdir %s: %v", dir, err) + } + return dir +} diff --git a/cmd/app/appicon.png b/cmd/app/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c929c6f23334e295e3b6ff20245377208133790f GIT binary patch literal 3791 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