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 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-28 21:38:28 +02:00
parent d233f4d79e
commit 0789cf8840
29 changed files with 951 additions and 58 deletions

120
.gitea/workflows/build.yml Normal file
View File

@@ -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, '-') }}

2
.gitignore vendored
View File

@@ -20,3 +20,5 @@ deploy-web.sh
deploy-daemon.sh deploy-daemon.sh
serve-web.sh serve-web.sh
web/public/config.js web/public/config.js
cmd/app/frontend/dist/*
!cmd/app/frontend/dist/.gitkeep

View File

@@ -118,7 +118,7 @@ proper signed invite (if the network enforces it) to be accepted by peers.
```jsonc ```jsonc
{"type":"add_share","path":"/home/alice/Music"} // global {"type":"add_share","path":"/home/alice/Music"} // global
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // scoped {"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // scoped
{"type":"remove_share","path":"/home/alice/Music"} {"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"} {"type":"list_shares"}
``` ```
@@ -148,7 +148,7 @@ entries from all applicable share roots, with relative `path` fields
## EXT-004 — TURN Relay (browser mode) ## 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 **Affects:** ICE server configuration only, no wire changes
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret` 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, `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`. 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": "<peer-id>", "size": 1048576 }
```
The sidecar is written when the transfer starts and removed on completion or
corruption. Interrupted transfers keep the sidecar indefinitely.

View File

@@ -91,10 +91,10 @@ DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages a
- Live progress bar per active transfer - Live progress bar per active transfer
- Push (📎) sends directly to a peer without them needing to share a folder - 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 ### 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 | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
| ✅ shipped | TUI room creation + daemon-side room persistence | | ✅ shipped | TUI room creation + daemon-side room persistence |
| ✅ shipped | Unread room indicators in TUI (`*` prefix) | | ✅ shipped | Unread room indicators in TUI (`*` prefix) |
| next | File transfer resume after disconnection | | ✅ shipped | Per-network download directories (`-download-dir` flag + `set_download_dir` IPC) |
| future | Native UI (React + Tauri) | | ✅ 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) |
--- ---

View File

@@ -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. **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). **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) ### 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. 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. > 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-<network-id>/
```
Pass `-download-dir` at startup to use a different base:
```bash
go run ./cmd/daemon -download-dir ~/Downloads ...
```
Files then land in `~/Downloads/downloads-<network-id>/`. 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 ## Local development
@@ -339,17 +413,19 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"get_file_list","peer_id":"<64-hex>"} {"type":"get_file_list","peer_id":"<64-hex>"}
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} {"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/Music"} // global share
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped {"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped
{"type":"remove_share","path":"/home/alice/Music"} {"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"} {"type":"list_shares"}
{"type":"create_room","room":"dev"} {"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":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."} {"type":"import_identity","passphrase":"...","backup":"..."}
``` ```
**Events:** **Events:**
```jsonc ```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":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"} {"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"} {"type":"peer_disconnected","peer_id":"<64-hex>"}

30
build-app.sh Executable file
View File

@@ -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

130
cmd/app/app.go Normal file
View File

@@ -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
}

BIN
cmd/app/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

0
cmd/app/frontend/dist/.gitkeep vendored Normal file
View File

66
cmd/app/main.go Normal file
View File

@@ -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)
}
}

35
cmd/app/tray.go Normal file
View File

@@ -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() {})
}

14
cmd/app/tray_darwin.go Normal file
View File

@@ -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() {}

8
cmd/app/tray_stub.go Normal file
View File

@@ -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() {}

12
cmd/app/wails.json Normal file
View File

@@ -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"
}
}

View File

@@ -21,6 +21,7 @@ func main() {
wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled") 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") 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") 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") 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") 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") turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
@@ -76,6 +77,7 @@ func main() {
StoreDir: dir, StoreDir: dir,
AnchorURL: *anchorURL, AnchorURL: *anchorURL,
ShareDir: expandHome(*shareDir), ShareDir: expandHome(*shareDir),
DownloadDir: expandHome(*downloadDir),
TurnURL: *turnURL, TurnURL: *turnURL,
TurnSecret: *turnSecret, TurnSecret: *turnSecret,
}) })

39
go.mod
View File

@@ -6,14 +6,16 @@ require (
filippo.io/edwards25519 v1.2.0 filippo.io/edwards25519 v1.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pion/webrtc/v3 v3.3.6 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 modernc.org/sqlite v1.53.0
nhooyr.io/websocket v1.8.17 nhooyr.io/websocket v1.8.17
) )
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // 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/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // 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/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.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/datachannel v1.5.8 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/ice/v2 v2.3.38 // 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/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/turn/v2 v2.1.6 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // 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/wlynxg/anet v0.0.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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 gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

85
go.sum
View File

@@ -1,9 +1,13 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 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/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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= 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.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/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 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.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.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.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.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 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.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 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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-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-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.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.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.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.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 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.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-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.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.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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-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-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-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-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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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= 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/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.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.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.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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 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.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-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.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.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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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= 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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -344,6 +344,22 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
continue 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: case proto.CmdSendFile:
n := mgr.Resolve(cmd.NetworkID) n := mgr.Resolve(cmd.NetworkID)
if n == nil { if n == nil {

View File

@@ -267,7 +267,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
m.acceptIncoming(msg, from) m.acceptIncoming(msg, from)
case proto.MsgFileAccept: case proto.MsgFileAccept:
m.startSend(msg.Xid, from) m.startSend(msg.Xid, from, msg.ResumeOffset)
case proto.MsgFileCancel: case proto.MsgFileCancel:
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason) log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)

View File

@@ -40,7 +40,55 @@ type inboundTransfer struct {
mu sync.Mutex mu sync.Mutex
tmp *os.File tmp *os.File
hasher hash.Hash hasher hash.Hash
written int64 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 // 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. // 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) { func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
m.transferMu.Lock() t := &inboundTransfer{
m.inbound[msg.Xid] = &inboundTransfer{
from: from, from: from,
name: msg.Name, name: msg.Name,
size: msg.Size, size: msg.Size,
sha256: msg.SHA256, 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() m.transferMu.Unlock()
accept, _ := json.Marshal(proto.PeerMessage{ accept, _ := json.Marshal(proto.PeerMessage{
Type: proto.MsgFileAccept, Type: proto.MsgFileAccept,
Xid: msg.Xid, Xid: msg.Xid,
ResumeOffset: resumeOffset,
}) })
m.SendTo(from, accept) m.SendTo(from, accept)
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]) 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:<xid>" and streams the file. // startSend opens the binary DataChannel "f:<xid>" and streams the file.
// Called when we receive file-accept from the peer. // Called when we receive file-accept from the peer. resumeOffset is non-zero
func (m *Mesh) startSend(xid string, from proto.PeerID) { // when the receiver is resuming a previous partial download.
func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) {
m.transferMu.Lock() m.transferMu.Lock()
t, ok := m.outbound[xid] t, ok := m.outbound[xid]
m.transferMu.Unlock() m.transferMu.Unlock()
@@ -156,13 +225,13 @@ func (m *Mesh) startSend(xid string, from proto.PeerID) {
dc.OnOpen(func() { dc.OnOpen(func() {
dc.OnMessage(func(msg webrtc.DataChannelMessage) { dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if msg.IsString && string(msg.Data) == "ok" { 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() { defer func() {
m.transferMu.Lock() m.transferMu.Lock()
delete(m.outbound, xid) delete(m.outbound, xid)
@@ -177,6 +246,15 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
} }
defer f.Close() 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. // Backpressure: block when the DC buffer is full.
resume := make(chan struct{}, 1) resume := make(chan struct{}, 1)
dc.SetBufferedAmountLowThreshold(fileBufferLowWater) dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
@@ -188,7 +266,7 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
}) })
buf := make([]byte, fileChunkSize) buf := make([]byte, fileChunkSize)
var sent int64 sent := offset // start progress reporting from where the receiver left off
for { for {
n, readErr := f.Read(buf) 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:<xid>" DataChannel // HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
// arrives on a PeerConnection. It writes chunks to a temp file, verifies the // arrives on a PeerConnection. It writes chunks to a temp file (or resumes an
// SHA-256 on close, and emits EvtFileComplete. // 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) { func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
m.transferMu.Lock() m.transferMu.Lock()
t, ok := m.inbound[xid] 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) log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
return return
} }
t.mu.Lock()
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") tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
if err != nil { if err != nil {
log.Printf("transfer: create temp file: %v", err) log.Printf("transfer: create temp file: %v", err)
return return
} }
t.mu.Lock()
t.tmp = tmp t.tmp = tmp
t.hasher = sha256.New() t.hasher = sha256.New()
t.mu.Unlock() t.metaPath = tmp.Name() + ".meta"
writePartialMeta(t.metaPath, t)
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8]) log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
}
// Signal sender that we are ready — it will not start streaming until // Signal sender that we are ready — it will not start streaming until
// it receives this, guaranteeing our OnMessage/OnClose are registered first. // it receives this, guaranteeing our OnMessage/OnClose are registered first.
dc.SendText("ok") //nolint:errcheck dc.SendText("ok") //nolint:errcheck
@@ -298,11 +404,12 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
dc.OnClose(func() { dc.OnClose(func() {
t.mu.Lock() t.mu.Lock()
tmp := t.tmp tmp := t.tmp
metaPath := t.metaPath
actualSha := "" actualSha := ""
if t.hasher != nil { if t.hasher != nil {
actualSha = hex.EncodeToString(t.hasher.Sum(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() t.mu.Unlock()
m.transferMu.Lock() m.transferMu.Lock()
@@ -312,10 +419,21 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
if tmp == nil { if tmp == nil {
return return
} }
tmpName := tmp.Name()
tmp.Close() 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 { 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]) log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
m.Emit(proto.IpcMessage{ m.Emit(proto.IpcMessage{
Type: proto.EvtError, Type: proto.EvtError,
@@ -325,13 +443,17 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
return return
} }
// Success — remove sidecar and move to final path.
if metaPath != "" {
os.Remove(metaPath)
}
final := filepath.Join(m.DownloadDir, name) final := filepath.Join(m.DownloadDir, name)
if _, err := os.Stat(final); err == nil { if _, err := os.Stat(final); err == nil {
ext := filepath.Ext(name) ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext) base := strings.TrimSuffix(name, ext)
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+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) log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
return return
} }

View File

@@ -33,6 +33,7 @@ import (
type Config struct { type Config struct {
MasterIdentity *crypto.Identity MasterIdentity *crypto.Identity
StoreDir string // base directory for per-network SQLite files 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 AnchorURL string // WebSocket anchor URL used for all networks
ShareDir string // default share directory; overridden per network via Join or SetShareDir ShareDir string // default share directory; overridden per network via Join or SetShareDir
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478" 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 != "" { } else if mgr.cfg.ShareDir != "" {
m.ShareDir = 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 capturedNetID := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) } m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
if ice := mgr.turnICEServers(); ice != nil { if ice := mgr.turnICEServers(); ice != nil {
@@ -207,7 +208,7 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
} else if mgr.cfg.ShareDir != "" { } else if mgr.cfg.ShareDir != "" {
m.ShareDir = 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 capturedNetID2 := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) } m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
if ice := mgr.turnICEServers(); ice != nil { if ice := mgr.turnICEServers(); ice != nil {
@@ -293,6 +294,28 @@ func (mgr *Manager) SetShareDir(netID, path string) bool {
return true 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. // LeaveAll leaves every joined network.
func (mgr *Manager) LeaveAll() { func (mgr *Manager) LeaveAll() {
mgr.mu.RLock() mgr.mu.RLock()

View File

@@ -77,6 +77,7 @@ type PeerMessage struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
SHA256 string `json:"sha256,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) // file-done / file-cancel / file-accept just need xid (already above)
Reason string `json:"reason,omitempty"` // file-cancel Reason string `json:"reason,omitempty"` // file-cancel
@@ -237,6 +238,7 @@ const (
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
CmdListShares IpcMsgType = "list_shares" // returns shares_list event 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) // Events (daemon → UI)
EvtMessageReceived IpcMsgType = "message_received" EvtMessageReceived IpcMsgType = "message_received"
@@ -312,7 +314,7 @@ type IpcMessage struct {
InviteGenerated string `json:"invite,omitempty"` InviteGenerated string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"` Files []FileEntry `json:"files,omitempty"`
Shares []ShareEntry `json:"shares,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 // export_identity / import_identity
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
Backup string `json:"backup,omitempty"` // JSON backup blob Backup string `json:"backup,omitempty"` // JSON backup blob

View File

@@ -3,7 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#863bff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="waste" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<title>waste</title> <title>waste</title>
</head> </head>
<body> <body>

View File

@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"wails:dev": "wails dev",
"wails:build": "wails build"
}, },
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4", "libsodium-wrappers": "^0.8.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
web/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
web/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

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

@@ -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"
}
]
}

View File

@@ -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 // Use browser mode if a signal URL is configured, even on localhost
const useBrowser = !isLocal || !!cfg?.signalURL 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() { export default function App() {
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste() const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
@@ -21,6 +50,8 @@ export default function App() {
} }
}, [connect, connectBrowser]) }, [connect, connectBrowser])
useWailsNotifications()
if (daemonStatus !== 'connected' || !localPeer) { if (daemonStatus !== 'connected' || !localPeer) {
return <Onboarding status={daemonStatus} /> return <Onboarding status={daemonStatus} />
} }