/** * `useDeployment` — view-model for `Settings → Deployment` (ticket #68). * * Owns the draft exposure config, the backend-derived preview, and the embedded * server lifecycle. All behaviour lives here so `DeploymentSettings` stays * presentation-only (no `invoke()`, no network reasoning in JSX). * * **The backend owns every network fact.** LAN candidates and the proxy upstream * URL are read from `previewExposure`; nothing here derives an address. * * Two backend behaviours shape this hook: * * 1. `preview_server_exposure_settings` *validates* before previewing, so an * incomplete draft is rejected rather than previewed. That makes it the * UI's validation authority (it returns the same message `start` would), but * it also means a `remoteProxyOtherMachine` draft cannot be previewed until * it already carries a LAN bind address — which is what the preview is for. * So the LAN candidate list is fetched with a separate always-valid * `localOnly` probe; the backend builds that list independently of the mode. * 2. Only `start` reports a runtime failure, so a rejected save/start surfaces * the backend message verbatim — it is the concrete correction to apply. */ import { useCallback, useEffect, useRef, useState } from "react"; import type { DiagnosticWarning, EmbeddedServerStatus, GatewayError, ServerExposureMode, ServerExposureSettings, } from "@/domain"; import { useGateways } from "@/app/di"; /** Debounce before previewing a draft, so typing doesn't spam the backend. */ const PREVIEW_DEBOUNCE_MS = 250; function messageOf(e: unknown): string { return e && typeof e === "object" && "message" in e ? String((e as GatewayError).message) : String(e); } export interface DeploymentVm { /** False until the persisted settings have loaded. */ ready: boolean; /** The draft config being edited (persisted config until the user edits). */ settings: ServerExposureSettings | null; /** Current server status. */ status: EmbeddedServerStatus; /** LAN addresses discovered by the backend (never derived client-side). */ candidateLanAddresses: string[]; /** Backend-built upstream URL for the current draft; absent when invalid. */ upstreamUrl?: string; /** Non-fatal diagnostics for the draft — informational, never blocking. */ warnings: DiagnosticWarning[]; /** Why the draft is rejected, as told by the backend. Actionable, not decorative. */ validationError: string | null; /** Last save/start/stop/auto-start-toggle failure. */ actionError: string | null; /** True while a start/stop is in flight. */ busy: boolean; /** True while the auto-start toggle's own save is in flight (#89). */ autoStartBusy: boolean; setMode: (mode: ServerExposureMode) => void; setPublicOrigin: (origin: string) => void; setLanBindAddress: (address: string) => void; setTrustedProxies: (raw: string) => void; setPort: (port: number) => void; /** * Persists `autoStart` immediately (ticket #89) — never calls `start()`. * Unlike the other setters, this does not update the draft optimistically: * the local `settings.autoStart` only flips once the save has actually * succeeded, so a rejected save (the same validations as `start`/`save` * apply — e.g. a remote mode still missing its public origin or LAN/proxy) * leaves the checkbox showing the persisted value, not a lie. */ setAutoStart: (enabled: boolean) => Promise; /** Persists the draft, then starts the server so what runs is what is shown. */ start: () => Promise; /** Stops the running server. Never touches the persisted `autoStart` flag. */ stop: () => Promise; } export function useDeployment(): DeploymentVm { const { desktopServer } = useGateways(); const [settings, setSettings] = useState(null); const [status, setStatus] = useState({ state: "stopped", }); const [candidateLanAddresses, setCandidates] = useState([]); const [upstreamUrl, setUpstreamUrl] = useState(); const [warnings, setWarnings] = useState([]); const [validationError, setValidationError] = useState(null); const [actionError, setActionError] = useState(null); const [busy, setBusy] = useState(false); const [autoStartBusy, setAutoStartBusy] = useState(false); // Guards against a stale in-flight preview overwriting a newer one. const previewSeq = useRef(0); useEffect(() => { let alive = true; void (async () => { try { const [persisted, current] = await Promise.all([ desktopServer.getExposureSettings(), desktopServer.status(), ]); if (!alive) return; setSettings(persisted); setStatus(current); } catch (e) { if (alive) setActionError(messageOf(e)); } })(); return () => { alive = false; }; }, [desktopServer]); // LAN candidates via an always-valid `localOnly` probe (see the header note). useEffect(() => { let alive = true; void (async () => { try { const preview = await desktopServer.previewExposure({ mode: "localOnly", autoStart: false, port: 0, trustedProxies: [], }); if (alive) setCandidates(preview.candidateLanAddresses); } catch { // No candidates ⇒ the LAN field falls back to free text; the backend // still rejects a bad address on save. } })(); return () => { alive = false; }; }, [desktopServer]); useEffect(() => { let unsubscribe: (() => void) | undefined; let cancelled = false; void desktopServer.onStatusChanged(setStatus).then((u) => { // The effect may have torn down while the subscription was resolving. if (cancelled) u(); else unsubscribe = u; }); return () => { cancelled = true; unsubscribe?.(); }; }, [desktopServer]); // Preview (and thereby validate) the draft, debounced. useEffect(() => { if (!settings) return; const seq = ++previewSeq.current; const timer = setTimeout(() => { void desktopServer.previewExposure(settings).then( (preview) => { if (seq !== previewSeq.current) return; setUpstreamUrl(preview.upstreamUrl); setWarnings(preview.warnings); setValidationError(null); }, (e) => { if (seq !== previewSeq.current) return; // The draft is incomplete/invalid: the backend message *is* the fix. setUpstreamUrl(undefined); setWarnings([]); setValidationError(messageOf(e)); }, ); }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); }, [desktopServer, settings]); const patch = useCallback((change: Partial) => { setActionError(null); setSettings((prev) => (prev ? { ...prev, ...change } : prev)); }, []); const setMode = useCallback( (mode: ServerExposureMode) => { // Dropping to a narrower mode clears the fields that mode does not use, so // a stale origin/proxy can never be persisted behind the user's back. if (mode === "localOnly") { patch({ mode, publicOrigin: undefined, lanBindAddress: undefined, trustedProxies: [] }); } else if (mode === "remoteProxyLocal") { patch({ mode, lanBindAddress: undefined, trustedProxies: [] }); } else { patch({ mode }); } }, [patch], ); const setPublicOrigin = useCallback( (origin: string) => patch({ publicOrigin: origin.trim() || undefined }), [patch], ); const setLanBindAddress = useCallback( (address: string) => patch({ lanBindAddress: address || undefined }), [patch], ); const setTrustedProxies = useCallback( (raw: string) => patch({ trustedProxies: raw .split(/[\s,]+/) .map((entry) => entry.trim()) .filter(Boolean), }), [patch], ); const setPort = useCallback((port: number) => patch({ port }), [patch]); const setAutoStart = useCallback( async (enabled: boolean) => { if (!settings) return; const next = { ...settings, autoStart: enabled }; setAutoStartBusy(true); setActionError(null); try { // Persist only — never `start()`. Auto-start is applied at the next // app launch, not now. await desktopServer.saveExposureSettings(next); // Update the draft only after the save actually succeeded, so a // rejected save (e.g. a remote mode still missing its public origin) // never shows a checkbox state that was not actually persisted. setSettings(next); } catch (e) { setActionError(messageOf(e)); } finally { setAutoStartBusy(false); } }, [desktopServer, settings], ); const start = useCallback(async () => { if (!settings) return; setBusy(true); setActionError(null); try { // `start` runs the *persisted* config, so persist the draft first — // otherwise the server would run something other than what is on screen. await desktopServer.saveExposureSettings(settings); setStatus(await desktopServer.start()); } catch (e) { setActionError(messageOf(e)); } finally { setBusy(false); } }, [desktopServer, settings]); const stop = useCallback(async () => { setBusy(true); setActionError(null); try { setStatus(await desktopServer.stop()); } catch (e) { setActionError(messageOf(e)); } finally { setBusy(false); } }, [desktopServer]); return { ready: settings !== null, settings, status, candidateLanAddresses, upstreamUrl, warnings, validationError, actionError, busy, autoStartBusy, setMode, setPublicOrigin, setLanBindAddress, setTrustedProxies, setPort, setAutoStart, start, stop, }; }