From 49e1d5a1574cafdb1ec5300d5e0e13afae1c81eb Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 23:43:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(app-tauri):=20cycle=20de=20vie=20du=20?= =?UTF-8?q?serveur=20embarqu=C3=A9=20et=20persistance=20de=20l'exposition?= =?UTF-8?q?=20(#68=20B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet d'activer le serveur web depuis l'app desktop — la fonctionnalité demandée à l'origine du ticket. - `embedded_server.rs` : `EmbeddedServerController` porté par `AppState`, avec les commandes `embedded_server_start` / `stop` / `status` et `get` / `save` / `preview_server_exposure_settings`. - `FsServerExposureSettingsStore` : écrit `/deployment/server-exposure.json`. C'est de la config de sécurité, pas une préférence d'UI — d'où l'app-data-dir global plutôt que `localStorage`. Écriture atomique : temporaire, `0600` posé AVANT le `rename`, donc le fichier final n'est jamais brièvement lisible par tous. - Arrêt du serveur câblé sur la sortie de l'app (`lib.rs`), aux côtés des autres nettoyages. Le serveur embarqué appelle `run_embedded_with_core` avec le `BackendCore` partagé du desktop, jamais `run_embedded` — c'est tout l'intérêt du seam ouvert par B1 : une seule composition root dans le processus, pas deux. DETTE CONNUE, consignée au carnet, non bloquante : - `preview_settings` valide avant de prévisualiser (`embedded_server.rs:211`), ce qui verrouille la liste des candidats : un brouillon en mode `remoteProxyOtherMachine` ne peut pas être prévisualisé tant qu'il n'a pas le `lanBindAddress` que cette liste doit justement fournir. Contourné côté frontend par une sonde `localOnly` toujours valide. Défaut d'ordonnancement, pas une fatalité. - Le warning `missingTrustedProxy` (`embedded_server.rs:452`) est du code mort : `validate_settings` rejette les `trustedProxies` vides en mode 3 avant que `preview_settings` ne puisse le produire. QA ré-exécutée par Git HORS SANDBOX (DevBackend et QA sont bloqués par EPERM sur `bind` ; un vert sandboxé ne prouve rien sur ces crates) : `cargo test -p app-tauri` 43 passed, 1 ignored · `-p web-server` 66 passed, 0 échec. Le test ignoré `mcp_bridge::tests::end_to_end_over_real_loopback` est PRÉEXISTANT sur `develop` et non touché par ce lot ; lancé explicitement hors sandbox avec `--ignored`, il passe. C'est une garde d'environnement, pas un faux vert — distinction qui nous a déjà coûté deux fois aujourd'hui. Invariants vérifiés par Git : appelant `run_embedded_with_core` confirmé, aucune fuite d'`EmbeddedServer*`/`ServerExposure*` dans `domain` ni `application`, ordre chmod/rename correct, `stop()` bien appelé à la fermeture. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 66 +++ crates/app-tauri/src/embedded_server.rs | 756 ++++++++++++++++++++++++ crates/app-tauri/src/lib.rs | 9 + crates/app-tauri/src/state.rs | 13 +- 4 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 crates/app-tauri/src/embedded_server.rs 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 From 7efa634f2386fb0e9124ba189e01cb2a12103d34 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 23:43:37 +0200 Subject: [PATCH 2/2] feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Donne à l'utilisateur la surface pour activer le serveur depuis l'app. - `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`. - `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ; adapters mock et http/unsupported alignés (le mode web n'expose pas le contrôle du serveur qui l'héberge). - `ProjectsView` : le `showSettings: boolean` devient une navigation interne `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles » disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu d'ajouter un `PanelId`. CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette loopback comme unspecified. Construit selon la spec, chaque save et chaque start en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose donc un select alimenté par `candidateLanAddresses` fourni par le backend : la règle « le frontend n'invente jamais une IP » tient. QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 · `npx vitest run` 87 files / 789 passed, 0 échec. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/desktopServer.ts | 82 ++++ frontend/src/adapters/http/index.ts | 4 + frontend/src/adapters/http/unsupported.ts | 46 ++- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 148 ++++++++ frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/domain/index.ts | 91 +++++ .../src/features/projects/ProjectsView.tsx | 64 ++-- .../src/features/projects/projects.test.tsx | 27 +- .../settings/DeploymentSettings.test.tsx | 209 +++++++++++ .../features/settings/DeploymentSettings.tsx | 355 ++++++++++++++++++ .../src/features/settings/SettingsView.tsx | 85 +++++ .../features/settings/desktop-only.test.ts | 60 +++ frontend/src/features/settings/index.ts | 11 + .../src/features/settings/useDeployment.ts | 253 +++++++++++++ frontend/src/ports/index.ts | 50 +++ 16 files changed, 1458 insertions(+), 31 deletions(-) create mode 100644 frontend/src/adapters/desktopServer.ts create mode 100644 frontend/src/features/settings/DeploymentSettings.test.tsx create mode 100644 frontend/src/features/settings/DeploymentSettings.tsx create mode 100644 frontend/src/features/settings/SettingsView.tsx create mode 100644 frontend/src/features/settings/desktop-only.test.ts create mode 100644 frontend/src/features/settings/index.ts create mode 100644 frontend/src/features/settings/useDeployment.ts diff --git a/frontend/src/adapters/desktopServer.ts b/frontend/src/adapters/desktopServer.ts new file mode 100644 index 0000000..52a9402 --- /dev/null +++ b/frontend/src/adapters/desktopServer.ts @@ -0,0 +1,82 @@ +/** + * Tauri adapter for {@link DesktopServerGateway} (ticket #68). One of the only + * places that calls `invoke()`; features reach it exclusively through the port. + * + * Commands and payload keys are camelCase, matching the backend DTO convention. + * `save_server_exposure_settings` / `preview_server_exposure_settings` take the + * settings directly under a `settings` key (no `request` wrapper). + * + * **Polling note.** The backend exposes no status *event* — `embedded_server.rs` + * emits nothing — so `onStatusChanged` is implemented here by polling + * `embedded_server_status`. That keeps the port shape push-like (and swappable + * for a real event later) while the polling schedule stays an adapter detail. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { + EmbeddedServerStatus, + ServerExposurePreview, + ServerExposureSettings, + Unsubscribe, +} from "@/domain"; +import type { DesktopServerGateway } from "@/ports"; + +/** Status poll interval (ms). Cheap, in-process command; transitions are coarse. */ +const STATUS_POLL_MS = 2000; + +export class TauriDesktopServerGateway implements DesktopServerGateway { + constructor(private readonly pollMs: number = STATUS_POLL_MS) {} + + getExposureSettings(): Promise { + return invoke("get_server_exposure_settings"); + } + + async saveExposureSettings(settings: ServerExposureSettings): Promise { + await invoke("save_server_exposure_settings", { settings }); + } + + previewExposure( + settings: ServerExposureSettings, + ): Promise { + return invoke("preview_server_exposure_settings", { + settings, + }); + } + + status(): Promise { + return invoke("embedded_server_status"); + } + + start(): Promise { + return invoke("embedded_server_start"); + } + + stop(): Promise { + return invoke("embedded_server_stop"); + } + + async onStatusChanged( + handler: (status: EmbeddedServerStatus) => void, + ): Promise { + let stopped = false; + const timer = setInterval(() => { + void this.status().then( + (status) => { + // The unsubscribe may land while a poll is in flight; drop late results + // so a torn-down consumer never gets called. + if (!stopped) handler(status); + }, + () => { + // A failed poll is not a status: swallow it and let the next tick try + // again, rather than tearing the subscription down. + }, + ); + }, this.pollMs); + + return () => { + stopped = true; + clearInterval(timer); + }; + } +} diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 0f1871b..7873f36 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -42,6 +42,7 @@ import { HttpTicketGateway, } from "./streamGateways"; import { + WebDesktopServerGateway, WebFocusedProjectGateway, WebRemoteGateway, WebWindowGateway, @@ -121,6 +122,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway remote: new WebRemoteGateway(), profile: new HttpProfileGateway(http), modelServer: new HttpModelServerGateway(http), + // Desktop-only (#68): the web client is served *by* the embedded server and + // must not reconfigure it. + desktopServer: new WebDesktopServerGateway(), template: new HttpTemplateGateway(http), skill: new HttpSkillGateway(http), memory: new HttpMemoryGateway(http), diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index a407a5a..7add974 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -9,8 +9,15 @@ * browser to replace `pickFolder`), it gets its own lot. */ -import type { GatewayError, Unsubscribe } from "@/domain"; import type { + EmbeddedServerStatus, + GatewayError, + ServerExposurePreview, + ServerExposureSettings, + Unsubscribe, +} from "@/domain"; +import type { + DesktopServerGateway, FocusedProject, FocusedProjectGateway, RemoteGateway, @@ -75,3 +82,40 @@ export class WebRemoteGateway implements RemoteGateway { return unsupportedOnWeb("Remote (SSH/WSL) connection"); } } + +/** + * Web stub: the embedded server is desktop-only (ticket #68). A web client is + * *served by* this very server, so letting it reconfigure or stop the server + * would let a remote device saw off the branch it sits on. The desktop app owns + * this surface; every call fails explicitly rather than reaching for Tauri. + */ +export class WebDesktopServerGateway implements DesktopServerGateway { + // `async` on purpose: these must *reject* rather than throw synchronously, so + // callers using `.then(ok, err)` (not just `await`) still see the failure. + async getExposureSettings(): Promise { + return unsupportedOnWeb("Embedded server settings"); + } + async saveExposureSettings(_settings: ServerExposureSettings): Promise { + return unsupportedOnWeb("Embedded server settings"); + } + async previewExposure( + _settings: ServerExposureSettings, + ): Promise { + return unsupportedOnWeb("Embedded server settings"); + } + async status(): Promise { + return unsupportedOnWeb("Embedded server status"); + } + async start(): Promise { + return unsupportedOnWeb("Starting the embedded server"); + } + async stop(): Promise { + return unsupportedOnWeb("Stopping the embedded server"); + } + onStatusChanged( + _handler: (status: EmbeddedServerStatus) => void, + ): Promise { + // Never fires on web; a no-op unsubscribe keeps callers' teardown honest. + return Promise.resolve(() => {}); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index cb06a3c..0d5dcb2 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -20,6 +20,7 @@ import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; import { TauriModelServerGateway } from "./modelServer"; +import { TauriDesktopServerGateway } from "./desktopServer"; import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; @@ -60,6 +61,7 @@ export function createTauriGateways(): Gateways { remote: new TauriRemoteGateway(), profile: new TauriProfileGateway(), modelServer: new TauriModelServerGateway(), + desktopServer: new TauriDesktopServerGateway(), template: new TauriTemplateGateway(), skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), @@ -83,6 +85,7 @@ export { TauriLayoutGateway, TauriProfileGateway, TauriModelServerGateway, + TauriDesktopServerGateway, TauriTemplateGateway, TauriSkillGateway, TauriMemoryGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f972b69..3e377cc 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -8,9 +8,11 @@ import type { Agent, AgentDrift, AgentProfile, + DiagnosticWarning, DomainEvent, EmbedderEngines, EmbedderProfile, + EmbeddedServerStatus, FirstRunState, GatewayError, GitBranches, @@ -36,6 +38,8 @@ import type { ProjectWorkState, ProfileAvailability, ResumableAgent, + ServerExposurePreview, + ServerExposureSettings, Skill, ReplyChunk, SkillScope, @@ -63,6 +67,7 @@ import type { CreateAgentInput, CreateMemoryInput, CreateSkillInput, + DesktopServerGateway, EmbedderGateway, CreateTemplateInput, Gateways, @@ -1373,6 +1378,148 @@ export class MockModelServerGateway implements ModelServerGateway { } } +/** Mirror of the backend `default_settings()` (ticket #68). */ +const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = { + mode: "localOnly", + port: 17373, + trustedProxies: [], +}; + +/** Fixed LAN candidates so the offline UI has something to pick from. */ +const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"]; + +function invalid(message: string): never { + const err: GatewayError = { code: "INVALID", message }; + throw err; +} + +/** + * In-memory embedded-server gateway (ticket #68). + * + * Mirrors the backend `validate_settings` / `preview_settings` closely enough + * that the Deployment UI — including its rejection paths — is testable offline. + * It is never the source of truth: production runs the real Tauri commands. + * `candidateLanAddresses` is fixed data here precisely because the UI must take + * addresses from the gateway rather than deriving them. + */ +export class MockDesktopServerGateway implements DesktopServerGateway { + private settings: ServerExposureSettings = structuredClone( + DEFAULT_EXPOSURE_SETTINGS, + ); + private current: EmbeddedServerStatus = { state: "stopped" }; + private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>(); + + async getExposureSettings(): Promise { + return structuredClone(this.settings); + } + + async saveExposureSettings(settings: ServerExposureSettings): Promise { + this.validate(settings); + this.settings = structuredClone(settings); + } + + async previewExposure( + settings: ServerExposureSettings, + ): Promise { + // The real `preview_server_exposure_settings` validates *before* previewing, + // so an incomplete draft is rejected rather than previewed. Mirrored here — + // the UI relies on it as its validation authority. + this.validate(settings); + const warnings: DiagnosticWarning[] = []; + if ( + settings.mode === "remoteProxyOtherMachine" && + settings.trustedProxies.length === 0 + ) { + // Mirrors the backend's `missingTrustedProxy` warning — which the backend + // itself cannot currently reach, since `validate_settings` rejects an + // empty `trustedProxies` for this mode first. Kept aligned on purpose: if + // the backend reorders, the mock already matches. + warnings.push({ + code: "missingTrustedProxy", + message: + "Add the IP address or CIDR of the reverse proxy that connects to this server.", + }); + } + const bindAddress = + settings.mode === "remoteProxyOtherMachine" + ? settings.lanBindAddress + : "127.0.0.1"; + const upstreamUrl = + settings.mode !== "localOnly" && bindAddress + ? `http://${bindAddress}:${settings.port}` + : undefined; + return { + candidateLanAddresses: [...MOCK_LAN_ADDRESSES], + upstreamUrl, + warnings, + }; + } + + async status(): Promise { + return structuredClone(this.current); + } + + async start(): Promise { + // The backend re-validates on start; a bad config fails there, not silently. + this.validate(this.settings); + const preview = await this.previewExposure(this.settings); + this.emit({ + state: "running", + localUrl: `http://127.0.0.1:${this.settings.port}`, + publicUrl: + this.settings.mode === "localOnly" + ? undefined + : this.settings.publicOrigin, + upstreamUrl: preview.upstreamUrl, + // Runtime-only, regenerated per start — never persisted. + pairingCode: "MOCK-PAIR-4242", + }); + return structuredClone(this.current); + } + + async stop(): Promise { + this.emit({ state: "stopped" }); + return structuredClone(this.current); + } + + async onStatusChanged( + handler: (status: EmbeddedServerStatus) => void, + ): Promise { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + /** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */ + setStatus(status: EmbeddedServerStatus): void { + this.emit(status); + } + + private emit(status: EmbeddedServerStatus): void { + this.current = status; + for (const handler of this.handlers) handler(structuredClone(status)); + } + + private validate(settings: ServerExposureSettings): void { + if (settings.mode !== "localOnly") { + const origin = settings.publicOrigin; + if (!origin) invalid("remote exposure requires publicOrigin"); + if (!origin.startsWith("https://")) { + invalid("remote exposure requires publicOrigin to start with https://"); + } + if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard"); + if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'"); + } + if (settings.mode === "remoteProxyOtherMachine") { + if (!settings.lanBindAddress) { + invalid("remoteProxyOtherMachine requires lanBindAddress"); + } + if (settings.trustedProxies.length === 0) { + invalid("remoteProxyOtherMachine requires at least one trustedProxies entry"); + } + } + } +} + /** * Stateful in-memory template gateway. * @@ -2630,6 +2777,7 @@ export function createMockGateways(): Gateways { remote: new MockRemoteGateway(), profile: new MockProfileGateway(), modelServer: new MockModelServerGateway(), + desktopServer: new MockDesktopServerGateway(), template: new MockTemplateGateway(agentGateway), skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 64b8712..693f254 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -16,6 +16,7 @@ describe("createMockGateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", "conversation", + "desktopServer", "embedder", "focusedProject", "git", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 1bcfd68..aeb14a9 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -117,6 +117,97 @@ export interface ModelServerCommandPreview { display: string; } +/** + * How the embedded web server is exposed (ticket #68, mirror of the backend + * `ServerExposureMode`). + * + * - `localOnly` — binds loopback, no remote access. + * - `remoteProxyLocal` — binds loopback; an HTTPS reverse proxy runs on *this* + * machine and reaches IdeA over loopback. + * - `remoteProxyOtherMachine` — binds a concrete LAN address so a proxy on + * *another* host can reach it; that proxy must be declared in + * `trustedProxies`. + */ +export type ServerExposureMode = + | "localOnly" + | "remoteProxyLocal" + | "remoteProxyOtherMachine"; + +/** + * Persisted embedded-server exposure settings (mirror of the backend + * `ServerExposureSettingsDto`). + * + * The backend validates every field on save/start; the UI never derives network + * facts (LAN addresses, upstream URL) from these — it asks `previewExposure`. + */ +export interface ServerExposureSettings { + mode: ServerExposureMode; + /** TCP port to bind. `0` asks the OS for an ephemeral port. */ + port: number; + /** Public HTTPS origin, required by both remote modes. */ + publicOrigin?: string; + /** + * Peers allowed to contact IdeA, as IPs or CIDRs. **Not** a listen address. + * Required (non-empty) by `remoteProxyOtherMachine`. + */ + trustedProxies: string[]; + /** + * Concrete LAN address to bind, required by `remoteProxyOtherMachine`. Must + * come from {@link ServerExposurePreview.candidateLanAddresses} — the UI is + * never allowed to invent one. + */ + lanBindAddress?: string; +} + +/** A non-fatal diagnostic from the exposure preview (never blocks a start). */ +export interface DiagnosticWarning { + /** Stable warning code (e.g. `missingTrustedProxy`). */ + code: string; + /** Human-readable, actionable message. */ + message: string; +} + +/** + * Backend-derived preview of a draft exposure config (mirror of + * `ServerExposurePreviewDto`). The backend is the sole authority on LAN + * addresses and the proxy upstream URL. + */ +export interface ServerExposurePreview { + /** LAN addresses the backend discovered on this host. */ + candidateLanAddresses: string[]; + /** URL the reverse proxy should target; absent in `localOnly`. */ + upstreamUrl?: string; + /** Non-fatal diagnostics to surface alongside the form. */ + warnings: DiagnosticWarning[]; +} + +/** Embedded-server lifecycle state (mirror of `EmbeddedServerStatusStateDto`). */ +export type EmbeddedServerState = + | "stopped" + | "starting" + | "running" + | "stopping" + | "failed"; + +/** + * Embedded-server status (mirror of `EmbeddedServerStatusDto`). `pairingCode` + * is a runtime-only secret: it exists while the server runs, is never + * persisted, and must never be written into a settings field. + */ +export interface EmbeddedServerStatus { + state: EmbeddedServerState; + /** Local URL, when running. */ + localUrl?: string; + /** Public URL, when a remote mode is configured. */ + publicUrl?: string; + /** Upstream URL to hand to the reverse proxy. */ + upstreamUrl?: string; + /** Runtime pairing code — present only while running. */ + pairingCode?: string; + /** Last failure, when `state` is `failed`. */ + error?: GatewayError; +} + /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index ec6c660..016f7a8 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -9,9 +9,13 @@ * │ MENU BAR Panneaux │ Settings │ * ├───────────────────────────────────────────────────────┤ * │ MAIN — LayoutGrid (fills the FULL width) │ - * │ — or the projects manager / welcome when no project │ + * │ — or Settings / the projects manager / welcome │ * └───────────────────────────────────────────────────────┘ * + * **Settings** (`Settings → AI Profiles | Deployment`, #68) is a main surface + * with its own internal section nav — it has no `PanelId` and is outside the + * `viewPlacement` model, unlike every panel below. + * * The former left sidebar is gone: every panel (context, work, tickets, agents, * templates, skills, permissions, memory, git, projects) is reached from the * single **Panneaux** menu (#26), whose per-panel submenu picks the placement @@ -34,7 +38,12 @@ import { useEffect, useState, type ReactNode } from "react"; import type { DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; -import { ProfilesSettings } from "@/features/first-run"; +import { + SettingsView, + SETTINGS_SECTIONS, + SETTINGS_SECTION_LABEL, + type SettingsSection, +} from "@/features/settings"; import { GitGraphView } from "@/features/git"; import { Button, @@ -114,10 +123,13 @@ export function ProjectsView() { // Width (px) of each dock column, driven by the DockRegion resize handle. const [leftDockWidth, setLeftDockWidth] = useState(340); const [rightDockWidth, setRightDockWidth] = useState(340); - // Top-level view switch (#16): when true, the main area shows the AI Profiles - // settings instead of the project surface. The single menu bar stays visible - // so the user can toggle back from Settings → AI Profiles. - const [showSettings, setShowSettings] = useState(false); + // Top-level view switch (#16, extended in #68): the open Settings section, or + // null when the main area shows the project surface. Settings is a main + // surface with its own internal nav — deliberately not a placeable panel. The + // menu bar stays visible above it. + const [settingsSection, setSettingsSection] = useState( + null, + ); // The active layout (id + kind), reported by LayoutTabs — the single source of // truth. `kind` decides whether the main area is the terminal grid or the git // graph view. @@ -390,17 +402,17 @@ export function ProjectsView() { { id: "settings", label: "Settings", - items: [ - { - id: "ai-profiles", - label: showSettings ? "Close AI Profiles" : "AI Profiles", - active: showSettings, - onSelect: () => { - setShowSettings((v) => !v); - dismissFloating(); - }, + // One entry per section (#68). The entries name sections and mark the open + // one; closing lives in the view ("Close Settings"), so no label alternates. + items: SETTINGS_SECTIONS.map((section) => ({ + id: section, + label: SETTINGS_SECTION_LABEL[section], + active: settingsSection === section, + onSelect: () => { + setSettingsSection(section); + dismissFloating(); }, - ], + })), }, ]; @@ -621,7 +633,7 @@ export function ProjectsView() { onClose={(id) => void vm.closeTab(id)} projectsPanelOpen={placementOf(placements, "projects") !== "closed"} onOpenProjectsPanel={() => { - setShowSettings(false); + setSettingsSection(null); setPlacement("projects", "floating"); }} /> @@ -645,14 +657,14 @@ export function ProjectsView() { {/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
- {showSettings ? ( - // Top-level view switch (#16): AI Profiles settings takes over the main - // area while the menu bar above stays visible to toggle back. -
-
- -
-
+ {settingsSection ? ( + // Top-level view switch (#16/#68): Settings takes over the main area + // while the menu bar above stays visible. + setSettingsSection(null)} + /> ) : active && viewerConversationId ? ( tab.id === toast.projectId, ); if (projectOpen) vm.activateTab(toast.projectId); - setShowSettings(false); + setSettingsSection(null); setViewerConversationId(null); setPlacement("work", "floating"); setTaskToasts((prev) => diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index e30fbaa..9dfa5ce 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -12,7 +12,7 @@ import { fireEvent, } from "@testing-library/react"; -import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock"; +import { MockAgentGateway, MockDesktopServerGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { ProjectsView } from "./ProjectsView"; @@ -25,6 +25,7 @@ function renderView( // The project gateway drives the primary assertions; agent + profile stubs are // required because ProjectsView now renders AgentsPanel for the active tab. // template gateway is required because ProjectsView now renders TemplatesPanel. + // desktopServer is required by Settings → Deployment (#68). const agentGateway = new MockAgentGateway(); const gateways = { system, @@ -35,6 +36,7 @@ function renderView( git: new MockGitGateway(), workState: new MockWorkStateGateway(), window: new MockWindowGateway(), + desktopServer: new MockDesktopServerGateway(), } as unknown as Gateways; return { project, @@ -197,7 +199,7 @@ describe("ProjectsView (with MockProjectGateway)", () => { expect(betaTab.getAttribute("aria-selected")).toBe("true"); }); - it("toggles the AI Profiles view from the single Settings menu (#16)", async () => { + it("opens the Settings surface from the Settings menu and closes it from the view (#16/#68)", async () => { renderView(); await waitForIdle(); @@ -210,14 +212,31 @@ describe("ProjectsView (with MockProjectGateway)", () => { expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy(); expect(screen.queryByLabelText("project name")).toBeNull(); - // Settings → Close AI Profiles returns to the project surface. - openMenuItem("Settings", "Close AI Profiles"); + // #68: closing is an explicit action in the view, not an alternating menu + // label — the label never scaled past one section. + fireEvent.click(screen.getByRole("button", { name: "Close Settings" })); await waitFor(() => expect(screen.getByLabelText("project name")).toBeTruthy(), ); expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); }); + it("navigates between Settings sections from the menu and the nav column (#68)", async () => { + renderView(); + await waitForIdle(); + + // The Settings menu now names sections; Deployment opens directly. + openMenuItem("Settings", "Deployment"); + expect(await screen.findByLabelText("deployment settings")).toBeTruthy(); + expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); + + // The internal nav column switches sections without leaving Settings. + const nav = screen.getByRole("navigation", { name: "settings sections" }); + fireEvent.click(within(nav).getByRole("button", { name: "AI Profiles" })); + expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy(); + expect(screen.queryByLabelText("deployment settings")).toBeNull(); + }); + it("closing a tab removes it from the tab bar", async () => { renderView(); await createProject("alpha", "/home/me/alpha"); diff --git a/frontend/src/features/settings/DeploymentSettings.test.tsx b/frontend/src/features/settings/DeploymentSettings.test.tsx new file mode 100644 index 0000000..937f974 --- /dev/null +++ b/frontend/src/features/settings/DeploymentSettings.test.tsx @@ -0,0 +1,209 @@ +/** + * Ticket #68 — `Settings → Deployment`, driven through the real `DIProvider` + * and the mock gateway (no backend). + * + * These pin the UX invariants the screen exists for, not its styling: the mode + * is a radio choice with consequences, the authorized-proxy field is explicitly + * *not* a listen address, addresses come from the backend, a refusal is + * actionable, and the pairing code is runtime-only. + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; + +import { MockDesktopServerGateway } from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { DeploymentSettings } from "./DeploymentSettings"; + +function renderView(desktopServer = new MockDesktopServerGateway()) { + const gateways = { desktopServer } as unknown as Gateways; + return { + desktopServer, + ...render( + + + , + ), + }; +} + +/** Waits past the hook's preview debounce. */ +async function settle() { + await screen.findByRole("radiogroup", { name: "exposure mode" }); + await waitFor(() => expect(screen.getByLabelText("Port")).toBeTruthy()); +} + +function selectMode(title: string) { + fireEvent.click(screen.getByRole("radio", { name: new RegExp(title) })); +} + +describe("DeploymentSettings", () => { + it("offers the three exposure modes as radio cards with their consequences", async () => { + renderView(); + await settle(); + + const group = screen.getByRole("radiogroup", { name: "exposure mode" }); + expect(within(group).getAllByRole("radio")).toHaveLength(3); + + // The plain-language consequence is part of the choice, not a tooltip. + expect( + within(group).getByText(/Remote devices cannot connect/), + ).toBeTruthy(); + expect( + within(group).getByText(/same machine as IdeA Desktop/), + ).toBeTruthy(); + expect( + within(group).getByText(/only accept traffic from that proxy/), + ).toBeTruthy(); + }); + + it("shows no exposure fields in 'This computer only'", async () => { + renderView(); + await settle(); + + // Default is localOnly: nothing to configure, nothing to get wrong. + expect(screen.queryByLabelText("Public origin")).toBeNull(); + expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull(); + expect(screen.queryByLabelText("LAN address to bind")).toBeNull(); + }); + + it("asks only for a public origin when the proxy is on this computer", async () => { + renderView(); + await settle(); + selectMode("Remote access, proxy on this computer"); + + expect(await screen.findByLabelText("Public origin")).toBeTruthy(); + // The proxy is local, so there is nothing to authorize and nothing to bind. + expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull(); + expect(screen.queryByLabelText("LAN address to bind")).toBeNull(); + }); + + it("explains that the authorized proxy is not a listen address, and warns about the mode", async () => { + renderView(); + await settle(); + selectMode("Remote access, proxy on another machine"); + + // This help text is the whole point of the screen: it corrects the + // "that's where IdeA listens" misreading. + expect( + await screen.findByText( + "This is not where IdeA listens. It is the machine allowed to contact IdeA.", + ), + ).toBeTruthy(); + + // The permanent warning about the silent-timeout failure mode. + expect( + screen.getByText(/the proxy may time out without an IdeA error/), + ).toBeTruthy(); + }); + + it("offers LAN addresses from the backend rather than deriving any", async () => { + const gateway = new MockDesktopServerGateway(); + const preview = vi.spyOn(gateway, "previewExposure"); + renderView(gateway); + await settle(); + selectMode("Remote access, proxy on another machine"); + + const select = await screen.findByLabelText("LAN address to bind"); + const offered = within(select as HTMLElement) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value) + .filter(Boolean); + + // Exactly the gateway's candidates — the UI invents nothing. + const fromBackend = (await preview.mock.results[0]!.value).candidateLanAddresses; + expect(offered).toEqual(fromBackend); + }); + + it("surfaces the backend's refusal as a concrete correction", async () => { + renderView(); + await settle(); + // A remote mode with no origin: the backend says exactly what to fix. + selectMode("Remote access, proxy on this computer"); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/requires publicOrigin/); + }); + + it("shows the upstream to paste, read-only, once the config is valid", async () => { + renderView(); + await settle(); + selectMode("Remote access, proxy on this computer"); + fireEvent.change(await screen.findByLabelText("Public origin"), { + target: { value: "https://idea.example.com" }, + }); + + const upstream = await screen.findByRole("button", { + name: "copy upstream url", + }); + expect(upstream).toBeTruthy(); + // The upstream is IdeA-provided, never a field the user edits. + expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull(); + }); + + it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => { + renderView(); + await settle(); + + expect( + screen.getByText("Start the server to generate a pairing code."), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Start" })); + + expect( + await screen.findByRole("button", { name: "copy pairing code" }), + ).toBeTruthy(); + expect( + screen.getByText(/Temporary code. It disappears when the server stops/), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Stop" })); + await waitFor(() => + expect( + screen.getByText("Start the server to generate a pairing code."), + ).toBeTruthy(), + ); + }); + + it("starts the server and reports the local URL", async () => { + renderView(); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + + await waitFor(() => expect(screen.getByText("Running")).toBeTruthy()); + expect(screen.getByText(/Local URL:/)).toBeTruthy(); + }); + + it("persists the draft before starting, so what runs is what is shown", async () => { + const gateway = new MockDesktopServerGateway(); + const save = vi.spyOn(gateway, "saveExposureSettings"); + renderView(gateway); + await settle(); + + fireEvent.change(screen.getByLabelText("Port"), { target: { value: "18080" } }); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + + await waitFor(() => expect(save).toHaveBeenCalled()); + expect(save.mock.calls[0]![0]).toMatchObject({ port: 18080 }); + await waitFor(() => + expect(screen.getByText("http://127.0.0.1:18080")).toBeTruthy(), + ); + }); + + it("reports a failed start with the backend message", async () => { + const gateway = new MockDesktopServerGateway(); + renderView(gateway); + await settle(); + + gateway.setStatus({ + state: "failed", + error: { code: "INVALID", message: "port 17373 already in use" }, + }); + + await waitFor(() => expect(screen.getByText("Failed")).toBeTruthy()); + expect(screen.getByRole("alert").textContent).toMatch(/already in use/); + }); +}); diff --git a/frontend/src/features/settings/DeploymentSettings.tsx b/frontend/src/features/settings/DeploymentSettings.tsx new file mode 100644 index 0000000..cf7130b --- /dev/null +++ b/frontend/src/features/settings/DeploymentSettings.tsx @@ -0,0 +1,355 @@ +/** + * `Settings → Deployment` (ticket #68) — turn IdeA Desktop into a server other + * devices can reach, without a command line. + * + * Pure presentation over {@link useDeployment}; no `invoke()`, and no address is + * ever derived here — LAN candidates and the upstream URL come from the backend + * preview (`DesktopServerGateway`). + * + * Two UX invariants this screen exists to protect: + * + * - **The exposure mode is a decision, not a setting.** It is rendered as radio + * *cards* with plain-language consequences, never a technical select, because + * choosing wrong (proxy elsewhere, mode "on this computer") fails as a silent + * proxy timeout with no IdeA error to read. + * - **The authorized-proxy field is not a listen address.** That confusion is + * the whole reason this screen is worded the way it is; the help text under + * the field says so explicitly. + * + * The pairing code is runtime-only: shown while running, never rendered into a + * persisted field, never mixed with the upstream value. + */ + +import { useState } from "react"; + +import type { ServerExposureMode } from "@/domain"; +import { Button, Field, Input, Panel, cn } from "@/shared"; +import { useDeployment } from "./useDeployment"; + +interface ModeOption { + mode: ServerExposureMode; + title: string; + description: string; +} + +/** The three exposure choices, in increasing order of reach. */ +const MODE_OPTIONS: ModeOption[] = [ + { + mode: "localOnly", + title: "This computer only", + description: + "For using IdeA on this desktop only. Remote devices cannot connect.", + }, + { + mode: "remoteProxyLocal", + title: "Remote access, proxy on this computer", + description: + "Use this when your HTTPS proxy runs on the same machine as IdeA Desktop.", + }, + { + mode: "remoteProxyOtherMachine", + title: "Remote access, proxy on another machine", + description: + "Use this when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy.", + }, +]; + +const STATE_LABEL: Record = { + stopped: "Stopped", + starting: "Starting…", + running: "Running", + stopping: "Stopping…", + failed: "Failed", +}; + +/** Copy-to-clipboard button; degrades to disabled where the API is absent. */ +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + const supported = + typeof navigator !== "undefined" && Boolean(navigator.clipboard); + return ( + + ); +} + +/** A read-only value the user is meant to copy elsewhere (never editable). */ +function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string }) { + return ( +
+ + {value} + + +
+ ); +} + +export function DeploymentSettings() { + const vm = useDeployment(); + + if (!vm.ready || !vm.settings) { + return ( + +

Loading…

+
+ ); + } + + const { settings, status } = vm; + const running = status.state === "running"; + const remote = settings.mode !== "localOnly"; + const otherMachine = settings.mode === "remoteProxyOtherMachine"; + const transitioning = status.state === "starting" || status.state === "stopping"; + + return ( +
+ {/* ── Status ────────────────────────────────────────────────────────── */} + void vm.stop()} + disabled={vm.busy || transitioning} + > + Stop + + ) : ( + + ) + } + > +
+

+ + {STATE_LABEL[status.state]} +

+ + {status.localUrl && ( +

+ Local URL: {status.localUrl} +

+ )} + {status.publicUrl && ( +

+ Public URL: {status.publicUrl} +

+ )} + + {/* A failure carries the backend's message: it is the correction. */} + {status.state === "failed" && status.error && ( +

+ {status.error.message} +

+ )} + {vm.actionError && ( +

+ {vm.actionError} +

+ )} +
+
+ + {/* ── Exposure ──────────────────────────────────────────────────────── */} + +
+
+ {MODE_OPTIONS.map((option) => { + const selected = settings.mode === option.mode; + return ( + + ); + })} +
+ + + {({ id }) => ( + vm.setPort(Number(e.target.value))} + /> + )} + + + {remote && ( + + {({ id, describedBy }) => ( + vm.setPublicOrigin(e.target.value)} + /> + )} + + )} + + {otherMachine && ( + <> + {/* Addresses come from the backend probe — never invented here. */} + + {({ id, describedBy }) => + vm.candidateLanAddresses.length > 0 ? ( + + ) : ( + vm.setLanBindAddress(e.target.value)} + /> + ) + } + + + + {({ id, describedBy }) => ( + vm.setTrustedProxies(e.target.value)} + /> + )} + + +

+ If your proxy is not on this computer, choose this mode. + Otherwise the proxy may time out without an IdeA error. +

+ + )} + + {/* Warnings inform; they never block a start. */} + {vm.warnings.map((warning) => ( +

+ {warning.message} +

+ ))} + + {vm.validationError && ( +

+ {vm.validationError} +

+ )} +
+
+ + {/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */} + {remote && ( + +
+

+ Point your HTTPS reverse proxy at this upstream. +

+ {vm.upstreamUrl ? ( + + ) : ( +

+ Complete the settings above to get the upstream URL. +

+ )} +
+
+ )} + + {/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */} + + {running && status.pairingCode ? ( +
+ +

+ Temporary code. It disappears when the server stops. Do not save it + in configuration files. +

+
+ ) : ( +

+ Start the server to generate a pairing code. +

+ )} +
+
+ ); +} diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx new file mode 100644 index 0000000..bfe7613 --- /dev/null +++ b/frontend/src/features/settings/SettingsView.tsx @@ -0,0 +1,85 @@ +/** + * `SettingsView` — the Settings surface shell (ticket #68). + * + * Settings is a **main surface**, not a dockable project view: it deliberately + * has no `PanelId` and stays outside the `viewPlacement` model. It takes over + * the main area while the menu bar stays visible above it. + * + * Ticket #68 gives it a second section, so the section list becomes real + * navigation (a left column) instead of the old single toggle. That also kills + * the alternating "Close AI Profiles" menu label, which never scaled past one + * entry: the menu now names sections, the active one is marked, and closing is + * an explicit action inside the view. + * + * `EmbedderSettings` / `ModelServersPanel` are **not** pulled in here — that is + * a separate lateral rework. The section list is the seam they would slot into. + */ + +import { Button, cn } from "@/shared"; +import { ProfilesSettings } from "@/features/first-run"; +import { DeploymentSettings } from "./DeploymentSettings"; + +/** The Settings sections, in menu/nav order. */ +export type SettingsSection = "aiProfiles" | "deployment"; + +/** Human labels, shared by the nav column and the `Settings` menu. */ +export const SETTINGS_SECTION_LABEL: Record = { + aiProfiles: "AI Profiles", + deployment: "Deployment", +}; + +/** Section order — the single source of truth for both nav and menu. */ +export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"]; + +interface SettingsViewProps { + section: SettingsSection; + onSectionChange: (section: SettingsSection) => void; + onClose: () => void; +} + +export function SettingsView({ + section, + onSectionChange, + onClose, +}: SettingsViewProps) { + return ( +
+ + +
+
+ {section === "aiProfiles" ? : } +
+
+
+ ); +} diff --git a/frontend/src/features/settings/desktop-only.test.ts b/frontend/src/features/settings/desktop-only.test.ts new file mode 100644 index 0000000..bb72815 --- /dev/null +++ b/frontend/src/features/settings/desktop-only.test.ts @@ -0,0 +1,60 @@ +/** + * Ticket #68 — "Desktop only" is a code/test guard, not just a claim. + * + * The web client never mounts `ProjectsView` (it routes through `features/web`), + * so the Deployment surface is unreachable there *today*. This pins that: the + * web feature must not import the settings surface, and the web transport must + * refuse the embedded-server port rather than reach for Tauri (absent on web). + */ + +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { WebDesktopServerGateway } from "@/adapters/http/unsupported"; +import type { GatewayError } from "@/domain"; + +const WEB_FEATURE_DIR = join(process.cwd(), "src", "features", "web"); + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) collectSourceFiles(full, out); + else if (/\.tsx?$/.test(entry)) out.push(full); + } + return out; +} + +describe("the Deployment surface is desktop-only", () => { + it("features/web does not import the settings surface", () => { + const offenders = collectSourceFiles(WEB_FEATURE_DIR).filter((file) => + /@\/features\/settings/.test(readFileSync(file, "utf8")), + ); + expect(offenders, `offending files: ${offenders.join(", ")}`).toEqual([]); + }); + + it("the web transport refuses the embedded-server port", async () => { + const gateway = new WebDesktopServerGateway(); + + // Every real operation fails explicitly, with a stable code the UI can branch on. + for (const call of [ + () => gateway.getExposureSettings(), + () => gateway.status(), + () => gateway.start(), + () => gateway.stop(), + () => gateway.saveExposureSettings({ mode: "localOnly", port: 0, trustedProxies: [] }), + () => gateway.previewExposure({ mode: "localOnly", port: 0, trustedProxies: [] }), + ]) { + const error: Partial = { code: "UNSUPPORTED_ON_WEB" }; + await expect(call()).rejects.toMatchObject(error); + } + }); + + it("web status subscription is inert rather than throwing", async () => { + // Teardown must stay callable: consumers unsubscribe unconditionally. + const unsubscribe = await new WebDesktopServerGateway().onStatusChanged(() => { + throw new Error("must never fire on web"); + }); + expect(() => unsubscribe()).not.toThrow(); + }); +}); diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts new file mode 100644 index 0000000..09a0cc4 --- /dev/null +++ b/frontend/src/features/settings/index.ts @@ -0,0 +1,11 @@ +/** Settings surface (ticket #68): section shell + the Deployment screen. */ + +export { + SettingsView, + SETTINGS_SECTIONS, + SETTINGS_SECTION_LABEL, + type SettingsSection, +} from "./SettingsView"; +export { DeploymentSettings } from "./DeploymentSettings"; +export { useDeployment } from "./useDeployment"; +export type { DeploymentVm } from "./useDeployment"; diff --git a/frontend/src/features/settings/useDeployment.ts b/frontend/src/features/settings/useDeployment.ts new file mode 100644 index 0000000..ae9a0a8 --- /dev/null +++ b/frontend/src/features/settings/useDeployment.ts @@ -0,0 +1,253 @@ +/** + * `useDeployment` — view-model for `Settings → Deployment` (ticket #68). + * + * Owns the draft exposure config, the backend-derived preview, and the embedded + * server lifecycle. All behaviour lives here so `DeploymentSettings` stays + * presentation-only (no `invoke()`, no network reasoning in JSX). + * + * **The backend owns every network fact.** LAN candidates and the proxy upstream + * URL are read from `previewExposure`; nothing here derives an address. + * + * Two backend behaviours shape this hook: + * + * 1. `preview_server_exposure_settings` *validates* before previewing, so an + * incomplete draft is rejected rather than previewed. That makes it the + * UI's validation authority (it returns the same message `start` would), but + * it also means a `remoteProxyOtherMachine` draft cannot be previewed until + * it already carries a LAN bind address — which is what the preview is for. + * So the LAN candidate list is fetched with a separate always-valid + * `localOnly` probe; the backend builds that list independently of the mode. + * 2. Only `start` reports a runtime failure, so a rejected save/start surfaces + * the backend message verbatim — it is the concrete correction to apply. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { + DiagnosticWarning, + EmbeddedServerStatus, + GatewayError, + ServerExposureMode, + ServerExposureSettings, +} from "@/domain"; +import { useGateways } from "@/app/di"; + +/** Debounce before previewing a draft, so typing doesn't spam the backend. */ +const PREVIEW_DEBOUNCE_MS = 250; + +function messageOf(e: unknown): string { + return e && typeof e === "object" && "message" in e + ? String((e as GatewayError).message) + : String(e); +} + +export interface DeploymentVm { + /** False until the persisted settings have loaded. */ + ready: boolean; + /** The draft config being edited (persisted config until the user edits). */ + settings: ServerExposureSettings | null; + /** Current server status. */ + status: EmbeddedServerStatus; + /** LAN addresses discovered by the backend (never derived client-side). */ + candidateLanAddresses: string[]; + /** Backend-built upstream URL for the current draft; absent when invalid. */ + upstreamUrl?: string; + /** Non-fatal diagnostics for the draft — informational, never blocking. */ + warnings: DiagnosticWarning[]; + /** Why the draft is rejected, as told by the backend. Actionable, not decorative. */ + validationError: string | null; + /** Last save/start/stop failure. */ + actionError: string | null; + /** True while a start/stop is in flight. */ + busy: boolean; + setMode: (mode: ServerExposureMode) => void; + setPublicOrigin: (origin: string) => void; + setLanBindAddress: (address: string) => void; + setTrustedProxies: (raw: string) => void; + setPort: (port: number) => void; + /** Persists the draft, then starts the server so what runs is what is shown. */ + start: () => Promise; + stop: () => Promise; +} + +export function useDeployment(): DeploymentVm { + const { desktopServer } = useGateways(); + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState({ + state: "stopped", + }); + const [candidateLanAddresses, setCandidates] = useState([]); + const [upstreamUrl, setUpstreamUrl] = useState(); + const [warnings, setWarnings] = useState([]); + const [validationError, setValidationError] = useState(null); + const [actionError, setActionError] = useState(null); + const [busy, setBusy] = useState(false); + // Guards against a stale in-flight preview overwriting a newer one. + const previewSeq = useRef(0); + + useEffect(() => { + let alive = true; + void (async () => { + try { + const [persisted, current] = await Promise.all([ + desktopServer.getExposureSettings(), + desktopServer.status(), + ]); + if (!alive) return; + setSettings(persisted); + setStatus(current); + } catch (e) { + if (alive) setActionError(messageOf(e)); + } + })(); + return () => { + alive = false; + }; + }, [desktopServer]); + + // LAN candidates via an always-valid `localOnly` probe (see the header note). + useEffect(() => { + let alive = true; + void (async () => { + try { + const preview = await desktopServer.previewExposure({ + mode: "localOnly", + port: 0, + trustedProxies: [], + }); + if (alive) setCandidates(preview.candidateLanAddresses); + } catch { + // No candidates ⇒ the LAN field falls back to free text; the backend + // still rejects a bad address on save. + } + })(); + return () => { + alive = false; + }; + }, [desktopServer]); + + useEffect(() => { + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void desktopServer.onStatusChanged(setStatus).then((u) => { + // The effect may have torn down while the subscription was resolving. + if (cancelled) u(); + else unsubscribe = u; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [desktopServer]); + + // Preview (and thereby validate) the draft, debounced. + useEffect(() => { + if (!settings) return; + const seq = ++previewSeq.current; + const timer = setTimeout(() => { + void desktopServer.previewExposure(settings).then( + (preview) => { + if (seq !== previewSeq.current) return; + setUpstreamUrl(preview.upstreamUrl); + setWarnings(preview.warnings); + setValidationError(null); + }, + (e) => { + if (seq !== previewSeq.current) return; + // The draft is incomplete/invalid: the backend message *is* the fix. + setUpstreamUrl(undefined); + setWarnings([]); + setValidationError(messageOf(e)); + }, + ); + }, PREVIEW_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [desktopServer, settings]); + + const patch = useCallback((change: Partial) => { + setActionError(null); + setSettings((prev) => (prev ? { ...prev, ...change } : prev)); + }, []); + + const setMode = useCallback( + (mode: ServerExposureMode) => { + // Dropping to a narrower mode clears the fields that mode does not use, so + // a stale origin/proxy can never be persisted behind the user's back. + if (mode === "localOnly") { + patch({ mode, publicOrigin: undefined, lanBindAddress: undefined, trustedProxies: [] }); + } else if (mode === "remoteProxyLocal") { + patch({ mode, lanBindAddress: undefined, trustedProxies: [] }); + } else { + patch({ mode }); + } + }, + [patch], + ); + + const setPublicOrigin = useCallback( + (origin: string) => patch({ publicOrigin: origin.trim() || undefined }), + [patch], + ); + const setLanBindAddress = useCallback( + (address: string) => patch({ lanBindAddress: address || undefined }), + [patch], + ); + const setTrustedProxies = useCallback( + (raw: string) => + patch({ + trustedProxies: raw + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean), + }), + [patch], + ); + const setPort = useCallback((port: number) => patch({ port }), [patch]); + + const start = useCallback(async () => { + if (!settings) return; + setBusy(true); + setActionError(null); + try { + // `start` runs the *persisted* config, so persist the draft first — + // otherwise the server would run something other than what is on screen. + await desktopServer.saveExposureSettings(settings); + setStatus(await desktopServer.start()); + } catch (e) { + setActionError(messageOf(e)); + } finally { + setBusy(false); + } + }, [desktopServer, settings]); + + const stop = useCallback(async () => { + setBusy(true); + setActionError(null); + try { + setStatus(await desktopServer.stop()); + } catch (e) { + setActionError(messageOf(e)); + } finally { + setBusy(false); + } + }, [desktopServer]); + + return { + ready: settings !== null, + settings, + status, + candidateLanAddresses, + upstreamUrl, + warnings, + validationError, + actionError, + busy, + setMode, + setPublicOrigin, + setLanBindAddress, + setTrustedProxies, + setPort, + start, + stop, + }; +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index b1b6137..6dc481f 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -15,6 +15,7 @@ import type { DomainEvent, EmbedderEngines, EmbedderProfile, + EmbeddedServerStatus, FirstRunState, GitBranches, GitCommit, @@ -41,6 +42,8 @@ import type { ProfileAvailability, ResumableAgent, ReplyChunk, + ServerExposurePreview, + ServerExposureSettings, Skill, SkillScope, Sprint, @@ -678,6 +681,52 @@ export interface ModelServerGateway { ): Promise; } +/** + * Embedded web server lifecycle + exposure settings (ticket #68). Backs the + * desktop `Settings → Deployment` surface, so the user can expose IdeA to + * remote devices without a command line. + * + * **Desktop-only.** Only the Tauri transport implements it for real; the web + * transport rejects (a web client is *served by* this server and must not + * reconfigure it). See `desktop-only.test.ts`. + * + * The backend owns every network fact: LAN candidates and the proxy upstream + * URL come from {@link previewExposure}, never from client-side derivation. + */ +export interface DesktopServerGateway { + /** Reads the persisted exposure settings. */ + getExposureSettings(): Promise; + /** + * Validates and persists exposure settings. Rejects with a `GatewayError` + * (`INVALID`) when the config is inconsistent — e.g. a remote mode without an + * `https://` `publicOrigin`, or `remoteProxyOtherMachine` without a concrete + * `lanBindAddress` / a non-empty `trustedProxies`. + */ + saveExposureSettings(settings: ServerExposureSettings): Promise; + /** + * Derives LAN candidates, the upstream URL and non-fatal warnings for a + * **draft** config, without persisting it. The single source of truth for the + * addresses the UI displays. + */ + previewExposure( + settings: ServerExposureSettings, + ): Promise; + /** Current server status. */ + status(): Promise; + /** Starts the server with the persisted settings; resolves with the new status. */ + start(): Promise; + /** Stops the server; resolves with the new status. */ + stop(): Promise; + /** + * Observes status transitions. The backend emits no status event today, so + * the Tauri adapter polls `embedded_server_status`; the schedule is a + * transport detail owned by the adapter, and callers must not depend on it. + */ + onStatusChanged( + handler: (status: EmbeddedServerStatus) => void, + ): Promise; +} + /** Input for {@link EmbedderGateway.saveEmbedderProfile}. */ export interface SaveEmbedderProfileInput { profile: EmbedderProfile; @@ -1076,6 +1125,7 @@ export interface Gateways { remote: RemoteGateway; profile: ProfileGateway; modelServer: ModelServerGateway; + desktopServer: DesktopServerGateway; template: TemplateGateway; skill: SkillGateway; memory: MemoryGateway;