Files
Mycelium-ui-private/CLAUDE.md
syoul a930c035c0 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.
2026-04-27 02:29:31 +02:00

8.9 KiB

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 typecheckvue-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 (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.jsonproductName, 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.rsAppState { sidecar: Arc<SidecarHandle>, poller: Arc<Poller> }, single instance via app.manage().
  • sidecar.rsthe 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.rsAppError 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 pkills any orphan mycelium / mycelium-private and ip link dels leftover TUN devices before execing 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.tstyped 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.