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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
57
src/stores/config.ts
Normal 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 };
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user