diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e6a2bdddb..9da1bbcf9 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1283,6 +1283,17 @@ fn down_owned_containers(shell: &Shell, root: &Path) -> Result<(), String> { } } +/// Merely viewing setup does not give this session containers to shut down. +/// Start records ownership before Compose up, including partial failures. Quit must still +/// retire those runs, but an old Compose file alone is not a failed shutdown by this session. +/// Explicit Stop keeps the unknown-runtime error so it cannot claim an unverified cleanup. +fn down_containers_on_quit(shell: &Shell, root: &Path) -> Result<(), String> { + if shell.containers.lock().unwrap().is_none() { + return Ok(()); + } + down_owned_containers(shell, root) +} + /// Reclaim held replacements before consulting durable inventory. Keep the handles and pidfile /// if any phase fails, so the next Stop or Start can retry with the same ownership evidence. fn cleanup_host_children( @@ -1991,33 +2002,37 @@ fn remember_setup_url(app: &tauri::AppHandle) -> Result<() Ok(()) } +fn setup_destination(app: &tauri::AppHandle) -> Result { + // Use the intended setup destination even before the initial page has finished loading. + let setup = app.state::().setup_url.lock().unwrap().clone(); + match setup { + Some(setup) => setup + .parse() + .map_err(|error| format!("{setup} is not a URL: {error}")), + None => configured_setup_url(app.config(), tauri::is_dev(), cfg!(windows)), + } +} + /// Put the setup screen back, when there is something to set up again. #[tauri::command] fn show_setup(app: tauri::AppHandle) -> Result<(), String> { let window = app .get_webview_window("main") .ok_or("the OpenBot window is not there")?; - // Use the intended setup destination even before the initial page has finished loading. - let setup = app - .state::() - .setup_url - .lock() - .unwrap() - .clone() - .map(Ok) - .unwrap_or_else(|| { - configured_setup_url(app.config(), tauri::is_dev(), cfg!(windows)) - .map(|url| url.to_string()) - })?; window - .navigate( - setup - .parse() - .map_err(|error| format!("{setup} is not a URL: {error}"))?, - ) + .navigate(setup_destination(&app)?) .map_err(|error| format!("could not go back to setup: {error}")) } +/// Both pages are single-page apps. Keep their route and in-memory state on restore. +/// Compare the authority explicitly: tauri:// has an opaque URL origin. +fn same_window_app(current: &tauri::Url, destination: &tauri::Url) -> bool { + destination.host_str().is_some() + && current.scheme() == destination.scheme() + && current.host_str() == destination.host_str() + && current.port_or_known_default() == destination.port_or_known_default() +} + fn show_setup_and_focus(app: tauri::AppHandle) -> Result<(), String> { show_setup(app.clone())?; let window = app @@ -2745,15 +2760,21 @@ fn restore_window_on(app: &tauri::AppHandle, ports: &openb let root = cleanup_root(&shell, &stack::default_root()); // Restore has the same deployment ownership requirement as the setup page's passive probe. // A successful app-port response alone may belong to another installation or application. - if let Some(url) = (!recovery_required_or_pending_quit_notice(&shell, &root)) + let destination = if let Some(url) = (!recovery_required_or_pending_quit_notice(&shell, &root)) .then(|| owned_app_url(&root, ports)) .flatten() { - if let Ok(parsed) = url.parse() { - let _ = window.navigate(parsed); - } + url.parse().ok() } else { - let _ = show_setup(app.clone()); + setup_destination(app).ok() + }; + if let Some(destination) = destination { + let already_showing = window + .url() + .is_ok_and(|current| same_window_app(¤t, &destination)); + if !already_showing { + let _ = window.navigate(destination); + } } let _ = window.show(); let _ = window.unminimize(); @@ -3021,7 +3042,7 @@ fn main() { &shell, &stack::default_root(), stack::stop_processes_under, - |root| down_owned_containers(&shell, root), + |root| down_containers_on_quit(&shell, root), ) }, QuitDiagnostics { @@ -4824,7 +4845,7 @@ fn main() { &app.state::(), &fallback, stack::stop_processes_under, - |root| down_owned_containers(&app.state::(), root), + |root| down_containers_on_quit(&app.state::(), root), ) }, QuitDiagnostics { @@ -5311,6 +5332,23 @@ fn main() { runtime_affinity("docker_default_stop"); } + #[test] + fn quit_after_only_viewing_setup_does_not_report_unowned_containers() { + if crate::test_support::isolated_process( + "tests::container_root::quit_after_only_viewing_setup_does_not_report_unowned_containers", + ) { + return; + } + let fixture = Fixture::new(); + remember_selected_root(&fixture.app.state::(), &fixture.a); + assert!(fixture.a.join("docker-compose.yml").exists()); + fixture.quit(); + assert!( + fixture.commands().is_empty(), + "viewing setup must not stop another runtime" + ); + } + #[test] fn runtime_affinity_unknown_legacy_and_repeated_cleanup() { if crate::test_support::isolated_process( @@ -8401,6 +8439,75 @@ fn main() { } } + #[test] + fn restore_matches_only_the_destination_app() { + for destination in [ + "tauri://localhost/", + "http://tauri.localhost/", + "https://tauri.localhost/", + "http://localhost:3020/", + "http://127.0.0.1:3010/", + ] { + let destination: tauri::Url = destination.parse().unwrap(); + assert!( + same_window_app(&destination, &destination), + "even identical navigation reloads the page" + ); + assert!(same_window_app( + &destination.join("ask?thread=existing#message").unwrap(), + &destination + )); + for unrelated in [ + "about:blank", + "http://127.0.0.1:9/stale-page", + "https://example.com/", + ] { + assert!(!same_window_app(&unrelated.parse().unwrap(), &destination)); + } + } + assert!(!same_window_app( + &"http://localhost:3020/".parse().unwrap(), + &"http://localhost:3010/".parse().unwrap() + )); + assert!(!same_window_app( + &"http://tauri.localhost/".parse().unwrap(), + &"https://tauri.localhost/".parse().unwrap() + )); + } + + #[test] + fn restore_window_preserves_setup_location_when_runtime_is_unavailable() { + let f = RestoreFixture::new(); + for setup in [ + "tauri://localhost/", + "http://tauri.localhost/", + "http://localhost:3020/", + ] { + let app = f.app(&f.selected, setup); + let window = app.get_webview_window("main").unwrap(); + let current = format!("{setup}#credentials"); + window.navigate(current.parse().unwrap()).unwrap(); + window.hide().unwrap(); + restore_window_on(app.handle(), &f.ports); + assert_eq!(window.url().unwrap().as_str(), current); + assert!(window.is_visible().unwrap()); + } + } + + #[test] + fn restore_window_preserves_owned_app_route() { + let f = RestoreFixture::new(); + let app = f.app(&f.owned, "tauri://localhost/"); + let window = app.get_webview_window("main").unwrap(); + let current = format!( + "http://127.0.0.1:{}/ask?thread=existing#message", + f.ports.app + ); + window.navigate(current.parse().unwrap()).unwrap(); + restore_window_on(app.handle(), &f.ports); + assert_eq!(window.url().unwrap().as_str(), current); + } + #[test] fn restore_window_refuses_answering_other_deployment_and_shows_recorded_setup() { let f = RestoreFixture::new();