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;
|
2026-08-23 11:45:28 -07:00
|
|
|
mod project_lock;
|
2026-02-27 04:29:51 +00:00
|
|
|
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_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
|
|
|
|
2026-08-23 08:35:10 -07:00
|
|
|
// ── Startup disk housekeeping ────────────────────────────────
|
|
|
|
|
// Until now the only sweep ran *after* a recreation, so a user who
|
|
|
|
|
// simply stopped launching a project kept its orphaned snapshot
|
|
|
|
|
// layers forever, and anything a crash left behind (a probe
|
|
|
|
|
// container pinning a base image, a rollback pin whose migration
|
2026-08-23 15:20:22 -07:00
|
|
|
// record is gone) had no path back at all. All of it is
|
|
|
|
|
// read-mostly and finishes in well under a second on an idle daemon,
|
2026-08-23 08:35:10 -07:00
|
|
|
// but they are detached anyway: housekeeping must never delay the
|
|
|
|
|
// window appearing, and a daemon that is not running yet is a
|
|
|
|
|
// logged warning rather than a failed start.
|
|
|
|
|
//
|
|
|
|
|
// Ordering matters. Probes are removed first because a probe holds
|
|
|
|
|
// an image open and the sweep will not force; pins are untagged
|
|
|
|
|
// second so the images they were holding are dangling by the time
|
|
|
|
|
// the sweep lists them; the sweep runs last and collects both.
|
2026-08-23 09:12:59 -07:00
|
|
|
tauri::async_runtime::spawn(async move {
|
2026-08-23 08:35:10 -07:00
|
|
|
crate::docker::reap_probe_containers().await;
|
|
|
|
|
let reaped = crate::docker::reap_stale_migration_pins().await;
|
|
|
|
|
if reaped > 0 {
|
|
|
|
|
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
|
|
|
|
}
|
|
|
|
|
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
|
|
|
|
});
|
|
|
|
|
|
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,
|
|
|
|
|
// 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-08-23 15:42:20 -07:00
|
|
|
commands::auth_token_commands::sweep_claude_token_snapshots,
|
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,
|
2026-06-30 13:48:04 -07:00
|
|
|
commands::file_commands::download_container_backup,
|
2026-08-23 08:30:48 -07:00
|
|
|
commands::file_commands::read_container_file,
|
|
|
|
|
commands::file_commands::rename_container_path,
|
|
|
|
|
commands::file_commands::create_container_directory,
|
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));
|
|
|
|
|
}
|
2026-08-23 13:23:00 -07:00
|
|
|
|
|
|
|
|
/// The capability file is the app's entire IPC attack surface, and it is
|
|
|
|
|
/// data — nothing in `cargo test` reads it, so a widened grant lands with a
|
|
|
|
|
/// green suite. This is what noticing looks like.
|
|
|
|
|
///
|
|
|
|
|
/// It exists because `core:default` was granted for months. That alias
|
|
|
|
|
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
|
|
|
|
/// unconditional `std::fs::read` of any host path with no scope check, and
|
|
|
|
|
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
2026-08-23 17:12:34 -07:00
|
|
|
/// Every `#[tauri::command]` is registered, and every registration names a
|
|
|
|
|
/// command that exists.
|
|
|
|
|
///
|
|
|
|
|
/// This is the shape of the bug that caused the original OAuth-callback
|
|
|
|
|
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
|
|
|
|
|
/// frontend wrapper — with **zero call sites**. The switch the docs told
|
|
|
|
|
/// users to flip was never wired to anything, so the bridge stayed off and
|
|
|
|
|
/// every login callback was refused. Nothing failed; the feature was simply
|
|
|
|
|
/// absent, and no test noticed because both halves compiled.
|
|
|
|
|
///
|
|
|
|
|
/// The reverse direction matters too, and for a sharper reason: a command
|
|
|
|
|
/// that is registered but reachable from nowhere is still IPC surface a
|
2026-08-23 17:15:38 -07:00
|
|
|
/// compromised webview can call. `list_sibling_containers` — which returned
|
2026-08-23 17:12:34 -07:00
|
|
|
/// every container on the daemon, including the user's unrelated work —
|
2026-08-23 17:15:38 -07:00
|
|
|
/// sat in exactly that state, and this test is what found it. It has since
|
|
|
|
|
/// been removed at all four levels: registration, command, docker helper,
|
|
|
|
|
/// and the frontend wrapper and type.
|
2026-08-23 17:12:34 -07:00
|
|
|
///
|
|
|
|
|
/// So this asserts the two lists agree, and leaves *deciding* what belongs
|
|
|
|
|
/// on them to a human. It cannot see frontend call sites; `tsc` and the
|
|
|
|
|
/// vitest suite cover that side.
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_command_is_registered_exactly_once() {
|
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
|
|
|
|
|
let mut defined: BTreeSet<String> = BTreeSet::new();
|
|
|
|
|
|
2026-08-23 18:45:11 -07:00
|
|
|
// Walk the source tree for the command attribute and take the `fn` name
|
|
|
|
|
// that follows.
|
|
|
|
|
//
|
|
|
|
|
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
|
|
|
|
// exactly and broke on the first non-`#` line. An audit got five real,
|
|
|
|
|
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
|
|
|
|
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
|
|
|
|
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
|
|
|
|
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
|
|
|
|
// Every one of those is a command the frontend could not call, which is
|
|
|
|
|
// the bug this test exists for, and the test stayed green.
|
|
|
|
|
//
|
|
|
|
|
// The asymmetry matters: confusion on the *definition* side is a silent
|
|
|
|
|
// pass, while on the *registration* side it fails loudly against
|
|
|
|
|
// legitimate code — and rustc already covers that direction. So this
|
|
|
|
|
// errs toward over-matching definitions.
|
2026-08-23 17:12:34 -07:00
|
|
|
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
|
|
|
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.is_dir() {
|
|
|
|
|
collect(&path, out);
|
|
|
|
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
|
|
|
|
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
|
|
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
2026-08-23 18:45:11 -07:00
|
|
|
let t = line.trim();
|
|
|
|
|
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
|
|
|
|
// `#[tauri :: command]`, a bare `#[command]` under
|
|
|
|
|
// `use tauri::command`, and any of those with a
|
|
|
|
|
// trailing comment.
|
|
|
|
|
let attr = t.strip_prefix("#[").map(|a| {
|
|
|
|
|
a.split(']').next().unwrap_or("").replace(' ', "")
|
|
|
|
|
});
|
|
|
|
|
let is_command_attr = attr.is_some_and(|a| {
|
|
|
|
|
a == "command" || a == "tauri::command"
|
|
|
|
|
|| a.starts_with("command(")
|
|
|
|
|
|| a.starts_with("tauri::command(")
|
|
|
|
|
});
|
|
|
|
|
if !is_command_attr {
|
2026-08-23 17:12:34 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-23 18:45:11 -07:00
|
|
|
// Skip further attributes and doc comments rather than
|
|
|
|
|
// giving up at the first line that is not an attribute.
|
2026-08-23 17:12:34 -07:00
|
|
|
for next in lines.iter().skip(i + 1) {
|
|
|
|
|
let t = next.trim();
|
2026-08-23 18:45:11 -07:00
|
|
|
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
2026-08-23 17:12:34 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-23 18:45:11 -07:00
|
|
|
// Any visibility, then `fn` or `async fn`.
|
|
|
|
|
let after_vis = t
|
|
|
|
|
.strip_prefix("pub(crate) ")
|
|
|
|
|
.or_else(|| t.strip_prefix("pub(super) "))
|
|
|
|
|
.or_else(|| t.strip_prefix("pub(in crate) "))
|
|
|
|
|
.or_else(|| t.strip_prefix("pub "))
|
|
|
|
|
.unwrap_or(t);
|
|
|
|
|
let after_async =
|
|
|
|
|
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
|
|
|
|
if let Some(rest) = after_async.strip_prefix("fn ") {
|
2026-08-23 17:12:34 -07:00
|
|
|
if let Some(name) = rest.split(['(', '<']).next() {
|
|
|
|
|
out.insert(name.trim().to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
collect(
|
|
|
|
|
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
|
|
|
|
|
&mut defined,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The registration list, read from this file rather than from a macro
|
|
|
|
|
// expansion so the test does not depend on `generate_handler!`'s shape.
|
|
|
|
|
let this = include_str!("lib.rs");
|
|
|
|
|
let handler = this
|
|
|
|
|
.split_once("generate_handler![")
|
|
|
|
|
.and_then(|(_, rest)| rest.split_once("])"))
|
|
|
|
|
.map(|(inside, _)| inside)
|
|
|
|
|
.expect("lib.rs should contain a generate_handler! list");
|
|
|
|
|
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
|
|
|
|
// style comments, and splitting on commas glues each comment to the
|
|
|
|
|
// command that follows it. A `starts_with("//")` filter then drops that
|
|
|
|
|
// command — silently, and once per group.
|
|
|
|
|
let registered: BTreeSet<String> = handler
|
|
|
|
|
.lines()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
|
|
|
|
.filter_map(|l| {
|
|
|
|
|
l.trim_end_matches(',')
|
|
|
|
|
.rsplit("::")
|
|
|
|
|
.next()
|
|
|
|
|
.map(|n| n.trim().to_string())
|
|
|
|
|
})
|
|
|
|
|
.filter(|n| !n.is_empty())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!defined.is_empty() && !registered.is_empty(),
|
|
|
|
|
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
|
|
|
|
|
defined.len(),
|
|
|
|
|
registered.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let unregistered: Vec<&String> = defined.difference(®istered).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
unregistered.is_empty(),
|
|
|
|
|
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
|
|
|
|
|
unregistered
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let undefined: Vec<&String> = registered.difference(&defined).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
undefined.is_empty(),
|
|
|
|
|
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
|
|
|
|
undefined
|
|
|
|
|
);
|
2026-08-23 18:45:11 -07:00
|
|
|
|
|
|
|
|
// "exactly once" was in this test's name and not in its body: both
|
|
|
|
|
// sides were sets, so registering the same command twice in a
|
|
|
|
|
// hand-maintained 118-line list compiled, warned about nothing, and
|
|
|
|
|
// passed here.
|
|
|
|
|
let mut seen: Vec<&str> = Vec::new();
|
|
|
|
|
let mut duplicated: Vec<&str> = Vec::new();
|
|
|
|
|
for line in handler
|
|
|
|
|
.lines()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
|
|
|
|
{
|
|
|
|
|
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
|
|
|
|
let name = name.trim();
|
|
|
|
|
if name.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if seen.contains(&name) {
|
|
|
|
|
duplicated.push(name);
|
|
|
|
|
} else {
|
|
|
|
|
seen.push(name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
duplicated.is_empty(),
|
|
|
|
|
"these are registered more than once: {:?}",
|
|
|
|
|
duplicated
|
|
|
|
|
);
|
2026-08-23 17:12:34 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:23:00 -07:00
|
|
|
#[test]
|
|
|
|
|
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
|
|
|
|
let raw = include_str!("../capabilities/default.json");
|
|
|
|
|
let parsed: serde_json::Value =
|
|
|
|
|
serde_json::from_str(raw).expect("capabilities/default.json must parse");
|
|
|
|
|
let listed: Vec<String> = parsed["permissions"]
|
|
|
|
|
.as_array()
|
|
|
|
|
.expect("a `permissions` array")
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| match p {
|
|
|
|
|
// A scoped grant is an object; its identifier is what matters here.
|
|
|
|
|
serde_json::Value::Object(o) => o["identifier"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("a scoped grant needs an identifier")
|
|
|
|
|
.to_string(),
|
|
|
|
|
other => other.as_str().expect("a grant is a string or an object").to_string(),
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let mut sorted = listed.clone();
|
|
|
|
|
sorted.sort();
|
|
|
|
|
let mut expected = vec![
|
|
|
|
|
"core:event:allow-listen",
|
|
|
|
|
"core:event:allow-unlisten",
|
|
|
|
|
"core:webview:allow-internal-toggle-devtools",
|
|
|
|
|
"dialog:allow-open",
|
|
|
|
|
"dialog:allow-save",
|
|
|
|
|
"opener:allow-open-url",
|
|
|
|
|
];
|
|
|
|
|
expected.sort();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
sorted, expected,
|
|
|
|
|
"the capability set changed. That is allowed — but it is the IPC \
|
|
|
|
|
surface a compromised webview can call, so update this list \
|
|
|
|
|
deliberately rather than to make the test pass."
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Belt and braces: the `*:default` aliases are the specific trap here,
|
|
|
|
|
// because they expand to a set the file never spells out. `store:*` in
|
|
|
|
|
// particular was an arbitrary host-file read/write primitive.
|
|
|
|
|
for grant in &listed {
|
|
|
|
|
assert!(
|
|
|
|
|
!grant.ends_with(":default"),
|
|
|
|
|
"{} is an alias — it expands to permissions this file does not \
|
|
|
|
|
name. Enumerate them instead.",
|
|
|
|
|
grant
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!grant.starts_with("store:"),
|
|
|
|
|
"store:* is `PathBuf::push` against AppData, which an absolute \
|
|
|
|
|
path discards: an arbitrary host-file read/write."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
}
|