2026-08-09 11:35:42 -07:00
|
|
|
mod auth_bridge;
|
2026-08-09 16:55:28 -07:00
|
|
|
mod browser_view;
|
2026-02-27 04:29:51 +00:00
|
|
|
mod commands;
|
|
|
|
|
mod docker;
|
2026-04-24 10:18:46 -07:00
|
|
|
mod install_helper;
|
2026-03-01 01:45:59 +00:00
|
|
|
mod logging;
|
2026-02-27 04:29:51 +00:00
|
|
|
mod models;
|
|
|
|
|
mod storage;
|
2026-03-17 19:31:16 -07:00
|
|
|
pub mod web_terminal;
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use std::time::Duration;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
use auth_bridge::AuthBridgeManager;
|
2026-02-27 04:29:51 +00:00
|
|
|
use docker::exec::ExecSessionManager;
|
|
|
|
|
use storage::projects_store::ProjectsStore;
|
2026-02-27 15:22:49 +00:00
|
|
|
use storage::settings_store::SettingsStore;
|
2026-08-09 19:35:39 -07:00
|
|
|
use tauri::async_runtime::JoinHandle;
|
|
|
|
|
use tauri::{Emitter, Manager};
|
|
|
|
|
use tokio::sync::watch;
|
2026-03-17 19:31:16 -07:00
|
|
|
use web_terminal::WebTerminalServer;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
|
|
|
pub struct AppState {
|
2026-03-17 19:31:16 -07:00
|
|
|
pub projects_store: Arc<ProjectsStore>,
|
|
|
|
|
pub settings_store: Arc<SettingsStore>,
|
|
|
|
|
pub exec_manager: Arc<ExecSessionManager>,
|
2026-08-09 11:35:42 -07:00
|
|
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
2026-03-17 19:31:16 -07:00
|
|
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
2026-08-09 19:35:39 -07:00
|
|
|
pub lifecycle: Arc<Lifecycle>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Startup / shutdown coordination
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Total wall-clock budget for teardown before the process exits regardless.
|
|
|
|
|
///
|
|
|
|
|
/// Six teardown steps used to run *serially* inside a `block_on` on the
|
|
|
|
|
/// window-event thread with no timeout: two container stops at Docker's default
|
|
|
|
|
/// 10s grace, a `docker exec` per browser-view project, and every bollard call
|
|
|
|
|
/// inheriting a 120s client timeout. Quitting after Docker Desktop had already
|
|
|
|
|
/// gone away froze the window for minutes. Nothing here is worth more than a
|
|
|
|
|
/// few seconds of a user's exit.
|
|
|
|
|
const SHUTDOWN_BUDGET: Duration = Duration::from_secs(8);
|
|
|
|
|
|
|
|
|
|
/// How long the in-flight auto-start tasks get to notice cancellation before
|
|
|
|
|
/// they are aborted. They only have to reach their next await point.
|
|
|
|
|
const STARTUP_CANCEL_BUDGET: Duration = Duration::from_secs(3);
|
|
|
|
|
|
|
|
|
|
/// Backoff (seconds) between auto-start attempts. Docker Desktop routinely
|
|
|
|
|
/// takes 30-60s to accept API calls after login, which is exactly the window in
|
|
|
|
|
/// which Triple-C used to be launched, fail once, and stay broken for the whole
|
|
|
|
|
/// session.
|
|
|
|
|
const AUTOSTART_DELAYS: [u64; 8] = [0, 2, 4, 8, 15, 15, 30, 30];
|
|
|
|
|
|
|
|
|
|
/// Owns the "is the app going away?" signal and the handles of the background
|
|
|
|
|
/// tasks started during `setup`.
|
|
|
|
|
///
|
|
|
|
|
/// Both auto-starts are fire-and-forget, and quitting quickly used to race
|
|
|
|
|
/// them: `CloseRequested` stopped a gateway container that did not exist yet,
|
|
|
|
|
/// and the detached task then created and started it *after* the app was gone —
|
|
|
|
|
/// leaving an orphan proxy holding a provider key. The same shape orphaned the
|
|
|
|
|
/// web terminal, whose task wrote its server into the state slot that
|
|
|
|
|
/// `CloseRequested` had already `take()`-n. Shutdown therefore cancels and
|
|
|
|
|
/// waits for these tasks *before* running teardown, so teardown always sees the
|
|
|
|
|
/// final state of the world.
|
|
|
|
|
pub struct Lifecycle {
|
|
|
|
|
cancel: watch::Sender<bool>,
|
|
|
|
|
tasks: Mutex<Vec<JoinHandle<()>>>,
|
|
|
|
|
shutting_down: AtomicBool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Lifecycle {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
let (cancel, _) = watch::channel(false);
|
|
|
|
|
Self {
|
|
|
|
|
cancel,
|
|
|
|
|
tasks: Mutex::new(Vec::new()),
|
|
|
|
|
shutting_down: AtomicBool::new(false),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A receiver that flips to `true` when the app starts shutting down.
|
|
|
|
|
pub fn cancellation(&self) -> watch::Receiver<bool> {
|
|
|
|
|
self.cancel.subscribe()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn is_shutting_down(&self) -> bool {
|
|
|
|
|
*self.cancel.borrow()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Register a startup task so shutdown can wait for it.
|
|
|
|
|
fn track(&self, handle: JoinHandle<()>) {
|
|
|
|
|
self.tasks
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
|
|
|
.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `true` the first time only — the window can emit `CloseRequested` again
|
|
|
|
|
/// once we ask the app to exit, and teardown must not restart.
|
|
|
|
|
fn begin_shutdown(&self) -> bool {
|
|
|
|
|
if self.shutting_down.swap(true, Ordering::SeqCst) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// `send_replace`, not `send`: `send` reports an error *and leaves the
|
|
|
|
|
// value untouched* when nothing is subscribed, which is exactly the
|
|
|
|
|
// case when neither auto-start is enabled — and `is_shutting_down` (the
|
|
|
|
|
// web terminal's check) reads that stored value.
|
|
|
|
|
self.cancel.send_replace(true);
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Let the tracked startup tasks unwind, then abort whatever is left.
|
|
|
|
|
async fn settle_startup_tasks(&self) {
|
|
|
|
|
let mut handles: Vec<JoinHandle<()>> = std::mem::take(
|
|
|
|
|
&mut *self.tasks.lock().unwrap_or_else(|e| e.into_inner()),
|
|
|
|
|
);
|
|
|
|
|
if handles.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let settle = async {
|
|
|
|
|
for handle in &mut handles {
|
|
|
|
|
let _ = handle.await;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if tokio::time::timeout(STARTUP_CANCEL_BUDGET, settle).await.is_err() {
|
|
|
|
|
log::warn!("Startup tasks did not settle in time — aborting them");
|
|
|
|
|
for handle in &handles {
|
|
|
|
|
handle.abort();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run an auto-start until it succeeds, the app quits, or the retries run out.
|
|
|
|
|
///
|
|
|
|
|
/// Without this a launch that beats the Docker daemon (or Docker Desktop) to
|
|
|
|
|
/// readiness left the gateway and STT down for the entire session, with no
|
|
|
|
|
/// path back: nothing re-attempts them.
|
|
|
|
|
async fn autostart_with_retry<F, Fut>(label: &str, mut cancel: watch::Receiver<bool>, mut attempt: F)
|
|
|
|
|
where
|
|
|
|
|
F: FnMut() -> Fut,
|
|
|
|
|
Fut: std::future::Future<Output = Result<(), String>>,
|
|
|
|
|
{
|
|
|
|
|
for (index, delay) in AUTOSTART_DELAYS.iter().enumerate() {
|
|
|
|
|
if *delay > 0 {
|
|
|
|
|
tokio::select! {
|
|
|
|
|
_ = cancel.changed() => return,
|
|
|
|
|
_ = tokio::time::sleep(Duration::from_secs(*delay)) => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if *cancel.borrow() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancellation races the attempt itself, not just the backoff, so a
|
|
|
|
|
// quick quit isn't held up by an in-flight Docker call — and, more
|
|
|
|
|
// importantly, so the attempt cannot complete after teardown has run.
|
|
|
|
|
let result = tokio::select! {
|
|
|
|
|
_ = cancel.changed() => return,
|
|
|
|
|
r = attempt() => r,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
if index > 0 {
|
|
|
|
|
log::info!("{} auto-start succeeded on attempt {}", label, index + 1);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let last = index + 1 == AUTOSTART_DELAYS.len();
|
|
|
|
|
if index == 0 {
|
|
|
|
|
log::warn!("{} auto-start failed ({}) — will retry", label, e);
|
|
|
|
|
} else if last {
|
|
|
|
|
log::error!("{} auto-start gave up after {} attempts: {}", label, index + 1, e);
|
|
|
|
|
} else {
|
|
|
|
|
log::debug!("{} auto-start attempt {} failed: {}", label, index + 1, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn run() {
|
2026-03-01 01:45:59 +00:00
|
|
|
logging::init();
|
|
|
|
|
|
2026-03-17 19:31:16 -07:00
|
|
|
let projects_store = Arc::new(match ProjectsStore::new() {
|
2026-03-01 01:45:59 +00:00
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to initialize projects store: {}", e);
|
|
|
|
|
panic!("Failed to initialize projects store: {}", e);
|
|
|
|
|
}
|
2026-03-17 19:31:16 -07:00
|
|
|
});
|
|
|
|
|
let settings_store = Arc::new(match SettingsStore::new() {
|
2026-03-01 01:45:59 +00:00
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to initialize settings store: {}", e);
|
|
|
|
|
panic!("Failed to initialize settings store: {}", e);
|
|
|
|
|
}
|
2026-03-17 19:31:16 -07:00
|
|
|
});
|
|
|
|
|
let exec_manager = Arc::new(ExecSessionManager::new());
|
2026-08-09 11:35:42 -07:00
|
|
|
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
2026-08-09 19:35:39 -07:00
|
|
|
let lifecycle = Arc::new(Lifecycle::new());
|
2026-03-17 19:31:16 -07:00
|
|
|
|
|
|
|
|
// Clone Arcs for the setup closure (web terminal auto-start)
|
|
|
|
|
let projects_store_setup = projects_store.clone();
|
|
|
|
|
let settings_store_setup = settings_store.clone();
|
|
|
|
|
let exec_manager_setup = exec_manager.clone();
|
2026-08-09 19:35:39 -07:00
|
|
|
let lifecycle_setup = lifecycle.clone();
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
|
|
|
tauri::Builder::default()
|
|
|
|
|
.plugin(tauri_plugin_store::Builder::default().build())
|
|
|
|
|
.plugin(tauri_plugin_dialog::init())
|
|
|
|
|
.plugin(tauri_plugin_opener::init())
|
|
|
|
|
.manage(AppState {
|
2026-03-01 01:45:59 +00:00
|
|
|
projects_store,
|
|
|
|
|
settings_store,
|
2026-03-17 19:31:16 -07:00
|
|
|
exec_manager,
|
2026-08-09 11:35:42 -07:00
|
|
|
auth_bridge,
|
2026-03-17 19:31:16 -07:00
|
|
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
2026-08-09 19:35:39 -07:00
|
|
|
lifecycle,
|
2026-02-27 04:29:51 +00:00
|
|
|
})
|
2026-03-17 19:31:16 -07:00
|
|
|
.setup(move |app| {
|
2026-03-01 03:10:57 +00:00
|
|
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
2026-03-01 01:45:59 +00:00
|
|
|
Ok(icon) => {
|
|
|
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
|
|
|
let _ = window.set_icon(icon);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to load window icon: {}", e);
|
|
|
|
|
}
|
2026-03-01 00:49:08 +00:00
|
|
|
}
|
2026-03-17 19:31:16 -07:00
|
|
|
|
|
|
|
|
// Auto-start web terminal server if enabled in settings
|
|
|
|
|
let settings = settings_store_setup.get();
|
|
|
|
|
if settings.web_terminal.enabled {
|
|
|
|
|
if let Some(token) = &settings.web_terminal.access_token {
|
|
|
|
|
let token = token.clone();
|
|
|
|
|
let port = settings.web_terminal.port;
|
|
|
|
|
let exec_mgr = exec_manager_setup.clone();
|
|
|
|
|
let proj_store = projects_store_setup.clone();
|
|
|
|
|
let set_store = settings_store_setup.clone();
|
|
|
|
|
let state = app.state::<AppState>();
|
|
|
|
|
let web_server_mutex = state.web_terminal_server.clone();
|
2026-08-09 19:35:39 -07:00
|
|
|
let lifecycle = lifecycle_setup.clone();
|
2026-03-17 19:31:16 -07:00
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
let handle = tauri::async_runtime::spawn(async move {
|
2026-03-17 19:31:16 -07:00
|
|
|
match WebTerminalServer::start(
|
|
|
|
|
port,
|
|
|
|
|
token,
|
|
|
|
|
exec_mgr,
|
|
|
|
|
proj_store,
|
|
|
|
|
set_store,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(server) => {
|
2026-08-09 19:35:39 -07:00
|
|
|
// The app may have been asked to quit while the
|
|
|
|
|
// server was coming up, in which case teardown
|
|
|
|
|
// has already emptied this slot and would never
|
|
|
|
|
// look at it again. Stop it here instead of
|
|
|
|
|
// storing an orphan.
|
|
|
|
|
if lifecycle.is_shutting_down() {
|
|
|
|
|
server.stop();
|
|
|
|
|
log::info!("Web terminal stopped immediately: app is exiting");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-17 19:31:16 -07:00
|
|
|
let mut guard = web_server_mutex.lock().await;
|
|
|
|
|
*guard = Some(server);
|
|
|
|
|
log::info!("Web terminal auto-started on port {}", port);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to auto-start web terminal: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-09 19:35:39 -07:00
|
|
|
lifecycle_setup.track(handle);
|
2026-03-17 19:31:16 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 06:34:36 -07:00
|
|
|
// Auto-start STT container if enabled in settings
|
|
|
|
|
if settings.stt.enabled {
|
|
|
|
|
let stt_settings = settings.stt.clone();
|
2026-08-09 19:35:39 -07:00
|
|
|
let cancel = lifecycle_setup.cancellation();
|
|
|
|
|
let handle = tauri::async_runtime::spawn(async move {
|
|
|
|
|
autostart_with_retry("STT container", cancel, || async {
|
|
|
|
|
let status = docker::stt::ensure_stt_running(&stt_settings).await?;
|
|
|
|
|
if status.running {
|
|
|
|
|
log::info!("STT container auto-started on port {}", stt_settings.port);
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err("container not running after ensure_stt_running".to_string())
|
2026-04-13 06:34:36 -07:00
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
})
|
|
|
|
|
.await;
|
2026-04-13 06:34:36 -07:00
|
|
|
});
|
2026-08-09 19:35:39 -07:00
|
|
|
lifecycle_setup.track(handle);
|
2026-04-13 06:34:36 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
// Auto-start model gateway container if enabled in settings
|
|
|
|
|
if settings.gateway.enabled {
|
|
|
|
|
let gateway_settings = settings.gateway.clone();
|
2026-08-09 19:35:39 -07:00
|
|
|
let cancel = lifecycle_setup.cancellation();
|
|
|
|
|
let handle = tauri::async_runtime::spawn(async move {
|
|
|
|
|
autostart_with_retry("Model gateway", cancel, || async {
|
|
|
|
|
let status =
|
|
|
|
|
docker::gateway::ensure_gateway_running(&gateway_settings).await?;
|
|
|
|
|
if status.running {
|
|
|
|
|
log::info!(
|
|
|
|
|
"Model gateway auto-started on port {}",
|
|
|
|
|
gateway_settings.port
|
|
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err("container not running after ensure_gateway_running".to_string())
|
2026-08-09 16:55:28 -07:00
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
})
|
|
|
|
|
.await;
|
2026-08-09 16:55:28 -07:00
|
|
|
});
|
2026-08-09 19:35:39 -07:00
|
|
|
lifecycle_setup.track(handle);
|
2026-08-09 16:55:28 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 00:49:08 +00:00
|
|
|
Ok(())
|
|
|
|
|
})
|
2026-02-28 20:42:55 +00:00
|
|
|
.on_window_event(|window, event| {
|
2026-08-09 19:35:39 -07:00
|
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
2026-08-11 06:50:40 -07:00
|
|
|
// This handler fires for *every* window, and what follows stops
|
|
|
|
|
// containers and exits the process. Only the main window means
|
|
|
|
|
// that. Secondary windows — the browser view's pop-out — are
|
|
|
|
|
// closed and reopened freely and must just close.
|
|
|
|
|
if window.label() != "main" {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 20:42:55 +00:00
|
|
|
let state = window.state::<AppState>();
|
2026-08-09 19:35:39 -07:00
|
|
|
let lifecycle = state.lifecycle.clone();
|
|
|
|
|
|
|
|
|
|
// Already shutting down: let the window close. That covers our
|
|
|
|
|
// own `exit` unwinding it, and it deliberately leaves a second
|
|
|
|
|
// click on the X as a force-quit — teardown is a courtesy, not
|
|
|
|
|
// a hostage situation.
|
|
|
|
|
if !lifecycle.begin_shutdown() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let exec_manager = state.exec_manager.clone();
|
|
|
|
|
let auth_bridge = state.auth_bridge.clone();
|
|
|
|
|
let web_terminal_server = state.web_terminal_server.clone();
|
|
|
|
|
drop(state);
|
|
|
|
|
|
|
|
|
|
// Teardown talks to Docker, so it cannot be instant. Keep the
|
|
|
|
|
// window alive and tell the UI what is happening rather than
|
|
|
|
|
// blocking the event thread on it and looking hung.
|
|
|
|
|
api.prevent_close();
|
|
|
|
|
let _ = window.emit("app-shutting-down", ());
|
|
|
|
|
|
|
|
|
|
let app_handle = window.app_handle().clone();
|
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
|
|
|
let teardown = async {
|
|
|
|
|
// First: let the auto-starts unwind. Anything they are
|
|
|
|
|
// midway through creating has to exist before the stops
|
|
|
|
|
// below run, or it outlives the app.
|
|
|
|
|
lifecycle.settle_startup_tasks().await;
|
|
|
|
|
|
|
|
|
|
// Then everything else, concurrently — these touch
|
|
|
|
|
// different subsystems and nothing here depends on
|
|
|
|
|
// another's result. Serially, the two container stops
|
|
|
|
|
// alone were 20s of Docker's default grace period.
|
|
|
|
|
let web_terminal = async {
|
|
|
|
|
if let Some(server) = web_terminal_server.lock().await.take() {
|
|
|
|
|
server.stop();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let stop_stt = async {
|
|
|
|
|
if let Err(e) = docker::stt::stop_stt_container().await {
|
|
|
|
|
log::warn!("Failed to stop the STT container on exit: {}", e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let stop_gateway = async {
|
|
|
|
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
|
|
|
|
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
tokio::join!(
|
|
|
|
|
web_terminal,
|
|
|
|
|
stop_stt,
|
|
|
|
|
stop_gateway,
|
|
|
|
|
exec_manager.close_all_sessions(),
|
|
|
|
|
auth_bridge.stop_all(),
|
|
|
|
|
browser_view::manager().stop_all(),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if tokio::time::timeout(SHUTDOWN_BUDGET, teardown).await.is_err() {
|
|
|
|
|
log::warn!(
|
|
|
|
|
"Shutdown exceeded {}s — exiting with teardown incomplete",
|
|
|
|
|
SHUTDOWN_BUDGET.as_secs()
|
|
|
|
|
);
|
2026-03-17 19:31:16 -07:00
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
app_handle.exit(0);
|
2026-02-28 20:42:55 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-02-27 04:29:51 +00:00
|
|
|
.invoke_handler(tauri::generate_handler![
|
|
|
|
|
// Docker
|
|
|
|
|
commands::docker_commands::check_docker,
|
|
|
|
|
commands::docker_commands::check_image_exists,
|
|
|
|
|
commands::docker_commands::build_image,
|
|
|
|
|
commands::docker_commands::get_container_info,
|
|
|
|
|
commands::docker_commands::list_sibling_containers,
|
|
|
|
|
// Projects
|
|
|
|
|
commands::project_commands::list_projects,
|
|
|
|
|
commands::project_commands::add_project,
|
|
|
|
|
commands::project_commands::remove_project,
|
|
|
|
|
commands::project_commands::update_project,
|
|
|
|
|
commands::project_commands::start_project_container,
|
|
|
|
|
commands::project_commands::stop_project_container,
|
|
|
|
|
commands::project_commands::rebuild_project_container,
|
2026-03-10 08:29:06 -07:00
|
|
|
commands::project_commands::reconcile_project_statuses,
|
2026-08-09 18:19:12 -07:00
|
|
|
// Container base-image migration
|
|
|
|
|
commands::migration_commands::get_container_staleness,
|
|
|
|
|
commands::migration_commands::migrate_project_to_base,
|
|
|
|
|
commands::migration_commands::confirm_migration,
|
|
|
|
|
commands::migration_commands::rollback_migration,
|
|
|
|
|
commands::migration_commands::get_migration_state,
|
2026-08-09 11:35:42 -07:00
|
|
|
// Auth bridge
|
|
|
|
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
|
|
|
|
commands::auth_bridge_commands::get_auth_bridge_status,
|
2026-08-09 16:55:28 -07:00
|
|
|
// Browser view (Playwright dashboard pane)
|
|
|
|
|
browser_view::commands::set_browser_view_enabled,
|
|
|
|
|
browser_view::commands::get_browser_view_status,
|
|
|
|
|
browser_view::commands::check_browser_view_support,
|
2026-08-10 10:13:53 -07:00
|
|
|
browser_view::commands::install_browser_view_support,
|
|
|
|
|
browser_view::commands::install_browser_view_browser,
|
2026-08-11 06:50:40 -07:00
|
|
|
browser_view::commands::open_browser_view_popout,
|
|
|
|
|
browser_view::commands::close_browser_view_popout,
|
2026-08-11 07:40:51 -07:00
|
|
|
browser_view::commands::get_browser_view_popout_state,
|
2026-08-11 06:50:40 -07:00
|
|
|
browser_view::commands::set_browser_view_popout_always_on_top,
|
2026-08-11 09:15:12 -07:00
|
|
|
browser_view::commands::open_page_in_container_browser,
|
|
|
|
|
browser_view::commands::set_container_page_viewport,
|
|
|
|
|
browser_view::commands::get_container_page_state,
|
|
|
|
|
browser_view::commands::close_container_page,
|
|
|
|
|
browser_view::commands::set_browser_view_match_window,
|
|
|
|
|
browser_view::commands::get_browser_view_match_window,
|
2026-08-09 11:35:42 -07:00
|
|
|
// Shared Claude Code auth token
|
|
|
|
|
commands::auth_token_commands::acquire_claude_token,
|
|
|
|
|
commands::auth_token_commands::submit_claude_token_code,
|
2026-08-09 11:49:03 -07:00
|
|
|
commands::auth_token_commands::cancel_claude_token,
|
2026-08-09 11:35:42 -07:00
|
|
|
commands::auth_token_commands::has_claude_token,
|
|
|
|
|
commands::auth_token_commands::clear_claude_token,
|
2026-02-27 04:29:51 +00:00
|
|
|
// Settings
|
2026-02-27 15:22:49 +00:00
|
|
|
commands::settings_commands::get_settings,
|
|
|
|
|
commands::settings_commands::update_settings,
|
|
|
|
|
commands::settings_commands::pull_image,
|
|
|
|
|
commands::settings_commands::detect_aws_config,
|
2026-08-10 10:40:21 -07:00
|
|
|
commands::settings_commands::inspect_ca_cert_path,
|
2026-02-27 15:22:49 +00:00
|
|
|
commands::settings_commands::list_aws_profiles,
|
2026-03-01 15:57:22 +00:00
|
|
|
commands::settings_commands::detect_host_timezone,
|
2026-02-27 04:29:51 +00:00
|
|
|
// Terminal
|
|
|
|
|
commands::terminal_commands::open_terminal_session,
|
|
|
|
|
commands::terminal_commands::terminal_input,
|
|
|
|
|
commands::terminal_commands::terminal_resize,
|
|
|
|
|
commands::terminal_commands::close_terminal_session,
|
2026-03-01 10:52:08 -08:00
|
|
|
commands::terminal_commands::paste_image_to_terminal,
|
2026-06-30 14:11:09 -07:00
|
|
|
commands::terminal_commands::upload_host_file_to_terminal,
|
2026-03-05 06:11:33 -08:00
|
|
|
commands::terminal_commands::start_audio_bridge,
|
|
|
|
|
commands::terminal_commands::send_audio_data,
|
|
|
|
|
commands::terminal_commands::stop_audio_bridge,
|
2026-03-06 06:32:53 -08:00
|
|
|
// Files
|
|
|
|
|
commands::file_commands::list_container_files,
|
|
|
|
|
commands::file_commands::download_container_file,
|
2026-06-30 13:48:04 -07:00
|
|
|
commands::file_commands::download_container_backup,
|
2026-03-06 06:32:53 -08:00
|
|
|
commands::file_commands::upload_file_to_container,
|
2026-03-11 12:24:16 -07:00
|
|
|
// AWS
|
|
|
|
|
commands::aws_commands::aws_sso_refresh,
|
2026-02-28 21:18:33 +00:00
|
|
|
// Updates
|
|
|
|
|
commands::update_commands::get_app_version,
|
|
|
|
|
commands::update_commands::check_for_updates,
|
2026-03-12 09:26:58 -07:00
|
|
|
commands::update_commands::check_image_update,
|
2026-03-12 11:00:59 -07:00
|
|
|
// Help
|
|
|
|
|
commands::help_commands::get_help_content,
|
2026-04-24 10:18:46 -07:00
|
|
|
// Install helper
|
|
|
|
|
commands::install_helper_commands::detect_install_options,
|
|
|
|
|
commands::install_helper_commands::run_docker_install,
|
2026-03-17 19:31:16 -07:00
|
|
|
// Web Terminal
|
|
|
|
|
commands::web_terminal_commands::start_web_terminal,
|
|
|
|
|
commands::web_terminal_commands::stop_web_terminal,
|
|
|
|
|
commands::web_terminal_commands::get_web_terminal_status,
|
|
|
|
|
commands::web_terminal_commands::regenerate_web_terminal_token,
|
2026-04-12 20:02:39 -07:00
|
|
|
// STT
|
|
|
|
|
commands::stt_commands::get_stt_status,
|
|
|
|
|
commands::stt_commands::start_stt,
|
|
|
|
|
commands::stt_commands::stop_stt,
|
|
|
|
|
commands::stt_commands::build_stt_image,
|
|
|
|
|
commands::stt_commands::pull_stt_image,
|
|
|
|
|
commands::stt_commands::transcribe_audio,
|
2026-08-09 16:55:28 -07:00
|
|
|
// Model gateway (LiteLLM)
|
|
|
|
|
commands::gateway_commands::get_gateway_status,
|
|
|
|
|
commands::gateway_commands::start_gateway,
|
|
|
|
|
commands::gateway_commands::stop_gateway,
|
|
|
|
|
commands::gateway_commands::check_gateway_health,
|
|
|
|
|
commands::gateway_commands::build_gateway_image,
|
|
|
|
|
commands::gateway_commands::pull_gateway_image,
|
|
|
|
|
commands::gateway_commands::set_gateway_api_key,
|
|
|
|
|
commands::gateway_commands::clear_gateway_api_key,
|
|
|
|
|
commands::gateway_commands::get_gateway_auth_token,
|
|
|
|
|
commands::gateway_commands::regenerate_gateway_auth_token,
|
2026-08-09 10:51:34 -07:00
|
|
|
// Container introspection (sessions / capabilities / scheduler)
|
|
|
|
|
commands::inspect_commands::list_claude_sessions,
|
|
|
|
|
commands::inspect_commands::resume_session_command,
|
|
|
|
|
commands::inspect_commands::list_container_capabilities,
|
|
|
|
|
commands::inspect_commands::list_scheduled_tasks,
|
2026-08-09 12:20:52 -07:00
|
|
|
commands::inspect_commands::add_scheduled_task,
|
|
|
|
|
commands::inspect_commands::update_scheduled_task,
|
2026-08-09 10:51:34 -07:00
|
|
|
commands::inspect_commands::get_scheduled_task_log,
|
|
|
|
|
commands::inspect_commands::set_scheduled_task_enabled,
|
|
|
|
|
commands::inspect_commands::run_scheduled_task_now,
|
|
|
|
|
commands::inspect_commands::remove_scheduled_task,
|
|
|
|
|
commands::inspect_commands::get_scheduler_notifications,
|
|
|
|
|
commands::inspect_commands::clear_scheduler_notifications,
|
2026-02-27 04:29:51 +00:00
|
|
|
])
|
|
|
|
|
.run(tauri::generate_context!())
|
|
|
|
|
.expect("error while running tauri application");
|
|
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::sync::atomic::AtomicUsize;
|
|
|
|
|
|
|
|
|
|
/// Drives the retry loop under a paused clock, so the real backoff schedule
|
|
|
|
|
/// is exercised without waiting for it.
|
|
|
|
|
async fn run_autostart(
|
|
|
|
|
cancel: watch::Receiver<bool>,
|
|
|
|
|
outcomes: Vec<Result<(), String>>,
|
|
|
|
|
) -> usize {
|
|
|
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
|
|
|
let counter = calls.clone();
|
|
|
|
|
let outcomes = Arc::new(Mutex::new(outcomes.into_iter()));
|
|
|
|
|
autostart_with_retry("test", cancel, move || {
|
|
|
|
|
let counter = counter.clone();
|
|
|
|
|
let outcomes = outcomes.clone();
|
|
|
|
|
async move {
|
|
|
|
|
counter.fetch_add(1, Ordering::SeqCst);
|
|
|
|
|
outcomes
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.next()
|
|
|
|
|
.unwrap_or(Err("still down".to_string()))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
calls.load(Ordering::SeqCst)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
|
|
|
async fn a_working_autostart_runs_exactly_once() {
|
|
|
|
|
let (_tx, rx) = watch::channel(false);
|
|
|
|
|
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
|
|
|
async fn an_autostart_that_beat_docker_to_readiness_recovers() {
|
|
|
|
|
// The regression: Docker not being up yet used to cost the whole
|
|
|
|
|
// session — gateway down, STT down, and nothing ever retried.
|
|
|
|
|
let (_tx, rx) = watch::channel(false);
|
|
|
|
|
let calls = run_autostart(
|
|
|
|
|
rx,
|
|
|
|
|
vec![
|
|
|
|
|
Err("daemon not running".to_string()),
|
|
|
|
|
Err("daemon not running".to_string()),
|
|
|
|
|
Ok(()),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(calls, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
|
|
|
async fn a_permanently_failing_autostart_gives_up_rather_than_looping_forever() {
|
|
|
|
|
let (_tx, rx) = watch::channel(false);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
run_autostart(rx, vec![]).await,
|
|
|
|
|
AUTOSTART_DELAYS.len(),
|
|
|
|
|
"should attempt once per backoff step and then stop"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
|
|
|
async fn a_quick_quit_stops_the_retries_before_they_start() {
|
|
|
|
|
// Quitting before the first attempt must not leave a task that creates
|
|
|
|
|
// and starts a container after teardown has already run.
|
|
|
|
|
let (tx, rx) = watch::channel(false);
|
|
|
|
|
tx.send(true).unwrap();
|
|
|
|
|
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
|
|
|
async fn cancelling_between_attempts_stops_the_retries() {
|
|
|
|
|
let (tx, rx) = watch::channel(false);
|
|
|
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
|
|
|
let counter = calls.clone();
|
|
|
|
|
autostart_with_retry("test", rx, move || {
|
|
|
|
|
let counter = counter.clone();
|
|
|
|
|
let tx = tx.clone();
|
|
|
|
|
async move {
|
|
|
|
|
counter.fetch_add(1, Ordering::SeqCst);
|
|
|
|
|
// The app starts quitting while this attempt is in flight.
|
|
|
|
|
let _ = tx.send(true);
|
|
|
|
|
Err("daemon not running".to_string())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shutdown_begins_exactly_once() {
|
|
|
|
|
// `CloseRequested` fires again when our own `exit(0)` unwinds the
|
|
|
|
|
// window; teardown must not start a second time.
|
|
|
|
|
let lifecycle = Lifecycle::new();
|
|
|
|
|
assert!(!lifecycle.is_shutting_down());
|
|
|
|
|
assert!(lifecycle.begin_shutdown());
|
|
|
|
|
assert!(lifecycle.is_shutting_down());
|
|
|
|
|
assert!(!lifecycle.begin_shutdown());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn beginning_shutdown_notifies_already_running_startup_tasks() {
|
|
|
|
|
let lifecycle = Lifecycle::new();
|
|
|
|
|
let mut cancel = lifecycle.cancellation();
|
|
|
|
|
assert!(!*cancel.borrow());
|
|
|
|
|
lifecycle.begin_shutdown();
|
|
|
|
|
assert!(cancel.changed().await.is_ok());
|
|
|
|
|
assert!(*cancel.borrow());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
|
|
|
async fn a_startup_task_that_ignores_cancellation_is_abandoned_not_awaited() {
|
|
|
|
|
// The budget is what keeps a wedged auto-start from turning quit into a
|
|
|
|
|
// multi-minute freeze.
|
|
|
|
|
let lifecycle = Lifecycle::new();
|
|
|
|
|
lifecycle.track(tauri::async_runtime::spawn(async {
|
|
|
|
|
tokio::time::sleep(Duration::from_secs(600)).await;
|
|
|
|
|
}));
|
|
|
|
|
lifecycle.begin_shutdown();
|
|
|
|
|
let started = tokio::time::Instant::now();
|
|
|
|
|
lifecycle.settle_startup_tasks().await;
|
|
|
|
|
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
|
|
|
|
}
|
|
|
|
|
}
|