P5: settings, persistence, polkit packaging, README

Backend
- regenerate_identity command stops the daemon, deletes
  priv_key.bin, leaves the user to restart for a fresh identity;
  falls back to the canonical XDG path when sidecar.key_path()
  isn't populated yet
- tauri.conf.json ships the polkit policy via deb.files mapping;
  src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy
  declares the spawn action with auth_admin_keep so the dialog
  appears once per session

Frontend
- config store persists SidecarConfig (peers, tunName, noTun)
  through tauri-plugin-store; App.vue reads it and forwards to
  start_daemon, replacing the hard-coded defaults
- Settings view: daemon-config form, identity panel with the
  destructive regenerate button, sidecar log viewer, About
- README rewritten end-to-end: HTTP-loopback architecture, polkit
  install path, build commands, verification matrix, and a
  honest "known limitations" section
This commit is contained in:
syoul
2026-04-25 23:15:35 +02:00
parent f28d0e1338
commit eb86fdd182
10 changed files with 519 additions and 15 deletions

View File

@@ -13,10 +13,12 @@ import {
} from "lucide-vue-next";
import StartupOverlay from "@/components/StartupOverlay.vue";
import { useNodeStore } from "@/stores/node";
import { useConfigStore } from "@/stores/config";
import { storeToRefs } from "pinia";
const route = useRoute();
const node = useNodeStore();
const config = useConfigStore();
const { phase, info, error } = storeToRefs(node);
const navItems = [
@@ -30,8 +32,9 @@ const navItems = [
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
onMounted(() => {
node.bootstrap();
onMounted(async () => {
await config.load();
await node.bootstrap();
});
onBeforeUnmount(() => {
@@ -40,7 +43,7 @@ onBeforeUnmount(() => {
async function handleStart() {
try {
await node.start();
await node.start(config.config);
} catch {
// error already in store
}

View File

@@ -164,6 +164,7 @@ export const api = {
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. */

57
src/stores/config.ts Normal file
View File

@@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Store as TauriStore } from "@tauri-apps/plugin-store";
import type { SidecarConfig } from "@/lib/api";
const STORE_FILE = "config.json";
const KEY = "sidecar";
const DEFAULT_CONFIG: SidecarConfig = {
peers: [
"tcp://188.40.132.242:9651",
"quic://[2a01:4f8:212:fa6::2]:9651",
],
tunName: null,
noTun: false,
};
export const useConfigStore = defineStore("config", () => {
const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG });
const loaded = ref(false);
let store: TauriStore | null = null;
async function ensureStore() {
if (!store) store = await TauriStore.load(STORE_FILE);
return store;
}
async function load() {
const s = await ensureStore();
const saved = await s.get<SidecarConfig>(KEY);
if (saved) {
config.value = {
peers: Array.isArray(saved.peers) ? saved.peers : DEFAULT_CONFIG.peers,
tunName: saved.tunName ?? null,
noTun: !!saved.noTun,
};
}
loaded.value = true;
}
async function save(next: SidecarConfig) {
config.value = {
peers: next.peers.map((p) => p.trim()).filter(Boolean),
tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun,
};
const s = await ensureStore();
await s.set(KEY, config.value);
await s.save();
}
function reset() {
return save({ ...DEFAULT_CONFIG });
}
return { config, loaded, load, save, reset };
});

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api, type DaemonStatus, type NodeInfo } from "@/lib/api";
import { api, type DaemonStatus, type NodeInfo, type SidecarConfig } from "@/lib/api";
import { Events, on } from "@/lib/events";
export type Phase = "idle" | "starting" | "ready" | "error";
@@ -41,11 +41,11 @@ export const useNodeStore = defineStore("node", () => {
}
}
async function start() {
async function start(config?: SidecarConfig) {
phase.value = "starting";
error.value = null;
try {
const s = await api.startDaemon();
const s = await api.startDaemon(config);
status.value = s;
info.value = await api.nodeInfo();
phase.value = "ready";

View File

@@ -1,5 +1,272 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import {
Save,
RotateCcw,
RefreshCw,
KeyRound,
AlertTriangle,
TerminalSquare,
} from "lucide-vue-next";
import { api } from "@/lib/api";
import { useConfigStore } from "@/stores/config";
import { useNodeStore } from "@/stores/node";
const configStore = useConfigStore();
const node = useNodeStore();
const { config } = storeToRefs(configStore);
const { phase, status } = storeToRefs(node);
// Local working copy for the form. We mirror the canonical config and
// synchronise on every write to the store.
const draft = reactive({
peers: "",
tunName: "",
noTun: false,
});
const dirty = ref(false);
function loadDraft() {
draft.peers = config.value.peers.join("\n");
draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun;
dirty.value = false;
}
watch(config, loadDraft, { immediate: true });
watch(
draft,
() => {
dirty.value = true;
},
{ deep: true },
);
async function save() {
await configStore.save({
peers: draft.peers
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean),
tunName: draft.tunName.trim() || null,
noTun: draft.noTun,
});
dirty.value = false;
}
async function reset() {
if (!confirm("Restore default daemon configuration?")) return;
await configStore.reset();
}
// ─── Identity ───────────────────────────────────────────────────────────────
const regenBusy = ref(false);
const regenError = ref<string | null>(null);
async function regenerate() {
if (
!confirm(
"Regenerate identity? This deletes the current private key. Your overlay IPv6 and public key will change. Anyone with peers configured by your old IP/key will have to update them.",
)
) {
return;
}
regenBusy.value = true;
regenError.value = null;
try {
await api.regenerateIdentity();
} catch (e) {
regenError.value = String(e);
} finally {
regenBusy.value = false;
}
}
// ─── Logs ───────────────────────────────────────────────────────────────────
const logs = ref<string[]>([]);
const logsLoading = ref(false);
async function refreshLogs() {
logsLoading.value = true;
try {
logs.value = await api.sidecarLogs();
} finally {
logsLoading.value = false;
}
}
const isReady = computed(() => phase.value === "ready");
onMounted(async () => {
if (isReady.value) await refreshLogs();
});
</script>
<template>
<p class="text-sm text-muted-foreground">Settings view wired in P5.</p>
<div class="grid gap-4 lg:grid-cols-2">
<!-- Daemon configuration -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium">Daemon configuration</h2>
<p class="mt-0.5 text-xs text-muted-foreground">
Applied next time the daemon starts. Currently running daemon
isn't reconfigured live.
</p>
</header>
<div class="space-y-4 px-4 py-4">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Static peers (one per line)
</label>
<textarea
v-model="draft.peers"
rows="6"
spellcheck="false"
class="mt-1 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="tcp://188.40.132.242:9651"
/>
</div>
<div class="flex items-center gap-3">
<input
id="no-tun"
v-model="draft.noTun"
type="checkbox"
class="h-4 w-4"
/>
<label for="no-tun" class="text-sm">
Disable TUN interface (<code class="font-mono text-xs">--no-tun</code>)
</label>
</div>
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
TUN interface name (optional)
</label>
<input
v-model="draft.tunName"
type="text"
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="mycelium0"
:disabled="draft.noTun"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary"
@click="reset"
>
<RotateCcw class="h-3 w-3" />
Defaults
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
:disabled="!dirty"
@click="save"
>
<Save class="h-3 w-3" />
Save
</button>
</div>
</div>
</section>
<!-- Identity ───────────────────────────────────────────────────────── -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<KeyRound class="h-4 w-4" /> Identity
</h2>
</header>
<div class="space-y-3 px-4 py-4 text-sm">
<div>
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Key file
</div>
<div class="mt-1 break-all font-mono text-xs">
{{ status?.keyPath ?? "(unknown — start the daemon at least once)" }}
</div>
</div>
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs">
<div class="flex items-start gap-2">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="space-y-2">
<p>
Regenerating discards your current private key, which means a
new overlay IPv6 and public key. This is irreversible.
</p>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1 text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:opacity-50"
:disabled="regenBusy"
@click="regenerate"
>
<RotateCcw class="h-3 w-3" />
Regenerate identity
</button>
<p v-if="regenError" class="text-destructive">{{ regenError }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- Logs ──────────────────────────────────────────────────────────── -->
<section class="rounded-lg border border-border bg-card lg:col-span-2">
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<TerminalSquare class="h-4 w-4" /> Sidecar logs
</h2>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-xs hover:bg-secondary disabled:opacity-50"
:disabled="logsLoading"
@click="refreshLogs"
>
<RefreshCw
class="h-3 w-3"
:class="logsLoading ? 'animate-spin' : ''"
/>
Refresh
</button>
</header>
<div
class="max-h-72 overflow-y-auto bg-background px-4 py-2 font-mono text-[11px]"
>
<p v-if="!logs.length" class="py-4 text-center text-muted-foreground">
No log entries — start the daemon to begin capturing.
</p>
<pre v-else class="whitespace-pre-wrap break-all">{{ logs.join("\n") }}</pre>
</div>
</section>
<!-- About ────────────────────────────────────────────────────────── -->
<section class="rounded-lg border border-border bg-card lg:col-span-2">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium">About</h2>
</header>
<dl class="grid grid-cols-1 gap-4 px-4 py-4 text-sm sm:grid-cols-3">
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
App
</dt>
<dd class="mt-1 font-mono text-xs">mycellium-ui 0.1.0</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
Mycelium daemon
</dt>
<dd class="mt-1 font-mono text-xs">v0.6.1 (bundled)</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
API endpoint
</dt>
<dd class="mt-1 font-mono text-xs">
{{ status?.apiUrl ?? "" }}
</dd>
</div>
</dl>
</section>
</div>
</template>