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 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, };