Files
Triple-C/app/src-tauri/src/lib.rs
T

650 lines
28 KiB
Rust
Raw Normal View History

mod auth_bridge;
mod browser_view;
mod commands;
mod docker;
mod install_helper;
mod logging;
mod models;
mod storage;
pub mod web_terminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use auth_bridge::AuthBridgeManager;
use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore;
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager};
use tokio::sync::watch;
use web_terminal::WebTerminalServer;
pub struct AppState {
pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>,
pub exec_manager: Arc<ExecSessionManager>,
pub auth_bridge: Arc<AuthBridgeManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
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);
}
}
}
}
}
pub fn run() {
logging::init();
let projects_store = Arc::new(match ProjectsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize projects store: {}", e);
panic!("Failed to initialize projects store: {}", e);
}
});
let settings_store = Arc::new(match SettingsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize settings store: {}", e);
panic!("Failed to initialize settings store: {}", e);
}
});
let exec_manager = Arc::new(ExecSessionManager::new());
let auth_bridge = Arc::new(AuthBridgeManager::new());
let lifecycle = Arc::new(Lifecycle::new());
// 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();
let lifecycle_setup = lifecycle.clone();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.manage(AppState {
projects_store,
settings_store,
exec_manager,
auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle,
})
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
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);
}
}
// 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();
let lifecycle = lifecycle_setup.clone();
let handle = tauri::async_runtime::spawn(async move {
match WebTerminalServer::start(
port,
token,
exec_mgr,
proj_store,
set_store,
)
.await
{
Ok(server) => {
// 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;
}
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);
}
}
});
lifecycle_setup.track(handle);
}
}
// Auto-start STT container if enabled in settings
if settings.stt.enabled {
let stt_settings = settings.stt.clone();
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())
}
})
.await;
});
lifecycle_setup.track(handle);
}
// Auto-start model gateway container if enabled in settings
if settings.gateway.enabled {
let gateway_settings = settings.gateway.clone();
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())
}
})
.await;
});
lifecycle_setup.track(handle);
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// 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;
}
let state = window.state::<AppState>();
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()
);
}
app_handle.exit(0);
});
}
})
.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,
commands::project_commands::reconcile_project_statuses,
// 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,
// Auth bridge
commands::auth_bridge_commands::set_auth_bridge_enabled,
commands::auth_bridge_commands::get_auth_bridge_status,
// 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,
browser_view::commands::install_browser_view_support,
browser_view::commands::install_browser_view_browser,
browser_view::commands::open_browser_view_popout,
browser_view::commands::close_browser_view_popout,
browser_view::commands::is_browser_view_popout_open,
browser_view::commands::set_browser_view_popout_always_on_top,
// Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code,
commands::auth_token_commands::cancel_claude_token,
commands::auth_token_commands::has_claude_token,
commands::auth_token_commands::clear_claude_token,
// Settings
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
commands::settings_commands::pull_image,
commands::settings_commands::detect_aws_config,
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Terminal
commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
2026-06-30 14:11:09 -07:00
commands::terminal_commands::upload_host_file_to_terminal,
commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
commands::update_commands::check_image_update,
// Help
commands::help_commands::get_help_content,
// Install helper
commands::install_helper_commands::detect_install_options,
commands::install_helper_commands::run_docker_install,
// 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,
// 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,
// 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,
// 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,
commands::inspect_commands::add_scheduled_task,
commands::inspect_commands::update_scheduled_task,
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,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[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));
}
}