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 0000000..c929c6f Binary files /dev/null and b/cmd/app/appicon.png differ diff --git a/cmd/app/frontend/dist/.gitkeep b/cmd/app/frontend/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..82d37b1 --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,66 @@ +// waste desktop app — Wails shell wrapping the daemon and React UI. +// In dev mode the UI is served from the Vite dev server (http://localhost:5173). +// In production the compiled frontend is embedded from frontend/dist/. +package main + +import ( + "embed" + "log" + "os" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/logger" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/linux" + "github.com/wailsapp/wails/v2/pkg/options/mac" + "github.com/wailsapp/wails/v2/pkg/options/windows" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := newApp() + + logLevel := logger.INFO + if os.Getenv("WASTE_DEBUG") != "" { + logLevel = logger.DEBUG + } + + err := wails.Run(&options.App{ + Title: "waste", + Width: 1200, + Height: 800, + MinWidth: 800, + MinHeight: 600, + DisableResize: false, + Fullscreen: false, + LogLevel: logLevel, + LogLevelProduction: logger.ERROR, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{app}, + // Hide the window instead of quitting when the close button is clicked. + // The user can quit via the app menu or by stopping the process. + HideWindowOnClose: true, + Mac: &mac.Options{ + TitleBar: mac.TitleBarHiddenInset(), + About: &mac.AboutInfo{ + Title: "waste", + Message: "Decentralized friend-to-friend encrypted mesh networking.", + }, + }, + Windows: &windows.Options{ + WebviewIsTransparent: false, + WindowIsTranslucent: false, + }, + Linux: &linux.Options{}, + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/app/tray.go b/cmd/app/tray.go new file mode 100644 index 0000000..a24ab75 --- /dev/null +++ b/cmd/app/tray.go @@ -0,0 +1,35 @@ +//go:build !darwin && !notray + +package main + +import ( + _ "embed" + + "github.com/getlantern/systray" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +//go:embed appicon.png +var trayIcon []byte + +func (a *App) startTray() { + go systray.Run(func() { + systray.SetIcon(trayIcon) + systray.SetTooltip("waste") + + mShow := systray.AddMenuItem("Open waste", "Show the waste window") + systray.AddSeparator() + mQuit := systray.AddMenuItem("Quit", "Quit waste") + + for { + select { + case <-mShow.ClickedCh: + runtime.WindowShow(a.ctx) + case <-mQuit.ClickedCh: + systray.Quit() + runtime.Quit(a.ctx) + return + } + } + }, func() {}) +} diff --git a/cmd/app/tray_darwin.go b/cmd/app/tray_darwin.go new file mode 100644 index 0000000..f454e95 --- /dev/null +++ b/cmd/app/tray_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin + +package main + +// On macOS, Cocoa requires the system tray to run on the main thread, which +// Wails already owns. Full menu-bar tray integration is future work and would +// require an NSStatusItem helper or a fork of systray that supports +// RunWithExternalLoop on macOS. +// +// For now the app hides to the Dock when the window is closed (HideWindowOnClose) +// and users can reopen it from the Dock or Cmd+Tab. The standard macOS Cmd+Q +// binding quits cleanly via Wails. + +func (a *App) startTray() {} diff --git a/cmd/app/tray_stub.go b/cmd/app/tray_stub.go new file mode 100644 index 0000000..dda8119 --- /dev/null +++ b/cmd/app/tray_stub.go @@ -0,0 +1,8 @@ +//go:build notray + +package main + +// Build with -tags notray to omit the system tray (no libayatana-appindicator3-dev needed). +// Used for headless builds and CI environments that haven't installed the GTK tray headers. + +func (a *App) startTray() {} diff --git a/cmd/app/wails.json b/cmd/app/wails.json new file mode 100644 index 0000000..c254101 --- /dev/null +++ b/cmd/app/wails.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "waste", + "outputfilename": "waste", + "frontend:install": "echo 'run: cd web && npm install'", + "frontend:build": "echo 'run: ./build-app.sh'", + "frontend:dev:watcher": "", + "frontend:dev:serverUrl": "http://localhost:5173", + "author": { + "name": "waste-go" + } +} diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 5c8b09f..ee7d58b 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -21,6 +21,7 @@ func main() { wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled") anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") shareDir := flag.String("share-dir", "", "directory to share with peers on the network") + downloadDir := flag.String("download-dir", "", "base directory for received files; defaults to data-dir, split by network") joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478") turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential") @@ -76,6 +77,7 @@ func main() { StoreDir: dir, AnchorURL: *anchorURL, ShareDir: expandHome(*shareDir), + DownloadDir: expandHome(*downloadDir), TurnURL: *turnURL, TurnSecret: *turnSecret, }) diff --git a/go.mod b/go.mod index dc26508..ff21ccb 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,16 @@ require ( filippo.io/edwards25519 v1.2.0 github.com/google/uuid v1.6.0 github.com/pion/webrtc/v3 v3.3.6 - golang.org/x/crypto v0.24.0 + golang.org/x/crypto v0.33.0 modernc.org/sqlite v1.53.0 nhooyr.io/websocket v1.8.17 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect @@ -27,7 +29,26 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/getlantern/systray v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect @@ -35,6 +56,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // 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 @@ -50,15 +72,24 @@ require ( 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/stretchr/testify v1.9.0 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/wailsapp/wails/v2 v2.12.0 // indirect github.com/wlynxg/anet v0.0.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/net v0.22.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 858c044..c236fc5 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -31,21 +35,63 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= 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/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -60,6 +106,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 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= @@ -102,22 +150,45 @@ 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -130,12 +201,15 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= @@ -144,17 +218,24 @@ 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.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210809222454-d867a43fc93e/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -173,6 +254,7 @@ 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/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.6/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= @@ -180,6 +262,8 @@ 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/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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= @@ -187,6 +271,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= 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= diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 890dd6b..ab27be2 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -344,6 +344,22 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { continue } + case proto.CmdSetDownloadDir: + n := mgr.Resolve(cmd.NetworkID) + if n == nil { + send(errMsg("set_download_dir: not joined to any network")) + continue + } + if cmd.Path == "" { + send(errMsg("set_download_dir: path is required")) + continue + } + if !mgr.SetDownloadDir(n.ID, cmd.Path) { + send(errMsg("set_download_dir: network not found")) + continue + } + send(stateSnapshot(mgr)) + case proto.CmdSendFile: n := mgr.Resolve(cmd.NetworkID) if n == nil { diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go index a382c0d..00d5edd 100644 --- a/internal/mesh/peer.go +++ b/internal/mesh/peer.go @@ -267,7 +267,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) { m.acceptIncoming(msg, from) case proto.MsgFileAccept: - m.startSend(msg.Xid, from) + m.startSend(msg.Xid, from, msg.ResumeOffset) case proto.MsgFileCancel: log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason) diff --git a/internal/mesh/transfer.go b/internal/mesh/transfer.go index fd3f97d..4544955 100644 --- a/internal/mesh/transfer.go +++ b/internal/mesh/transfer.go @@ -33,14 +33,62 @@ type outboundTransfer struct { } type inboundTransfer struct { - from proto.PeerID - name string - size int64 - sha256 string - mu sync.Mutex - tmp *os.File - hasher hash.Hash - written int64 + from proto.PeerID + name string + size int64 + sha256 string + mu sync.Mutex + tmp *os.File + hasher hash.Hash + written int64 // total bytes received; starts at resumeOffset when resuming + resumePath string // path to existing .tmp when resuming; empty for new transfers + resumeOffset int64 // bytes already present in resumePath + metaPath string // path to the .tmp.meta sidecar +} + +// partialMeta is written as a JSON sidecar alongside each in-progress .tmp file. +// It survives interruptions so the receiver can resume later. +type partialMeta struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + From string `json:"from"` + Size int64 `json:"size"` +} + +// findPartial scans dir for a .tmp.meta sidecar whose sha256 matches. +// Returns (tmpPath, metaPath, offset) — all empty/zero if no match. +func findPartial(dir, sha256hex string) (tmpPath, metaPath string, offset int64) { + metas, _ := filepath.Glob(filepath.Join(dir, "*.tmp.meta")) + for _, mp := range metas { + data, err := os.ReadFile(mp) + if err != nil { + continue + } + var m partialMeta + if err := json.Unmarshal(data, &m); err != nil { + continue + } + if m.SHA256 != sha256hex { + continue + } + tp := strings.TrimSuffix(mp, ".meta") + info, err := os.Stat(tp) + if err != nil { + continue + } + return tp, mp, info.Size() + } + return "", "", 0 +} + +func writePartialMeta(path string, t *inboundTransfer) { + data, _ := json.Marshal(partialMeta{ + Name: t.name, + SHA256: t.sha256, + From: string(t.from), + Size: t.size, + }) + os.WriteFile(path, data, 0o644) //nolint:errcheck } // OfferFile reads filename from ShareDir, computes its SHA-256, and sends a @@ -102,28 +150,49 @@ func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error { } // acceptIncoming is called when we receive file-offer from a peer. -// It stores the inbound state and immediately sends file-accept (auto-accept). +// Checks for a resumable partial download matching the offer's sha256. +// Sends file-accept immediately, with a non-zero resume_offset when resuming. func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) { - m.transferMu.Lock() - m.inbound[msg.Xid] = &inboundTransfer{ + t := &inboundTransfer{ from: from, name: msg.Name, size: msg.Size, sha256: msg.SHA256, } + + var resumeOffset int64 + if m.DownloadDir != "" { + if tp, mp, off := findPartial(m.DownloadDir, msg.SHA256); tp != "" { + t.resumePath = tp + t.metaPath = mp + t.resumeOffset = off + t.written = off + resumeOffset = off + log.Printf("transfer: found partial for %s at %s (%d/%d bytes)", msg.Name, tp, off, msg.Size) + } + } + + m.transferMu.Lock() + m.inbound[msg.Xid] = t m.transferMu.Unlock() accept, _ := json.Marshal(proto.PeerMessage{ - Type: proto.MsgFileAccept, - Xid: msg.Xid, + Type: proto.MsgFileAccept, + Xid: msg.Xid, + ResumeOffset: resumeOffset, }) m.SendTo(from, accept) - log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8]) + if resumeOffset > 0 { + log.Printf("transfer: resuming %s from byte %d xid=%s", msg.Name, resumeOffset, msg.Xid[:8]) + } else { + log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8]) + } } // startSend opens the binary DataChannel "f:" and streams the file. -// Called when we receive file-accept from the peer. -func (m *Mesh) startSend(xid string, from proto.PeerID) { +// Called when we receive file-accept from the peer. resumeOffset is non-zero +// when the receiver is resuming a previous partial download. +func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) { m.transferMu.Lock() t, ok := m.outbound[xid] m.transferMu.Unlock() @@ -156,13 +225,13 @@ func (m *Mesh) startSend(xid string, from proto.PeerID) { dc.OnOpen(func() { dc.OnMessage(func(msg webrtc.DataChannelMessage) { if msg.IsString && string(msg.Data) == "ok" { - go m.sendFileChunks(dc, t, xid) + go m.sendFileChunks(dc, t, xid, resumeOffset) } }) }) } -func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string) { +func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string, offset int64) { defer func() { m.transferMu.Lock() delete(m.outbound, xid) @@ -177,6 +246,15 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s } defer f.Close() + if offset > 0 { + if _, err := f.Seek(offset, io.SeekStart); err != nil { + log.Printf("transfer: seek to %d xid=%s: %v", offset, xid[:8], err) + dc.Close() + return + } + log.Printf("transfer: resuming send from byte %d xid=%s", offset, xid[:8]) + } + // Backpressure: block when the DC buffer is full. resume := make(chan struct{}, 1) dc.SetBufferedAmountLowThreshold(fileBufferLowWater) @@ -188,7 +266,7 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s }) buf := make([]byte, fileChunkSize) - var sent int64 + sent := offset // start progress reporting from where the receiver left off for { n, readErr := f.Read(buf) @@ -229,8 +307,9 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s } // HandleInboundFileDC is called from the anchor when a "f:" DataChannel -// arrives on a PeerConnection. It writes chunks to a temp file, verifies the -// SHA-256 on close, and emits EvtFileComplete. +// arrives on a PeerConnection. It writes chunks to a temp file (or resumes an +// existing partial), verifies the SHA-256 on close, and emits EvtFileComplete. +// Interrupted transfers keep their .tmp and .meta files for future resume. func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) { m.transferMu.Lock() t, ok := m.inbound[xid] @@ -247,16 +326,43 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err) return } - tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp") - if err != nil { - log.Printf("transfer: create temp file: %v", err) - return - } + t.mu.Lock() - t.tmp = tmp - t.hasher = sha256.New() - t.mu.Unlock() - log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8]) + defer t.mu.Unlock() + + if t.resumePath != "" { + // Resume: open existing .tmp in read-write mode, seek to end for appending. + f, err := os.OpenFile(t.resumePath, os.O_RDWR, 0o644) + if err != nil { + log.Printf("transfer: open resume file %s: %v", t.resumePath, err) + return + } + // Restore the hasher state by re-hashing the bytes already on disk. + h := sha256.New() + if _, err := f.Seek(0, io.SeekStart); err == nil { + io.Copy(h, f) //nolint:errcheck + } + if _, err := f.Seek(0, io.SeekEnd); err != nil { + log.Printf("transfer: seek to end xid=%s: %v", xid[:8], err) + f.Close() + return + } + t.tmp = f + t.hasher = h + log.Printf("transfer: resuming inbound %s at byte %d xid=%s", t.name, t.resumeOffset, xid[:8]) + } else { + tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp") + if err != nil { + log.Printf("transfer: create temp file: %v", err) + return + } + t.tmp = tmp + t.hasher = sha256.New() + t.metaPath = tmp.Name() + ".meta" + writePartialMeta(t.metaPath, t) + log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8]) + } + // Signal sender that we are ready — it will not start streaming until // it receives this, guaranteeing our OnMessage/OnClose are registered first. dc.SendText("ok") //nolint:errcheck @@ -298,11 +404,12 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot dc.OnClose(func() { t.mu.Lock() tmp := t.tmp + metaPath := t.metaPath actualSha := "" if t.hasher != nil { actualSha = hex.EncodeToString(t.hasher.Sum(nil)) } - name, expectedSha := t.name, t.sha256 + name, expectedSha, size, written := t.name, t.sha256, t.size, t.written t.mu.Unlock() m.transferMu.Lock() @@ -312,10 +419,21 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot if tmp == nil { return } + tmpName := tmp.Name() tmp.Close() + // Incomplete — keep .tmp and .meta for future resume. + if written < size { + log.Printf("transfer: interrupted %s at %d/%d bytes xid=%s (resumable)", name, written, size, xid[:8]) + return + } + + // Full receive — verify integrity. if actualSha != expectedSha { - os.Remove(tmp.Name()) + os.Remove(tmpName) + if metaPath != "" { + os.Remove(metaPath) + } log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8]) m.Emit(proto.IpcMessage{ Type: proto.EvtError, @@ -325,13 +443,17 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot return } + // Success — remove sidecar and move to final path. + if metaPath != "" { + os.Remove(metaPath) + } final := filepath.Join(m.DownloadDir, name) if _, err := os.Stat(final); err == nil { ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext) } - if err := os.Rename(tmp.Name(), final); err != nil { + if err := os.Rename(tmpName, final); err != nil { log.Printf("transfer: rename xid=%s: %v", xid[:8], err) return } diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index 628b737..5545bc3 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -33,6 +33,7 @@ import ( type Config struct { MasterIdentity *crypto.Identity StoreDir string // base directory for per-network SQLite files + DownloadDir string // base directory for received files; defaults to StoreDir if empty AnchorURL string // WebSocket anchor URL used for all networks ShareDir string // default share directory; overridden per network via Join or SetShareDir TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478" @@ -119,7 +120,7 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) { } else if mgr.cfg.ShareDir != "" { m.ShareDir = mgr.cfg.ShareDir } - m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full) + m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID_full) capturedNetID := netID m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) } if ice := mgr.turnICEServers(); ice != nil { @@ -207,7 +208,7 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) { } else if mgr.cfg.ShareDir != "" { m.ShareDir = mgr.cfg.ShareDir } - m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID) + m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID) capturedNetID2 := netID m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) } if ice := mgr.turnICEServers(); ice != nil { @@ -293,6 +294,28 @@ func (mgr *Manager) SetShareDir(netID, path string) bool { return true } +// downloadBase returns the configured download base directory, falling back to StoreDir. +func (mgr *Manager) downloadBase() string { + if mgr.cfg.DownloadDir != "" { + return mgr.cfg.DownloadDir + } + return mgr.cfg.StoreDir +} + +// SetDownloadDir updates the download directory for an already-joined network. +// Changes take effect for the next incoming file transfer on that network. +func (mgr *Manager) SetDownloadDir(netID, path string) bool { + mgr.mu.RLock() + net, ok := mgr.networks[netID] + mgr.mu.RUnlock() + if !ok { + return false + } + net.Mesh.DownloadDir = path + log.Printf("netmgr: download dir for %q set to %q", net.Name, path) + return true +} + // LeaveAll leaves every joined network. func (mgr *Manager) LeaveAll() { mgr.mu.RLock() diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 2fb263c..e3d2c30 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -73,10 +73,11 @@ type PeerMessage struct { FileListResp *FileListResp `json:"file_list_resp,omitempty"` // file transfer (§9) — fields are flat on the wire - Xid string `json:"xid,omitempty"` - Name string `json:"name,omitempty"` - Size int64 `json:"size,omitempty"` - SHA256 string `json:"sha256,omitempty"` + Xid string `json:"xid,omitempty"` + Name string `json:"name,omitempty"` + Size int64 `json:"size,omitempty"` + SHA256 string `json:"sha256,omitempty"` + ResumeOffset int64 `json:"resume_offset,omitempty"` // waste-go ext: non-zero in file-accept when resuming // file-done / file-cancel / file-accept just need xid (already above) Reason string `json:"reason,omitempty"` // file-cancel @@ -236,7 +237,8 @@ const ( CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path CmdListShares IpcMsgType = "list_shares" // returns shares_list event - CmdCreateRoom IpcMsgType = "create_room" // field: room (name) + CmdCreateRoom IpcMsgType = "create_room" // field: room (name) + CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path // Events (daemon → UI) EvtMessageReceived IpcMsgType = "message_received" @@ -312,7 +314,7 @@ type IpcMessage struct { InviteGenerated string `json:"invite,omitempty"` Files []FileEntry `json:"files,omitempty"` Shares []ShareEntry `json:"shares,omitempty"` - ShareNetworks []string `json:"networks,omitempty"` // for add_share command + ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global // export_identity / import_identity Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back Backup string `json:"backup,omitempty"` // JSON backup blob diff --git a/web/index.html b/web/index.html index fe9c30c..c888bf7 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,14 @@ - + + + + + + + + waste diff --git a/web/package.json b/web/package.json index 403e3ad..50739de 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "wails:dev": "wails dev", + "wails:build": "wails build" }, "dependencies": { "libsodium-wrappers": "^0.8.4", diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..1b9863a Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/icon-192.png b/web/public/icon-192.png new file mode 100644 index 0000000..298c153 Binary files /dev/null and b/web/public/icon-192.png differ diff --git a/web/public/icon-512.png b/web/public/icon-512.png new file mode 100644 index 0000000..0f0a8dc Binary files /dev/null and b/web/public/icon-512.png differ diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..96cb14c --- /dev/null +++ b/web/public/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "waste", + "short_name": "waste", + "description": "Decentralized friend-to-friend encrypted mesh networking", + "start_url": "/", + "display": "standalone", + "background_color": "#0d0d0d", + "theme_color": "#863bff", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 95b321c..c6eaf17 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,35 @@ const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WAS // Use browser mode if a signal URL is configured, even on localhost const useBrowser = !isLocal || !!cfg?.signalURL +// Wails runtime is injected at /wails/runtime.js when running inside the desktop app. +// EventsOn/EventsOff are no-ops in plain browser mode. +type WailsRuntime = { EventsOn: (event: string, cb: (data: unknown) => void) => void } +const wails = (window as unknown as { runtime?: WailsRuntime }).runtime + +function useWailsNotifications() { + useEffect(() => { + if (!wails) return + + const handler = (data: unknown) => { + const { title, body } = data as { title: string; body: string } + if (Notification.permission === 'granted') { + new Notification(title, { body }) + } else if (Notification.permission !== 'denied') { + Notification.requestPermission().then(p => { + if (p === 'granted') new Notification(title, { body }) + }) + } + } + + wails.EventsOn('notify', handler) + return () => { + // EventsOff added in Wails v2.4; guard in case of older runtime. + ;(window as unknown as { runtime?: { EventsOff?: (...e: string[]) => void } }) + .runtime?.EventsOff?.('notify') + } + }, []) +} + export default function App() { const { connect, connectBrowser, daemonStatus, localPeer } = useWaste() @@ -21,6 +50,8 @@ export default function App() { } }, [connect, connectBrowser]) + useWailsNotifications() + if (daemonStatus !== 'connected' || !localPeer) { return }