feat: pin TCP/QUIC listen ports + visible error banner

Two related improvements requested while testing the private fork:

1. Custom TCP/QUIC listen ports (SidecarConfig.tcpListenPort,
   .quicListenPort). With ephemeral ports, the daemon's peer-listen
   port changes at every restart, which makes port-forwarding on a
   home router useless after the first daemon stop. Pinning these
   keeps the same port across restarts so other private-network
   nodes can dial in reliably.

   Backend uses the configured port if Some, falls back to
   pick_port_skip otherwise. Frontend exposes two inputs in
   Settings → Daemon configuration with a help line explaining
   when to fill them.

2. Daemon-failure banner in App.vue. The previous behaviour was
   silent: a click on \"Start daemon\" that hit a backend error
   only flipped the sidebar dot to red, with no message visible.
   Now an inline banner at the top of the main content area shows
   the error, plus a \"Go to Settings\" shortcut when the message
   mentions a network config issue.
This commit is contained in:
syoul
2026-04-27 02:29:31 +02:00
parent 8b83fc10d5
commit a930c035c0
7 changed files with 183 additions and 2 deletions

83
CLAUDE.md Normal file
View File

@@ -0,0 +1,83 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common commands
Frontend (run from repo root):
- `pnpm install` — install JS deps (pnpm, not npm/yarn).
- `pnpm typecheck``vue-tsc --noEmit`. The CI gate.
- `pnpm build` — typecheck + `vite build` into `dist/`. Tauri runs this as `beforeBuildCommand`.
- `pnpm dev` — Vite alone on port 1420. Mostly only useful through `tauri dev`.
Tauri / desktop:
- `bash scripts/fetch-mycelium.sh [VERSION]` — downloads the `mycelium-private` release tarball from `threefoldtech/mycelium` and installs it as `src-tauri/binaries/mycelium-private-<target-triple>` (the suffix Tauri's `externalBin` requires). Default version pinned in the script (currently `v0.6.1`). **Must be run before the first `tauri dev` / `tauri build` on a fresh checkout.**
- `pnpm tauri dev` — full dev cycle (Vite + cargo + window).
- `pnpm tauri build --bundles deb` — produces `src-tauri/target/release/bundle/deb/Mycellium UI Private_*.deb`.
Backend (run from `src-tauri/`):
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets --locked -- -D warnings`
- `cargo test --locked`
- `cargo test --locked <name>` — run a single test by name substring.
CI (`.github/workflows/ci.yml`) gates on: `pnpm typecheck`, `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`. There is no JS test runner configured.
## Fork relationship
This is the **private-network** fork of [`syoul/Mycell-UI`](https://git.open.us.org/syoul/Mycell-UI) (public variant). Tracked as `origin/upstream`; bugfixes are cherry-picked. The two apps are designed to coexist on the same machine — distinct identifier, binary, polkit action, `.deb` package.
The only divergence files (per `README.md`):
- `src-tauri/src/sidecar.rs` — adds `--network-name` + `--network-key-file` args, refuses to start without them.
- `src-tauri/src/commands.rs` — adds `network_key_*` command family.
- `src-tauri/packaging/``mycellium-bootstrap-private` cleanup wrapper, polkit policy with new action ID.
- `src-tauri/tauri.conf.json``productName`, `identifier`, `externalBin`.
- `src/views/Settings.vue` — "Private network" section.
- `src/stores/config.ts` — empty default peer list (no Threefold-operated seed exists for private).
Everything else should match upstream byte-for-byte; cherry-picks usually apply cleanly.
## Architecture
Two-layer Tauri 2 desktop app. Rust supervises a `mycelium-private` daemon child process and exposes it to the Vue frontend via Tauri commands + events.
### Backend (`src-tauri/src/`)
- `lib.rs::run()` — Tauri builder. Registers store/shell/dialog plugins, manages `AppState`, declares every `commands::*` handler.
- `state.rs``AppState { sidecar: Arc<SidecarHandle>, poller: Arc<Poller> }`, single instance via `app.manage()`.
- `sidecar.rs`**the core lifecycle**. Spawns `mycelium-private` as a child via `pkexec`, runs a 20s health-check loop against the daemon's HTTP API, captures stdout/stderr into a 500-line ring buffer, watches for child exit and emits `sidecar://exited`. Pre-flight refuses to start without a non-empty `network_name` (2..=64 UTF-8 bytes) AND an existing `network_key.bin` — surfaces clear errors before pkexec fires.
- `elevation.rs` — thin `pkexec` wrapper. `is_auth_failure(code)` distinguishes pkexec dialog cancel/deny (`126`/`127`) from daemon crashes.
- `poller.rs` — three independent tokio loops on a started sidecar: peers (3s), routes (5s), inbox (2s short-poll). Each emits its own event; the inbox loop is **a short-poll workaround** for an upstream bug (mycelium 0.6.1's HTTP server serialises requests behind one worker, so a 30s long-poll starves every other endpoint).
- `commands.rs` — every `#[tauri::command]`. The frontend does not talk to mycelium HTTP directly; commands either delegate to `MyceliumClient` (`require_client(state)`) or manage local state (network key files, sidecar logs).
- `api/` — typed `MyceliumClient` over `reqwest`. **`pool_max_idle_per_host(0)` is intentional** — mycelium drops idle keep-alives around 10s, and reusing a stale pooled connection surfaces as a generic send error after `start_daemon` already returned. Each submodule (`admin`, `peers`, `routes`, `messages`, `topics`, `pubkey`) wraps one API surface.
- `error.rs``AppError` is `Serialize`-as-string so `invoke()` rejects with the bare message. Frontend matches on substring or shows it raw.
### Sidecar startup details
- Four ephemeral ports are picked per spawn (`api`, `tcp-listen`, `quic-listen`, `metrics`). Pinning `--metrics-api-address` to a fresh port matters: mycelium 0.6.1 hardcodes the JSON-RPC/metrics endpoint to `127.0.0.1:8990` by default, so an orphan from a previous run kills the new instance a few seconds in.
- In a `.deb` install, the elevation target is `/usr/bin/mycellium-bootstrap-private`, not the daemon directly — the wrapper `pkill`s any orphan `mycelium` / `mycelium-private` and `ip link del`s leftover TUN devices before `exec`ing the daemon. Because both the wrapper and daemon run under one pkexec call, polkit's `auth_admin_keep` shows only **one** dialog per session. In `tauri dev` (where the wrapper isn't installed) the bare binary is used and orphan cleanup must be handled manually.
- The sidecar binary is located by probing, in order: dir of `current_exe()``app.path().resource_dir()``CARGO_MANIFEST_DIR/binaries/``$PATH`. Both the suffixed name (`mycelium-private-<triple>`) and the bare name are tried, since `tauri build` strips the suffix when bundling into `/usr/bin/`.
- The daemon's HTTP API only exposes the overlay subnet, so `sidecar.rs` parses the full IPv6 host portion out of the daemon's `Node overlay IP: ...` log line. The scanner strips ANSI SGR escapes (no `regex` crate dependency) and stores the result in `SidecarHandle::overlay_ip` — the Status page surfaces both subnet and full IP.
### Frontend (`src/`)
- `main.ts` mounts Vue 3 + Pinia + Vue Router (`createWebHashHistory`).
- `App.vue` is the only persistent shell — sidebar nav + start/stop daemon button + `StartupOverlay` for the `phase === "starting"` state. The amber accent on "Mycellium **Private**" distinguishes the fork visually.
- `lib/api.ts`**typed `invoke()` wrappers, one per Rust command.** All TS types here mirror Rust structs (`#[serde(rename_all = "camelCase")]` on the Rust side, camelCase fields on the TS side). When you add or change a `#[tauri::command]`, update this file in lockstep — it is the only source of truth for the frontend's view of the backend surface.
- `lib/events.ts` — typed `listen()` over the six emitted events (`sidecar://ready`, `sidecar://exited`, `peers://updated`, `stats://updated`, `routes://updated`, `messages://incoming`). The `Events` constant must stay in sync with `app.emit()` calls in `sidecar.rs` and `poller.rs`.
- `stores/` — Pinia setup-style stores. `node.ts` owns the `Phase` machine (`idle | starting | ready | error`) and bridges sidecar events to UI state. `config.ts` persists `SidecarConfig` via `@tauri-apps/plugin-store` (`config.json`); private fork's `DEFAULT_CONFIG.peers` is `[]` (no seed). `peers/routes/messages/topics` are screen-specific stores.
- `views/` — one component per route (Status, Peers, Routes, Messages, Topics, Settings). `Settings.vue` is the only place that touches the network-key commands.
- `components/` — leaf widgets only; no business logic.
### Path alias
`@/*` resolves to `src/*` in both `tsconfig.json` and `vite.config.ts`. Use `@/lib/api`, `@/stores/node`, etc.
## Cross-cutting conventions
- **Adding a new daemon-backed command** requires four edits in lockstep: `src-tauri/src/api/<area>.rs` (HTTP wrapper) → `src-tauri/src/commands.rs` (`#[tauri::command]`) → `lib.rs::run()` `invoke_handler!` macro → `src/lib/api.ts` (TS wrapper + types). Forgetting the `invoke_handler!` registration is the most common silent failure — the command compiles but `invoke()` rejects with "command not found".
- **Serde casing**: every Rust type crossing into JS uses `#[serde(rename_all = "camelCase")]`; TS types use camelCase. Don't add snake_case TS types or you'll get runtime undefined fields.
- **No `unwrap()` in command paths.** Errors flow through `AppError` so the frontend gets a clean string. `panic!` will tear down the Tauri runtime.
- **One private overlay at a time.** No multi-network support. The network key lives at `$XDG_DATA_HOME/tech.threefold.mycellium-ui-private/network_key.bin` (mode `0600`, raw 32 bytes). It is not encrypted at rest — host disk encryption is the only layer.
- **`*.bin` is gitignored.** Never commit the dev network key or daemon identity. `docs-syoul/` (personal notes) is also gitignored — don't add files there expecting CI to see them.
- **Linux-only.** No macOS/Windows code paths. `pkexec` and the polkit action ID are wired into the architecture; do not add `cfg(target_os = "...")` branches without thinking about the elevation model.