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}
+
+
+ {
+ portInputRef.current?.scrollIntoView?.({
+ behavior: "smooth",
+ block: "center",
+ });
+ portInputRef.current?.focus();
+ }}
+ >
+ Modifier le port
+
+ void vm.start()}
+ disabled={vm.busy}
+ >
+ Réessayer
+
+
+
+ ) : (
+
+ {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. */}
+
+ void vm.setAutoStart(e.target.checked)}
+ />
+
+
+ Démarrer le serveur au lancement d'IdeA
+
+
+ IdeA utilisera les réglages réseau enregistrés ci-dessous au
+ prochain démarrage de l'application desktop.
+
+
+
@@ -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,
};