Files
Triple-C/app/src-tauri/src/lib.rs
T
shadow-testandClaude Opus 5 2c9482a67d
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Give the Files tab back its uploads and downloads
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.

The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.

None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.

Against a container that is actively hostile rather than merely surprising:

  * the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
    are two syscalls and the container owns the filesystem in between; a loop
    swapping the file for a FIFO wins that race, and `cat` then blocks forever
    with no writer and no timeout anywhere on the path — the `invoke` never
    settles and a partial is left in the user's directory for good. Verified in
    a real container that `cat` hangs, that `iflag=nonblock` returns, and that
    it is byte-identical on a regular file.
  * the read is bracketed by a second `[ -f ]`, because non-blocking turns that
    hang into an empty file that would otherwise be renamed over the
    destination and reported as a successful save.
  * an *undeterminable* exit code is a failure. Backup catches this class with
    its `total == 0` check, which download cannot have because an empty file is
    a legitimate save; without a replacement, a project restarted mid-download
    renames a truncated partial over the user's file and reports the byte count
    as if it were whole.
  * container stderr is capped. Every other reader of container output in the
    tree is capped for this reason; the two streaming commands were the
    exception, and stdout was bounded by disk while stderr was bounded by
    nothing.
  * the script's refusals are framed rather than used verbatim, so a directory
    named to look like one of our own sentences cannot become the toast
    headline through `readableRefusal`.
  * the partial name is capped at NAME_MAX. A bundler's 230-character content
    hash is a name that fits its directory and produces a partial name that
    does not.

Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.

Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.

Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.

Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.

600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
2026-08-25 10:00:21 -07:00

923 lines
41 KiB
Rust

mod auth_bridge;
mod browser_view;
mod commands;
mod docker;
mod install_helper;
mod logging;
mod models;
mod project_lock;
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_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);
}
}
// ── 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
// 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,
// 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.
tauri::async_runtime::spawn(async move {
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;
});
// 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,
// 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::get_browser_view_popout_state,
browser_view::commands::set_browser_view_popout_always_on_top,
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,
// 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,
commands::auth_token_commands::sweep_claude_token_snapshots,
// 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,
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_backup,
commands::file_commands::download_container_file,
commands::file_commands::upload_files_to_container,
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
// 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));
}
/// 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`.
/// 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
/// compromised webview can call. `list_sibling_containers` — which returned
/// every container on the daemon, including the user's unrelated work —
/// 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.
///
/// 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();
// 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.
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() {
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 {
continue;
}
// Skip further attributes and doc comments rather than
// giving up at the first line that is not an attribute.
for next in lines.iter().skip(i + 1) {
let t = next.trim();
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
continue;
}
// 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 ") {
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(&registered).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
);
// "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
);
}
#[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."
);
}
}
}