diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0672bcb..4ac33ad 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -61,6 +61,9 @@ use crate::dto::{ UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; +use crate::embedded_server::{ + EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, +}; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::{AppState, FocusedProjectDto}; use domain::{SkillRef, SkillScope}; @@ -83,6 +86,69 @@ pub fn health( .map_err(ErrorDto::from) } +/// `get_server_exposure_settings` — read persisted embedded-server exposure settings. +/// +/// # Errors +/// Returns an [`ErrorDto`] when persisted settings are invalid. +#[tauri::command] +pub fn get_server_exposure_settings( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.get_settings() +} + +/// `save_server_exposure_settings` — validate and persist embedded-server exposure settings. +/// +/// # Errors +/// Returns an [`ErrorDto`] when settings are invalid or cannot be written. +#[tauri::command] +pub fn save_server_exposure_settings( + settings: ServerExposureSettingsDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state.embedded_server.save_settings(settings) +} + +/// `preview_server_exposure_settings` — derive LAN candidates and upstream URL. +/// +/// # Errors +/// Returns an [`ErrorDto`] when settings are invalid. +#[tauri::command] +pub fn preview_server_exposure_settings( + settings: ServerExposureSettingsDto, + state: State<'_, AppState>, +) -> Result { + state.embedded_server.preview(settings) +} + +/// `embedded_server_status` — return the current embedded server status. +#[tauri::command] +pub fn embedded_server_status(state: State<'_, AppState>) -> EmbeddedServerStatusDto { + state.embedded_server.status() +} + +/// `embedded_server_start` — start the embedded server with the shared backend core. +/// +/// # Errors +/// Returns an [`ErrorDto`] when settings are invalid or the listener cannot start. +#[tauri::command] +pub async fn embedded_server_start( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.start(state.core()).await +} + +/// `embedded_server_stop` — stop the embedded server. +/// +/// # Errors +/// Returns an [`ErrorDto`] when stopping the listener fails. +#[tauri::command] +pub async fn embedded_server_stop( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.stop().await +} + /// `create_project` — create a project from a root: init `.ideai/`, register it. /// /// # Errors diff --git a/crates/app-tauri/src/embedded_server.rs b/crates/app-tauri/src/embedded_server.rs new file mode 100644 index 0000000..56391d0 --- /dev/null +++ b/crates/app-tauri/src/embedded_server.rs @@ -0,0 +1,756 @@ +//! Desktop adapter for the embedded HTTP server lifecycle and exposure config. +//! +//! This module stays in the Tauri adapter layer: it owns persisted exposure +//! settings, starts/stops the embedded web server, and never crosses into the +//! domain/application layers. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use backend::BackendCore; +use serde::{Deserialize, Serialize}; +use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy}; + +use crate::dto::ErrorDto; + +/// Remote exposure mode selected by the desktop settings UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ServerExposureMode { + /// Bind only loopback and serve local HTTP. + LocalOnly, + /// Bind loopback and expect a same-machine HTTPS reverse proxy. + RemoteProxyLocal, + /// Bind a LAN address and expect a trusted reverse proxy on another host. + RemoteProxyOtherMachine, +} + +/// Persisted server exposure settings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerExposureSettingsDto { + /// Exposure mode. + pub mode: ServerExposureMode, + /// TCP port to bind. `0` asks the OS for an ephemeral port. + pub port: u16, + /// Public HTTPS origin used by reverse-proxy modes. + pub public_origin: Option, + /// Authorized proxy peers as IPs or CIDR ranges. + #[serde(default)] + pub trusted_proxies: Vec, + /// LAN bind address for `remoteProxyOtherMachine`. + pub lan_bind_address: Option, +} + +/// Non-fatal diagnostic shown by the settings UI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticWarningDto { + /// Stable warning code. + pub code: String, + /// Human-readable warning. + pub message: String, +} + +/// Derived exposure preview for the desktop UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerExposurePreviewDto { + /// Candidate LAN addresses discovered by the backend. + pub candidate_lan_addresses: Vec, + /// URL the reverse proxy should use as its upstream. + pub upstream_url: Option, + /// Non-fatal diagnostics. + pub warnings: Vec, +} + +/// Current embedded server lifecycle state exposed to the desktop UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EmbeddedServerStatusStateDto { + /// No embedded server is running. + Stopped, + /// Start is in progress. + Starting, + /// Server is running. + Running, + /// Stop is in progress. + Stopping, + /// Last start/stop failed. + Failed, +} + +/// Embedded server status exposed to the desktop UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbeddedServerStatusDto { + /// Lifecycle state. + pub state: EmbeddedServerStatusStateDto, + /// Local URL, when running. + pub local_url: Option, + /// Public URL, when configured. + pub public_url: Option, + /// Reverse-proxy upstream URL derived from settings. + pub upstream_url: Option, + /// Runtime pairing code, never persisted. + pub pairing_code: Option, + /// Last failure, when state is `failed`. + pub error: Option, +} + +#[derive(Default)] +struct EmbeddedServerInner { + handle: Option, + state: EmbeddedServerStatusStateDto, + upstream_url: Option, + public_url: Option, + error: Option, +} + +impl Default for EmbeddedServerStatusStateDto { + fn default() -> Self { + Self::Stopped + } +} + +/// Filesystem-backed exposure settings store. +#[derive(Debug, Clone)] +pub struct FsServerExposureSettingsStore { + path: PathBuf, +} + +impl FsServerExposureSettingsStore { + /// Creates a store at `/deployment/server-exposure.json`. + #[must_use] + pub fn new(app_data_dir: PathBuf) -> Self { + Self { + path: app_data_dir.join("deployment").join("server-exposure.json"), + } + } + + /// Reads persisted settings, or returns defaults when no file exists. + /// + /// # Errors + /// Returns an [`ErrorDto`] when the file exists but is invalid. + pub fn read(&self) -> Result { + if !self.path.exists() { + return Ok(default_settings()); + } + let bytes = std::fs::read(&self.path).map_err(io_error)?; + let settings = serde_json::from_slice::(&bytes) + .map_err(|err| invalid_error(format!("invalid server exposure settings: {err}")))?; + validate_settings(&settings)?; + Ok(settings) + } + + /// Validates and atomically writes settings with user-only permissions when + /// the platform supports them. + /// + /// # Errors + /// Returns an [`ErrorDto`] when validation or persistence fails. + pub fn write(&self, settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> { + validate_settings(settings)?; + let bytes = serde_json::to_vec_pretty(settings).map_err(|err| ErrorDto { + code: "INTERNAL".to_owned(), + message: err.to_string(), + })?; + write_atomic_user_only(&self.path, &bytes).map_err(io_error) + } +} + +/// Desktop-owned embedded server manager. +pub struct EmbeddedServerController { + app_data_dir: PathBuf, + store: FsServerExposureSettingsStore, + inner: Mutex, +} + +impl EmbeddedServerController { + /// Creates the controller. + #[must_use] + pub fn new(app_data_dir: PathBuf) -> Self { + Self { + store: FsServerExposureSettingsStore::new(app_data_dir.clone()), + app_data_dir, + inner: Mutex::new(EmbeddedServerInner::default()), + } + } + + /// Returns the current status. + #[must_use] + pub fn status(&self) -> EmbeddedServerStatusDto { + let inner = self.inner.lock().expect("embedded server mutex poisoned"); + status_from_inner(&inner) + } + + /// Reads persisted exposure settings. + /// + /// # Errors + /// Returns an [`ErrorDto`] if persisted settings are invalid. + pub fn get_settings(&self) -> Result { + self.store.read() + } + + /// Persists exposure settings. + /// + /// # Errors + /// Returns an [`ErrorDto`] if settings are invalid or cannot be written. + pub fn save_settings(&self, settings: ServerExposureSettingsDto) -> Result<(), ErrorDto> { + self.store.write(&settings) + } + + /// Builds a preview for the provided settings without starting the server. + /// + /// # Errors + /// Returns an [`ErrorDto`] if settings are invalid. + pub fn preview( + &self, + settings: ServerExposureSettingsDto, + ) -> Result { + validate_settings(&settings)?; + Ok(preview_settings(&settings)) + } + + /// Starts the embedded server. Idempotent when already running/starting. + /// + /// # Errors + /// Returns an [`ErrorDto`] if settings are invalid or the listener fails. + pub async fn start(&self, core: Arc) -> Result { + { + let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); + if inner.handle.is_some() || inner.state == EmbeddedServerStatusStateDto::Starting { + return Ok(status_from_inner(&inner)); + } + inner.state = EmbeddedServerStatusStateDto::Starting; + inner.error = None; + } + + let settings = match self.store.read() { + Ok(settings) => settings, + Err(err) => { + self.mark_failed(err.clone()); + return Err(err); + } + }; + let preview = preview_settings(&settings); + let config = match server_config_from_settings( + &settings, + self.app_data_dir.clone(), + resolve_web_root()?, + ) { + Ok(config) => config, + Err(err) => { + self.mark_failed(err.clone()); + return Err(err); + } + }; + + match web_server::run_embedded_with_core(config, core).await { + Ok(handle) => { + let status = { + let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); + inner.public_url = settings.public_origin.clone(); + inner.upstream_url = preview.upstream_url; + inner.state = EmbeddedServerStatusStateDto::Running; + inner.error = None; + inner.handle = Some(handle); + status_from_inner(&inner) + }; + Ok(status) + } + Err(message) => { + let err = ErrorDto { + code: "PROCESS".to_owned(), + message, + }; + self.mark_failed(err.clone()); + Err(err) + } + } + } + + /// Stops the embedded server. Idempotent when already stopped. + /// + /// # Errors + /// Returns an [`ErrorDto`] when stopping the listener task fails. + pub async fn stop(&self) -> Result { + let handle = { + let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); + let Some(handle) = inner.handle.take() else { + inner.state = EmbeddedServerStatusStateDto::Stopped; + inner.public_url = None; + inner.upstream_url = None; + return Ok(status_from_inner(&inner)); + }; + inner.state = EmbeddedServerStatusStateDto::Stopping; + handle + }; + + if let Err(message) = handle.stop().await { + let err = ErrorDto { + code: "PROCESS".to_owned(), + message, + }; + self.mark_failed(err.clone()); + return Err(err); + } + + let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); + inner.state = EmbeddedServerStatusStateDto::Stopped; + inner.public_url = None; + inner.upstream_url = None; + inner.error = None; + Ok(status_from_inner(&inner)) + } + + fn mark_failed(&self, err: ErrorDto) { + let mut inner = self.inner.lock().expect("embedded server mutex poisoned"); + inner.handle = None; + inner.state = EmbeddedServerStatusStateDto::Failed; + inner.public_url = None; + inner.upstream_url = None; + inner.error = Some(err); + } +} + +fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto { + let error = (inner.state == EmbeddedServerStatusStateDto::Failed) + .then(|| inner.error.clone()) + .flatten(); + EmbeddedServerStatusDto { + state: inner.state, + local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()), + public_url: inner.public_url.clone(), + upstream_url: inner.upstream_url.clone(), + pairing_code: inner + .handle + .as_ref() + .map(|handle| handle.pairing_code().to_owned()), + error, + } +} + +fn default_settings() -> ServerExposureSettingsDto { + ServerExposureSettingsDto { + mode: ServerExposureMode::LocalOnly, + port: 17373, + public_origin: None, + trusted_proxies: Vec::new(), + lan_bind_address: None, + } +} + +fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> { + match settings.mode { + ServerExposureMode::LocalOnly => {} + ServerExposureMode::RemoteProxyLocal => { + let origin = require_https_origin(settings)?; + validate_origin_shape(origin)?; + } + ServerExposureMode::RemoteProxyOtherMachine => { + let origin = require_https_origin(settings)?; + validate_origin_shape(origin)?; + let Some(addr) = &settings.lan_bind_address else { + return Err(invalid_error( + "remoteProxyOtherMachine requires lanBindAddress", + )); + }; + let ip = parse_ip(addr, "lanBindAddress")?; + if ip.is_loopback() || ip.is_unspecified() { + return Err(invalid_error( + "remoteProxyOtherMachine requires a concrete LAN bind address", + )); + } + if settings.trusted_proxies.is_empty() { + return Err(invalid_error( + "remoteProxyOtherMachine requires at least one trustedProxies entry", + )); + } + } + } + for proxy in &settings.trusted_proxies { + TrustedProxy::parse(proxy).map_err(invalid_error)?; + } + Ok(()) +} + +fn require_https_origin(settings: &ServerExposureSettingsDto) -> Result<&str, ErrorDto> { + let Some(origin) = settings.public_origin.as_deref() else { + return Err(invalid_error("remote exposure requires publicOrigin")); + }; + if !origin.starts_with("https://") { + return Err(invalid_error( + "remote exposure requires publicOrigin to start with https://", + )); + } + Ok(origin) +} + +fn validate_origin_shape(origin: &str) -> Result<(), ErrorDto> { + if origin == "*" || origin.contains('*') { + return Err(invalid_error("publicOrigin must be exact, not a wildcard")); + } + if origin.ends_with('/') { + return Err(invalid_error("publicOrigin must not end with '/'")); + } + Ok(()) +} + +fn server_config_from_settings( + settings: &ServerExposureSettingsDto, + app_data_dir: PathBuf, + web_root: PathBuf, +) -> Result { + validate_settings(settings)?; + let listen_ip = match settings.mode { + ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => { + IpAddr::V4(Ipv4Addr::LOCALHOST) + } + ServerExposureMode::RemoteProxyOtherMachine => parse_ip( + settings.lan_bind_address.as_deref().unwrap_or_default(), + "lanBindAddress", + )?, + }; + let allow_remote = settings.mode != ServerExposureMode::LocalOnly; + let trusted_proxies = settings + .trusted_proxies + .iter() + .map(|proxy| TrustedProxy::parse(proxy).map_err(invalid_error)) + .collect::, _>>()?; + let config = ServerConfig { + listen: SocketAddr::new(listen_ip, settings.port), + public_origin: allow_remote + .then(|| settings.public_origin.clone()) + .flatten(), + allow_remote, + trust_reverse_proxy: allow_remote, + trusted_proxies, + app_data_dir, + web_root, + }; + config.validate().map_err(invalid_error)?; + Ok(config) +} + +fn preview_settings(settings: &ServerExposureSettingsDto) -> ServerExposurePreviewDto { + let candidate_lan_addresses = candidate_lan_addresses(); + let bind_address = match settings.mode { + ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => { + Some(Ipv4Addr::LOCALHOST.to_string()) + } + ServerExposureMode::RemoteProxyOtherMachine => settings.lan_bind_address.clone(), + }; + let upstream_url = bind_address.map(|addr| format!("http://{addr}:{}", settings.port)); + let upstream_url = (settings.mode != ServerExposureMode::LocalOnly) + .then_some(upstream_url) + .flatten(); + let mut warnings = Vec::new(); + if settings.mode == ServerExposureMode::RemoteProxyOtherMachine + && settings.trusted_proxies.is_empty() + { + warnings.push(DiagnosticWarningDto { + code: "missingTrustedProxy".to_owned(), + message: + "Add the IP address or CIDR of the reverse proxy that connects to this server." + .to_owned(), + }); + } + ServerExposurePreviewDto { + candidate_lan_addresses, + upstream_url, + warnings, + } +} + +fn candidate_lan_addresses() -> Vec { + let mut addresses = Vec::new(); + if let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) { + if socket.connect((Ipv4Addr::new(192, 0, 2, 1), 80)).is_ok() { + if let Ok(addr) = socket.local_addr() { + if let IpAddr::V4(ip) = addr.ip() { + if !ip.is_loopback() && !ip.is_unspecified() { + addresses.push(ip.to_string()); + } + } + } + } + } + addresses.sort(); + addresses.dedup(); + addresses +} + +fn parse_ip(value: &str, field: &str) -> Result { + value + .parse::() + .map_err(|_| invalid_error(format!("{field} must be an IP address"))) +} + +fn resolve_web_root() -> Result { + let mut candidates = Vec::new(); + if let Some(path) = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { + candidates.push(path); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("web")); + candidates.push(dir.join("frontend").join("dist")); + } + } + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("frontend").join("dist")); + } + candidates + .into_iter() + .find(|candidate| candidate.join("index.html").is_file()) + .ok_or_else(|| { + invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT") + }) +} + +fn write_atomic_user_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + set_user_only_permissions(&tmp)?; + std::fs::rename(tmp, path) +} + +#[cfg(unix)] +fn set_user_only_permissions(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_user_only_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +fn invalid_error(message: impl Into) -> ErrorDto { + ErrorDto { + code: "INVALID".to_owned(), + message: message.into(), + } +} + +fn io_error(err: std::io::Error) -> ErrorDto { + ErrorDto { + code: "STORE".to_owned(), + message: err.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } + + fn tmp_app_data() -> PathBuf { + std::env::temp_dir().join(format!("idea-embedded-server-test-{}", Uuid::new_v4())) + } + + fn tmp_web_root() -> PathBuf { + let web_root = + std::env::temp_dir().join(format!("idea-embedded-server-web-root-{}", Uuid::new_v4())); + std::fs::create_dir_all(&web_root).unwrap(); + std::fs::write( + web_root.join("index.html"), + "IdeA", + ) + .unwrap(); + web_root + } + + #[test] + fn non_loopback_remote_requires_trusted_proxy() { + let settings = ServerExposureSettingsDto { + mode: ServerExposureMode::RemoteProxyOtherMachine, + port: 17373, + public_origin: Some("https://idea.example.com".to_owned()), + trusted_proxies: Vec::new(), + lan_bind_address: Some("192.0.2.75".to_owned()), + }; + + let err = validate_settings(&settings).unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("trustedProxies")); + } + + #[test] + fn remote_requires_https_public_origin() { + let settings = ServerExposureSettingsDto { + mode: ServerExposureMode::RemoteProxyLocal, + port: 17373, + public_origin: Some("http://idea.example.com".to_owned()), + trusted_proxies: Vec::new(), + lan_bind_address: None, + }; + + let err = validate_settings(&settings).unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("https://")); + } + + #[test] + fn local_only_derives_loopback_config() { + let settings = ServerExposureSettingsDto { + mode: ServerExposureMode::LocalOnly, + port: 0, + public_origin: Some("https://ignored.example".to_owned()), + trusted_proxies: Vec::new(), + lan_bind_address: None, + }; + + let config = + server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap(); + + assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap()); + assert!(!config.allow_remote); + assert_eq!(config.public_origin, None); + } + + #[test] + fn remote_proxy_local_accepts_loopback_ephemeral_port() { + let settings = ServerExposureSettingsDto { + mode: ServerExposureMode::RemoteProxyLocal, + port: 0, + public_origin: Some("https://idea.example.com".to_owned()), + trusted_proxies: Vec::new(), + lan_bind_address: None, + }; + + let config = + server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap(); + + assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap()); + assert!(config.allow_remote); + assert!(config.trusted_proxies.is_empty()); + } + + #[test] + fn store_round_trips_without_pairing_code() { + let store = FsServerExposureSettingsStore::new(tmp_app_data()); + let settings = ServerExposureSettingsDto { + mode: ServerExposureMode::RemoteProxyOtherMachine, + port: 17373, + public_origin: Some("https://idea.example.com".to_owned()), + trusted_proxies: vec!["192.0.2.22".to_owned()], + lan_bind_address: Some("192.0.2.75".to_owned()), + }; + + store.write(&settings).unwrap(); + let read = store.read().unwrap(); + + assert_eq!(read, settings); + } + + #[tokio::test] + async fn stop_is_idempotent_when_already_stopped() { + let controller = EmbeddedServerController::new(tmp_app_data()); + + let first = controller.stop().await.unwrap(); + let second = controller.stop().await.unwrap(); + + assert!(matches!(first.state, EmbeddedServerStatusStateDto::Stopped)); + assert!(matches!( + second.state, + EmbeddedServerStatusStateDto::Stopped + )); + } + + #[tokio::test] + async fn start_is_idempotent_and_stop_stops_running_server() { + 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, + port: 0, + public_origin: None, + trusted_proxies: Vec::new(), + lan_bind_address: None, + }) + .unwrap(); + let core = Arc::new(BackendCore::build(app_data)); + + let first = controller.start(Arc::clone(&core)).await.unwrap(); + let second = controller.start(core).await.unwrap(); + + assert!(matches!(first.state, EmbeddedServerStatusStateDto::Running)); + assert!(matches!( + second.state, + EmbeddedServerStatusStateDto::Running + )); + assert_eq!(first.local_url, second.local_url); + assert!(first + .local_url + .as_deref() + .is_some_and(|url| url.starts_with("http://127.0.0.1:"))); + assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0")); + assert_eq!(first.pairing_code, second.pairing_code); + + let stopped = controller.stop().await.unwrap(); + + assert!(matches!( + stopped.state, + EmbeddedServerStatusStateDto::Stopped + )); + assert!(stopped.local_url.is_none()); + assert!(stopped.pairing_code.is_none()); + } + + #[tokio::test] + async fn start_with_invalid_persisted_settings_fails_closed() { + let app_data = tmp_app_data(); + let controller = EmbeddedServerController::new(app_data.clone()); + let bad = ServerExposureSettingsDto { + mode: ServerExposureMode::RemoteProxyLocal, + port: 17373, + public_origin: Some("http://idea.example.com".to_owned()), + trusted_proxies: Vec::new(), + lan_bind_address: None, + }; + let path = app_data.join("deployment").join("server-exposure.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).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, "INVALID"); + assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); + assert!(status.pairing_code.is_none()); + } +} diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index dd8728e..5ad0a59 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -16,6 +16,7 @@ pub mod chat; pub mod commands; pub mod dto; +pub mod embedded_server; pub mod events; pub mod mcp_bridge; pub mod mcp_endpoint; @@ -129,6 +130,7 @@ pub fn run() { let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server); + let embedded_server = std::sync::Arc::clone(&state.embedded_server); let open_projects = state.open_project_ids(); let handles = state.terminal_sessions.handles(); tauri::async_runtime::block_on(async move { @@ -148,6 +150,7 @@ pub fn run() { let _ = pty.kill(&h).await; } let _ = model_servers.stop_on_app_exit().await; + let _ = embedded_server.stop().await; }); } @@ -282,6 +285,12 @@ pub fn run() { commands::cancel_background_task, commands::retry_background_task, commands::list_background_tasks, + commands::get_server_exposure_settings, + commands::save_server_exposure_settings, + commands::preview_server_exposure_settings, + commands::embedded_server_status, + commands::embedded_server_start, + commands::embedded_server_stop, ]) .run(tauri::generate_context!()) .expect("error while running IdeA Tauri application"); diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 56ff561..e3aaa86 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -14,6 +14,7 @@ use infrastructure::TicketToolProvider; use serde::{Deserialize, Serialize}; use crate::chat::ChatBridge; +use crate::embedded_server::EmbeddedServerController; use crate::pty::PtyBridge; use crate::tickets::AppTicketToolProvider; @@ -38,6 +39,8 @@ pub struct AppState { pub pty_bridge: Arc, /// Structured reply to Tauri Channel bridge registry. pub chat_bridge: Arc, + /// Desktop-owned embedded HTTP server lifecycle manager. + pub embedded_server: Arc, /// Project currently focused by the main window; panel-only windows follow it. focused_project: Mutex>, } @@ -48,7 +51,7 @@ impl AppState { /// into the backend core. #[must_use] pub fn build(app_data_dir: PathBuf) -> Self { - let core = Arc::new(BackendCore::build(app_data_dir)); + let core = Arc::new(BackendCore::build(app_data_dir.clone())); core.ticket_tool_binder .bind(Arc::new(AppTicketToolProvider { create: Arc::clone(&core.create_issue), @@ -66,10 +69,18 @@ impl AppState { core, pty_bridge: Arc::new(PtyBridge::new()), chat_bridge: Arc::new(ChatBridge::new()), + embedded_server: Arc::new(EmbeddedServerController::new(app_data_dir)), focused_project: Mutex::new(None), } } + /// Clones the shared backend core for adapter components that must share + /// the desktop composition root. + #[must_use] + pub fn core(&self) -> Arc { + Arc::clone(&self.core) + } + /// Updates the focused project state. `None` means no project is focused/open. pub fn set_focused_project(&self, project: Option) { *self