Compare commits
10 Commits
95e7cb4bd3
...
b5909ccb56
| Author | SHA1 | Date | |
|---|---|---|---|
| b5909ccb56 | |||
| a31a40a477 | |||
| 939565b88a | |||
| 7981fc571c | |||
| 9fe24c72cb | |||
| 45174ebe7d | |||
| 2cd14f06ae | |||
| 4dd278e62a | |||
| eb86fdd182 | |||
| f28d0e1338 |
@@ -2,11 +2,110 @@
|
|||||||
|
|
||||||
Cross-platform desktop GUI for [Mycelium](https://github.com/threefoldtech/mycelium) — Threefold's end-to-end encrypted IPv6 overlay network.
|
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
|
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.
|
||||||
- **Daemon**: Rust (`myceliumd`) running as system service, IPC via Unix socket / named pipe
|
|
||||||
- **Mycelium engine**: official `mycelium` binary embedded as Tauri sidecar
|
## Architecture
|
||||||
- **Targets v1**: Linux + Windows
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 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.
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.2.0",
|
"@tauri-apps/api": "^2.2.0",
|
||||||
"@tauri-apps/plugin-dialog": "^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-shell": "^2.2.0",
|
||||||
"@tauri-apps/plugin-store": "^2.2.0",
|
"@tauri-apps/plugin-store": "^2.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
Generated
-10
@@ -14,9 +14,6 @@ importers:
|
|||||||
'@tauri-apps/plugin-dialog':
|
'@tauri-apps/plugin-dialog':
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.7.0
|
version: 2.7.0
|
||||||
'@tauri-apps/plugin-log':
|
|
||||||
specifier: ^2.2.0
|
|
||||||
version: 2.8.0
|
|
||||||
'@tauri-apps/plugin-shell':
|
'@tauri-apps/plugin-shell':
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.3.5
|
version: 2.3.5
|
||||||
@@ -511,9 +508,6 @@ packages:
|
|||||||
'@tauri-apps/plugin-dialog@2.7.0':
|
'@tauri-apps/plugin-dialog@2.7.0':
|
||||||
resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==}
|
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':
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||||
|
|
||||||
@@ -1375,10 +1369,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@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':
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
c85a2f6584949bd04a2a56b7fc3a5f7ed6e99c3291dec6c7eef99e8b28a0b2be release/mycellium-ui_0.1.0_amd64.deb
|
||||||
Binary file not shown.
Generated
-289
@@ -8,17 +8,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -43,23 +32,6 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"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]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -75,12 +47,6 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -158,18 +124,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -188,30 +142,6 @@ dependencies = [
|
|||||||
"objc2",
|
"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]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.2"
|
||||||
@@ -239,40 +169,6 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
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]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
@@ -816,16 +712,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -868,15 +754,6 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fern"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -957,12 +834,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "funty"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1366,9 +1237,6 @@ name = "hashbrown"
|
|||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
@@ -1950,9 +1818,6 @@ name = "log"
|
|||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
dependencies = [
|
|
||||||
"value-bag",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -2092,7 +1957,6 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -2189,15 +2053,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -2758,26 +2613,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -2863,12 +2698,6 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "radium"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -3054,15 +2883,6 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rend"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
|
||||||
dependencies = [
|
|
||||||
"bytecheck",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -3173,52 +2993,6 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -3347,12 +3121,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "seahash"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -3653,12 +3421,6 @@ version = "0.3.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simdutf8"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -3923,12 +3685,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tap"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -4108,28 +3864,6 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-shell"
|
name = "tauri-plugin-shell"
|
||||||
version = "2.3.5"
|
version = "2.3.5"
|
||||||
@@ -4345,9 +4079,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
"itoa",
|
||||||
"libc",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -4808,12 +4540,6 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8-width"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -4838,12 +4564,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "value-bag"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -5760,15 +5480,6 @@ dependencies = [
|
|||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wyz"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
|
||||||
dependencies = [
|
|
||||||
"tap",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-log = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"store:default",
|
"store:default",
|
||||||
"log:default",
|
|
||||||
"dialog: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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod messages;
|
||||||
pub mod peers;
|
pub mod peers;
|
||||||
|
pub mod pubkey;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod topics;
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use reqwest::{Client, Response};
|
use reqwest::{Client, Response};
|
||||||
@@ -19,8 +22,15 @@ pub struct MyceliumClient {
|
|||||||
|
|
||||||
impl MyceliumClient {
|
impl MyceliumClient {
|
||||||
pub fn new(base: impl Into<String>) -> Self {
|
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()
|
let http = Client::builder()
|
||||||
|
.pool_max_idle_per_host(0)
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
|
.connect_timeout(Duration::from_secs(3))
|
||||||
.build()
|
.build()
|
||||||
.expect("reqwest client build");
|
.expect("reqwest client build");
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -93,13 +93,9 @@ fn url_encode_path_segment(s: &str) -> String {
|
|||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
for b in s.bytes() {
|
for b in s.bytes() {
|
||||||
match b {
|
match b {
|
||||||
b'a'..=b'z'
|
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
| b'A'..=b'Z'
|
out.push(b as char)
|
||||||
| b'0'..=b'9'
|
}
|
||||||
| b'-'
|
|
||||||
| b'_'
|
|
||||||
| b'.'
|
|
||||||
| b'~' => out.push(b as char),
|
|
||||||
_ => out.push_str(&format!("%{:02X}", b)),
|
_ => out.push_str(&format!("%{:02X}", b)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,17 +37,29 @@ pub struct RoutesSnapshot {
|
|||||||
|
|
||||||
impl MyceliumClient {
|
impl MyceliumClient {
|
||||||
pub async fn routes_selected(&self) -> AppResult<Vec<Route>> {
|
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
|
Self::parse(r).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn routes_fallback(&self) -> AppResult<Vec<Route>> {
|
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
|
Self::parse(r).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn routes_queried(&self) -> AppResult<Vec<QueriedSubnet>> {
|
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
|
Self::parse(r).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
use crate::api::admin::NodeInfo;
|
use crate::api::admin::NodeInfo;
|
||||||
|
use crate::api::messages::{
|
||||||
|
IncomingMessage, MessageDestination, MessageStatus, PushMessageBody, PushMessageReceipt,
|
||||||
|
};
|
||||||
use crate::api::peers::{AggregatedStats, PeerInfo};
|
use crate::api::peers::{AggregatedStats, PeerInfo};
|
||||||
|
use crate::api::pubkey::PubkeyLookup;
|
||||||
use crate::api::routes::RoutesSnapshot;
|
use crate::api::routes::RoutesSnapshot;
|
||||||
|
use crate::api::topics::DefaultAction;
|
||||||
use crate::api::MyceliumClient;
|
use crate::api::MyceliumClient;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::sidecar::SidecarConfig;
|
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> {
|
pub async fn routes_snapshot(state: State<'_, AppState>) -> AppResult<RoutesSnapshot> {
|
||||||
require_client(&state)?.routes_snapshot().await
|
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
@@ -21,7 +21,6 @@ pub fn run() {
|
|||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_log::Builder::new().build())
|
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -40,6 +39,24 @@ pub fn run() {
|
|||||||
commands::peer_remove,
|
commands::peer_remove,
|
||||||
commands::peers_stats,
|
commands::peers_stats,
|
||||||
commands::routes_snapshot,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+66
-8
@@ -1,6 +1,8 @@
|
|||||||
|
use crate::api::messages::IncomingMessage;
|
||||||
use crate::api::peers;
|
use crate::api::peers;
|
||||||
use crate::sidecar::SidecarHandle;
|
use crate::sidecar::SidecarHandle;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
@@ -9,10 +11,15 @@ use tracing::warn;
|
|||||||
|
|
||||||
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
|
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
const ROUTES_INTERVAL: Duration = Duration::from_secs(5);
|
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 {
|
pub struct Poller {
|
||||||
peers_handle: Mutex<Option<JoinHandle<()>>>,
|
peers_handle: Mutex<Option<JoinHandle<()>>>,
|
||||||
routes_handle: Mutex<Option<JoinHandle<()>>>,
|
routes_handle: Mutex<Option<JoinHandle<()>>>,
|
||||||
|
inbox_handle: Mutex<Option<JoinHandle<()>>>,
|
||||||
|
inbox: Mutex<VecDeque<IncomingMessage>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Poller {
|
impl Poller {
|
||||||
@@ -20,30 +27,49 @@ impl Poller {
|
|||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
peers_handle: Mutex::new(None),
|
peers_handle: Mutex::new(None),
|
||||||
routes_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>) {
|
pub fn start(self: &Arc<Self>, app: AppHandle, sidecar: Arc<SidecarHandle>) {
|
||||||
self.stop();
|
self.stop();
|
||||||
*self.peers_handle.lock() = Some(spawn_peers_loop(app.clone(), Arc::clone(&sidecar)));
|
*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) {
|
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] {
|
||||||
h.abort();
|
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<()> {
|
fn spawn_peers_loop(app: AppHandle, sidecar: Arc<SidecarHandle>) -> JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Tick once immediately so the UI doesn't wait the full interval.
|
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
loop {
|
loop {
|
||||||
if !first {
|
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
@@ -102,8 +102,20 @@ impl SidecarHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bin = locate_sidecar(app)?;
|
let bin = locate_sidecar(app)?;
|
||||||
let port = portpicker::pick_unused_port()
|
// Three ports: HTTP API (loopback), TCP listen, QUIC (UDP) listen.
|
||||||
.ok_or_else(|| AppError::Other("no free port available".into()))?;
|
// 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
|
let data_dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -115,7 +127,13 @@ impl SidecarHandle {
|
|||||||
|
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--api-addr".to_string(),
|
"--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-file".to_string(),
|
||||||
key_path.display().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);
|
let mut cmd = elevation::elevated(&bin, &args);
|
||||||
cmd.stdout(Stdio::piped())
|
cmd.stdout(Stdio::piped())
|
||||||
@@ -150,7 +172,7 @@ impl SidecarHandle {
|
|||||||
|
|
||||||
// Stash before we await the health check, so a slow daemon
|
// Stash before we await the health check, so a slow daemon
|
||||||
// doesn't leave us with a zombie process if anything panics.
|
// 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.child.lock() = Some(child);
|
||||||
*self.api_url.lock() = Some(api_url.clone());
|
*self.api_url.lock() = Some(api_url.clone());
|
||||||
*self.config_path.lock() = Some(config_path);
|
*self.config_path.lock() = Some(config_path);
|
||||||
@@ -257,30 +279,83 @@ impl SidecarHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the bundled `mycelium-<triple>` binary in both `tauri dev`
|
fn pick_port() -> AppResult<u16> {
|
||||||
/// (cargo manifest) and bundled (resource_dir) modes.
|
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> {
|
fn locate_sidecar(app: &AppHandle) -> AppResult<PathBuf> {
|
||||||
let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE")
|
let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE")
|
||||||
.ok()
|
.ok()
|
||||||
.or_else(|| option_env!("TARGET").map(|s| s.to_string()))
|
.or_else(|| option_env!("TARGET").map(|s| s.to_string()))
|
||||||
.unwrap_or_else(|| "x86_64-unknown-linux-gnu".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();
|
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() {
|
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() {
|
if p.exists() {
|
||||||
return Ok(p);
|
return Ok(p);
|
||||||
}
|
}
|
||||||
tried.push(p);
|
tried.push(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
// Final fallback: trust $PATH if a system-installed mycelium is around.
|
||||||
let dev_path = manifest_dir.join("binaries").join(&name);
|
if let Ok(path) = std::env::var("PATH") {
|
||||||
if dev_path.exists() {
|
for entry in path.split(':') {
|
||||||
return Ok(dev_path);
|
let p = PathBuf::from(entry).join(&plain);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tried.push(dev_path);
|
|
||||||
|
|
||||||
Err(AppError::SidecarNotFound(tried))
|
Err(AppError::SidecarNotFound(tried))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["deb", "appimage"],
|
"targets": ["deb", "appimage"],
|
||||||
"category": "Network",
|
"category": "Utility",
|
||||||
"shortDescription": "Mycelium overlay network client",
|
"shortDescription": "Mycelium overlay network client",
|
||||||
"longDescription": "Desktop GUI for the Mycelium end-to-end encrypted IPv6 overlay network.",
|
"longDescription": "Desktop GUI for the Mycelium end-to-end encrypted IPv6 overlay network.",
|
||||||
"icon": [
|
"icon": [
|
||||||
@@ -40,7 +40,10 @@
|
|||||||
"externalBin": ["binaries/mycelium"],
|
"externalBin": ["binaries/mycelium"],
|
||||||
"linux": {
|
"linux": {
|
||||||
"deb": {
|
"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
@@ -13,10 +13,12 @@ import {
|
|||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import StartupOverlay from "@/components/StartupOverlay.vue";
|
import StartupOverlay from "@/components/StartupOverlay.vue";
|
||||||
import { useNodeStore } from "@/stores/node";
|
import { useNodeStore } from "@/stores/node";
|
||||||
|
import { useConfigStore } from "@/stores/config";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const node = useNodeStore();
|
const node = useNodeStore();
|
||||||
|
const config = useConfigStore();
|
||||||
const { phase, info, error } = storeToRefs(node);
|
const { phase, info, error } = storeToRefs(node);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -30,8 +32,9 @@ const navItems = [
|
|||||||
|
|
||||||
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
|
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
node.bootstrap();
|
await config.load();
|
||||||
|
await node.bootstrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -40,7 +43,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
try {
|
try {
|
||||||
await node.start();
|
await node.start(config.config);
|
||||||
} catch {
|
} catch {
|
||||||
// error already in store
|
// error already in store
|
||||||
}
|
}
|
||||||
@@ -123,8 +126,8 @@ async function handleStop() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<StartupOverlay
|
<StartupOverlay
|
||||||
v-if="phase !== 'ready' && phase !== 'idle'"
|
v-if="phase === 'starting'"
|
||||||
:phase="phase as 'starting' | 'error'"
|
phase="starting"
|
||||||
:error="error"
|
:error="error"
|
||||||
@start="handleStart"
|
@start="handleStart"
|
||||||
@retry="handleStart"
|
@retry="handleStart"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -74,6 +74,34 @@ export interface RoutesSnapshot {
|
|||||||
queried: QueriedSubnet[];
|
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 = {
|
export const api = {
|
||||||
daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
|
daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
|
||||||
startDaemon: (config?: SidecarConfig) =>
|
startDaemon: (config?: SidecarConfig) =>
|
||||||
@@ -88,6 +116,55 @@ export const api = {
|
|||||||
peersStats: () => cmd<AggregatedStats>("peers_stats"),
|
peersStats: () => cmd<AggregatedStats>("peers_stats"),
|
||||||
|
|
||||||
routesSnapshot: () => cmd<RoutesSnapshot>("routes_snapshot"),
|
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. */
|
/** Format the canonical peer endpoint string the API expects. */
|
||||||
|
|||||||
@@ -21,3 +21,37 @@ export function shortenIpv6(addr: string): string {
|
|||||||
if (addr.length <= 24) return addr;
|
if (addr.length <= 24) return addr;
|
||||||
return `${addr.slice(0, 10)}…${addr.slice(-8)}`;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
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";
|
import { Events, on } from "@/lib/events";
|
||||||
|
|
||||||
export type Phase = "idle" | "starting" | "ready" | "error";
|
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";
|
phase.value = "starting";
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const s = await api.startDaemon();
|
const s = await api.startDaemon(config);
|
||||||
status.value = s;
|
status.value = s;
|
||||||
info.value = await api.nodeInfo();
|
info.value = await api.nodeInfo();
|
||||||
phase.value = "ready";
|
phase.value = "ready";
|
||||||
|
|||||||
@@ -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
@@ -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>
|
<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>
|
</template>
|
||||||
|
|||||||
+269
-2
@@ -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>
|
<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>
|
</template>
|
||||||
|
|||||||
+259
-2
@@ -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>
|
<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>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user