Compare commits

..

3 Commits

Author SHA1 Message Date
syoul 874dd33a6d release: v0.1.1
Bump package + crate + tauri identifier from 0.1.0 to 0.1.1 and
rebuild the .deb. The 0.1.1 binary includes everything between
fork-init and HEAD~0:
  - custom TCP/QUIC listen ports (default 9651/9652)
  - daemon-failure banner with shortcut to Settings
  - misc UX wording

CHANGELOG split: 0.1.1 section closes the previously-Unreleased
list; 0.1.0 untouched.

release/mycellium-ui-private_0.1.0_amd64.deb removed and replaced
by 0.1.1 with refreshed SHA256SUMS.
2026-04-27 02:44:14 +02:00
syoul e4c91426be feat(config): default tcp/quic listen ports to mycelium standard 9651/9652
Pinning ephemeral ports made the existing field useful but left the
default behaviour (random port at every start) hostile to the
typical private-network setup, where the user pre-configures a
port-forward on their home router and expects mycelium to keep
using the same port.

Default to 9651 (TCP) and 9652 (QUIC), which match the public
mycelium convention. Clearing either field still falls back to
ephemeral. Help text updated; placeholder now says "leave empty
for ephemeral" instead of "ephemeral" so users understand the
field is currently filled with the default.
2026-04-27 02:31:58 +02:00
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
14 changed files with 196 additions and 9 deletions
+10
View File
@@ -6,6 +6,16 @@ This fork tracks [`syoul/Mycell-UI`](https://git.open.us.org/syoul/Mycell-UI) (t
## [Unreleased] ## [Unreleased]
## [0.1.1] — 2026-04-27
### Added
- `SidecarConfig.tcpListenPort` and `SidecarConfig.quicListenPort` (Option<u16>): pin the daemon's peer-listen ports across restarts so they can be reliably port-forwarded on a home router. Defaults to mycelium's standard `9651` (TCP) and `9652` (QUIC); clearing the field falls back to ephemeral.
- Two input fields in **Settings → Daemon configuration** to expose them, with help text explaining when to override.
- App.vue surfaces a top banner when `phase === 'error'` with the daemon error message and a shortcut "Go to Settings" button when the failure mentions a network-config issue.
### Changed
- `release/mycellium-ui-private_*.deb` rebuilt against the above.
## [0.1.0] — 2026-04-27 ## [0.1.0] — 2026-04-27
Forked from `Mycell-UI@5229e2c` ("feat(packaging): pre-spawn cleanup wrapper for clean restarts"). Forked from `Mycell-UI@5229e2c` ("feat(packaging): pre-spawn cleanup wrapper for clean restarts").
+83
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.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mycellium-ui-private", "name": "mycellium-ui-private",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -5,7 +5,7 @@ Pre-built `.deb` of the private-network desktop client. Designed to coexist with
## Install ## Install
```bash ```bash
sudo apt install ./mycellium-ui-private_0.1.0_amd64.deb sudo apt install ./mycellium-ui-private_0.1.1_amd64.deb
``` ```
`apt install` with a local path resolves runtime deps (`pkexec | policykit-1`, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing. `apt install` with a local path resolves runtime deps (`pkexec | policykit-1`, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing.
+1 -1
View File
@@ -1 +1 @@
fe9e98ff6b2ee740345a9cac5bc1fd828cf7b53c96242c0c15fdc26dd8249b4e release/mycellium-ui-private_0.1.0_amd64.deb b70044915c695ffa3fbd32501b6d2fd7fd255f0be73d65a62cb1e940dc15e118 release/mycellium-ui-private_0.1.1_amd64.deb
+1 -1
View File
@@ -1947,7 +1947,7 @@ dependencies = [
[[package]] [[package]]
name = "mycellium-ui-private" name = "mycellium-ui-private"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"hex", "hex",
"parking_lot", "parking_lot",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mycellium-ui-private" name = "mycellium-ui-private"
version = "0.1.0" version = "0.1.1"
description = "Mycelium private network desktop client" description = "Mycelium private network desktop client"
authors = ["syoul"] authors = ["syoul"]
edition = "2021" edition = "2021"
+14 -2
View File
@@ -33,6 +33,12 @@ pub struct SidecarConfig {
/// UTF-8 network identifier (2..=64 bytes). Public; not a secret. /// UTF-8 network identifier (2..=64 bytes). Public; not a secret.
/// All nodes joining the same private overlay must agree on this. /// All nodes joining the same private overlay must agree on this.
pub network_name: Option<String>, pub network_name: Option<String>,
/// 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<u16>,
/// Same as above for QUIC (UDP). `None` ⇒ ephemeral port.
pub quic_listen_port: Option<u16>,
} }
/// Holds the running mycelium child process plus a small in-memory log /// 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 // is already up. Always picking ephemeral ports avoids that at the
// cost of inbound peers needing the actual port number. // cost of inbound peers needing the actual port number.
let api_port = pick_port()?; let api_port = pick_port()?;
let tcp_port = pick_port_skip(&[api_port])?; let tcp_port = match config.tcp_listen_port {
let quic_port = pick_port_skip(&[api_port, tcp_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 // 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 // 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 // orphan from a previous run we couldn't SIGKILL because it ran as
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Mycellium UI Private", "productName": "Mycellium UI Private",
"version": "0.1.0", "version": "0.1.1",
"identifier": "tech.threefold.mycellium-ui-private", "identifier": "tech.threefold.mycellium-ui-private",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+16
View File
@@ -120,6 +120,22 @@ async function handleStop() {
<header class="flex h-14 items-center border-b border-border px-6 shrink-0"> <header class="flex h-14 items-center border-b border-border px-6 shrink-0">
<h1 class="text-lg font-semibold">{{ currentTitle }}</h1> <h1 class="text-lg font-semibold">{{ currentTitle }}</h1>
</header> </header>
<div
v-if="phase === 'error' && error"
class="border-b border-destructive/40 bg-destructive/10 px-6 py-3"
>
<div class="flex items-start gap-3 text-sm">
<span class="font-medium text-destructive">Daemon failed to start </span>
<span class="flex-1 break-words">{{ error }}</span>
<button
v-if="error.toLowerCase().includes('network')"
class="shrink-0 rounded-md border border-destructive px-2 py-0.5 text-xs text-destructive hover:bg-destructive hover:text-destructive-foreground"
@click="$router.push('/settings')"
>
Go to Settings
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto p-6">
<RouterView /> <RouterView />
</div> </div>
+4
View File
@@ -23,6 +23,10 @@ export interface SidecarConfig {
/** UTF-8, 2..=64 bytes. Public must match across all nodes of the /** UTF-8, 2..=64 bytes. Public must match across all nodes of the
* same private overlay. */ * same private overlay. */
networkName: string | null; 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 { export interface NetworkKeyStatus {
+16 -1
View File
@@ -8,14 +8,25 @@ const KEY = "sidecar";
// A private overlay has no Threefold-operated seed peer. The user must // A private overlay has no Threefold-operated seed peer. The user must
// declare bootstrap peers they trust (their own VPS, known friends…) // declare bootstrap peers they trust (their own VPS, known friends…)
// before the daemon can usefully start. // before the daemon can usefully start. Default to mycelium's
// well-known peer-listen ports (9651 TCP, 9652 QUIC) so port-forwards
// on a home router are predictable from the first run.
const DEFAULT_CONFIG: SidecarConfig = { const DEFAULT_CONFIG: SidecarConfig = {
peers: [], peers: [],
tunName: null, tunName: null,
noTun: false, noTun: false,
networkName: null, networkName: null,
tcpListenPort: 9651,
quicListenPort: 9652,
}; };
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", () => { export const useConfigStore = defineStore("config", () => {
const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG }); const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG });
const loaded = ref(false); const loaded = ref(false);
@@ -35,6 +46,8 @@ export const useConfigStore = defineStore("config", () => {
tunName: saved.tunName ?? null, tunName: saved.tunName ?? null,
noTun: !!saved.noTun, noTun: !!saved.noTun,
networkName: saved.networkName ?? null, networkName: saved.networkName ?? null,
tcpListenPort: normalizePort(saved.tcpListenPort),
quicListenPort: normalizePort(saved.quicListenPort),
}; };
} }
loaded.value = true; loaded.value = true;
@@ -46,6 +59,8 @@ export const useConfigStore = defineStore("config", () => {
tunName: next.tunName?.trim() ? next.tunName.trim() : null, tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun, noTun: !!next.noTun,
networkName: next.networkName?.trim() ? next.networkName.trim() : null, networkName: next.networkName?.trim() ? next.networkName.trim() : null,
tcpListenPort: normalizePort(next.tcpListenPort),
quicListenPort: normalizePort(next.quicListenPort),
}; };
const s = await ensureStore(); const s = await ensureStore();
await s.set(KEY, config.value); await s.set(KEY, config.value);
+47
View File
@@ -30,6 +30,8 @@ const draft = reactive({
tunName: "", tunName: "",
noTun: false, noTun: false,
networkName: "", networkName: "",
tcpListenPort: "",
quicListenPort: "",
}); });
const dirty = ref(false); const dirty = ref(false);
@@ -38,9 +40,19 @@ function loadDraft() {
draft.tunName = config.value.tunName ?? ""; draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun; draft.noTun = config.value.noTun;
draft.networkName = config.value.networkName ?? ""; draft.networkName = config.value.networkName ?? "";
draft.tcpListenPort = config.value.tcpListenPort?.toString() ?? "";
draft.quicListenPort = config.value.quicListenPort?.toString() ?? "";
dirty.value = false; 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(config, loadDraft, { immediate: true });
watch( watch(
@@ -60,6 +72,8 @@ async function save() {
tunName: draft.tunName.trim() || null, tunName: draft.tunName.trim() || null,
noTun: draft.noTun, noTun: draft.noTun,
networkName: draft.networkName.trim() || null, networkName: draft.networkName.trim() || null,
tcpListenPort: parsePort(draft.tcpListenPort),
quicListenPort: parsePort(draft.quicListenPort),
}); });
dirty.value = false; dirty.value = false;
} }
@@ -387,6 +401,39 @@ onMounted(async () => {
:disabled="draft.noTun" :disabled="draft.noTun"
/> />
</div> </div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
TCP listen port
</label>
<input
v-model="draft.tcpListenPort"
type="text"
inputmode="numeric"
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="leave empty for ephemeral"
/>
</div>
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
QUIC listen port
</label>
<input
v-model="draft.quicListenPort"
type="text"
inputmode="numeric"
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="leave empty for ephemeral"
/>
</div>
</div>
<p class="text-[11px] text-muted-foreground">
Defaults are <code class="font-mono">9651</code> (TCP) and
<code class="font-mono">9652</code> (QUIC) the conventional mycelium
peer-listen ports, predictable for port-forwarding on a home router.
Clear the field to fall back to a random ephemeral port at every start.
</p>
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<button <button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary" class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary"