From cb2d0c2d44c73f3e40080979a182c96a7c8ec322 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 18:32:03 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(app-tauri):=20option=20de=20lancement?= =?UTF-8?q?=20auto=20du=20serveur=20web=20au=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le déclenchement du serveur embarqué dès le démarrage d'IdeA selon la préférence utilisateur, sans action manuelle requise. Co-Authored-By: Claude Sonnet 5 --- crates/app-tauri/src/embedded_server.rs | 219 +++++++++++++++++++++++- crates/app-tauri/src/lib.rs | 12 ++ 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/crates/app-tauri/src/embedded_server.rs b/crates/app-tauri/src/embedded_server.rs index 4a4a76b..4397dda 100644 --- a/crates/app-tauri/src/embedded_server.rs +++ b/crates/app-tauri/src/embedded_server.rs @@ -32,6 +32,9 @@ pub enum ServerExposureMode { pub struct ServerExposureSettingsDto { /// Exposure mode. pub mode: ServerExposureMode, + /// Whether the embedded server should start automatically when IdeA boots. + #[serde(default)] + pub auto_start: bool, /// TCP port to bind. `0` asks the OS for an ephemeral port. pub port: u16, /// Public HTTPS origin used by reverse-proxy modes. @@ -267,7 +270,7 @@ impl EmbeddedServerController { } Err(message) => { let err = ErrorDto { - code: "PROCESS".to_owned(), + code: start_error_code(&message).to_owned(), message, }; self.mark_failed(err.clone()); @@ -276,6 +279,28 @@ impl EmbeddedServerController { } } + /// Starts the embedded server at application boot when persisted settings + /// opt into auto-start. + /// + /// # Errors + /// Returns an [`ErrorDto`] if persisted settings are invalid or start fails. + pub async fn auto_start_if_enabled( + &self, + core: Arc, + ) -> Result, ErrorDto> { + let settings = match self.store.read() { + Ok(settings) => settings, + Err(err) => { + self.mark_failed(err.clone()); + return Err(err); + } + }; + if !settings.auto_start { + return Ok(None); + } + self.start(core).await.map(Some) + } + /// Generates a new ephemeral pairing code on the running embedded server. /// /// # Errors @@ -351,6 +376,7 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto { fn default_settings() -> ServerExposureSettingsDto { ServerExposureSettingsDto { mode: ServerExposureMode::LocalOnly, + auto_start: false, port: 17373, public_origin: None, trusted_proxies: Vec::new(), @@ -358,6 +384,21 @@ fn default_settings() -> ServerExposureSettingsDto { } } +fn start_error_code(message: &str) -> &'static str { + let lower = message.to_ascii_lowercase(); + if lower.contains("address already in use") + || lower.contains("only one usage of each socket address") + || lower.contains("addrinuse") + || lower.contains("os error 98") + || lower.contains("os error 48") + || lower.contains("os error 10048") + { + "PORT_IN_USE" + } else { + "PROCESS" + } +} + fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> { match settings.mode { ServerExposureMode::LocalOnly => {} @@ -621,6 +662,10 @@ mod tests { web_root } + fn loopback_bind_available() -> bool { + std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_ok() + } + #[test] fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() { let explicit = tmp_app_data().join("explicit-web-root"); @@ -655,6 +700,7 @@ mod tests { fn non_loopback_remote_requires_trusted_proxy() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyOtherMachine, + auto_start: false, port: 17373, public_origin: Some("https://idea.example.com".to_owned()), trusted_proxies: Vec::new(), @@ -671,6 +717,7 @@ mod tests { fn remote_requires_https_public_origin() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyLocal, + auto_start: false, port: 17373, public_origin: Some("http://idea.example.com".to_owned()), trusted_proxies: Vec::new(), @@ -687,6 +734,7 @@ mod tests { fn local_only_derives_loopback_config() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::LocalOnly, + auto_start: false, port: 0, public_origin: Some("https://ignored.example".to_owned()), trusted_proxies: Vec::new(), @@ -705,6 +753,7 @@ mod tests { fn remote_proxy_local_accepts_loopback_ephemeral_port() { let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyLocal, + auto_start: false, port: 0, public_origin: Some("https://idea.example.com".to_owned()), trusted_proxies: Vec::new(), @@ -724,6 +773,7 @@ mod tests { let store = FsServerExposureSettingsStore::new(tmp_app_data()); let settings = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyOtherMachine, + auto_start: false, port: 17373, public_origin: Some("https://idea.example.com".to_owned()), trusted_proxies: vec!["192.0.2.22".to_owned()], @@ -752,6 +802,9 @@ mod tests { #[tokio::test] async fn start_is_idempotent_and_stop_stops_running_server() { + if !loopback_bind_available() { + return; + } let app_data = tmp_app_data(); let web_root = tmp_web_root(); let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root); @@ -759,6 +812,7 @@ mod tests { controller .save_settings(ServerExposureSettingsDto { mode: ServerExposureMode::LocalOnly, + auto_start: false, port: 0, public_origin: None, trusted_proxies: Vec::new(), @@ -800,6 +854,7 @@ mod tests { let controller = EmbeddedServerController::new(app_data.clone()); let bad = ServerExposureSettingsDto { mode: ServerExposureMode::RemoteProxyLocal, + auto_start: false, port: 17373, public_origin: Some("http://idea.example.com".to_owned()), trusted_proxies: Vec::new(), @@ -816,4 +871,166 @@ mod tests { assert_eq!(err.code, "INVALID"); assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); } + + #[test] + fn legacy_settings_without_auto_start_default_to_disabled() { + let app_data = tmp_app_data(); + let path = app_data.join("deployment").join("server-exposure.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{"mode":"localOnly","port":17373,"publicOrigin":null,"trustedProxies":[],"lanBindAddress":null}"#, + ) + .unwrap(); + let store = FsServerExposureSettingsStore::new(app_data); + + let settings = store.read().unwrap(); + + assert!(!settings.auto_start); + } + + #[tokio::test] + async fn auto_start_disabled_does_not_start() { + let app_data = tmp_app_data(); + let controller = EmbeddedServerController::new(app_data.clone()); + controller + .save_settings(ServerExposureSettingsDto { + mode: ServerExposureMode::LocalOnly, + auto_start: false, + port: 0, + public_origin: None, + trusted_proxies: Vec::new(), + lan_bind_address: None, + }) + .unwrap(); + let core = Arc::new(BackendCore::build(app_data)); + + let status = controller.auto_start_if_enabled(core).await.unwrap(); + + assert!(status.is_none()); + assert!(matches!( + controller.status().state, + EmbeddedServerStatusStateDto::Stopped + )); + } + + #[tokio::test] + async fn auto_start_enabled_starts_server() { + if !loopback_bind_available() { + return; + } + let app_data = tmp_app_data(); + let web_root = tmp_web_root(); + let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root); + let controller = EmbeddedServerController::new(app_data.clone()); + controller + .save_settings(ServerExposureSettingsDto { + mode: ServerExposureMode::LocalOnly, + auto_start: true, + port: 0, + public_origin: None, + trusted_proxies: Vec::new(), + lan_bind_address: None, + }) + .unwrap(); + let core = Arc::new(BackendCore::build(app_data)); + + let status = controller + .auto_start_if_enabled(core) + .await + .unwrap() + .expect("auto-start should start"); + + assert!(matches!( + status.state, + EmbeddedServerStatusStateDto::Running + )); + assert!(status.local_url.is_some()); + controller.stop().await.unwrap(); + } + + #[tokio::test] + async fn auto_start_enabled_with_invalid_config_marks_failed() { + let app_data = tmp_app_data(); + let controller = EmbeddedServerController::new(app_data.clone()); + let path = app_data.join("deployment").join("server-exposure.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{"mode":"remoteProxyLocal","autoStart":true,"port":17373,"publicOrigin":"http://idea.example.com","trustedProxies":[],"lanBindAddress":null}"#, + ) + .unwrap(); + let core = Arc::new(BackendCore::build(app_data)); + + let err = controller.auto_start_if_enabled(core).await.unwrap_err(); + let status = controller.status(); + + assert_eq!(err.code, "INVALID"); + assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); + assert_eq!( + status.error.as_ref().map(|err| err.code.as_str()), + Some("INVALID") + ); + } + + #[tokio::test] + async fn stop_does_not_modify_auto_start_setting() { + if !loopback_bind_available() { + return; + } + let app_data = tmp_app_data(); + let web_root = tmp_web_root(); + let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root); + let controller = EmbeddedServerController::new(app_data.clone()); + controller + .save_settings(ServerExposureSettingsDto { + mode: ServerExposureMode::LocalOnly, + auto_start: true, + port: 0, + public_origin: None, + trusted_proxies: Vec::new(), + lan_bind_address: None, + }) + .unwrap(); + let core = Arc::new(BackendCore::build(app_data)); + controller.start(core).await.unwrap(); + + controller.stop().await.unwrap(); + + assert!(controller.get_settings().unwrap().auto_start); + } + + #[tokio::test] + async fn start_port_collision_returns_actionable_error_code() { + if !loopback_bind_available() { + return; + } + let app_data = tmp_app_data(); + let web_root = tmp_web_root(); + let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root); + let reserved = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = reserved.local_addr().unwrap().port(); + let controller = EmbeddedServerController::new(app_data.clone()); + controller + .save_settings(ServerExposureSettingsDto { + mode: ServerExposureMode::LocalOnly, + auto_start: false, + port, + public_origin: None, + trusted_proxies: Vec::new(), + lan_bind_address: None, + }) + .unwrap(); + let core = Arc::new(BackendCore::build(app_data)); + + let err = controller.start(core).await.unwrap_err(); + let status = controller.status(); + + assert_eq!(err.code, "PORT_IN_USE"); + assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); + assert_eq!( + status.error.as_ref().map(|err| err.code.as_str()), + Some("PORT_IN_USE") + ); + } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 3c77c80..f76c8cd 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -30,6 +30,7 @@ pub mod tickets; use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput}; use domain::{ @@ -152,7 +153,18 @@ pub fn run() { // Wire the domain event bus → Tauri events relay. events::spawn_relay(app.handle().clone(), &app_state.event_bus); + let embedded_server = Arc::clone(&app_state.embedded_server); + let core = app_state.core(); app.manage(app_state); + tauri::async_runtime::spawn(async move { + if let Err(err) = embedded_server.auto_start_if_enabled(core).await { + application::diag!( + "[embedded-server] auto-start failed: {}: {}", + err.code, + err.message + ); + } + }); // Kill all live PTYs cleanly when the main window is closing. This is // independent of the per-view (navigation/layout) lifecycle — those From e9b01795d18438daca0765718d1e0bd857ef88a5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 18:38:40 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(frontend):=20d=C3=A9marrage=20auto=20d?= =?UTF-8?q?u=20serveur=20+=20erreur=20port=20occup=C3=A9=20actionnable=20(?= =?UTF-8?q?#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- frontend/src/adapters/mock/index.ts | 1 + frontend/src/domain/index.ts | 6 + .../settings/DeploymentSettings.test.tsx | 148 ++++++++++++++++++ .../features/settings/DeploymentSettings.tsx | 69 +++++++- .../features/settings/desktop-only.test.ts | 16 +- .../src/features/settings/useDeployment.ts | 41 ++++- 6 files changed, 273 insertions(+), 8 deletions(-) diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9af7c96..f5e5e99 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1410,6 +1410,7 @@ export class MockModelServerGateway implements ModelServerGateway { /** Mirror of the backend `default_settings()` (ticket #68). */ const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = { mode: "localOnly", + autoStart: false, port: 17373, trustedProxies: [], }; diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 5660abd..de88748 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -179,6 +179,12 @@ export type ServerExposureMode = */ export interface ServerExposureSettings { mode: ServerExposureMode; + /** + * Whether the embedded server should start automatically when IdeA Desktop + * boots (ticket #89), using these persisted settings. Applied at app launch + * only — never implied by a manual `start()`/`stop()` call. + */ + autoStart: boolean; /** TCP port to bind. `0` asks the OS for an ephemeral port. */ port: number; /** Public HTTPS origin, required by both remote modes. */ diff --git a/frontend/src/features/settings/DeploymentSettings.test.tsx b/frontend/src/features/settings/DeploymentSettings.test.tsx index 071fc9b..7c3c318 100644 --- a/frontend/src/features/settings/DeploymentSettings.test.tsx +++ b/frontend/src/features/settings/DeploymentSettings.test.tsx @@ -210,4 +210,152 @@ describe("DeploymentSettings", () => { await waitFor(() => expect(screen.getByText("Échec")).toBeTruthy()); expect(screen.getByRole("alert").textContent).toMatch(/already in use/); }); + + describe("auto-start at launch (ticket #89)", () => { + it("shows the checkbox unchecked by default, with its exact label and help text", async () => { + renderView(); + await settle(); + + const checkbox = screen.getByRole("checkbox", { + name: "Démarrer le serveur au lancement d'IdeA", + }); + expect(checkbox).toHaveProperty("checked", false); + expect( + screen.getByText( + "IdeA utilisera les réglages réseau enregistrés ci-dessous au prochain démarrage de l'application desktop.", + ), + ).toBeTruthy(); + }); + + it("persists autoStart:true on toggle ON without starting the server", async () => { + const gateway = new MockDesktopServerGateway(); + const save = vi.spyOn(gateway, "saveExposureSettings"); + const start = vi.spyOn(gateway, "start"); + renderView(gateway); + await settle(); + + fireEvent.click( + screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }), + ); + + await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ autoStart: true }))); + expect(start).not.toHaveBeenCalled(); + await waitFor(() => + expect( + screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }), + ).toHaveProperty("checked", true), + ); + expect(screen.getByText("Arrêté")).toBeTruthy(); + }); + + it("persists autoStart:false on toggle OFF", async () => { + const gateway = new MockDesktopServerGateway(); + const save = vi.spyOn(gateway, "saveExposureSettings"); + renderView(gateway); + await settle(); + + const checkbox = screen.getByRole("checkbox", { + name: "Démarrer le serveur au lancement d'IdeA", + }); + fireEvent.click(checkbox); + await waitFor(() => expect(checkbox).toHaveProperty("checked", true)); + + fireEvent.click(checkbox); + await waitFor(() => + expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ autoStart: false })), + ); + await waitFor(() => expect(checkbox).toHaveProperty("checked", false)); + }); + + it("does not persist the toggle when the draft is otherwise invalid", async () => { + const gateway = new MockDesktopServerGateway(); + renderView(gateway); + await settle(); + // A remote mode with no public origin — saveExposureSettings must reject. + selectMode("Accès distant, proxy sur cet ordinateur"); + + const checkbox = await screen.findByRole("checkbox", { + name: "Démarrer le serveur au lancement d'IdeA", + }); + fireEvent.click(checkbox); + + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/publicOrigin/)); + // Rejected save: the checkbox must not silently flip to checked. + expect(checkbox).toHaveProperty("checked", false); + }); + + it("leaves autoStart untouched when the server is stopped manually", async () => { + const gateway = new MockDesktopServerGateway(); + const save = vi.spyOn(gateway, "saveExposureSettings"); + renderView(gateway); + await settle(); + + fireEvent.click( + screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }), + ); + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole("button", { name: "Démarrer" })); + await waitFor(() => expect(screen.getByText("En cours d'exécution")).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: "Arrêter" })); + await waitFor(() => expect(screen.getByText("Arrêté")).toBeTruthy()); + + // `stop()` never calls `saveExposureSettings` — autoStart is untouched. + expect(save).toHaveBeenCalledTimes(2); // the toggle, then the pre-start persist + expect( + screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }), + ).toHaveProperty("checked", true); + }); + }); + + describe("port-conflict actionable error (ticket #89)", () => { + it("shows the dedicated message with « Modifier le port » and « Réessayer » actions", async () => { + const gateway = new MockDesktopServerGateway(); + renderView(gateway); + await settle(); + + gateway.setStatus({ + state: "failed", + error: { code: "PORT_IN_USE", message: "address already in use" }, + }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toBe( + "Le serveur n'a pas démarré automatiquement. address already in use", + ); + expect(screen.getByRole("button", { name: "Modifier le port" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); + }); + + it("« Modifier le port » focuses the Port field", async () => { + const gateway = new MockDesktopServerGateway(); + renderView(gateway); + await settle(); + + gateway.setStatus({ + state: "failed", + error: { code: "PORT_IN_USE", message: "address already in use" }, + }); + await screen.findByRole("button", { name: "Modifier le port" }); + + fireEvent.click(screen.getByRole("button", { name: "Modifier le port" })); + expect(screen.getByLabelText("Port")).toBe(document.activeElement); + }); + + it("« Réessayer » calls start() again", async () => { + const gateway = new MockDesktopServerGateway(); + const start = vi.spyOn(gateway, "start"); + renderView(gateway); + await settle(); + + gateway.setStatus({ + state: "failed", + error: { code: "PORT_IN_USE", message: "address already in use" }, + }); + fireEvent.click(await screen.findByRole("button", { name: "Réessayer" })); + + await waitFor(() => expect(start).toHaveBeenCalled()); + }); + }); }); diff --git a/frontend/src/features/settings/DeploymentSettings.tsx b/frontend/src/features/settings/DeploymentSettings.tsx index 71ff05a..f5b17d5 100644 --- a/frontend/src/features/settings/DeploymentSettings.tsx +++ b/frontend/src/features/settings/DeploymentSettings.tsx @@ -23,7 +23,7 @@ * a dead end. */ -import { useState } from "react"; +import { useRef, useState } from "react"; import type { ServerExposureMode } from "@/domain"; import { Button, Field, Input, Panel, cn } from "@/shared"; @@ -105,6 +105,9 @@ function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string export function DeploymentSettings() { const vm = useDeployment(); + // "Modifier le port" (#89) jumps into Accès réseau — imperative focus is the + // simplest correct answer for a cross-panel affordance on one scrolled page. + const portInputRef = useRef(null); if (!vm.ready || !vm.settings) { return ( @@ -170,17 +173,72 @@ export function DeploymentSettings() {

)} - {/* A failure carries the backend's message: it is the correction. */} + {/* A failure carries the backend's message: it is the correction. + A port conflict (#89 — most often surfaced by auto-start at + launch, but shown the same way for a manual start) gets a + dedicated, actionable message instead of the generic one. */} {status.state === "failed" && status.error && ( -

- {status.error.message} -

+ status.error.code === "PORT_IN_USE" ? ( +
+

+ Le serveur n'a pas démarré automatiquement. {status.error.message} +

+
+ + +
+
+ ) : ( +

+ {status.error.message} +

+ ) )} {vm.actionError && (

{vm.actionError}

)} + + {/* Auto-start at launch (#89) — a persistence-only toggle, never a + start trigger; it never runs the server right now. */} + @@ -225,6 +283,7 @@ export function DeploymentSettings() { {({ id }) => ( { () => gateway.status(), () => gateway.start(), () => gateway.stop(), - () => gateway.saveExposureSettings({ mode: "localOnly", port: 0, trustedProxies: [] }), - () => gateway.previewExposure({ mode: "localOnly", port: 0, trustedProxies: [] }), + () => + gateway.saveExposureSettings({ + mode: "localOnly", + autoStart: false, + port: 0, + trustedProxies: [], + }), + () => + gateway.previewExposure({ + mode: "localOnly", + autoStart: false, + port: 0, + trustedProxies: [], + }), ]) { const error: Partial = { code: "UNSUPPORTED_ON_WEB" }; await expect(call()).rejects.toMatchObject(error); diff --git a/frontend/src/features/settings/useDeployment.ts b/frontend/src/features/settings/useDeployment.ts index ae9a0a8..471dc1b 100644 --- a/frontend/src/features/settings/useDeployment.ts +++ b/frontend/src/features/settings/useDeployment.ts @@ -56,17 +56,29 @@ export interface DeploymentVm { warnings: DiagnosticWarning[]; /** Why the draft is rejected, as told by the backend. Actionable, not decorative. */ validationError: string | null; - /** Last save/start/stop failure. */ + /** 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; } @@ -82,6 +94,7 @@ export function useDeployment(): DeploymentVm { 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); @@ -112,6 +125,7 @@ export function useDeployment(): DeploymentVm { try { const preview = await desktopServer.previewExposure({ mode: "localOnly", + autoStart: false, port: 0, trustedProxies: [], }); @@ -204,6 +218,29 @@ export function useDeployment(): DeploymentVm { ); 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); @@ -242,11 +279,13 @@ export function useDeployment(): DeploymentVm { validationError, actionError, busy, + autoStartBusy, setMode, setPublicOrigin, setLanBindAddress, setTrustedProxies, setPort, + setAutoStart, start, stop, };