diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a14fe9..ea48f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This fork tracks [`syoul/Mycell-UI`](https://git.open.us.org/syoul/Mycell-UI) (t ## [Unreleased] +### Added +- `SidecarConfig.tcpListenPort` and `SidecarConfig.quicListenPort` (Option): pin the daemon's peer-listen ports across restarts so they can be reliably port-forwarded on a home router. `None` keeps the previous ephemeral behaviour. +- Two input fields in **Settings → Daemon configuration** to expose them, with an explanation of when to set them. +- App.vue surfaces a top banner when `phase === 'error'` with the daemon error message and a shortcut "Go to Settings" button. + ## [0.1.0] — 2026-04-27 Forked from `Mycell-UI@5229e2c` ("feat(packaging): pre-spawn cleanup wrapper for clean restarts"). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d077e2 --- /dev/null +++ b/CLAUDE.md @@ -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-` (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 ` — 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, poller: Arc }`, 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-`) 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/.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. diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 7d8cce3..bddf4f9 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -33,6 +33,12 @@ pub struct SidecarConfig { /// UTF-8 network identifier (2..=64 bytes). Public; not a secret. /// All nodes joining the same private overlay must agree on this. pub network_name: Option, + /// Pin the TCP listen port for inbound peer connections. Required + /// when the user port-forwards a fixed port on their router so + /// other nodes can reliably dial in. `None` ⇒ ephemeral port. + pub tcp_listen_port: Option, + /// Same as above for QUIC (UDP). `None` ⇒ ephemeral port. + pub quic_listen_port: Option, } /// Holds the running mycelium child process plus a small in-memory log @@ -149,8 +155,14 @@ impl SidecarHandle { // is already up. Always picking ephemeral ports avoids that at the // cost of inbound peers needing the actual port number. let api_port = pick_port()?; - let tcp_port = pick_port_skip(&[api_port])?; - let quic_port = pick_port_skip(&[api_port, tcp_port])?; + let tcp_port = match config.tcp_listen_port { + Some(p) if p != 0 => p, + _ => pick_port_skip(&[api_port])?, + }; + let quic_port = match config.quic_listen_port { + Some(p) if p != 0 => p, + _ => pick_port_skip(&[api_port, tcp_port])?, + }; // mycelium also opens an internal JSON-RPC / metrics endpoint on // 127.0.0.1:8990 by default; if 8990 is already taken (e.g. by an // orphan from a previous run we couldn't SIGKILL because it ran as diff --git a/src/App.vue b/src/App.vue index a4c1d56..65221be 100644 --- a/src/App.vue +++ b/src/App.vue @@ -120,6 +120,22 @@ async function handleStop() {

{{ currentTitle }}

+
+
+ Daemon failed to start — + {{ error }} + +
+
diff --git a/src/lib/api.ts b/src/lib/api.ts index 2a367f6..da6f1dd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -23,6 +23,10 @@ export interface SidecarConfig { /** UTF-8, 2..=64 bytes. Public — must match across all nodes of the * same private overlay. */ networkName: string | null; + /** Pin TCP/QUIC listen ports so they survive a daemon restart and can + * be port-forwarded reliably. `null` ⇒ ephemeral port at each start. */ + tcpListenPort: number | null; + quicListenPort: number | null; } export interface NetworkKeyStatus { diff --git a/src/stores/config.ts b/src/stores/config.ts index e3761ed..baf48a5 100644 --- a/src/stores/config.ts +++ b/src/stores/config.ts @@ -14,8 +14,17 @@ const DEFAULT_CONFIG: SidecarConfig = { tunName: null, noTun: false, networkName: null, + tcpListenPort: null, + quicListenPort: null, }; +function normalizePort(p: unknown): number | null { + if (p === null || p === undefined || p === "" || p === 0) return null; + const n = typeof p === "number" ? p : Number(p); + if (!Number.isInteger(n) || n < 1 || n > 65535) return null; + return n; +} + export const useConfigStore = defineStore("config", () => { const config = ref({ ...DEFAULT_CONFIG }); const loaded = ref(false); @@ -35,6 +44,8 @@ export const useConfigStore = defineStore("config", () => { tunName: saved.tunName ?? null, noTun: !!saved.noTun, networkName: saved.networkName ?? null, + tcpListenPort: normalizePort(saved.tcpListenPort), + quicListenPort: normalizePort(saved.quicListenPort), }; } loaded.value = true; @@ -46,6 +57,8 @@ export const useConfigStore = defineStore("config", () => { tunName: next.tunName?.trim() ? next.tunName.trim() : null, noTun: !!next.noTun, networkName: next.networkName?.trim() ? next.networkName.trim() : null, + tcpListenPort: normalizePort(next.tcpListenPort), + quicListenPort: normalizePort(next.quicListenPort), }; const s = await ensureStore(); await s.set(KEY, config.value); diff --git a/src/views/Settings.vue b/src/views/Settings.vue index fe99941..9a36f1c 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -30,6 +30,8 @@ const draft = reactive({ tunName: "", noTun: false, networkName: "", + tcpListenPort: "", + quicListenPort: "", }); const dirty = ref(false); @@ -38,9 +40,19 @@ function loadDraft() { draft.tunName = config.value.tunName ?? ""; draft.noTun = config.value.noTun; draft.networkName = config.value.networkName ?? ""; + draft.tcpListenPort = config.value.tcpListenPort?.toString() ?? ""; + draft.quicListenPort = config.value.quicListenPort?.toString() ?? ""; dirty.value = false; } +function parsePort(s: string): number | null { + const t = s.trim(); + if (!t) return null; + const n = Number(t); + if (!Number.isInteger(n) || n < 1 || n > 65535) return null; + return n; +} + watch(config, loadDraft, { immediate: true }); watch( @@ -60,6 +72,8 @@ async function save() { tunName: draft.tunName.trim() || null, noTun: draft.noTun, networkName: draft.networkName.trim() || null, + tcpListenPort: parsePort(draft.tcpListenPort), + quicListenPort: parsePort(draft.quicListenPort), }); dirty.value = false; } @@ -387,6 +401,40 @@ onMounted(async () => { :disabled="draft.noTun" /> + +
+
+ + +
+
+ + +
+
+

+ Pin these to a fixed value (e.g. 9651 for + TCP, 9652 for QUIC) when you port-forward + a stable port on your router so other private-network nodes can dial in + across daemon restarts. Leave empty to keep using a different ephemeral + port at every start. +