Étend ServerExposureSettings avec autoStart (défaut false, mock aligné sur le backend). useDeployment expose setAutoStart, qui persiste immédiatement le draft sans jamais appeler start() ni toucher le comportement de stop(), et ne met à jour l'état local qu'après confirmation de la sauvegarde (même piste de validation que start/save). DeploymentSettings ajoute la case à cocher sous le panneau Serveur, et des actions « Modifier le port »/« Réessayer » quand le statut échoue avec le code PORT_IN_USE stabilisé côté backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
293 lines
9.8 KiB
TypeScript
293 lines
9.8 KiB
TypeScript
/**
|
|
* `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<void>;
|
|
/** Persists the draft, then starts the server so what runs is what is shown. */
|
|
start: () => Promise<void>;
|
|
/** Stops the running server. Never touches the persisted `autoStart` flag. */
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
export function useDeployment(): DeploymentVm {
|
|
const { desktopServer } = useGateways();
|
|
const [settings, setSettings] = useState<ServerExposureSettings | null>(null);
|
|
const [status, setStatus] = useState<EmbeddedServerStatus>({
|
|
state: "stopped",
|
|
});
|
|
const [candidateLanAddresses, setCandidates] = useState<string[]>([]);
|
|
const [upstreamUrl, setUpstreamUrl] = useState<string | undefined>();
|
|
const [warnings, setWarnings] = useState<DiagnosticWarning[]>([]);
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(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<ServerExposureSettings>) => {
|
|
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,
|
|
};
|
|
}
|