Merge feature/ticket68-embedded-server-desktop into develop (#68)
Ferme #68 : le serveur web s'active désormais depuis l'app desktop, via un panneau Settings > Deployment. C'est la fonctionnalité demandée à l'origine du ticket, et l'aboutissement de la chaîne #65 (extraction d'idea-serve) → #68 B1 (seam shared-core) → #72 (durcissement proxy) → ce lot. Backend : `EmbeddedServerController` sur `AppState`, commandes start/stop/status, store `<app-data-dir>/deployment/server-exposure.json` (config de sécurité, pas préférence d'UI), arrêt câblé sur la sortie d'app. Le serveur embarqué consomme `run_embedded_with_core` avec le `BackendCore` du desktop — une seule composition root, ce pour quoi le seam de B1 existait. Frontend : `features/settings/` avec `SettingsView`/`DeploymentSettings`, gateway `DesktopServerGateway`, et `ProjectsView` dont le booléen `showSettings` devient une vraie navigation `AI Profiles` / `Deployment`. Vert avant merge, ré-exécuté par Git HORS SANDBOX — impératif sur ces crates, le sandbox bloque `bind` et a déjà produit un faux vert sur #68 B1 : `cargo test -p app-tauri` 43 passed / 1 ignored · `-p web-server` 66 passed · `npm run typecheck` exit 0 · `npx vitest run` 87 files / 789 passed. Le test ignoré est une garde d'environnement préexistante, vérifiée passante hors sandbox avec `--ignored`. Invariants vérifiés par Git avant merge : `run_embedded_with_core` bien l'appelant, aucune fuite des types d'exposition dans `domain`/`application`, `0600` posé avant le `rename`, `stop()` appelé à la fermeture. Dette consignée, non bloquante : `preview_settings` valide avant de prévisualiser (verrouille la liste des candidats LAN, contourné côté frontend), et le warning `missingTrustedProxy` est inatteignable. 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,
|
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
|
||||||
|
|||||||
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 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");
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
82
frontend/src/adapters/desktopServer.ts
Normal file
82
frontend/src/adapters/desktopServer.ts
Normal file
@ -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<ServerExposureSettings> {
|
||||||
|
return invoke<ServerExposureSettings>("get_server_exposure_settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
|
||||||
|
await invoke("save_server_exposure_settings", { settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
previewExposure(
|
||||||
|
settings: ServerExposureSettings,
|
||||||
|
): Promise<ServerExposurePreview> {
|
||||||
|
return invoke<ServerExposurePreview>("preview_server_exposure_settings", {
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): Promise<EmbeddedServerStatus> {
|
||||||
|
return invoke<EmbeddedServerStatus>("embedded_server_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): Promise<EmbeddedServerStatus> {
|
||||||
|
return invoke<EmbeddedServerStatus>("embedded_server_start");
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): Promise<EmbeddedServerStatus> {
|
||||||
|
return invoke<EmbeddedServerStatus>("embedded_server_stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onStatusChanged(
|
||||||
|
handler: (status: EmbeddedServerStatus) => void,
|
||||||
|
): Promise<Unsubscribe> {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,6 +42,7 @@ import {
|
|||||||
HttpTicketGateway,
|
HttpTicketGateway,
|
||||||
} from "./streamGateways";
|
} from "./streamGateways";
|
||||||
import {
|
import {
|
||||||
|
WebDesktopServerGateway,
|
||||||
WebFocusedProjectGateway,
|
WebFocusedProjectGateway,
|
||||||
WebRemoteGateway,
|
WebRemoteGateway,
|
||||||
WebWindowGateway,
|
WebWindowGateway,
|
||||||
@ -121,6 +122,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
remote: new WebRemoteGateway(),
|
remote: new WebRemoteGateway(),
|
||||||
profile: new HttpProfileGateway(http),
|
profile: new HttpProfileGateway(http),
|
||||||
modelServer: new HttpModelServerGateway(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),
|
template: new HttpTemplateGateway(http),
|
||||||
skill: new HttpSkillGateway(http),
|
skill: new HttpSkillGateway(http),
|
||||||
memory: new HttpMemoryGateway(http),
|
memory: new HttpMemoryGateway(http),
|
||||||
|
|||||||
@ -9,8 +9,15 @@
|
|||||||
* browser to replace `pickFolder`), it gets its own lot.
|
* browser to replace `pickFolder`), it gets its own lot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GatewayError, Unsubscribe } from "@/domain";
|
|
||||||
import type {
|
import type {
|
||||||
|
EmbeddedServerStatus,
|
||||||
|
GatewayError,
|
||||||
|
ServerExposurePreview,
|
||||||
|
ServerExposureSettings,
|
||||||
|
Unsubscribe,
|
||||||
|
} from "@/domain";
|
||||||
|
import type {
|
||||||
|
DesktopServerGateway,
|
||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
@ -75,3 +82,40 @@ export class WebRemoteGateway implements RemoteGateway {
|
|||||||
return unsupportedOnWeb("Remote (SSH/WSL) connection");
|
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<ServerExposureSettings> {
|
||||||
|
return unsupportedOnWeb("Embedded server settings");
|
||||||
|
}
|
||||||
|
async saveExposureSettings(_settings: ServerExposureSettings): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Embedded server settings");
|
||||||
|
}
|
||||||
|
async previewExposure(
|
||||||
|
_settings: ServerExposureSettings,
|
||||||
|
): Promise<ServerExposurePreview> {
|
||||||
|
return unsupportedOnWeb("Embedded server settings");
|
||||||
|
}
|
||||||
|
async status(): Promise<EmbeddedServerStatus> {
|
||||||
|
return unsupportedOnWeb("Embedded server status");
|
||||||
|
}
|
||||||
|
async start(): Promise<EmbeddedServerStatus> {
|
||||||
|
return unsupportedOnWeb("Starting the embedded server");
|
||||||
|
}
|
||||||
|
async stop(): Promise<EmbeddedServerStatus> {
|
||||||
|
return unsupportedOnWeb("Stopping the embedded server");
|
||||||
|
}
|
||||||
|
onStatusChanged(
|
||||||
|
_handler: (status: EmbeddedServerStatus) => void,
|
||||||
|
): Promise<Unsubscribe> {
|
||||||
|
// Never fires on web; a no-op unsubscribe keeps callers' teardown honest.
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { TauriTerminalGateway } from "./terminal";
|
|||||||
import { TauriLayoutGateway } from "./layout";
|
import { TauriLayoutGateway } from "./layout";
|
||||||
import { TauriProfileGateway } from "./profile";
|
import { TauriProfileGateway } from "./profile";
|
||||||
import { TauriModelServerGateway } from "./modelServer";
|
import { TauriModelServerGateway } from "./modelServer";
|
||||||
|
import { TauriDesktopServerGateway } from "./desktopServer";
|
||||||
import { TauriTemplateGateway } from "./template";
|
import { TauriTemplateGateway } from "./template";
|
||||||
import { TauriSkillGateway } from "./skill";
|
import { TauriSkillGateway } from "./skill";
|
||||||
import { TauriMemoryGateway } from "./memory";
|
import { TauriMemoryGateway } from "./memory";
|
||||||
@ -60,6 +61,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
remote: new TauriRemoteGateway(),
|
remote: new TauriRemoteGateway(),
|
||||||
profile: new TauriProfileGateway(),
|
profile: new TauriProfileGateway(),
|
||||||
modelServer: new TauriModelServerGateway(),
|
modelServer: new TauriModelServerGateway(),
|
||||||
|
desktopServer: new TauriDesktopServerGateway(),
|
||||||
template: new TauriTemplateGateway(),
|
template: new TauriTemplateGateway(),
|
||||||
skill: new TauriSkillGateway(),
|
skill: new TauriSkillGateway(),
|
||||||
memory: new TauriMemoryGateway(),
|
memory: new TauriMemoryGateway(),
|
||||||
@ -83,6 +85,7 @@ export {
|
|||||||
TauriLayoutGateway,
|
TauriLayoutGateway,
|
||||||
TauriProfileGateway,
|
TauriProfileGateway,
|
||||||
TauriModelServerGateway,
|
TauriModelServerGateway,
|
||||||
|
TauriDesktopServerGateway,
|
||||||
TauriTemplateGateway,
|
TauriTemplateGateway,
|
||||||
TauriSkillGateway,
|
TauriSkillGateway,
|
||||||
TauriMemoryGateway,
|
TauriMemoryGateway,
|
||||||
|
|||||||
@ -8,9 +8,11 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
AgentDrift,
|
AgentDrift,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
|
DiagnosticWarning,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
|
EmbeddedServerStatus,
|
||||||
FirstRunState,
|
FirstRunState,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
GitBranches,
|
GitBranches,
|
||||||
@ -36,6 +38,8 @@ import type {
|
|||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
|
ServerExposurePreview,
|
||||||
|
ServerExposureSettings,
|
||||||
Skill,
|
Skill,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
@ -63,6 +67,7 @@ import type {
|
|||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
CreateMemoryInput,
|
CreateMemoryInput,
|
||||||
CreateSkillInput,
|
CreateSkillInput,
|
||||||
|
DesktopServerGateway,
|
||||||
EmbedderGateway,
|
EmbedderGateway,
|
||||||
CreateTemplateInput,
|
CreateTemplateInput,
|
||||||
Gateways,
|
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<ServerExposureSettings> {
|
||||||
|
return structuredClone(this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
|
||||||
|
this.validate(settings);
|
||||||
|
this.settings = structuredClone(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewExposure(
|
||||||
|
settings: ServerExposureSettings,
|
||||||
|
): Promise<ServerExposurePreview> {
|
||||||
|
// 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<EmbeddedServerStatus> {
|
||||||
|
return structuredClone(this.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<EmbeddedServerStatus> {
|
||||||
|
// 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<EmbeddedServerStatus> {
|
||||||
|
this.emit({ state: "stopped" });
|
||||||
|
return structuredClone(this.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onStatusChanged(
|
||||||
|
handler: (status: EmbeddedServerStatus) => void,
|
||||||
|
): Promise<Unsubscribe> {
|
||||||
|
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.
|
* Stateful in-memory template gateway.
|
||||||
*
|
*
|
||||||
@ -2630,6 +2777,7 @@ export function createMockGateways(): Gateways {
|
|||||||
remote: new MockRemoteGateway(),
|
remote: new MockRemoteGateway(),
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
modelServer: new MockModelServerGateway(),
|
modelServer: new MockModelServerGateway(),
|
||||||
|
desktopServer: new MockDesktopServerGateway(),
|
||||||
template: new MockTemplateGateway(agentGateway),
|
template: new MockTemplateGateway(agentGateway),
|
||||||
skill: new MockSkillGateway(agentGateway),
|
skill: new MockSkillGateway(agentGateway),
|
||||||
memory: new MockMemoryGateway(),
|
memory: new MockMemoryGateway(),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ describe("createMockGateways", () => {
|
|||||||
expect(Object.keys(gateways).sort()).toEqual([
|
expect(Object.keys(gateways).sort()).toEqual([
|
||||||
"agent",
|
"agent",
|
||||||
"conversation",
|
"conversation",
|
||||||
|
"desktopServer",
|
||||||
"embedder",
|
"embedder",
|
||||||
"focusedProject",
|
"focusedProject",
|
||||||
"git",
|
"git",
|
||||||
|
|||||||
@ -117,6 +117,97 @@ export interface ModelServerCommandPreview {
|
|||||||
display: string;
|
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`). */
|
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| { type: "projectCreated"; projectId: string }
|
| { type: "projectCreated"; projectId: string }
|
||||||
|
|||||||
@ -9,9 +9,13 @@
|
|||||||
* │ MENU BAR Panneaux │ Settings │
|
* │ MENU BAR Panneaux │ Settings │
|
||||||
* ├───────────────────────────────────────────────────────┤
|
* ├───────────────────────────────────────────────────────┤
|
||||||
* │ MAIN — LayoutGrid (fills the FULL width) │
|
* │ 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,
|
* The former left sidebar is gone: every panel (context, work, tickets, agents,
|
||||||
* templates, skills, permissions, memory, git, projects) is reached from the
|
* templates, skills, permissions, memory, git, projects) is reached from the
|
||||||
* single **Panneaux** menu (#26), whose per-panel submenu picks the placement
|
* 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 type { DomainEvent, LayoutInfo } from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { ConversationViewer } from "@/features/conversations";
|
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 { GitGraphView } from "@/features/git";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -114,10 +123,13 @@ export function ProjectsView() {
|
|||||||
// Width (px) of each dock column, driven by the DockRegion resize handle.
|
// Width (px) of each dock column, driven by the DockRegion resize handle.
|
||||||
const [leftDockWidth, setLeftDockWidth] = useState(340);
|
const [leftDockWidth, setLeftDockWidth] = useState(340);
|
||||||
const [rightDockWidth, setRightDockWidth] = useState(340);
|
const [rightDockWidth, setRightDockWidth] = useState(340);
|
||||||
// Top-level view switch (#16): when true, the main area shows the AI Profiles
|
// Top-level view switch (#16, extended in #68): the open Settings section, or
|
||||||
// settings instead of the project surface. The single menu bar stays visible
|
// null when the main area shows the project surface. Settings is a main
|
||||||
// so the user can toggle back from Settings → AI Profiles.
|
// surface with its own internal nav — deliberately not a placeable panel. The
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
// menu bar stays visible above it.
|
||||||
|
const [settingsSection, setSettingsSection] = useState<SettingsSection | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
// The active layout (id + kind), reported by LayoutTabs — the single source of
|
// 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
|
// truth. `kind` decides whether the main area is the terminal grid or the git
|
||||||
// graph view.
|
// graph view.
|
||||||
@ -390,17 +402,17 @@ export function ProjectsView() {
|
|||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
items: [
|
// 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.
|
||||||
id: "ai-profiles",
|
items: SETTINGS_SECTIONS.map((section) => ({
|
||||||
label: showSettings ? "Close AI Profiles" : "AI Profiles",
|
id: section,
|
||||||
active: showSettings,
|
label: SETTINGS_SECTION_LABEL[section],
|
||||||
onSelect: () => {
|
active: settingsSection === section,
|
||||||
setShowSettings((v) => !v);
|
onSelect: () => {
|
||||||
dismissFloating();
|
setSettingsSection(section);
|
||||||
},
|
dismissFloating();
|
||||||
},
|
},
|
||||||
],
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -621,7 +633,7 @@ export function ProjectsView() {
|
|||||||
onClose={(id) => void vm.closeTab(id)}
|
onClose={(id) => void vm.closeTab(id)}
|
||||||
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
||||||
onOpenProjectsPanel={() => {
|
onOpenProjectsPanel={() => {
|
||||||
setShowSettings(false);
|
setSettingsSection(null);
|
||||||
setPlacement("projects", "floating");
|
setPlacement("projects", "floating");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -645,14 +657,14 @@ export function ProjectsView() {
|
|||||||
|
|
||||||
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
|
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{showSettings ? (
|
{settingsSection ? (
|
||||||
// Top-level view switch (#16): AI Profiles settings takes over the main
|
// Top-level view switch (#16/#68): Settings takes over the main area
|
||||||
// area while the menu bar above stays visible to toggle back.
|
// while the menu bar above stays visible.
|
||||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
<SettingsView
|
||||||
<div className="w-full max-w-2xl">
|
section={settingsSection}
|
||||||
<ProfilesSettings />
|
onSectionChange={setSettingsSection}
|
||||||
</div>
|
onClose={() => setSettingsSection(null)}
|
||||||
</div>
|
/>
|
||||||
) : active && viewerConversationId ? (
|
) : active && viewerConversationId ? (
|
||||||
<ConversationViewer
|
<ConversationViewer
|
||||||
key={`${active.id}-${viewerConversationId}`}
|
key={`${active.id}-${viewerConversationId}`}
|
||||||
@ -740,7 +752,7 @@ export function ProjectsView() {
|
|||||||
(tab) => tab.id === toast.projectId,
|
(tab) => tab.id === toast.projectId,
|
||||||
);
|
);
|
||||||
if (projectOpen) vm.activateTab(toast.projectId);
|
if (projectOpen) vm.activateTab(toast.projectId);
|
||||||
setShowSettings(false);
|
setSettingsSection(null);
|
||||||
setViewerConversationId(null);
|
setViewerConversationId(null);
|
||||||
setPlacement("work", "floating");
|
setPlacement("work", "floating");
|
||||||
setTaskToasts((prev) =>
|
setTaskToasts((prev) =>
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
} from "@testing-library/react";
|
} 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 type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { ProjectsView } from "./ProjectsView";
|
import { ProjectsView } from "./ProjectsView";
|
||||||
@ -25,6 +25,7 @@ function renderView(
|
|||||||
// The project gateway drives the primary assertions; agent + profile stubs are
|
// The project gateway drives the primary assertions; agent + profile stubs are
|
||||||
// required because ProjectsView now renders AgentsPanel for the active tab.
|
// required because ProjectsView now renders AgentsPanel for the active tab.
|
||||||
// template gateway is required because ProjectsView now renders TemplatesPanel.
|
// template gateway is required because ProjectsView now renders TemplatesPanel.
|
||||||
|
// desktopServer is required by Settings → Deployment (#68).
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
const gateways = {
|
const gateways = {
|
||||||
system,
|
system,
|
||||||
@ -35,6 +36,7 @@ function renderView(
|
|||||||
git: new MockGitGateway(),
|
git: new MockGitGateway(),
|
||||||
workState: new MockWorkStateGateway(),
|
workState: new MockWorkStateGateway(),
|
||||||
window: new MockWindowGateway(),
|
window: new MockWindowGateway(),
|
||||||
|
desktopServer: new MockDesktopServerGateway(),
|
||||||
} as unknown as Gateways;
|
} as unknown as Gateways;
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
@ -197,7 +199,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect(betaTab.getAttribute("aria-selected")).toBe("true");
|
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();
|
renderView();
|
||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
|
|
||||||
@ -210,14 +212,31 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
|
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
|
||||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||||
|
|
||||||
// Settings → Close AI Profiles returns to the project surface.
|
// #68: closing is an explicit action in the view, not an alternating menu
|
||||||
openMenuItem("Settings", "Close AI Profiles");
|
// label — the label never scaled past one section.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close Settings" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
|
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 () => {
|
it("closing a tab removes it from the tab bar", async () => {
|
||||||
renderView();
|
renderView();
|
||||||
await createProject("alpha", "/home/me/alpha");
|
await createProject("alpha", "/home/me/alpha");
|
||||||
|
|||||||
209
frontend/src/features/settings/DeploymentSettings.test.tsx
Normal file
209
frontend/src/features/settings/DeploymentSettings.test.tsx
Normal file
@ -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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<DeploymentSettings />
|
||||||
|
</DIProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
355
frontend/src/features/settings/DeploymentSettings.tsx
Normal file
355
frontend/src/features/settings/DeploymentSettings.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={!supported}
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(value).then(
|
||||||
|
() => {
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
},
|
||||||
|
() => setCopied(false),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only value the user is meant to copy elsewhere (never editable). */
|
||||||
|
function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 truncate rounded-md border border-border bg-raised px-2 py-1.5 font-mono text-xs text-content">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CopyButton value={value} label={copyLabel} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeploymentSettings() {
|
||||||
|
const vm = useDeployment();
|
||||||
|
|
||||||
|
if (!vm.ready || !vm.settings) {
|
||||||
|
return (
|
||||||
|
<Panel aria-label="deployment settings" title="Deployment">
|
||||||
|
<p className="text-sm text-muted">Loading…</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div aria-label="deployment settings" className="flex flex-col gap-4">
|
||||||
|
{/* ── Status ────────────────────────────────────────────────────────── */}
|
||||||
|
<Panel
|
||||||
|
title="Server"
|
||||||
|
actions={
|
||||||
|
running || status.state === "stopping" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void vm.stop()}
|
||||||
|
disabled={vm.busy || transitioning}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void vm.start()}
|
||||||
|
disabled={vm.busy || transitioning}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"size-2 rounded-full",
|
||||||
|
running && "bg-success",
|
||||||
|
status.state === "failed" && "bg-danger",
|
||||||
|
(status.state === "stopped" || transitioning) && "bg-faint",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-content">{STATE_LABEL[status.state]}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status.localUrl && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Local URL: <code className="font-mono text-content">{status.localUrl}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.publicUrl && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Public URL: <code className="font-mono text-content">{status.publicUrl}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* A failure carries the backend's message: it is the correction. */}
|
||||||
|
{status.state === "failed" && status.error && (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{status.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{vm.actionError && (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{vm.actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* ── Exposure ──────────────────────────────────────────────────────── */}
|
||||||
|
<Panel title="Exposure">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="exposure mode"
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{MODE_OPTIONS.map((option) => {
|
||||||
|
const selected = settings.mode === option.mode;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.mode}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-border-strong bg-raised"
|
||||||
|
: "border-border hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="exposure-mode"
|
||||||
|
className="mt-1"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => vm.setMode(option.mode)}
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-semibold text-content">
|
||||||
|
{option.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Port">
|
||||||
|
{({ id }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
value={String(settings.port)}
|
||||||
|
onChange={(e) => vm.setPort(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{remote && (
|
||||||
|
<Field label="Public origin" hint="Example: https://idea.example.com">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
placeholder="https://idea.example.com"
|
||||||
|
value={settings.publicOrigin ?? ""}
|
||||||
|
onChange={(e) => vm.setPublicOrigin(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{otherMachine && (
|
||||||
|
<>
|
||||||
|
{/* Addresses come from the backend probe — never invented here. */}
|
||||||
|
<Field
|
||||||
|
label="LAN address to bind"
|
||||||
|
hint="The address on this machine that the proxy will connect to."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) =>
|
||||||
|
vm.candidateLanAddresses.length > 0 ? (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-content"
|
||||||
|
value={settings.lanBindAddress ?? ""}
|
||||||
|
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select an address…</option>
|
||||||
|
{vm.candidateLanAddresses.map((address) => (
|
||||||
|
<option key={address} value={address}>
|
||||||
|
{address}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
placeholder="192.168.1.42"
|
||||||
|
value={settings.lanBindAddress ?? ""}
|
||||||
|
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Authorized proxy IP/CIDR"
|
||||||
|
hint="This is not where IdeA listens. It is the machine allowed to contact IdeA."
|
||||||
|
>
|
||||||
|
{({ id, describedBy }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
placeholder="203.0.113.7 or 203.0.113.0/24"
|
||||||
|
value={settings.trustedProxies.join(", ")}
|
||||||
|
onChange={(e) => vm.setTrustedProxies(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
If your proxy is not on this computer, choose this mode.
|
||||||
|
Otherwise the proxy may time out without an IdeA error.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings inform; they never block a start. */}
|
||||||
|
{vm.warnings.map((warning) => (
|
||||||
|
<p key={warning.code} className="text-xs text-warning">
|
||||||
|
{warning.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{vm.validationError && (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{vm.validationError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */}
|
||||||
|
{remote && (
|
||||||
|
<Panel title="Proxy setup">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Point your HTTPS reverse proxy at this upstream.
|
||||||
|
</p>
|
||||||
|
{vm.upstreamUrl ? (
|
||||||
|
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copy upstream url" />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-faint">
|
||||||
|
Complete the settings above to get the upstream URL.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */}
|
||||||
|
<Panel title="Pairing">
|
||||||
|
{running && status.pairingCode ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ReadOnlyValue value={status.pairingCode} copyLabel="copy pairing code" />
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Temporary code. It disappears when the server stops. Do not save it
|
||||||
|
in configuration files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Start the server to generate a pairing code.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/features/settings/SettingsView.tsx
Normal file
85
frontend/src/features/settings/SettingsView.tsx
Normal file
@ -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<SettingsSection, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<nav
|
||||||
|
aria-label="settings sections"
|
||||||
|
className="flex w-52 shrink-0 flex-col gap-1 border-r border-border p-3"
|
||||||
|
>
|
||||||
|
{SETTINGS_SECTIONS.map((id) => {
|
||||||
|
const active = id === section;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
onClick={() => onSectionChange(id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-raised font-semibold text-content"
|
||||||
|
: "text-muted hover:bg-raised hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{SETTINGS_SECTION_LABEL[id]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
<Button size="sm" variant="ghost" className="w-full" onClick={onClose}>
|
||||||
|
Close Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
{section === "aiProfiles" ? <ProfilesSettings /> : <DeploymentSettings />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/features/settings/desktop-only.test.ts
Normal file
60
frontend/src/features/settings/desktop-only.test.ts
Normal file
@ -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<GatewayError> = { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
frontend/src/features/settings/index.ts
Normal file
11
frontend/src/features/settings/index.ts
Normal file
@ -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";
|
||||||
253
frontend/src/features/settings/useDeployment.ts
Normal file
253
frontend/src/features/settings/useDeployment.ts
Normal file
@ -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<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeployment(): DeploymentVm {
|
||||||
|
const { desktopServer } = useGateways();
|
||||||
|
const [settings, setSettings] = useState<ServerExposureSettings | null>(null);
|
||||||
|
const [status, setStatus] = useState<EmbeddedServerStatus>({
|
||||||
|
state: "stopped",
|
||||||
|
});
|
||||||
|
const [candidateLanAddresses, setCandidates] = useState<string[]>([]);
|
||||||
|
const [upstreamUrl, setUpstreamUrl] = useState<string | undefined>();
|
||||||
|
const [warnings, setWarnings] = useState<DiagnosticWarning[]>([]);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(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<ServerExposureSettings>) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import type {
|
|||||||
DomainEvent,
|
DomainEvent,
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
|
EmbeddedServerStatus,
|
||||||
FirstRunState,
|
FirstRunState,
|
||||||
GitBranches,
|
GitBranches,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@ -41,6 +42,8 @@ import type {
|
|||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
|
ServerExposurePreview,
|
||||||
|
ServerExposureSettings,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
Sprint,
|
Sprint,
|
||||||
@ -678,6 +681,52 @@ export interface ModelServerGateway {
|
|||||||
): Promise<ModelServerCommandPreview>;
|
): Promise<ModelServerCommandPreview>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ServerExposureSettings>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
/**
|
||||||
|
* 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<ServerExposurePreview>;
|
||||||
|
/** Current server status. */
|
||||||
|
status(): Promise<EmbeddedServerStatus>;
|
||||||
|
/** Starts the server with the persisted settings; resolves with the new status. */
|
||||||
|
start(): Promise<EmbeddedServerStatus>;
|
||||||
|
/** Stops the server; resolves with the new status. */
|
||||||
|
stop(): Promise<EmbeddedServerStatus>;
|
||||||
|
/**
|
||||||
|
* 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<Unsubscribe>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
||||||
export interface SaveEmbedderProfileInput {
|
export interface SaveEmbedderProfileInput {
|
||||||
profile: EmbedderProfile;
|
profile: EmbedderProfile;
|
||||||
@ -1076,6 +1125,7 @@ export interface Gateways {
|
|||||||
remote: RemoteGateway;
|
remote: RemoteGateway;
|
||||||
profile: ProfileGateway;
|
profile: ProfileGateway;
|
||||||
modelServer: ModelServerGateway;
|
modelServer: ModelServerGateway;
|
||||||
|
desktopServer: DesktopServerGateway;
|
||||||
template: TemplateGateway;
|
template: TemplateGateway;
|
||||||
skill: SkillGateway;
|
skill: SkillGateway;
|
||||||
memory: MemoryGateway;
|
memory: MemoryGateway;
|
||||||
|
|||||||
Reference in New Issue
Block a user