L'AppImage ne pouvait pas servir d'assets web : `resolve_web_root` ne connaissait que `IDEA_WEB_ROOT`, le dossier de l'exécutable et le cwd — aucun ne pointe vers un bundle packagé. - `bundle.resources` embarque les assets sous `web/`, `beforeBuildCommand` déclenche le build du frontend. - Le resource dir Tauri descend de `lib.rs` jusqu'à `EmbeddedServerController` via `AppState::build_with_resource_dir`, en gardant les constructeurs historiques comme façade. - `resolve_web_root` insère le candidat packagé juste après `IDEA_WEB_ROOT`, qui garde donc la priorité pour le développement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
810 lines
27 KiB
Rust
810 lines
27 KiB
Rust
//! 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,
|
|
resource_dir: Option<PathBuf>,
|
|
store: FsServerExposureSettingsStore,
|
|
inner: Mutex<EmbeddedServerInner>,
|
|
}
|
|
|
|
impl EmbeddedServerController {
|
|
/// Creates the controller.
|
|
#[must_use]
|
|
pub fn new(app_data_dir: PathBuf) -> Self {
|
|
Self::with_resource_dir(app_data_dir, None)
|
|
}
|
|
|
|
/// Creates the controller with the Tauri bundle resource directory.
|
|
#[must_use]
|
|
pub fn with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
|
|
Self {
|
|
store: FsServerExposureSettingsStore::new(app_data_dir.clone()),
|
|
app_data_dir,
|
|
resource_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(self.resource_dir.as_deref())?,
|
|
) {
|
|
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(resource_dir: Option<&Path>) -> Result<PathBuf, ErrorDto> {
|
|
let explicit_web_root = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from);
|
|
let candidates = web_root_candidates(explicit_web_root, resource_dir);
|
|
first_web_root(candidates).ok_or_else(|| {
|
|
invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT")
|
|
})
|
|
}
|
|
|
|
fn web_root_candidates(
|
|
explicit_web_root: Option<PathBuf>,
|
|
resource_dir: Option<&Path>,
|
|
) -> Vec<PathBuf> {
|
|
let mut candidates = Vec::new();
|
|
if let Some(path) = explicit_web_root {
|
|
candidates.push(path);
|
|
}
|
|
if let Some(resource_dir) = resource_dir {
|
|
candidates.push(resource_dir.join("web"));
|
|
}
|
|
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
|
|
}
|
|
|
|
fn first_web_root(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
|
|
candidates
|
|
.into_iter()
|
|
.find(|candidate| candidate.join("index.html").is_file())
|
|
}
|
|
|
|
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 web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
|
|
let explicit = tmp_app_data().join("explicit-web-root");
|
|
let resource_dir = tmp_app_data().join("resources");
|
|
|
|
let candidates = web_root_candidates(Some(explicit.clone()), Some(&resource_dir));
|
|
|
|
assert_eq!(candidates[0], explicit);
|
|
assert_eq!(candidates[1], resource_dir.join("web"));
|
|
}
|
|
|
|
#[test]
|
|
fn first_web_root_uses_first_candidate_with_index_html() {
|
|
let missing = tmp_app_data().join("missing-web-root");
|
|
let resource_dir = tmp_app_data().join("resources");
|
|
let resource_web = resource_dir.join("web");
|
|
std::fs::create_dir_all(&resource_web).unwrap();
|
|
std::fs::write(
|
|
resource_web.join("index.html"),
|
|
"<!doctype html><title>Packaged</title>",
|
|
)
|
|
.unwrap();
|
|
let later = tmp_web_root();
|
|
|
|
let resolved = first_web_root([missing, resource_web.clone(), later])
|
|
.expect("web root should resolve");
|
|
|
|
assert_eq!(resolved, resource_web);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|