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:
@ -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<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.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
756
crates/app-tauri/src/embedded_server.rs
Normal file
756
crates/app-tauri/src/embedded_server.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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<PtyBridge>,
|
||||
/// Structured reply to Tauri Channel bridge registry.
|
||||
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.
|
||||
focused_project: Mutex<Option<FocusedProjectDto>>,
|
||||
}
|
||||
@ -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<BackendCore> {
|
||||
Arc::clone(&self.core)
|
||||
}
|
||||
|
||||
/// Updates the focused project state. `None` means no project is focused/open.
|
||||
pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) {
|
||||
*self
|
||||
|
||||
Reference in New Issue
Block a user