Compare commits

...

10 Commits

Author SHA1 Message Date
syoul b5909ccb56 release: v0.1.0 .deb bundle
Bundle of all phases (P0-P5) plus the diagnostic fixes from the
end-to-end test session: ephemeral peer/metrics ports, no reqwest
connection pool, short-poll inbox, expanded sidecar path probe.

Smoke-tested by exchanging messages with a second mycelium node in
a Docker container against the public Threefold seed
tcp://188.40.132.242:9651 — bidirectional delivery confirmed.

Built on Debian 12 (bookworm), x86_64, glibc-targeted but the
embedded mycelium binary is musl static.
2026-04-26 01:18:46 +02:00
syoul a31a40a477 fix(sidecar): probe installed and dev paths for the binary
dpkg-deb -c on the bundled .deb shows mycelium at /usr/bin/mycelium
(no triple suffix), next to the app launcher. Our previous resolver
only looked for the suffixed dev name in resource_dir, so the
installed app could not find its sidecar.

Probe order is now: directory of current_exe, then resource_dir,
then CARGO_MANIFEST_DIR/binaries, then $PATH, with both the plain
"mycelium" name and the dev-style "mycelium-<triple>" alias
checked at each location.
2026-04-26 01:09:44 +02:00
syoul 939565b88a fix(poller): short-poll inbox instead of 30s long-poll
The 10s-after-healthy failure pattern was reproducing even with
the connection pool disabled. Smoking gun: the inbox loop opens
GET /messages?timeout=30 right after start_daemon returns, and
every subsequent peers/routes call timed out exactly when our
client-side reqwest timeout (10s) fired.

Concluded mycelium 0.6.1's HTTP server serialises requests: while
the long-poll connection is held, no other admin endpoint can
respond. The sidecar process kept logging routes the whole time
(seen in the in-app log buffer) — proof the daemon was alive,
just unable to serve concurrent calls.

Switch to short-poll: timeout=0 returns immediately, sleep 2s
between iterations. Per-iteration server hold time is now
millisecond-scale instead of 30s.
2026-04-26 00:32:58 +02:00
syoul 7981fc571c fix(api): disable reqwest connection pool
Direct mycelium runs and our pkexec spawns are both healthy
(sidecar logs show acquired routes streaming for 20+s). Yet our
reqwest poller can't reach 127.0.0.1:port after the first
successful request. Smoking gun: failure happens ~10s after the
first reply — exactly when an idle keep-alive connection would
have been reaped.

Disable pooling (pool_max_idle_per_host(0)) so every call opens a
fresh TCP connection. Loopback overhead is negligible (~50us per
request) and we're immune to server-side idle closes. Also pin
connect_timeout to 3s so a wedged half-open doesn't block for
the full 10s request timeout.
2026-04-26 00:18:59 +02:00
syoul 9fe24c72cb fix(ui): only show overlay when starting, not on error
The overlay covered the whole window in error phase too, blocking
access to the sidebar and Settings page where the user needs to
read the sidecar logs to diagnose the failure.

Now in error phase the sidebar status dot turns red, the start
button is back in the sidebar, and the Settings page is reachable
to inspect the in-app log buffer.
2026-04-26 00:13:47 +02:00
syoul 45174ebe7d fix(sidecar): pin metrics-api-address to ephemeral port
Even with --api-addr set, mycelium also opens an internal JSON-RPC
endpoint on 127.0.0.1:8990 by default (visible in --debug as
\"Starting JSON-RPC server listen_addr=127.0.0.1:8990\"). When a
previous run left an orphan mycelium running as root — which we
can't SIGKILL from a user-level Child::kill_on_drop — the new
instance fails to bind 8990 and exits ~10s after startup, with no
sidecar://exited event because the kill never landed.

Pin --metrics-api-address to a fresh ephemeral port alongside the
other three. Known limitation: stopping the daemon still doesn't
reliably kill the root child; the user has to `sudo pkill mycelium`
between runs. Will be addressed by an elevated-shutdown hook.
2026-04-26 00:06:50 +02:00
syoul 2cd14f06ae fix(sidecar): use ephemeral ports for tcp/quic listen too
mycelium defaults --tcp-listen-port and --quic-listen-port to 9651
when not provided. If anything else holds 9651 (a previous test
instance, a Docker container from the level-1 procedure, another
mycelium running on the host), the daemon comes up far enough to
serve the loopback API for a few seconds before tearing itself
down on the listen failure.

Pick three distinct ephemeral ports (api, tcp, quic) per spawn.
Trade-off: inbound peers need the actual port number, which we
already log; the user can pin via a future SidecarConfig field.
2026-04-25 23:51:35 +02:00
syoul 4dd278e62a fix: remove tauri-plugin-log to avoid logger conflict
`tracing_subscriber::fmt().try_init()` installs a global log
implementation via the tracing-log shim. tauri-plugin-log then
calls `log::set_logger()` which panics with "attempted to set a
logger after the logging system was already initialized".

The plugin was never wired in JS code (no @tauri-apps/plugin-log
import) — backend logging via tracing is sufficient. Drop:
  - tauri-plugin-log dep + plugin registration
  - log:default capability permission
  - @tauri-apps/plugin-log JS dep

Verified: cargo check is clean, app starts past the panic.
2026-04-25 23:35:34 +02:00
syoul eb86fdd182 P5: settings, persistence, polkit packaging, README
Backend
- regenerate_identity command stops the daemon, deletes
  priv_key.bin, leaves the user to restart for a fresh identity;
  falls back to the canonical XDG path when sidecar.key_path()
  isn't populated yet
- tauri.conf.json ships the polkit policy via deb.files mapping;
  src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy
  declares the spawn action with auth_admin_keep so the dialog
  appears once per session

Frontend
- config store persists SidecarConfig (peers, tunName, noTun)
  through tauri-plugin-store; App.vue reads it and forwards to
  start_daemon, replacing the hard-coded defaults
- Settings view: daemon-config form, identity panel with the
  destructive regenerate button, sidecar log viewer, About
- README rewritten end-to-end: HTTP-loopback architecture, polkit
  install path, build commands, verification matrix, and a
  honest "known limitations" section
2026-04-25 23:15:35 +02:00
syoul f28d0e1338 P4: messages, topics, pubkey
Backend
- api/messages.rs covers send/pop/reply/status with an externally
  tagged MessageDestination enum that matches the daemon's
  {ip|pk: ...} body shape; pop_message uses an inflated request
  timeout to outlast the long-poll window
- api/topics.rs implements default action, topic CRUD, sources
  whitelist, and forward-socket get/set/remove. POST /topics ships
  the raw base64 string as the body (not JSON); path segments are
  percent-encoded inline (topics contain '/' and '+')
- api/pubkey.rs resolves an overlay IPv6 to a hex public key
- poller spawns a third long-poll loop on /messages?peek=false
  that fans every inbound message into a 200-deep ring buffer and
  emits messages://incoming for the UI

Frontend
- messages store: live inbox via the event, persisted outbox via
  tauri-plugin-store keyed under outbox.json
- ComposeMessage form: ip/pk toggle, optional UTF-8 topic and
  payload that get base64-encoded with a TextEncoder-based helper
- MessageList renders printable payloads decoded; binary payloads
  fall back to a "(N bytes binary)" hint
- Topics view: split layout with whitelist on the left, per-topic
  sources/forward editor on the right; default-action toggle is
  surfaced at the top
2026-04-25 23:10:21 +02:00
33 changed files with 2104 additions and 357 deletions
+105 -6
View File
@@ -2,11 +2,110 @@
Cross-platform desktop GUI for [Mycelium](https://github.com/threefoldtech/mycelium) — Threefold's end-to-end encrypted IPv6 overlay network.
Status: scaffolding. See the implementation plan for architecture, IPC protocol, and phasing.
The app embeds the official `mycelium` binary as a Tauri sidecar and pilots it through its HTTP API on a loopback ephemeral port. Root privileges (required to create the TUN interface) are obtained via `pkexec`.
## Stack (planned)
## Status
- **App**: Tauri v2 + Vue 3 + TypeScript
- **Daemon**: Rust (`myceliumd`) running as system service, IPC via Unix socket / named pipe
- **Mycelium engine**: official `mycelium` binary embedded as Tauri sidecar
- **Targets v1**: Linux + Windows
v1, Linux-only. Implements the full `docs/api.yaml` surface of mycelium v0.6.1: admin, peers (CRUD), routes (selected/fallback/queried), messages (send/receive/reply/status), topics (default + whitelist + sources + forward), pubkey lookup.
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ WebView (Vue 3 + TS + Tailwind + radix-vue + Pinia) │
│ Status / Peers / Routes / Messages / Topics / Settings │
└────────────────┬─────────────────────────────────────────────┘
│ invoke() / Tauri events
┌────────────────┴─────────────────────────────────────────────┐
│ Tauri core (Rust, tokio + reqwest) │
│ • sidecar.rs — supervises mycelium via pkexec │
│ • api/* — typed REST client │
│ • poller.rs — emits peers://, stats://, routes://, messages://incoming │
└────────────────┬─────────────────────────────────────────────┘
│ HTTP loopback :ephemeral
┌────────────────┴─────────────────────────────────────────────┐
│ mycelium daemon (sidecar binary, runs as root via pkexec) │
│ TUN0 ◄─► overlay network │
└──────────────────────────────────────────────────────────────┘
```
There is no Unix socket / named pipe IPC — the daemon's own HTTP API is the integration point.
## Prerequisites (Debian / Ubuntu)
```bash
sudo apt install -y \
libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev \
build-essential curl wget file libssl-dev libgtk-3-dev libxdo-dev \
pkg-config policykit-1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
```
Then Node 20+ and pnpm 10+.
## Setup
```bash
# 1. Install JS deps
pnpm install
# 2. Fetch the mycelium sidecar binary for your target triple
bash scripts/fetch-mycelium.sh # uses MYCELIUM_VERSION (default v0.6.1)
# or: MYCELIUM_VERSION=v0.6.1 bash scripts/fetch-mycelium.sh
# 3. Run in dev
pnpm tauri dev
```
The first start triggers a `pkexec` dialog asking you to authenticate; the polkit policy installed by the `.deb` caches the auth for the user session.
## Build
```bash
pnpm tauri build # → src-tauri/target/release/bundle/{deb,appimage}/
```
The `.deb` declares `Depends: policykit-1` and ships the polkit policy under `/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy`. The AppImage relies on `pkexec` being present on the host — on systems without polkit, fall back to running with `sudo` after disabling the sidecar's pkexec wrapper.
## Layout
```
src/ # Vue 3 frontend
views/ # one file per nav item
components/ # shadcn-style UI primitives + dialogs
stores/ # Pinia: node, peers, routes, messages, topics, config
lib/ # api wrapper, events, base64 + format helpers
src-tauri/
src/
sidecar.rs # spawn + supervise mycelium
elevation.rs # pkexec command builder
poller.rs # 3 background loops (peers, routes, inbox long-poll)
api/ # REST client modules (admin, peers, routes,
# messages, topics, pubkey)
commands.rs # #[tauri::command] handlers, 1:1 with REST
error.rs # AppError + Serialize-as-string for invoke()
binaries/ # gitignored; populated by scripts/fetch-mycelium.sh
packaging/polkit/ # XML policy bundled into the .deb
scripts/fetch-mycelium.sh
.github/workflows/ci.yml # pnpm typecheck + cargo fmt/clippy/test
```
## Verification matrix
| Test | How |
|------|-----|
| Sidecar starts under pkexec | `pnpm tauri dev`, daemon visible in `ps`, splash disappears in <10 s |
| Peers connect | Add `tcp://188.40.132.242:9651` from the Peers page; state turns to `alive` within ~10 s |
| Routes propagate | `Routes/Selected` becomes non-empty after ~30 s |
| Live event stream | Sidebar status dot tracks ready/idle, peers table updates without manual refresh |
| Bidirectional messages | Two instances on different VMs, exchange via Compose → Inbox |
| Identity regen | Settings → Regenerate; restart daemon; new IP appears on Status |
| `.deb` install | Fresh Ubuntu LTS / Debian 12; daemon spawns under polkit on first start |
## Known limitations (v1)
- Linux only. Windows is reachable (sidecar via `runas` / Wintun driver) but not implemented.
- Auto-start at login isn't wired — the desktop entry installed by the `.deb` is the manual launcher.
- The TOML config editor in Settings only exposes `peers`, `tunName`, `noTun`. Other keys (`metricsApiAddress`, etc.) are passed-through if you edit the file directly at `~/.local/share/tech.threefold.mycellium-ui/mycelium.toml` and restart the daemon.
- `message_status` is forwarded as opaque JSON; the upstream schema isn't pinned in the spec, so we don't strongly type it.
-1
View File
@@ -13,7 +13,6 @@
"dependencies": {
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-log": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-store": "^2.2.0",
"class-variance-authority": "^0.7.1",
-10
View File
@@ -14,9 +14,6 @@ importers:
'@tauri-apps/plugin-dialog':
specifier: ^2.2.0
version: 2.7.0
'@tauri-apps/plugin-log':
specifier: ^2.2.0
version: 2.8.0
'@tauri-apps/plugin-shell':
specifier: ^2.2.0
version: 2.3.5
@@ -511,9 +508,6 @@ packages:
'@tauri-apps/plugin-dialog@2.7.0':
resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==}
'@tauri-apps/plugin-log@2.8.0':
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
@@ -1375,10 +1369,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-log@2.8.0':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.10.1
+32
View File
@@ -0,0 +1,32 @@
# Releases
Pre-built `.deb` of the mycellium-ui desktop client. Tested on Debian 12 (bookworm); should work on any apt-based distro shipping `libwebkit2gtk-4.1-0` (Ubuntu 24.04+, Debian 12+).
## Install
```bash
sudo apt install ./mycellium-ui_0.1.0_amd64.deb
```
`apt install` with a local path resolves runtime deps (`policykit-1`, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing.
## Verify
```bash
sha256sum -c SHA256SUMS
```
## What's inside
| Path | Purpose |
|---|---|
| `/usr/bin/mycellium-ui` | GUI launcher |
| `/usr/bin/mycelium` | Mycelium daemon (v0.6.1, runs as root via pkexec) |
| `/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy` | polkit action — auth cached per session (`auth_admin_keep`) |
| `/usr/share/applications/Mycellium UI.desktop` | Menu entry |
## Uninstall
```bash
sudo apt remove mycellium-ui
```
+1
View File
@@ -0,0 +1 @@
c85a2f6584949bd04a2a56b7fc3a5f7ed6e99c3291dec6c7eef99e8b28a0b2be release/mycellium-ui_0.1.0_amd64.deb
Binary file not shown.
-289
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -43,23 +32,6 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -75,12 +47,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "atk"
version = "0.18.2"
@@ -158,18 +124,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -188,30 +142,6 @@ dependencies = [
"objc2",
]
[[package]]
name = "borsh"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -239,40 +169,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -816,16 +712,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -868,15 +754,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -957,12 +834,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1366,9 +1237,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
@@ -1950,9 +1818,6 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lru-slab"
@@ -2092,7 +1957,6 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-store",
"thiserror 2.0.18",
@@ -2189,15 +2053,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -2758,26 +2613,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -2863,12 +2698,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3054,15 +2883,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -3173,52 +2993,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust_decimal"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.6",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3347,12 +3121,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3653,12 +3421,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -3923,12 +3685,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4108,28 +3864,6 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -4345,9 +4079,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -4808,12 +4540,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4838,12 +4564,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5760,15 +5480,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
-1
View File
@@ -17,7 +17,6 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-store = "2"
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-1
View File
@@ -6,7 +6,6 @@
"permissions": [
"core:default",
"store:default",
"log:default",
"dialog:default"
]
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"https://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>Threefold</vendor>
<vendor_url>https://threefold.io</vendor_url>
<action id="tech.threefold.mycellium-ui.spawn">
<description>Run the Mycelium overlay daemon</description>
<description xml:lang="fr">Lancer le démon de l'overlay Mycelium</description>
<message>Authentication is required to start the Mycelium overlay daemon.</message>
<message xml:lang="fr">Une authentification est requise pour démarrer le démon Mycelium.</message>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<!-- Cache the authentication for the user's session so the polkit
dialog only appears once per login (5-minute window). To allow
passwordless start for trusted desktops, change to "yes" — be
aware this lets any process on the machine spawn the daemon. -->
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
</policyconfig>
+132
View File
@@ -0,0 +1,132 @@
use crate::api::MyceliumClient;
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
/// Destination of an outgoing message: either a fully resolved overlay IPv6
/// or the recipient's public key.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MessageDestination {
#[serde(rename = "ip")]
Ip(String),
#[serde(rename = "pk")]
PublicKey(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushMessageBody {
pub dst: MessageDestination,
/// base64-encoded topic bytes (≤ 340 chars).
pub topic: String,
/// base64-encoded payload bytes.
pub payload: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushMessageReceipt {
/// 16-char hex id assigned by the daemon.
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IncomingMessage {
pub id: String,
pub src_ip: String,
pub src_pk: String,
pub dst_ip: String,
pub dst_pk: String,
pub topic: String,
pub payload: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageStatus {
/// Pass-through of the daemon's response. We deliberately keep it as a
/// JSON Value because the upstream schema isn't pinned in the spec and
/// fields can be added between releases.
#[serde(flatten)]
pub raw: serde_json::Value,
}
impl MyceliumClient {
pub async fn send_message(&self, body: &PushMessageBody) -> AppResult<PushMessageReceipt> {
let resp = self
.http()
.post(self.url("/messages"))
.json(body)
.send()
.await?;
Self::parse(resp).await
}
/// Long-poll the daemon for an incoming message. `timeout` is seconds and
/// must be ≥ 0; the daemon returns 204/empty when nothing arrives within
/// the window. Caller is responsible for swallowing the resulting Err.
pub async fn pop_message(
&self,
peek: bool,
timeout: u64,
topic: Option<&str>,
) -> AppResult<Option<IncomingMessage>> {
let mut req = self.http().get(self.url("/messages"));
// The daemon expects query params; we hand-build to avoid url crate.
let mut q: Vec<(&str, String)> =
vec![("peek", peek.to_string()), ("timeout", timeout.to_string())];
if let Some(t) = topic {
q.push(("topic", t.to_string()));
}
req = req.query(&q);
// The long-poll can run nearly as long as `timeout`; loosen the
// client-default request timeout for this single call.
req = req.timeout(std::time::Duration::from_secs(
timeout.saturating_add(5).max(15),
));
let resp = req.send().await?;
let status = resp.status();
if status == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::DaemonStatus {
status: status.as_u16(),
body,
});
}
// Some implementations also return 200 with empty body to signal
// "nothing to read". Try to parse and tolerate empty.
let bytes = resp.bytes().await?;
if bytes.is_empty() {
return Ok(None);
}
let msg: IncomingMessage = serde_json::from_slice(&bytes)
.map_err(|e| AppError::Other(format!("failed to parse incoming message: {e}")))?;
Ok(Some(msg))
}
pub async fn reply_message(
&self,
id: &str,
body: &PushMessageBody,
) -> AppResult<PushMessageReceipt> {
let resp = self
.http()
.post(self.url(&format!("/messages/reply/{id}")))
.json(body)
.send()
.await?;
Self::parse(resp).await
}
pub async fn message_status(&self, id: &str) -> AppResult<MessageStatus> {
let resp = self
.http()
.get(self.url(&format!("/messages/status/{id}")))
.send()
.await?;
Self::parse(resp).await
}
}
+10
View File
@@ -1,6 +1,9 @@
pub mod admin;
pub mod messages;
pub mod peers;
pub mod pubkey;
pub mod routes;
pub mod topics;
use crate::error::{AppError, AppResult};
use reqwest::{Client, Response};
@@ -19,8 +22,15 @@ pub struct MyceliumClient {
impl MyceliumClient {
pub fn new(base: impl Into<String>) -> Self {
// Mycelium's HTTP server seems to drop idle keep-alive connections
// around the 10s mark; reusing a pooled stale connection surfaces
// as a generic "error sending request" once `start_daemon`
// returned. Open a fresh TCP connection per request — overhead is
// negligible on loopback and immune to server-side closes.
let http = Client::builder()
.pool_max_idle_per_host(0)
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(3))
.build()
.expect("reqwest client build");
Self {
+3 -7
View File
@@ -93,13 +93,9 @@ fn url_encode_path_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'a'..=b'z'
| b'A'..=b'Z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~' => out.push(b as char),
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
+21
View File
@@ -0,0 +1,21 @@
use crate::api::MyceliumClient;
use crate::error::AppResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PubkeyLookup {
#[serde(rename = "NodePubKey")]
pub node_pub_key: String,
}
impl MyceliumClient {
/// Resolve the public key of an overlay node from its IPv6 address.
pub async fn lookup_pubkey(&self, ip: &str) -> AppResult<PubkeyLookup> {
let resp = self
.http()
.get(self.url(&format!("/pubkey/{ip}")))
.send()
.await?;
Self::parse(resp).await
}
}
+15 -3
View File
@@ -37,17 +37,29 @@ pub struct RoutesSnapshot {
impl MyceliumClient {
pub async fn routes_selected(&self) -> AppResult<Vec<Route>> {
let r = self.http().get(self.url("/admin/routes/selected")).send().await?;
let r = self
.http()
.get(self.url("/admin/routes/selected"))
.send()
.await?;
Self::parse(r).await
}
pub async fn routes_fallback(&self) -> AppResult<Vec<Route>> {
let r = self.http().get(self.url("/admin/routes/fallback")).send().await?;
let r = self
.http()
.get(self.url("/admin/routes/fallback"))
.send()
.await?;
Self::parse(r).await
}
pub async fn routes_queried(&self) -> AppResult<Vec<QueriedSubnet>> {
let r = self.http().get(self.url("/admin/routes/queried")).send().await?;
let r = self
.http()
.get(self.url("/admin/routes/queried"))
.send()
.await?;
Self::parse(r).await
}
+158
View File
@@ -0,0 +1,158 @@
use crate::api::MyceliumClient;
use crate::error::AppResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefaultAction {
pub accept: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceBody {
pub subnet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ForwardBody {
pub socket_path: String,
}
impl MyceliumClient {
pub async fn topics_default_get(&self) -> AppResult<DefaultAction> {
let resp = self
.http()
.get(self.url("/messages/topics/default"))
.send()
.await?;
Self::parse(resp).await
}
pub async fn topics_default_set(&self, action: &DefaultAction) -> AppResult<()> {
let resp = self
.http()
.put(self.url("/messages/topics/default"))
.json(action)
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topics_list(&self) -> AppResult<Vec<String>> {
let resp = self.http().get(self.url("/messages/topics")).send().await?;
Self::parse(resp).await
}
/// The daemon expects the raw base64 topic as the request body, not
/// wrapped in JSON.
pub async fn topic_add(&self, topic_b64: &str) -> AppResult<()> {
let resp = self
.http()
.post(self.url("/messages/topics"))
.header(reqwest::header::CONTENT_TYPE, "text/plain")
.body(topic_b64.to_string())
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_remove(&self, topic_b64: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.delete(self.url(&format!("/messages/topics/{encoded}")))
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_sources_list(&self, topic_b64: &str) -> AppResult<Vec<String>> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.get(self.url(&format!("/messages/topics/{encoded}/sources")))
.send()
.await?;
Self::parse(resp).await
}
pub async fn topic_source_add(&self, topic_b64: &str, subnet: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.post(self.url(&format!("/messages/topics/{encoded}/sources")))
.json(&SourceBody {
subnet: subnet.to_string(),
})
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_source_remove(&self, topic_b64: &str, subnet: &str) -> AppResult<()> {
let topic = encode_segment(topic_b64);
let sub = encode_segment(subnet);
let resp = self
.http()
.delete(self.url(&format!("/messages/topics/{topic}/sources/{sub}")))
.send()
.await?;
Self::check_status(resp).await
}
/// Returns Ok(None) if no forward is configured (the daemon yields
/// `null`).
pub async fn topic_forward_get(&self, topic_b64: &str) -> AppResult<Option<String>> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.get(self.url(&format!("/messages/topics/{encoded}/forward")))
.send()
.await?;
let v: serde_json::Value = Self::parse(resp).await?;
Ok(match v {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s),
other => Some(other.to_string()),
})
}
pub async fn topic_forward_set(&self, topic_b64: &str, socket_path: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.put(self.url(&format!("/messages/topics/{encoded}/forward")))
.json(&ForwardBody {
socket_path: socket_path.to_string(),
})
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_forward_remove(&self, topic_b64: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.delete(self.url(&format!("/messages/topics/{encoded}/forward")))
.send()
.await?;
Self::check_status(resp).await
}
}
/// Percent-encodes path segments. Topics are base64 (which contains `/` and
/// `+` and may end with `=`); subnets carry `/` and `:`. We encode anything
/// outside the unreserved set.
fn encode_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
+197
View File
@@ -1,6 +1,11 @@
use crate::api::admin::NodeInfo;
use crate::api::messages::{
IncomingMessage, MessageDestination, MessageStatus, PushMessageBody, PushMessageReceipt,
};
use crate::api::peers::{AggregatedStats, PeerInfo};
use crate::api::pubkey::PubkeyLookup;
use crate::api::routes::RoutesSnapshot;
use crate::api::topics::DefaultAction;
use crate::api::MyceliumClient;
use crate::error::{AppError, AppResult};
use crate::sidecar::SidecarConfig;
@@ -99,3 +104,195 @@ pub async fn peers_stats(state: State<'_, AppState>) -> AppResult<AggregatedStat
pub async fn routes_snapshot(state: State<'_, AppState>) -> AppResult<RoutesSnapshot> {
require_client(&state)?.routes_snapshot().await
}
// ─── Messages ────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn send_message(
state: State<'_, AppState>,
destination: MessageDestination,
topic_b64: String,
payload_b64: String,
) -> AppResult<PushMessageReceipt> {
let body = PushMessageBody {
dst: destination,
topic: topic_b64,
payload: payload_b64,
};
require_client(&state)?.send_message(&body).await
}
#[tauri::command]
pub async fn reply_message(
state: State<'_, AppState>,
id: String,
destination: MessageDestination,
topic_b64: String,
payload_b64: String,
) -> AppResult<PushMessageReceipt> {
let body = PushMessageBody {
dst: destination,
topic: topic_b64,
payload: payload_b64,
};
require_client(&state)?.reply_message(&id, &body).await
}
#[tauri::command]
pub async fn message_status(state: State<'_, AppState>, id: String) -> AppResult<MessageStatus> {
require_client(&state)?.message_status(&id).await
}
#[tauri::command]
pub fn inbox_messages(state: State<'_, AppState>) -> Vec<IncomingMessage> {
state.poller.inbox_snapshot()
}
#[tauri::command]
pub fn inbox_clear(state: State<'_, AppState>) {
state.poller.clear_inbox();
}
// ─── Topics ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn topics_default_get(state: State<'_, AppState>) -> AppResult<DefaultAction> {
require_client(&state)?.topics_default_get().await
}
#[tauri::command]
pub async fn topics_default_set(state: State<'_, AppState>, accept: bool) -> AppResult<()> {
require_client(&state)?
.topics_default_set(&DefaultAction { accept })
.await
}
#[tauri::command]
pub async fn topics_list(state: State<'_, AppState>) -> AppResult<Vec<String>> {
require_client(&state)?.topics_list().await
}
#[tauri::command]
pub async fn topic_add(state: State<'_, AppState>, topic_b64: String) -> AppResult<()> {
if topic_b64.trim().is_empty() {
return Err(AppError::BadInput("topic must not be empty".into()));
}
require_client(&state)?.topic_add(topic_b64.trim()).await
}
#[tauri::command]
pub async fn topic_remove(state: State<'_, AppState>, topic_b64: String) -> AppResult<()> {
require_client(&state)?.topic_remove(&topic_b64).await
}
#[tauri::command]
pub async fn topic_sources_list(
state: State<'_, AppState>,
topic_b64: String,
) -> AppResult<Vec<String>> {
require_client(&state)?.topic_sources_list(&topic_b64).await
}
#[tauri::command]
pub async fn topic_source_add(
state: State<'_, AppState>,
topic_b64: String,
subnet: String,
) -> AppResult<()> {
require_client(&state)?
.topic_source_add(&topic_b64, &subnet)
.await
}
#[tauri::command]
pub async fn topic_source_remove(
state: State<'_, AppState>,
topic_b64: String,
subnet: String,
) -> AppResult<()> {
require_client(&state)?
.topic_source_remove(&topic_b64, &subnet)
.await
}
#[tauri::command]
pub async fn topic_forward_get(
state: State<'_, AppState>,
topic_b64: String,
) -> AppResult<Option<String>> {
require_client(&state)?.topic_forward_get(&topic_b64).await
}
#[tauri::command]
pub async fn topic_forward_set(
state: State<'_, AppState>,
topic_b64: String,
socket_path: String,
) -> AppResult<()> {
require_client(&state)?
.topic_forward_set(&topic_b64, &socket_path)
.await
}
#[tauri::command]
pub async fn topic_forward_remove(state: State<'_, AppState>, topic_b64: String) -> AppResult<()> {
require_client(&state)?
.topic_forward_remove(&topic_b64)
.await
}
// ─── Pubkey ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn lookup_pubkey(state: State<'_, AppState>, ip: String) -> AppResult<PubkeyLookup> {
require_client(&state)?.lookup_pubkey(&ip).await
}
// ─── Identity ────────────────────────────────────────────────────────────────
/// Stops the daemon (if running), removes the saved private key file, and
/// returns the daemon to the idle state. The caller restarts the daemon
/// to provoke regeneration.
#[tauri::command]
pub async fn regenerate_identity(state: State<'_, AppState>) -> AppResult<()> {
state.poller.stop();
let key_path = state.sidecar.key_path();
state.sidecar.stop().await;
if let Some(path) = key_path {
if path.exists() {
std::fs::remove_file(&path).map_err(AppError::from)?;
}
} else {
// Sidecar never started; resolve the canonical path via app_data_dir.
if let Some(p) = default_key_path() {
if p.exists() {
std::fs::remove_file(&p).map_err(AppError::from)?;
}
}
}
Ok(())
}
fn default_key_path() -> Option<std::path::PathBuf> {
// Best-effort: fall back to the same XDG location the sidecar uses.
dirs_like_app_data().ok().map(|d| d.join("priv_key.bin"))
}
fn dirs_like_app_data() -> std::io::Result<std::path::PathBuf> {
// We can't reach the AppHandle here, so we mirror Tauri's path:
// $XDG_DATA_HOME/<identifier>/ or $HOME/.local/share/<identifier>/.
let identifier = "tech.threefold.mycellium-ui";
if let Ok(d) = std::env::var("XDG_DATA_HOME") {
return Ok(std::path::PathBuf::from(d).join(identifier));
}
if let Ok(home) = std::env::var("HOME") {
return Ok(std::path::PathBuf::from(home)
.join(".local/share")
.join(identifier));
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no app data dir",
))
}
+18 -1
View File
@@ -21,7 +21,6 @@ pub fn run() {
.ok();
tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
@@ -40,6 +39,24 @@ pub fn run() {
commands::peer_remove,
commands::peers_stats,
commands::routes_snapshot,
commands::send_message,
commands::reply_message,
commands::message_status,
commands::inbox_messages,
commands::inbox_clear,
commands::topics_default_get,
commands::topics_default_set,
commands::topics_list,
commands::topic_add,
commands::topic_remove,
commands::topic_sources_list,
commands::topic_source_add,
commands::topic_source_remove,
commands::topic_forward_get,
commands::topic_forward_set,
commands::topic_forward_remove,
commands::lookup_pubkey,
commands::regenerate_identity,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+65 -7
View File
@@ -1,6 +1,8 @@
use crate::api::messages::IncomingMessage;
use crate::api::peers;
use crate::sidecar::SidecarHandle;
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
@@ -9,10 +11,15 @@ use tracing::warn;
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
const ROUTES_INTERVAL: Duration = Duration::from_secs(5);
const INBOX_INTERVAL: Duration = Duration::from_secs(2);
const INBOX_RETRY_BACKOFF: Duration = Duration::from_secs(2);
const INBOX_CAPACITY: usize = 200;
pub struct Poller {
peers_handle: Mutex<Option<JoinHandle<()>>>,
routes_handle: Mutex<Option<JoinHandle<()>>>,
inbox_handle: Mutex<Option<JoinHandle<()>>>,
inbox: Mutex<VecDeque<IncomingMessage>>,
}
impl Poller {
@@ -20,30 +27,49 @@ impl Poller {
Arc::new(Self {
peers_handle: Mutex::new(None),
routes_handle: Mutex::new(None),
inbox_handle: Mutex::new(None),
inbox: Mutex::new(VecDeque::with_capacity(INBOX_CAPACITY)),
})
}
/// Spawn the two background loops. Cancels any previously-running tasks
/// so consecutive `start_daemon` calls don't leak handles.
pub fn start(self: &Arc<Self>, app: AppHandle, sidecar: Arc<SidecarHandle>) {
self.stop();
*self.peers_handle.lock() = Some(spawn_peers_loop(app.clone(), Arc::clone(&sidecar)));
*self.routes_handle.lock() = Some(spawn_routes_loop(app, sidecar));
*self.routes_handle.lock() = Some(spawn_routes_loop(app.clone(), Arc::clone(&sidecar)));
*self.inbox_handle.lock() = Some(spawn_inbox_loop(
app,
Arc::clone(&sidecar),
Arc::clone(self),
));
}
pub fn stop(&self) {
if let Some(h) = self.peers_handle.lock().take() {
for slot in [&self.peers_handle, &self.routes_handle, &self.inbox_handle] {
if let Some(h) = slot.lock().take() {
h.abort();
}
if let Some(h) = self.routes_handle.lock().take() {
h.abort();
}
}
pub fn inbox_snapshot(&self) -> Vec<IncomingMessage> {
self.inbox.lock().iter().cloned().collect()
}
pub fn clear_inbox(&self) {
self.inbox.lock().clear();
}
fn push_inbox(&self, msg: IncomingMessage) {
let mut buf = self.inbox.lock();
if buf.len() >= INBOX_CAPACITY {
buf.pop_front();
}
buf.push_back(msg);
}
}
fn spawn_peers_loop(app: AppHandle, sidecar: Arc<SidecarHandle>) -> JoinHandle<()> {
tokio::spawn(async move {
// Tick once immediately so the UI doesn't wait the full interval.
let mut first = true;
loop {
if !first {
@@ -87,3 +113,35 @@ fn spawn_routes_loop(app: AppHandle, sidecar: Arc<SidecarHandle>) -> JoinHandle<
}
})
}
fn spawn_inbox_loop(
app: AppHandle,
sidecar: Arc<SidecarHandle>,
me: Arc<Poller>,
) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(INBOX_INTERVAL).await;
let Some(client) = sidecar.client() else {
break;
};
// Short-poll: timeout=0 returns immediately if no message.
// We previously used a 30s long-poll, but mycelium 0.6.1's
// HTTP server appears to serialise requests behind a single
// worker — holding the connection for 30s starved every
// other endpoint (peers, routes, admin) until our own
// 10s reqwest timeout kicked in.
match client.pop_message(false, 0, None).await {
Ok(Some(msg)) => {
me.push_inbox(msg.clone());
let _ = app.emit("messages://incoming", &msg);
}
Ok(None) => {}
Err(e) => {
warn!(error = %e, "inbox: pop_message failed");
tokio::time::sleep(INBOX_RETRY_BACKOFF).await;
}
}
}
})
}
+89 -14
View File
@@ -102,8 +102,20 @@ impl SidecarHandle {
}
let bin = locate_sidecar(app)?;
let port = portpicker::pick_unused_port()
.ok_or_else(|| AppError::Other("no free port available".into()))?;
// Three ports: HTTP API (loopback), TCP listen, QUIC (UDP) listen.
// mycelium defaults to 9651 for both peer-listen ports, which
// collides if another instance (or a leftover from a previous test)
// 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])?;
// 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
// root) the new instance dies a few seconds after start. Pin this
// to a fresh ephemeral port too.
let metrics_port = pick_port_skip(&[api_port, tcp_port, quic_port])?;
let data_dir = app
.path()
@@ -115,7 +127,13 @@ impl SidecarHandle {
let mut args = vec![
"--api-addr".to_string(),
format!("127.0.0.1:{port}"),
format!("127.0.0.1:{api_port}"),
"--tcp-listen-port".to_string(),
tcp_port.to_string(),
"--quic-listen-port".to_string(),
quic_port.to_string(),
"--metrics-api-address".to_string(),
format!("127.0.0.1:{metrics_port}"),
"--key-file".to_string(),
key_path.display().to_string(),
];
@@ -137,7 +155,11 @@ impl SidecarHandle {
}
}
info!(?bin, port, "spawning mycelium sidecar via pkexec");
info!(
?bin,
api_port, tcp_port, quic_port, metrics_port,
"spawning mycelium sidecar via pkexec"
);
let mut cmd = elevation::elevated(&bin, &args);
cmd.stdout(Stdio::piped())
@@ -150,7 +172,7 @@ impl SidecarHandle {
// Stash before we await the health check, so a slow daemon
// doesn't leave us with a zombie process if anything panics.
let api_url = format!("http://127.0.0.1:{port}");
let api_url = format!("http://127.0.0.1:{api_port}");
*self.child.lock() = Some(child);
*self.api_url.lock() = Some(api_url.clone());
*self.config_path.lock() = Some(config_path);
@@ -257,30 +279,83 @@ impl SidecarHandle {
}
}
/// Resolve the bundled `mycelium-<triple>` binary in both `tauri dev`
/// (cargo manifest) and bundled (resource_dir) modes.
fn pick_port() -> AppResult<u16> {
portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into()))
}
fn pick_port_skip(taken: &[u16]) -> AppResult<u16> {
for _ in 0..16 {
let p = pick_port()?;
if !taken.contains(&p) {
return Ok(p);
}
}
Err(AppError::Other(
"could not find a unique free port".into(),
))
}
/// Resolve the bundled `mycelium` sidecar across our two build modes:
/// • `tauri dev` keeps the file under `src-tauri/binaries/` with the
/// `-<target_triple>` suffix Tauri's externalBin convention requires.
/// • `tauri build` for a `.deb` strips the suffix and places the
/// binary at `/usr/bin/mycelium` next to the app launcher.
/// We probe the bundled path first, then walk back to the dev location.
fn locate_sidecar(app: &AppHandle) -> AppResult<PathBuf> {
let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE")
.ok()
.or_else(|| option_env!("TARGET").map(|s| s.to_string()))
.unwrap_or_else(|| "x86_64-unknown-linux-gnu".to_string());
let name = format!("mycelium-{triple}");
let suffixed = format!("mycelium-{triple}");
let plain = "mycelium".to_string();
let mut tried: Vec<PathBuf> = Vec::new();
// Bundled .deb / AppImage: the launcher lives next to the sidecar
// under /usr/bin/. Resolve relative to the running executable.
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
for name in [&plain, &suffixed] {
let p = dir.join(name);
if p.exists() {
return Ok(p);
}
tried.push(p);
}
}
}
// Tauri's resource_dir() — used when externalBin is treated as a
// resource (older bundles, or when the user moves things around).
if let Ok(resource) = app.path().resource_dir() {
let p = resource.join(&name);
for name in [&plain, &suffixed] {
let p = resource.join(name);
if p.exists() {
return Ok(p);
}
tried.push(p);
}
}
// Dev mode: `pnpm tauri dev` runs the binary out of target/debug/ so
// current_exe() is far from src-tauri/binaries/. Use the manifest dir.
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for name in [&suffixed, &plain] {
let p = manifest_dir.join("binaries").join(name);
if p.exists() {
return Ok(p);
}
tried.push(p);
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let dev_path = manifest_dir.join("binaries").join(&name);
if dev_path.exists() {
return Ok(dev_path);
// Final fallback: trust $PATH if a system-installed mycelium is around.
if let Ok(path) = std::env::var("PATH") {
for entry in path.split(':') {
let p = PathBuf::from(entry).join(&plain);
if p.exists() {
return Ok(p);
}
}
}
tried.push(dev_path);
Err(AppError::SidecarNotFound(tried))
}
+5 -2
View File
@@ -29,7 +29,7 @@
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"category": "Network",
"category": "Utility",
"shortDescription": "Mycelium overlay network client",
"longDescription": "Desktop GUI for the Mycelium end-to-end encrypted IPv6 overlay network.",
"icon": [
@@ -40,7 +40,10 @@
"externalBin": ["binaries/mycelium"],
"linux": {
"deb": {
"depends": ["policykit-1"]
"depends": ["policykit-1"],
"files": {
"/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy": "packaging/polkit/tech.threefold.mycellium-ui.policy"
}
}
}
}
+8 -5
View File
@@ -13,10 +13,12 @@ import {
} from "lucide-vue-next";
import StartupOverlay from "@/components/StartupOverlay.vue";
import { useNodeStore } from "@/stores/node";
import { useConfigStore } from "@/stores/config";
import { storeToRefs } from "pinia";
const route = useRoute();
const node = useNodeStore();
const config = useConfigStore();
const { phase, info, error } = storeToRefs(node);
const navItems = [
@@ -30,8 +32,9 @@ const navItems = [
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
onMounted(() => {
node.bootstrap();
onMounted(async () => {
await config.load();
await node.bootstrap();
});
onBeforeUnmount(() => {
@@ -40,7 +43,7 @@ onBeforeUnmount(() => {
async function handleStart() {
try {
await node.start();
await node.start(config.config);
} catch {
// error already in store
}
@@ -123,8 +126,8 @@ async function handleStop() {
</main>
<StartupOverlay
v-if="phase !== 'ready' && phase !== 'idle'"
:phase="phase as 'starting' | 'error'"
v-if="phase === 'starting'"
phase="starting"
:error="error"
@start="handleStart"
@retry="handleStart"
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Send, Loader2 } from "lucide-vue-next";
import { useMessagesStore } from "@/stores/messages";
import { utf8ToBase64 } from "@/lib/utils";
import type { MessageDestination } from "@/lib/api";
const messages = useMessagesStore();
const destKind = ref<"ip" | "pk">("ip");
const destValue = ref("");
const topic = ref("");
const payload = ref("");
const submitting = ref(false);
const result = ref<{ kind: "ok" | "err"; text: string } | null>(null);
const canSubmit = computed(
() =>
destValue.value.trim().length > 0 &&
payload.value.length > 0 &&
!submitting.value,
);
async function submit() {
result.value = null;
if (!canSubmit.value) return;
submitting.value = true;
try {
const dest: MessageDestination =
destKind.value === "ip"
? { ip: destValue.value.trim() }
: { pk: destValue.value.trim() };
const topicB64 = topic.value ? utf8ToBase64(topic.value) : "";
const payloadB64 = utf8ToBase64(payload.value);
const receipt = await messages.send(dest, topicB64, payloadB64);
result.value = { kind: "ok", text: `Sent — id ${receipt.id}` };
payload.value = "";
} catch (e) {
result.value = { kind: "err", text: String(e) };
} finally {
submitting.value = false;
}
}
</script>
<template>
<form class="space-y-3 rounded-lg border border-border bg-card p-4" @submit.prevent="submit">
<div class="flex flex-col gap-1">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Destination
</label>
<div class="flex gap-2">
<select
v-model="destKind"
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm"
>
<option value="ip">IP</option>
<option value="pk">Public key</option>
</select>
<input
v-model="destValue"
type="text"
spellcheck="false"
class="flex-1 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="destKind === 'ip' ? '503:5478:df06:d79a::1' : 'hex public key'"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Topic (optional, plain text encoded as base64)
</label>
<input
v-model="topic"
type="text"
spellcheck="false"
class="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="my-topic"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Payload (UTF-8 encoded as base64)
</label>
<textarea
v-model="payload"
rows="4"
class="resize-y 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="Hello, mycelium!"
/>
</div>
<div class="flex items-center justify-between">
<p
v-if="result"
class="text-xs"
:class="result.kind === 'ok' ? 'text-emerald-500' : 'text-destructive'"
>
{{ result.text }}
</p>
<span v-else />
<button
type="submit"
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
:disabled="!canSubmit"
>
<Loader2 v-if="submitting" class="h-3.5 w-3.5 animate-spin" />
<Send v-else class="h-3.5 w-3.5" />
Send
</button>
</div>
</form>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Inbox, Trash2 } from "lucide-vue-next";
import { base64ToUtf8, isPrintableUtf8 } from "@/lib/utils";
import type { IncomingMessage } from "@/lib/api";
defineProps<{
messages: IncomingMessage[];
}>();
defineEmits<{
(e: "clear"): void;
}>();
function topicLabel(t: string): string {
return t ? (isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t}`) : "—";
}
function payloadLabel(p: string): string {
return isPrintableUtf8(p) ? base64ToUtf8(p) : `(${atob(p).length} bytes binary)`;
}
function shortIp(addr: string): string {
if (addr.length <= 28) return addr;
return `${addr.slice(0, 12)}${addr.slice(-10)}`;
}
</script>
<template>
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-4 py-2">
<div class="flex items-center gap-2">
<Inbox class="h-4 w-4 text-muted-foreground" />
<h3 class="text-sm font-medium">Inbox</h3>
<span class="text-xs text-muted-foreground">
{{ messages.length }} message{{ messages.length === 1 ? "" : "s" }}
</span>
</div>
<button
v-if="messages.length"
class="rounded p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
title="Clear inbox"
@click="$emit('clear')"
>
<Trash2 class="h-4 w-4" />
</button>
</header>
<div class="max-h-[55vh] divide-y divide-border overflow-y-auto">
<p v-if="!messages.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
No messages yet.
</p>
<article v-for="m in messages" :key="m.id" class="px-4 py-3 hover:bg-muted/30">
<div class="flex items-baseline justify-between gap-3">
<div class="text-xs text-muted-foreground">
from
<span class="font-mono">{{ shortIp(m.srcIp) }}</span>
<span class="mx-1">·</span>
topic <span class="font-mono">{{ topicLabel(m.topic) }}</span>
</div>
<code class="text-[10px] text-muted-foreground">{{ m.id }}</code>
</div>
<div class="mt-1 whitespace-pre-wrap break-all font-mono text-sm">
{{ payloadLabel(m.payload) }}
</div>
</article>
</div>
</div>
</template>
+77
View File
@@ -74,6 +74,34 @@ export interface RoutesSnapshot {
queried: QueriedSubnet[];
}
// Messages & topics
export type MessageDestination = { ip: string } | { pk: string };
export interface PushMessageReceipt {
id: string;
}
export interface IncomingMessage {
id: string;
srcIp: string;
srcPk: string;
dstIp: string;
dstPk: string;
/** base64-encoded topic bytes */
topic: string;
/** base64-encoded payload bytes */
payload: string;
}
export interface DefaultAction {
accept: boolean;
}
export interface PubkeyLookup {
NodePubKey: string;
}
export const api = {
daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
startDaemon: (config?: SidecarConfig) =>
@@ -88,6 +116,55 @@ export const api = {
peersStats: () => cmd<AggregatedStats>("peers_stats"),
routesSnapshot: () => cmd<RoutesSnapshot>("routes_snapshot"),
sendMessage: (
destination: MessageDestination,
topicB64: string,
payloadB64: string,
) =>
cmd<PushMessageReceipt>("send_message", {
destination,
topicB64,
payloadB64,
}),
replyMessage: (
id: string,
destination: MessageDestination,
topicB64: string,
payloadB64: string,
) =>
cmd<PushMessageReceipt>("reply_message", {
id,
destination,
topicB64,
payloadB64,
}),
messageStatus: (id: string) =>
cmd<Record<string, unknown>>("message_status", { id }),
inboxMessages: () => cmd<IncomingMessage[]>("inbox_messages"),
inboxClear: () => cmd<void>("inbox_clear"),
topicsDefaultGet: () => cmd<DefaultAction>("topics_default_get"),
topicsDefaultSet: (accept: boolean) =>
cmd<void>("topics_default_set", { accept }),
topicsList: () => cmd<string[]>("topics_list"),
topicAdd: (topicB64: string) => cmd<void>("topic_add", { topicB64 }),
topicRemove: (topicB64: string) => cmd<void>("topic_remove", { topicB64 }),
topicSourcesList: (topicB64: string) =>
cmd<string[]>("topic_sources_list", { topicB64 }),
topicSourceAdd: (topicB64: string, subnet: string) =>
cmd<void>("topic_source_add", { topicB64, subnet }),
topicSourceRemove: (topicB64: string, subnet: string) =>
cmd<void>("topic_source_remove", { topicB64, subnet }),
topicForwardGet: (topicB64: string) =>
cmd<string | null>("topic_forward_get", { topicB64 }),
topicForwardSet: (topicB64: string, socketPath: string) =>
cmd<void>("topic_forward_set", { topicB64, socketPath }),
topicForwardRemove: (topicB64: string) =>
cmd<void>("topic_forward_remove", { topicB64 }),
lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }),
regenerateIdentity: () => cmd<void>("regenerate_identity"),
};
/** Format the canonical peer endpoint string the API expects. */
+34
View File
@@ -21,3 +21,37 @@ export function shortenIpv6(addr: string): string {
if (addr.length <= 24) return addr;
return `${addr.slice(0, 10)}${addr.slice(-8)}`;
}
// ─── base64 helpers (UTF-8 safe) ─────────────────────────────────────────────
export function utf8ToBase64(s: string): string {
const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
export function base64ToUtf8(b64: string): string {
try {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
} catch {
return b64;
}
}
export function isPrintableUtf8(b64: string): boolean {
try {
const bin = atob(b64);
for (let i = 0; i < bin.length; i++) {
const c = bin.charCodeAt(i);
// Reject NUL and most C0 except CR/LF/TAB.
if (c === 0 || (c < 0x20 && c !== 9 && c !== 10 && c !== 13)) return false;
}
return true;
} catch {
return false;
}
}
+57
View File
@@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Store as TauriStore } from "@tauri-apps/plugin-store";
import type { SidecarConfig } from "@/lib/api";
const STORE_FILE = "config.json";
const KEY = "sidecar";
const DEFAULT_CONFIG: SidecarConfig = {
peers: [
"tcp://188.40.132.242:9651",
"quic://[2a01:4f8:212:fa6::2]:9651",
],
tunName: null,
noTun: false,
};
export const useConfigStore = defineStore("config", () => {
const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG });
const loaded = ref(false);
let store: TauriStore | null = null;
async function ensureStore() {
if (!store) store = await TauriStore.load(STORE_FILE);
return store;
}
async function load() {
const s = await ensureStore();
const saved = await s.get<SidecarConfig>(KEY);
if (saved) {
config.value = {
peers: Array.isArray(saved.peers) ? saved.peers : DEFAULT_CONFIG.peers,
tunName: saved.tunName ?? null,
noTun: !!saved.noTun,
};
}
loaded.value = true;
}
async function save(next: SidecarConfig) {
config.value = {
peers: next.peers.map((p) => p.trim()).filter(Boolean),
tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun,
};
const s = await ensureStore();
await s.set(KEY, config.value);
await s.save();
}
function reset() {
return save({ ...DEFAULT_CONFIG });
}
return { config, loaded, load, save, reset };
});
+139
View File
@@ -0,0 +1,139 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Store as TauriStore } from "@tauri-apps/plugin-store";
import {
api,
type IncomingMessage,
type MessageDestination,
} from "@/lib/api";
import { Events, on } from "@/lib/events";
const STORE_FILE = "outbox.json";
const OUTBOX_KEY = "outbox";
const OUTBOX_CAP = 100;
export interface OutboxEntry {
id: string;
destination: MessageDestination;
topicB64: string;
payloadB64: string;
sentAt: number;
/** "pending" until we resolve message_status, then daemon-reported value. */
status: string;
}
export const useMessagesStore = defineStore("messages", () => {
const inbox = ref<IncomingMessage[]>([]);
const outbox = ref<OutboxEntry[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
let store: TauriStore | null = null;
let unlisten: (() => void) | null = null;
async function ensureStore() {
if (!store) {
store = await TauriStore.load(STORE_FILE);
const saved = (await store.get<OutboxEntry[]>(OUTBOX_KEY)) ?? [];
outbox.value = saved;
}
return store;
}
async function persistOutbox() {
const s = await ensureStore();
await s.set(OUTBOX_KEY, outbox.value.slice(-OUTBOX_CAP));
await s.save();
}
async function bootstrap() {
await ensureStore();
if (!unlisten) {
unlisten = await on<IncomingMessage>(Events.MessageIncoming, (e) => {
// Emitted from src-tauri/src/poller.rs every time the daemon
// long-poll resolves with an inbound message.
inbox.value = [e.payload, ...inbox.value].slice(0, 200);
});
}
}
async function refreshInbox() {
loading.value = true;
error.value = null;
try {
// The poller's ring buffer is the source of truth; the page just
// catches up after navigation.
const list = await api.inboxMessages();
inbox.value = list.slice().reverse(); // newest first
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function clearInbox() {
await api.inboxClear();
inbox.value = [];
}
async function send(
destination: MessageDestination,
topicB64: string,
payloadB64: string,
) {
const receipt = await api.sendMessage(destination, topicB64, payloadB64);
outbox.value.push({
id: receipt.id,
destination,
topicB64,
payloadB64,
sentAt: Date.now(),
status: "pending",
});
if (outbox.value.length > OUTBOX_CAP) {
outbox.value = outbox.value.slice(-OUTBOX_CAP);
}
await persistOutbox();
return receipt;
}
async function refreshOutboxStatus(id: string) {
try {
const status = await api.messageStatus(id);
const entry = outbox.value.find((o) => o.id === id);
if (entry) {
const v = (status as { state?: unknown }).state;
entry.status = typeof v === "string" ? v : JSON.stringify(status);
await persistOutbox();
}
} catch (e) {
const entry = outbox.value.find((o) => o.id === id);
if (entry) entry.status = `error: ${String(e)}`;
}
}
async function clearOutbox() {
outbox.value = [];
await persistOutbox();
}
function dispose() {
unlisten?.();
unlisten = null;
}
return {
inbox,
outbox,
loading,
error,
bootstrap,
refreshInbox,
clearInbox,
send,
refreshOutboxStatus,
clearOutbox,
dispose,
};
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api, type DaemonStatus, type NodeInfo } from "@/lib/api";
import { api, type DaemonStatus, type NodeInfo, type SidecarConfig } from "@/lib/api";
import { Events, on } from "@/lib/events";
export type Phase = "idle" | "starting" | "ready" | "error";
@@ -41,11 +41,11 @@ export const useNodeStore = defineStore("node", () => {
}
}
async function start() {
async function start(config?: SidecarConfig) {
phase.value = "starting";
error.value = null;
try {
const s = await api.startDaemon();
const s = await api.startDaemon(config);
status.value = s;
info.value = await api.nodeInfo();
phase.value = "ready";
+89
View File
@@ -0,0 +1,89 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api, type DefaultAction } from "@/lib/api";
export const useTopicsStore = defineStore("topics", () => {
const defaultAction = ref<DefaultAction>({ accept: true });
const topics = ref<string[]>([]);
const sources = ref<Record<string, string[]>>({});
const forwards = ref<Record<string, string | null>>({});
const loading = ref(false);
const error = ref<string | null>(null);
async function refresh() {
loading.value = true;
error.value = null;
try {
defaultAction.value = await api.topicsDefaultGet();
topics.value = await api.topicsList();
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function setDefault(accept: boolean) {
await api.topicsDefaultSet(accept);
defaultAction.value = { accept };
}
async function addTopic(topicB64: string) {
await api.topicAdd(topicB64);
if (!topics.value.includes(topicB64)) topics.value.push(topicB64);
}
async function removeTopic(topicB64: string) {
await api.topicRemove(topicB64);
topics.value = topics.value.filter((t) => t !== topicB64);
delete sources.value[topicB64];
delete forwards.value[topicB64];
}
async function refreshSources(topicB64: string) {
sources.value[topicB64] = await api.topicSourcesList(topicB64);
}
async function addSource(topicB64: string, subnet: string) {
await api.topicSourceAdd(topicB64, subnet);
await refreshSources(topicB64);
}
async function removeSource(topicB64: string, subnet: string) {
await api.topicSourceRemove(topicB64, subnet);
await refreshSources(topicB64);
}
async function refreshForward(topicB64: string) {
forwards.value[topicB64] = await api.topicForwardGet(topicB64);
}
async function setForward(topicB64: string, socketPath: string) {
await api.topicForwardSet(topicB64, socketPath);
await refreshForward(topicB64);
}
async function removeForward(topicB64: string) {
await api.topicForwardRemove(topicB64);
forwards.value[topicB64] = null;
}
return {
defaultAction,
topics,
sources,
forwards,
loading,
error,
refresh,
setDefault,
addTopic,
removeTopic,
refreshSources,
addSource,
removeSource,
refreshForward,
setForward,
removeForward,
};
});
+111 -2
View File
@@ -1,5 +1,114 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { Trash2, RefreshCw } from "lucide-vue-next";
import ComposeMessage from "@/components/ComposeMessage.vue";
import MessageList from "@/components/MessageList.vue";
import { useMessagesStore, type OutboxEntry } from "@/stores/messages";
import { useNodeStore } from "@/stores/node";
import { base64ToUtf8, isPrintableUtf8 } from "@/lib/utils";
const messages = useMessagesStore();
const node = useNodeStore();
const { inbox, outbox } = storeToRefs(messages);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
onMounted(async () => {
await messages.bootstrap();
if (isReady.value) await messages.refreshInbox();
});
onBeforeUnmount(() => {
messages.dispose();
});
async function clearInbox() {
if (!confirm("Clear all received messages?")) return;
await messages.clearInbox();
}
function destLabel(d: OutboxEntry["destination"]): string {
return "ip" in d ? d.ip : d.pk;
}
function topicShort(t: string): string {
if (!t) return "—";
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 12)}`;
}
async function refreshStatus(id: string) {
await messages.refreshOutboxStatus(id);
}
async function clearOutbox() {
if (!confirm("Clear sent message history?")) return;
await messages.clearOutbox();
}
</script>
<template>
<p class="text-sm text-muted-foreground">Messages view wired in P4.</p>
<div class="grid gap-4 lg:grid-cols-2">
<section class="space-y-4">
<ComposeMessage />
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-4 py-2">
<h3 class="text-sm font-medium">Outbox</h3>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ outbox.length }}</span>
<button
v-if="outbox.length"
class="rounded p-1 hover:bg-secondary hover:text-foreground"
title="Clear outbox"
@click="clearOutbox"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</header>
<div class="max-h-[35vh] divide-y divide-border overflow-y-auto">
<p
v-if="!outbox.length"
class="px-4 py-6 text-center text-sm text-muted-foreground"
>
No sent messages.
</p>
<article
v-for="entry in outbox"
:key="entry.id"
class="flex items-start gap-3 px-4 py-2 hover:bg-muted/30"
>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2 text-xs">
<code class="text-muted-foreground">{{ entry.id }}</code>
<span class="text-muted-foreground">·</span>
<span>to <span class="font-mono">{{ destLabel(entry.destination) }}</span></span>
</div>
<div class="mt-0.5 truncate text-xs text-muted-foreground">
topic
<span class="font-mono">{{ topicShort(entry.topicB64) }}</span>
</div>
<div class="mt-0.5 text-xs">
status:
<span class="font-mono">{{ entry.status }}</span>
</div>
</div>
<button
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
title="Refresh status"
@click="refreshStatus(entry.id)"
>
<RefreshCw class="h-3.5 w-3.5" />
</button>
</article>
</div>
</div>
</section>
<section>
<MessageList :messages="inbox" @clear="clearInbox" />
</section>
</div>
</template>
+269 -2
View File
@@ -1,5 +1,272 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import {
Save,
RotateCcw,
RefreshCw,
KeyRound,
AlertTriangle,
TerminalSquare,
} from "lucide-vue-next";
import { api } from "@/lib/api";
import { useConfigStore } from "@/stores/config";
import { useNodeStore } from "@/stores/node";
const configStore = useConfigStore();
const node = useNodeStore();
const { config } = storeToRefs(configStore);
const { phase, status } = storeToRefs(node);
// Local working copy for the form. We mirror the canonical config and
// synchronise on every write to the store.
const draft = reactive({
peers: "",
tunName: "",
noTun: false,
});
const dirty = ref(false);
function loadDraft() {
draft.peers = config.value.peers.join("\n");
draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun;
dirty.value = false;
}
watch(config, loadDraft, { immediate: true });
watch(
draft,
() => {
dirty.value = true;
},
{ deep: true },
);
async function save() {
await configStore.save({
peers: draft.peers
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean),
tunName: draft.tunName.trim() || null,
noTun: draft.noTun,
});
dirty.value = false;
}
async function reset() {
if (!confirm("Restore default daemon configuration?")) return;
await configStore.reset();
}
// Identity
const regenBusy = ref(false);
const regenError = ref<string | null>(null);
async function regenerate() {
if (
!confirm(
"Regenerate identity? This deletes the current private key. Your overlay IPv6 and public key will change. Anyone with peers configured by your old IP/key will have to update them.",
)
) {
return;
}
regenBusy.value = true;
regenError.value = null;
try {
await api.regenerateIdentity();
} catch (e) {
regenError.value = String(e);
} finally {
regenBusy.value = false;
}
}
// Logs
const logs = ref<string[]>([]);
const logsLoading = ref(false);
async function refreshLogs() {
logsLoading.value = true;
try {
logs.value = await api.sidecarLogs();
} finally {
logsLoading.value = false;
}
}
const isReady = computed(() => phase.value === "ready");
onMounted(async () => {
if (isReady.value) await refreshLogs();
});
</script>
<template>
<p class="text-sm text-muted-foreground">Settings view wired in P5.</p>
<div class="grid gap-4 lg:grid-cols-2">
<!-- Daemon configuration -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium">Daemon configuration</h2>
<p class="mt-0.5 text-xs text-muted-foreground">
Applied next time the daemon starts. Currently running daemon
isn't reconfigured live.
</p>
</header>
<div class="space-y-4 px-4 py-4">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Static peers (one per line)
</label>
<textarea
v-model="draft.peers"
rows="6"
spellcheck="false"
class="mt-1 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="tcp://188.40.132.242:9651"
/>
</div>
<div class="flex items-center gap-3">
<input
id="no-tun"
v-model="draft.noTun"
type="checkbox"
class="h-4 w-4"
/>
<label for="no-tun" class="text-sm">
Disable TUN interface (<code class="font-mono text-xs">--no-tun</code>)
</label>
</div>
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
TUN interface name (optional)
</label>
<input
v-model="draft.tunName"
type="text"
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="mycelium0"
:disabled="draft.noTun"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary"
@click="reset"
>
<RotateCcw class="h-3 w-3" />
Defaults
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
:disabled="!dirty"
@click="save"
>
<Save class="h-3 w-3" />
Save
</button>
</div>
</div>
</section>
<!-- Identity -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<KeyRound class="h-4 w-4" /> Identity
</h2>
</header>
<div class="space-y-3 px-4 py-4 text-sm">
<div>
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Key file
</div>
<div class="mt-1 break-all font-mono text-xs">
{{ status?.keyPath ?? "(unknown — start the daemon at least once)" }}
</div>
</div>
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs">
<div class="flex items-start gap-2">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="space-y-2">
<p>
Regenerating discards your current private key, which means a
new overlay IPv6 and public key. This is irreversible.
</p>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1 text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:opacity-50"
:disabled="regenBusy"
@click="regenerate"
>
<RotateCcw class="h-3 w-3" />
Regenerate identity
</button>
<p v-if="regenError" class="text-destructive">{{ regenError }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- Logs -->
<section class="rounded-lg border border-border bg-card lg:col-span-2">
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<TerminalSquare class="h-4 w-4" /> Sidecar logs
</h2>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-xs hover:bg-secondary disabled:opacity-50"
:disabled="logsLoading"
@click="refreshLogs"
>
<RefreshCw
class="h-3 w-3"
:class="logsLoading ? 'animate-spin' : ''"
/>
Refresh
</button>
</header>
<div
class="max-h-72 overflow-y-auto bg-background px-4 py-2 font-mono text-[11px]"
>
<p v-if="!logs.length" class="py-4 text-center text-muted-foreground">
No log entries start the daemon to begin capturing.
</p>
<pre v-else class="whitespace-pre-wrap break-all">{{ logs.join("\n") }}</pre>
</div>
</section>
<!-- About -->
<section class="rounded-lg border border-border bg-card lg:col-span-2">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium">About</h2>
</header>
<dl class="grid grid-cols-1 gap-4 px-4 py-4 text-sm sm:grid-cols-3">
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
App
</dt>
<dd class="mt-1 font-mono text-xs">mycellium-ui 0.1.0</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
Mycelium daemon
</dt>
<dd class="mt-1 font-mono text-xs">v0.6.1 (bundled)</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
API endpoint
</dt>
<dd class="mt-1 font-mono text-xs">
{{ status?.apiUrl ?? "—" }}
</dd>
</div>
</dl>
</section>
</div>
</template>
+259 -2
View File
@@ -1,5 +1,262 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { Plus, Trash2, Hash } from "lucide-vue-next";
import { useTopicsStore } from "@/stores/topics";
import { useNodeStore } from "@/stores/node";
import { base64ToUtf8, isPrintableUtf8, utf8ToBase64 } from "@/lib/utils";
const topicsStore = useTopicsStore();
const node = useNodeStore();
const { topics, defaultAction, sources, forwards, error } = storeToRefs(topicsStore);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
const newTopic = ref("");
const selected = ref<string | null>(null);
const newSource = ref("");
const newForward = ref("");
onMounted(async () => {
if (isReady.value) await topicsStore.refresh();
});
watch(
() => topics.value,
(list) => {
if (selected.value && !list.includes(selected.value)) selected.value = null;
if (!selected.value && list.length) selected.value = list[0];
},
{ immediate: true },
);
watch(selected, async (t) => {
if (!t) return;
await Promise.all([topicsStore.refreshSources(t), topicsStore.refreshForward(t)]);
});
function topicLabel(t: string): string {
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 18)}`;
}
async function addTopic() {
const v = newTopic.value.trim();
if (!v) return;
try {
await topicsStore.addTopic(utf8ToBase64(v));
newTopic.value = "";
} catch (e) {
alert(`Could not add topic: ${e}`);
}
}
async function removeTopic(t: string) {
if (!confirm(`Remove topic "${topicLabel(t)}"?`)) return;
try {
await topicsStore.removeTopic(t);
} catch (e) {
alert(`Could not remove: ${e}`);
}
}
async function addSource() {
if (!selected.value || !newSource.value.trim()) return;
try {
await topicsStore.addSource(selected.value, newSource.value.trim());
newSource.value = "";
} catch (e) {
alert(`Could not add source: ${e}`);
}
}
async function removeSource(subnet: string) {
if (!selected.value) return;
try {
await topicsStore.removeSource(selected.value, subnet);
} catch (e) {
alert(`Could not remove source: ${e}`);
}
}
async function setForward() {
if (!selected.value || !newForward.value.trim()) return;
try {
await topicsStore.setForward(selected.value, newForward.value.trim());
newForward.value = "";
} catch (e) {
alert(`Could not set forward: ${e}`);
}
}
async function clearForward() {
if (!selected.value) return;
if (!confirm("Remove forward socket?")) return;
await topicsStore.removeForward(selected.value);
}
async function toggleDefault() {
await topicsStore.setDefault(!defaultAction.value.accept);
}
</script>
<template>
<p class="text-sm text-muted-foreground">Topics view wired in P4.</p>
<div v-if="!isReady" class="text-sm text-muted-foreground">Daemon offline.</div>
<div v-else class="grid gap-4 lg:grid-cols-[260px_1fr]">
<section class="space-y-3">
<div class="rounded-lg border border-border bg-card p-3">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Default action
</div>
<button
class="mt-2 w-full rounded-md border border-border px-3 py-2 text-sm hover:bg-secondary"
@click="toggleDefault"
>
{{ defaultAction.accept ? "Accept by default" : "Reject by default" }}
</button>
<p class="mt-2 text-[11px] text-muted-foreground">
Controls how the daemon treats topics that aren't in the whitelist
below.
</p>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-sm font-medium">Whitelisted topics</span>
<span class="text-xs text-muted-foreground">{{ topics.length }}</span>
</header>
<ul class="max-h-[40vh] divide-y divide-border overflow-y-auto">
<li v-if="!topics.length" class="px-3 py-4 text-center text-xs text-muted-foreground">
No topics yet.
</li>
<li
v-for="t in topics"
:key="t"
class="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/30"
:class="selected === t ? 'bg-secondary text-secondary-foreground' : ''"
@click="selected = t"
>
<Hash class="h-3 w-3 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">
{{ topicLabel(t) }}
</span>
<button
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click.stop="removeTopic(t)"
>
<Trash2 class="h-3 w-3" />
</button>
</li>
</ul>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newTopic"
type="text"
placeholder="topic name"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="addTopic"
/>
<button
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="addTopic"
>
<Plus class="h-3 w-3" /> Add
</button>
</footer>
</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</section>
<section v-if="selected" class="space-y-4">
<div class="rounded-lg border border-border bg-card p-4">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Selected topic
</div>
<div class="mt-1 break-all font-mono text-sm">
{{ topicLabel(selected) }}
</div>
<div class="mt-1 break-all text-[11px] text-muted-foreground">
base64: {{ selected }}
</div>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-2 text-sm font-medium">
Allowed source subnets
</header>
<ul class="divide-y divide-border">
<li
v-if="!(sources[selected]?.length)"
class="px-4 py-3 text-center text-xs text-muted-foreground"
>
No sources all subnets allowed for this topic.
</li>
<li
v-for="s in sources[selected] ?? []"
:key="s"
class="flex items-center justify-between px-4 py-2 text-sm hover:bg-muted/30"
>
<span class="font-mono text-xs">{{ s }}</span>
<button
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click="removeSource(s)"
>
<Trash2 class="h-3 w-3" />
</button>
</li>
</ul>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newSource"
type="text"
placeholder="503:5478:df06:d79a::/64"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="addSource"
/>
<button
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="addSource"
>
<Plus class="h-3 w-3" /> Allow
</button>
</footer>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-2 text-sm font-medium">
Forward socket
</header>
<div class="px-4 py-3 text-sm">
<span v-if="forwards[selected]" class="font-mono text-xs">
{{ forwards[selected] }}
</span>
<span v-else class="text-muted-foreground">No forward configured.</span>
</div>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newForward"
type="text"
placeholder="/var/run/myapp.sock"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="setForward"
/>
<button
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="setForward"
>
Set
</button>
<button
v-if="forwards[selected]"
class="rounded-md border border-border px-2 py-1 text-xs hover:bg-destructive/10 hover:text-destructive"
@click="clearForward"
>
Clear
</button>
</footer>
</div>
</section>
<section v-else class="text-sm text-muted-foreground">
Pick a topic on the left to manage its sources and forward.
</section>
</div>
</template>