feat: pin TCP/QUIC listen ports + visible error banner

Two related improvements requested while testing the private fork:

1. Custom TCP/QUIC listen ports (SidecarConfig.tcpListenPort,
   .quicListenPort). With ephemeral ports, the daemon's peer-listen
   port changes at every restart, which makes port-forwarding on a
   home router useless after the first daemon stop. Pinning these
   keeps the same port across restarts so other private-network
   nodes can dial in reliably.

   Backend uses the configured port if Some, falls back to
   pick_port_skip otherwise. Frontend exposes two inputs in
   Settings → Daemon configuration with a help line explaining
   when to fill them.

2. Daemon-failure banner in App.vue. The previous behaviour was
   silent: a click on \"Start daemon\" that hit a backend error
   only flipped the sidebar dot to red, with no message visible.
   Now an inline banner at the top of the main content area shows
   the error, plus a \"Go to Settings\" shortcut when the message
   mentions a network config issue.
This commit is contained in:
syoul
2026-04-27 02:29:31 +02:00
parent 8b83fc10d5
commit a930c035c0
7 changed files with 183 additions and 2 deletions

View File

@@ -120,6 +120,22 @@ async function handleStop() {
<header class="flex h-14 items-center border-b border-border px-6 shrink-0">
<h1 class="text-lg font-semibold">{{ currentTitle }}</h1>
</header>
<div
v-if="phase === 'error' && error"
class="border-b border-destructive/40 bg-destructive/10 px-6 py-3"
>
<div class="flex items-start gap-3 text-sm">
<span class="font-medium text-destructive">Daemon failed to start </span>
<span class="flex-1 break-words">{{ error }}</span>
<button
v-if="error.toLowerCase().includes('network')"
class="shrink-0 rounded-md border border-destructive px-2 py-0.5 text-xs text-destructive hover:bg-destructive hover:text-destructive-foreground"
@click="$router.push('/settings')"
>
Go to Settings
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6">
<RouterView />
</div>

View File

@@ -23,6 +23,10 @@ export interface SidecarConfig {
/** UTF-8, 2..=64 bytes. Public — must match across all nodes of the
* same private overlay. */
networkName: string | null;
/** Pin TCP/QUIC listen ports so they survive a daemon restart and can
* be port-forwarded reliably. `null` ⇒ ephemeral port at each start. */
tcpListenPort: number | null;
quicListenPort: number | null;
}
export interface NetworkKeyStatus {

View File

@@ -14,8 +14,17 @@ const DEFAULT_CONFIG: SidecarConfig = {
tunName: null,
noTun: false,
networkName: null,
tcpListenPort: null,
quicListenPort: null,
};
function normalizePort(p: unknown): number | null {
if (p === null || p === undefined || p === "" || p === 0) return null;
const n = typeof p === "number" ? p : Number(p);
if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
return n;
}
export const useConfigStore = defineStore("config", () => {
const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG });
const loaded = ref(false);
@@ -35,6 +44,8 @@ export const useConfigStore = defineStore("config", () => {
tunName: saved.tunName ?? null,
noTun: !!saved.noTun,
networkName: saved.networkName ?? null,
tcpListenPort: normalizePort(saved.tcpListenPort),
quicListenPort: normalizePort(saved.quicListenPort),
};
}
loaded.value = true;
@@ -46,6 +57,8 @@ export const useConfigStore = defineStore("config", () => {
tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun,
networkName: next.networkName?.trim() ? next.networkName.trim() : null,
tcpListenPort: normalizePort(next.tcpListenPort),
quicListenPort: normalizePort(next.quicListenPort),
};
const s = await ensureStore();
await s.set(KEY, config.value);

View File

@@ -30,6 +30,8 @@ const draft = reactive({
tunName: "",
noTun: false,
networkName: "",
tcpListenPort: "",
quicListenPort: "",
});
const dirty = ref(false);
@@ -38,9 +40,19 @@ function loadDraft() {
draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun;
draft.networkName = config.value.networkName ?? "";
draft.tcpListenPort = config.value.tcpListenPort?.toString() ?? "";
draft.quicListenPort = config.value.quicListenPort?.toString() ?? "";
dirty.value = false;
}
function parsePort(s: string): number | null {
const t = s.trim();
if (!t) return null;
const n = Number(t);
if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
return n;
}
watch(config, loadDraft, { immediate: true });
watch(
@@ -60,6 +72,8 @@ async function save() {
tunName: draft.tunName.trim() || null,
noTun: draft.noTun,
networkName: draft.networkName.trim() || null,
tcpListenPort: parsePort(draft.tcpListenPort),
quicListenPort: parsePort(draft.quicListenPort),
});
dirty.value = false;
}
@@ -387,6 +401,40 @@ onMounted(async () => {
:disabled="draft.noTun"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
TCP listen port
</label>
<input
v-model="draft.tcpListenPort"
type="text"
inputmode="numeric"
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="ephemeral"
/>
</div>
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
QUIC listen port
</label>
<input
v-model="draft.quicListenPort"
type="text"
inputmode="numeric"
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="ephemeral"
/>
</div>
</div>
<p class="text-[11px] text-muted-foreground">
Pin these to a fixed value (e.g. <code class="font-mono">9651</code> for
TCP, <code class="font-mono">9652</code> for QUIC) when you port-forward
a stable port on your router so other private-network nodes can dial in
across daemon restarts. Leave empty to keep using a different ephemeral
port at every start.
</p>
<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"