feat(app-tauri): option de lancement auto du serveur web au démarrage
Ajoute le déclenchement du serveur embarqué dès le démarrage d'IdeA selon la préférence utilisateur, sans action manuelle requise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -32,6 +32,9 @@ pub enum ServerExposureMode {
|
||||
pub struct ServerExposureSettingsDto {
|
||||
/// Exposure mode.
|
||||
pub mode: ServerExposureMode,
|
||||
/// Whether the embedded server should start automatically when IdeA boots.
|
||||
#[serde(default)]
|
||||
pub auto_start: bool,
|
||||
/// TCP port to bind. `0` asks the OS for an ephemeral port.
|
||||
pub port: u16,
|
||||
/// Public HTTPS origin used by reverse-proxy modes.
|
||||
@ -267,7 +270,7 @@ impl EmbeddedServerController {
|
||||
}
|
||||
Err(message) => {
|
||||
let err = ErrorDto {
|
||||
code: "PROCESS".to_owned(),
|
||||
code: start_error_code(&message).to_owned(),
|
||||
message,
|
||||
};
|
||||
self.mark_failed(err.clone());
|
||||
@ -276,6 +279,28 @@ impl EmbeddedServerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the embedded server at application boot when persisted settings
|
||||
/// opt into auto-start.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if persisted settings are invalid or start fails.
|
||||
pub async fn auto_start_if_enabled(
|
||||
&self,
|
||||
core: Arc<BackendCore>,
|
||||
) -> Result<Option<EmbeddedServerStatusDto>, ErrorDto> {
|
||||
let settings = match self.store.read() {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
self.mark_failed(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if !settings.auto_start {
|
||||
return Ok(None);
|
||||
}
|
||||
self.start(core).await.map(Some)
|
||||
}
|
||||
|
||||
/// Generates a new ephemeral pairing code on the running embedded server.
|
||||
///
|
||||
/// # Errors
|
||||
@ -351,6 +376,7 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
|
||||
fn default_settings() -> ServerExposureSettingsDto {
|
||||
ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
auto_start: false,
|
||||
port: 17373,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -358,6 +384,21 @@ fn default_settings() -> ServerExposureSettingsDto {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_error_code(message: &str) -> &'static str {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("address already in use")
|
||||
|| lower.contains("only one usage of each socket address")
|
||||
|| lower.contains("addrinuse")
|
||||
|| lower.contains("os error 98")
|
||||
|| lower.contains("os error 48")
|
||||
|| lower.contains("os error 10048")
|
||||
{
|
||||
"PORT_IN_USE"
|
||||
} else {
|
||||
"PROCESS"
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
|
||||
match settings.mode {
|
||||
ServerExposureMode::LocalOnly => {}
|
||||
@ -621,6 +662,10 @@ mod tests {
|
||||
web_root
|
||||
}
|
||||
|
||||
fn loopback_bind_available() -> bool {
|
||||
std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
|
||||
let explicit = tmp_app_data().join("explicit-web-root");
|
||||
@ -655,6 +700,7 @@ mod tests {
|
||||
fn non_loopback_remote_requires_trusted_proxy() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
||||
auto_start: false,
|
||||
port: 17373,
|
||||
public_origin: Some("https://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -671,6 +717,7 @@ mod tests {
|
||||
fn remote_requires_https_public_origin() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyLocal,
|
||||
auto_start: false,
|
||||
port: 17373,
|
||||
public_origin: Some("http://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -687,6 +734,7 @@ mod tests {
|
||||
fn local_only_derives_loopback_config() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
auto_start: false,
|
||||
port: 0,
|
||||
public_origin: Some("https://ignored.example".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -705,6 +753,7 @@ mod tests {
|
||||
fn remote_proxy_local_accepts_loopback_ephemeral_port() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyLocal,
|
||||
auto_start: false,
|
||||
port: 0,
|
||||
public_origin: Some("https://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -724,6 +773,7 @@ mod tests {
|
||||
let store = FsServerExposureSettingsStore::new(tmp_app_data());
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
||||
auto_start: false,
|
||||
port: 17373,
|
||||
public_origin: Some("https://idea.example.com".to_owned()),
|
||||
trusted_proxies: vec!["192.0.2.22".to_owned()],
|
||||
@ -752,6 +802,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_is_idempotent_and_stop_stops_running_server() {
|
||||
if !loopback_bind_available() {
|
||||
return;
|
||||
}
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
@ -759,6 +812,7 @@ mod tests {
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
auto_start: false,
|
||||
port: 0,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -800,6 +854,7 @@ mod tests {
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let bad = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyLocal,
|
||||
auto_start: false,
|
||||
port: 17373,
|
||||
public_origin: Some("http://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
@ -816,4 +871,166 @@ mod tests {
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_auto_start_default_to_disabled() {
|
||||
let app_data = tmp_app_data();
|
||||
let path = app_data.join("deployment").join("server-exposure.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"mode":"localOnly","port":17373,"publicOrigin":null,"trustedProxies":[],"lanBindAddress":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let store = FsServerExposureSettingsStore::new(app_data);
|
||||
|
||||
let settings = store.read().unwrap();
|
||||
|
||||
assert!(!settings.auto_start);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_start_disabled_does_not_start() {
|
||||
let app_data = tmp_app_data();
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
auto_start: false,
|
||||
port: 0,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
})
|
||||
.unwrap();
|
||||
let core = Arc::new(BackendCore::build(app_data));
|
||||
|
||||
let status = controller.auto_start_if_enabled(core).await.unwrap();
|
||||
|
||||
assert!(status.is_none());
|
||||
assert!(matches!(
|
||||
controller.status().state,
|
||||
EmbeddedServerStatusStateDto::Stopped
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_start_enabled_starts_server() {
|
||||
if !loopback_bind_available() {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
auto_start: true,
|
||||
port: 0,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
})
|
||||
.unwrap();
|
||||
let core = Arc::new(BackendCore::build(app_data));
|
||||
|
||||
let status = controller
|
||||
.auto_start_if_enabled(core)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("auto-start should start");
|
||||
|
||||
assert!(matches!(
|
||||
status.state,
|
||||
EmbeddedServerStatusStateDto::Running
|
||||
));
|
||||
assert!(status.local_url.is_some());
|
||||
controller.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_start_enabled_with_invalid_config_marks_failed() {
|
||||
let app_data = tmp_app_data();
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let path = app_data.join("deployment").join("server-exposure.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"mode":"remoteProxyLocal","autoStart":true,"port":17373,"publicOrigin":"http://idea.example.com","trustedProxies":[],"lanBindAddress":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let core = Arc::new(BackendCore::build(app_data));
|
||||
|
||||
let err = controller.auto_start_if_enabled(core).await.unwrap_err();
|
||||
let status = controller.status();
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||
assert_eq!(
|
||||
status.error.as_ref().map(|err| err.code.as_str()),
|
||||
Some("INVALID")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_does_not_modify_auto_start_setting() {
|
||||
if !loopback_bind_available() {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
auto_start: true,
|
||||
port: 0,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
})
|
||||
.unwrap();
|
||||
let core = Arc::new(BackendCore::build(app_data));
|
||||
controller.start(core).await.unwrap();
|
||||
|
||||
controller.stop().await.unwrap();
|
||||
|
||||
assert!(controller.get_settings().unwrap().auto_start);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_port_collision_returns_actionable_error_code() {
|
||||
if !loopback_bind_available() {
|
||||
return;
|
||||
}
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
let reserved = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = reserved.local_addr().unwrap().port();
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
auto_start: false,
|
||||
port,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
})
|
||||
.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, "PORT_IN_USE");
|
||||
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||
assert_eq!(
|
||||
status.error.as_ref().map(|err| err.code.as_str()),
|
||||
Some("PORT_IN_USE")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ pub mod tickets;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
|
||||
use domain::{
|
||||
@ -152,7 +153,18 @@ pub fn run() {
|
||||
// Wire the domain event bus → Tauri events relay.
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
|
||||
let embedded_server = Arc::clone(&app_state.embedded_server);
|
||||
let core = app_state.core();
|
||||
app.manage(app_state);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = embedded_server.auto_start_if_enabled(core).await {
|
||||
application::diag!(
|
||||
"[embedded-server] auto-start failed: {}: {}",
|
||||
err.code,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||
// independent of the per-view (navigation/layout) lifecycle — those
|
||||
|
||||
Reference in New Issue
Block a user