P1: sidecar lifecycle and HTTP bridge

Backend
- sidecar.rs supervises the bundled `mycelium` binary launched via
  pkexec; locates it in resource_dir or CARGO_MANIFEST_DIR/binaries
  matching $TAURI_ENV_TARGET_TRIPLE
- ephemeral port via portpicker, key + config persisted in
  app_data_dir, kill_on_drop with explicit start_kill on stop
- health-check loop calls /api/v1/admin until 2xx (timeout 20s);
  emits sidecar://ready and sidecar://exited
- 500-line ring buffer of stdout/stderr surfaced via sidecar_logs
  command for the upcoming Settings page
- elevation::is_auth_failure(126|127) maps pkexec cancel to a
  dedicated AppError variant
- AppError uses thiserror, Serialize impl renders messages as
  plain strings for the JS side

Frontend
- typed `api` wrapper around invoke() in src/lib/api.ts
- node store (Pinia) bootstraps on mount, listens on
  sidecar://ready and sidecar://exited
- StartupOverlay covers the whole window for idle/starting/error
  phases; sidebar status dot + start/stop button
- Status view renders subnet, pubkey, api endpoint and key path
  with one-click clipboard copy
This commit is contained in:
syoul
2026-04-25 22:45:52 +02:00
parent d79300caf8
commit d737231123
16 changed files with 950 additions and 14 deletions
+100
View File
@@ -0,0 +1,100 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api, type DaemonStatus, type NodeInfo } from "@/lib/api";
import { Events, on } from "@/lib/events";
export type Phase = "idle" | "starting" | "ready" | "error";
export const useNodeStore = defineStore("node", () => {
const phase = ref<Phase>("idle");
const status = ref<DaemonStatus | null>(null);
const info = ref<NodeInfo | null>(null);
const error = ref<string | null>(null);
let exitedUnlisten: (() => void) | null = null;
let readyUnlisten: (() => void) | null = null;
async function bootstrap() {
if (!exitedUnlisten) {
exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
error.value = `daemon exited (code ${e.payload})`;
phase.value = "error";
status.value = { running: false, apiUrl: null, keyPath: null, configPath: null };
info.value = null;
});
}
if (!readyUnlisten) {
readyUnlisten = await on<string>(Events.SidecarReady, async () => {
await refresh();
});
}
const cur = await api.daemonStatus();
status.value = cur;
if (cur.running) {
phase.value = "ready";
try {
info.value = await api.nodeInfo();
} catch (e) {
error.value = String(e);
}
}
}
async function start() {
phase.value = "starting";
error.value = null;
try {
const s = await api.startDaemon();
status.value = s;
info.value = await api.nodeInfo();
phase.value = "ready";
} catch (e) {
error.value = String(e);
phase.value = "error";
throw e;
}
}
async function stop() {
try {
const s = await api.stopDaemon();
status.value = s;
info.value = null;
phase.value = "idle";
} catch (e) {
error.value = String(e);
throw e;
}
}
async function refresh() {
status.value = await api.daemonStatus();
if (status.value.running) {
info.value = await api.nodeInfo();
phase.value = "ready";
} else {
info.value = null;
phase.value = "idle";
}
}
function dispose() {
exitedUnlisten?.();
readyUnlisten?.();
exitedUnlisten = null;
readyUnlisten = null;
}
return {
phase,
status,
info,
error,
bootstrap,
start,
stop,
refresh,
dispose,
};
});