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.
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 typecheck—vue-tsc --noEmit. The CI gate.pnpm build— typecheck +vite buildintodist/. Tauri runs this asbeforeBuildCommand.pnpm dev— Vite alone on port 1420. Mostly only useful throughtauri dev.
Tauri / desktop:
bash scripts/fetch-mycelium.sh [VERSION]— downloads themycelium-privaterelease tarball fromthreefoldtech/myceliumand installs it assrc-tauri/binaries/mycelium-private-<target-triple>(the suffix Tauri'sexternalBinrequires). Default version pinned in the script (currentlyv0.6.1). Must be run before the firsttauri dev/tauri buildon a fresh checkout.pnpm tauri dev— full dev cycle (Vite + cargo + window).pnpm tauri build --bundles deb— producessrc-tauri/target/release/bundle/deb/Mycellium UI Private_*.deb.
Backend (run from src-tauri/):
cargo fmt --all -- --checkcargo clippy --all-targets --locked -- -D warningscargo test --lockedcargo 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-fileargs, refuses to start without them.src-tauri/src/commands.rs— addsnetwork_key_*command family.src-tauri/packaging/—mycellium-bootstrap-privatecleanup 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, managesAppState, declares everycommands::*handler.state.rs—AppState { sidecar: Arc<SidecarHandle>, poller: Arc<Poller> }, single instance viaapp.manage().sidecar.rs— the core lifecycle. Spawnsmycelium-privateas a child viapkexec, 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 emitssidecar://exited. Pre-flight refuses to start without a non-emptynetwork_name(2..=64 UTF-8 bytes) AND an existingnetwork_key.bin— surfaces clear errors before pkexec fires.elevation.rs— thinpkexecwrapper.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 toMyceliumClient(require_client(state)) or manage local state (network key files, sidecar logs).api/— typedMyceliumClientoverreqwest.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 afterstart_daemonalready returned. Each submodule (admin,peers,routes,messages,topics,pubkey) wraps one API surface.error.rs—AppErrorisSerialize-as-string soinvoke()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-addressto a fresh port matters: mycelium 0.6.1 hardcodes the JSON-RPC/metrics endpoint to127.0.0.1:8990by default, so an orphan from a previous run kills the new instance a few seconds in. - In a
.debinstall, the elevation target is/usr/bin/mycellium-bootstrap-private, not the daemon directly — the wrapperpkills any orphanmycelium/mycelium-privateandip link dels leftover TUN devices beforeexecing the daemon. Because both the wrapper and daemon run under one pkexec call, polkit'sauth_admin_keepshows only one dialog per session. Intauri 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, sincetauri buildstrips the suffix when bundling into/usr/bin/. - The daemon's HTTP API only exposes the overlay subnet, so
sidecar.rsparses the full IPv6 host portion out of the daemon'sNode overlay IP: ...log line. The scanner strips ANSI SGR escapes (noregexcrate dependency) and stores the result inSidecarHandle::overlay_ip— the Status page surfaces both subnet and full IP.
Frontend (src/)
main.tsmounts Vue 3 + Pinia + Vue Router (createWebHashHistory).App.vueis the only persistent shell — sidebar nav + start/stop daemon button +StartupOverlayfor thephase === "starting"state. The amber accent on "Mycellium Private" distinguishes the fork visually.lib/api.ts— typedinvoke()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— typedlisten()over the six emitted events (sidecar://ready,sidecar://exited,peers://updated,stats://updated,routes://updated,messages://incoming). TheEventsconstant must stay in sync withapp.emit()calls insidecar.rsandpoller.rs.stores/— Pinia setup-style stores.node.tsowns thePhasemachine (idle | starting | ready | error) and bridges sidecar events to UI state.config.tspersistsSidecarConfigvia@tauri-apps/plugin-store(config.json); private fork'sDEFAULT_CONFIG.peersis[](no seed).peers/routes/messages/topicsare screen-specific stores.views/— one component per route (Status, Peers, Routes, Messages, Topics, Settings).Settings.vueis 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 theinvoke_handler!registration is the most common silent failure — the command compiles butinvoke()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 throughAppErrorso 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(mode0600, raw 32 bytes). It is not encrypted at rest — host disk encryption is the only layer. *.binis 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.
pkexecand the polkit action ID are wired into the architecture; do not addcfg(target_os = "...")branches without thinking about the elevation model.