feat(app-tauri): cycle de vie du serveur embarqué et persistance de l'exposition (#68 B2)

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 `<app-data-dir>/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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:43:24 +02:00
parent 7fbaa8eba6
commit 49e1d5a157
4 changed files with 843 additions and 1 deletions

View File

@ -61,6 +61,9 @@ use crate::dto::{
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
}; };
use crate::embedded_server::{
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
};
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::{AppState, FocusedProjectDto}; use crate::state::{AppState, FocusedProjectDto};
use domain::{SkillRef, SkillScope}; use domain::{SkillRef, SkillScope};
@ -83,6 +86,69 @@ pub fn health(
.map_err(ErrorDto::from) .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<ServerExposureSettingsDto, ErrorDto> {
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<ServerExposurePreviewDto, ErrorDto> {
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<EmbeddedServerStatusDto, ErrorDto> {
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<EmbeddedServerStatusDto, ErrorDto> {
state.embedded_server.stop().await
}
/// `create_project` — create a project from a root: init `.ideai/`, register it. /// `create_project` — create a project from a root: init `.ideai/`, register it.
/// ///
/// # Errors /// # Errors

View File

@ -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<String>,
/// Authorized proxy peers as IPs or CIDR ranges.
#[serde(default)]
pub trusted_proxies: Vec<String>,
/// LAN bind address for `remoteProxyOtherMachine`.
pub lan_bind_address: Option<String>,
}
/// 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<String>,
/// URL the reverse proxy should use as its upstream.
pub upstream_url: Option<String>,
/// Non-fatal diagnostics.
pub warnings: Vec<DiagnosticWarningDto>,
}
/// 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<String>,
/// Public URL, when configured.
pub public_url: Option<String>,
/// Reverse-proxy upstream URL derived from settings.
pub upstream_url: Option<String>,
/// Runtime pairing code, never persisted.
pub pairing_code: Option<String>,
/// Last failure, when state is `failed`.
pub error: Option<ErrorDto>,
}
#[derive(Default)]
struct EmbeddedServerInner {
handle: Option<EmbeddedServerHandle>,
state: EmbeddedServerStatusStateDto,
upstream_url: Option<String>,
public_url: Option<String>,
error: Option<ErrorDto>,
}
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 `<app-data-dir>/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<ServerExposureSettingsDto, ErrorDto> {
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::<ServerExposureSettingsDto>(&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<EmbeddedServerInner>,
}
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<ServerExposureSettingsDto, ErrorDto> {
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<ServerExposurePreviewDto, ErrorDto> {
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<BackendCore>) -> Result<EmbeddedServerStatusDto, ErrorDto> {
{
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<EmbeddedServerStatusDto, ErrorDto> {
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<ServerConfig, ErrorDto> {
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::<Result<Vec<_>, _>>()?;
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<String> {
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<IpAddr, ErrorDto> {
value
.parse::<IpAddr>()
.map_err(|_| invalid_error(format!("{field} must be an IP address")))
}
fn resolve_web_root() -> Result<PathBuf, ErrorDto> {
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<String>) -> 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<std::ffi::OsString>,
}
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"),
"<!doctype html><title>IdeA</title>",
)
.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());
}
}

View File

@ -16,6 +16,7 @@
pub mod chat; pub mod chat;
pub mod commands; pub mod commands;
pub mod dto; pub mod dto;
pub mod embedded_server;
pub mod events; pub mod events;
pub mod mcp_bridge; pub mod mcp_bridge;
pub mod mcp_endpoint; pub mod mcp_endpoint;
@ -129,6 +130,7 @@ pub fn run() {
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
let model_servers = let model_servers =
std::sync::Arc::clone(&state.ensure_local_model_server); 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 open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles(); let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
@ -148,6 +150,7 @@ pub fn run() {
let _ = pty.kill(&h).await; let _ = pty.kill(&h).await;
} }
let _ = model_servers.stop_on_app_exit().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::cancel_background_task,
commands::retry_background_task, commands::retry_background_task,
commands::list_background_tasks, 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!()) .run(tauri::generate_context!())
.expect("error while running IdeA Tauri application"); .expect("error while running IdeA Tauri application");

View File

@ -14,6 +14,7 @@ use infrastructure::TicketToolProvider;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::chat::ChatBridge; use crate::chat::ChatBridge;
use crate::embedded_server::EmbeddedServerController;
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
use crate::tickets::AppTicketToolProvider; use crate::tickets::AppTicketToolProvider;
@ -38,6 +39,8 @@ pub struct AppState {
pub pty_bridge: Arc<PtyBridge>, pub pty_bridge: Arc<PtyBridge>,
/// Structured reply to Tauri Channel bridge registry. /// Structured reply to Tauri Channel bridge registry.
pub chat_bridge: Arc<ChatBridge>, pub chat_bridge: Arc<ChatBridge>,
/// Desktop-owned embedded HTTP server lifecycle manager.
pub embedded_server: Arc<EmbeddedServerController>,
/// Project currently focused by the main window; panel-only windows follow it. /// Project currently focused by the main window; panel-only windows follow it.
focused_project: Mutex<Option<FocusedProjectDto>>, focused_project: Mutex<Option<FocusedProjectDto>>,
} }
@ -48,7 +51,7 @@ impl AppState {
/// into the backend core. /// into the backend core.
#[must_use] #[must_use]
pub fn build(app_data_dir: PathBuf) -> Self { 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 core.ticket_tool_binder
.bind(Arc::new(AppTicketToolProvider { .bind(Arc::new(AppTicketToolProvider {
create: Arc::clone(&core.create_issue), create: Arc::clone(&core.create_issue),
@ -66,10 +69,18 @@ impl AppState {
core, core,
pty_bridge: Arc::new(PtyBridge::new()), pty_bridge: Arc::new(PtyBridge::new()),
chat_bridge: Arc::new(ChatBridge::new()), chat_bridge: Arc::new(ChatBridge::new()),
embedded_server: Arc::new(EmbeddedServerController::new(app_data_dir)),
focused_project: Mutex::new(None), 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<BackendCore> {
Arc::clone(&self.core)
}
/// Updates the focused project state. `None` means no project is focused/open. /// Updates the focused project state. `None` means no project is focused/open.
pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) { pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) {
*self *self