Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
845 changes: 845 additions & 0 deletions apps/staged/src-tauri/src/app_lifecycle.rs

Large diffs are not rendered by default.

130 changes: 87 additions & 43 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod acp_tools;
pub mod acp_tools_reconciler;
pub mod actions;
pub mod agent;
pub mod app_lifecycle;
pub mod background_sync;
pub mod blox;
pub mod branches;
Expand Down Expand Up @@ -48,9 +49,7 @@ pub mod test_utils;

use serde::Serialize;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use store::Store;
use tauri::{Emitter, Manager};

Expand All @@ -67,11 +66,6 @@ struct DbState {
needs_reset: Mutex<Option<StoreIncompatibility>>,
}

#[derive(Default)]
struct ShutdownState {
quit_in_progress: AtomicBool,
}

pub(crate) fn preferences_store_path_buf() -> Option<PathBuf> {
crate::paths::data_dir().map(|d| d.join("preferences.json"))
}
Expand Down Expand Up @@ -258,29 +252,6 @@ pub(crate) fn get_store(
.ok_or_else(|| "Database not initialized — please reset from the startup prompt".into())
}

fn stop_actions_for_app_shutdown(app_handle: &tauri::AppHandle) {
let executor = app_handle.state::<Arc<actions::ActionExecutor>>();
let registry = app_handle.state::<Arc<actions::ActionRegistry>>();
let stopped_execution_ids = actions::commands::stop_all_actions(
&executor,
&registry,
actions::StopOptions {
force_kill_after: Some(Duration::from_secs(1)),
},
);

if stopped_execution_ids.is_empty() {
return;
}

if !executor.wait_for_executions(&stopped_execution_ids, Duration::from_secs(2)) {
log::warn!(
"Timed out waiting for {} action(s) to stop during app shutdown",
stopped_execution_ids.len()
);
}
}

fn start_store_services(
store: Arc<Store>,
pr_scheduler: Arc<pr_poll_scheduler::PrPollScheduler>,
Expand Down Expand Up @@ -1772,6 +1743,10 @@ enum MenuDispatch {
EmitToFocused(&'static str),
/// Create a window here in the backend, with no project seed.
OpenWindowUnseeded,
/// Run the quit gate in the backend (`app_lifecycle::request_quit`).
RequestQuit,
/// Reveal a window in the backend (`app_lifecycle::show_a_window`).
ShowWindow,
/// Nothing to do — unknown item, or a window-scoped item with no target.
Drop,
}
Expand All @@ -1790,6 +1765,15 @@ enum MenuDispatch {
/// can just create it. That also un-strands the other items: the new window is
/// focused, so Settings/Find/zoom route normally again.
fn dispatch_menu_event(id: &str, has_focused_window: bool) -> MenuDispatch {
// Lifecycle items are app-scoped and handled in the backend, focus or no
// focus — every window being hidden is exactly when `Window ▸ Staged` and a
// gateable `Cmd+Q` matter most.
match id {
app_lifecycle::QUIT_MENU_ID => return MenuDispatch::RequestQuit,
app_lifecycle::SHOW_WINDOW_MENU_ID => return MenuDispatch::ShowWindow,
_ => {}
}

let event_name = match id {
"new_window" => "menu:new-window",
"settings" => "menu:settings",
Expand Down Expand Up @@ -1933,6 +1917,26 @@ pub fn run() {
true,
Some("CmdOrCtrl+0"),
)?;
// Custom rather than `PredefinedMenuItem::quit`: that one maps
// straight to `NSApp terminate:`, which reaches no Tauri hook,
// so Cmd+Q could never be gated on running sessions.
let quit_item = MenuItem::with_id(
handle,
app_lifecycle::QUIT_MENU_ID,
"Quit Staged",
true,
Some("CmdOrCtrl+Q"),
)?;
// Recovery path for an app whose windows are all hidden: Cmd+Tab
// sends no reopen event, so without this the app looks dead (the
// same reason Slack exposes `Window ▸ Slack`).
let show_window_item = MenuItem::with_id(
handle,
app_lifecycle::SHOW_WINDOW_MENU_ID,
"Staged",
true,
None::<&str>,
)?;

let app_menu = Submenu::with_items(
handle,
Expand All @@ -1952,7 +1956,7 @@ pub fn run() {
&PredefinedMenuItem::hide(handle, None)?,
&PredefinedMenuItem::hide_others(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::quit(handle, Some("Quit Staged"))?,
&quit_item,
],
)?;

Expand Down Expand Up @@ -2010,6 +2014,8 @@ pub fn run() {
&PredefinedMenuItem::maximize(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::close_window(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&show_window_item,
],
)?;

Expand Down Expand Up @@ -2147,7 +2153,7 @@ pub fn run() {
app.manage(window_commands::UpdaterWindowState::default());
app.manage(Arc::new(actions::ActionExecutor::new()));
app.manage(Arc::new(actions::ActionRegistry::new()));
app.manage(ShutdownState::default());
app.manage(app_lifecycle::QuitState::default());
app.manage(DbState {
db_path,
needs_reset: Mutex::new(reset_info),
Expand Down Expand Up @@ -2207,10 +2213,15 @@ pub fn run() {
log::warn!("Failed to open window from menu: {e}");
}
}
MenuDispatch::RequestQuit => app_lifecycle::request_quit(app, false),
MenuDispatch::ShowWindow => app_lifecycle::show_a_window(app),
MenuDispatch::Drop => {}
}
})
.on_window_event(|window, event| {
// Close-to-hide / the quit gate (`CloseRequested`).
app_lifecycle::on_window_event(window, event);

if let tauri::WindowEvent::Destroyed = event {
// Native windows have no WS heartbeat and their PR-poll client
// ids are exempt from TTL eviction, so a closed window must
Expand Down Expand Up @@ -2246,6 +2257,9 @@ pub fn run() {
window_commands::new_window,
window_commands::take_window_seed,
window_commands::claim_updater_ownership,
// Lifecycle — desktop only; the web-mode `dispatch` table refuses
// this so a browser client can't quit the host.
app_lifecycle::quit_app,
list_projects,
create_project,
list_project_repos,
Expand Down Expand Up @@ -2441,17 +2455,30 @@ pub fn run() {
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
let shutdown = app_handle.state::<ShutdownState>();
if shutdown.quit_in_progress.swap(true, Ordering::SeqCst) {
return;
}

api.prevent_exit();
stop_actions_for_app_shutdown(app_handle);
app_handle.exit(0);
.run(|app_handle, event| match event {
// Now that window close is intercepted, the only producers are our
// own confirmed quit (which has already cleaned up) and the updater's
// relaunch — which ignores `prevent_exit` anyway, so nothing here
// tries to hold the exit back.
tauri::RunEvent::ExitRequested { .. } => {
app_lifecycle::shutdown_cleanup(app_handle);
}
// The only hook on the `NSApp terminate:` path (Dock ▸ Quit, logout),
// which never emits `ExitRequested`. Without it those quits orphan
// the agent and action child processes.
tauri::RunEvent::Exit => {
app_lifecycle::shutdown_cleanup(app_handle);
}
// Dock-icon click or `open -a Staged` on an app whose windows are
// all hidden.
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen {
has_visible_windows: false,
..
} => {
app_lifecycle::show_a_window(app_handle);
}
_ => {}
});
}

Expand Down Expand Up @@ -2587,12 +2614,29 @@ mod tests {

#[test]
fn unknown_menu_events_drop_regardless_of_focus() {
for id in ["", "quit", "menu:new-window", "New Window"] {
for id in ["", "menu:new-window", "New Window"] {
assert_eq!(dispatch_menu_event(id, true), MenuDispatch::Drop);
assert_eq!(dispatch_menu_event(id, false), MenuDispatch::Drop);
}
}

/// The lifecycle items must route with no window focused: every window
/// being hidden is exactly when `Window ▸ Staged` and a gateable `Cmd+Q`
/// matter most.
#[test]
fn lifecycle_menu_events_route_to_the_backend_regardless_of_focus() {
for focused in [true, false] {
assert_eq!(
dispatch_menu_event(crate::app_lifecycle::QUIT_MENU_ID, focused),
MenuDispatch::RequestQuit
);
assert_eq!(
dispatch_menu_event(crate::app_lifecycle::SHOW_WINDOW_MENU_ID, focused),
MenuDispatch::ShowWindow
);
}
}

fn remote_branch(
project_id: &str,
id: &str,
Expand Down
15 changes: 15 additions & 0 deletions apps/staged/src-tauri/src/pr_poll_scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,21 @@ pub fn set_foreground_project(
scheduler.set_foreground(client_id, project_id);
}

/// Report a native window's focus from the backend, bypassing the frontend.
///
/// `app_lifecycle` hides and shows windows itself, and a hidden native window
/// does not reliably deliver a blur to its webview — so without this the
/// scheduler would keep polling on the focused tier for a window nobody can
/// see. The id mirrors the frontend's own `tauri-{label}` scheme, so both sides
/// address the same per-window client.
pub(crate) fn set_tauri_client_focus(
scheduler: &PrPollScheduler,
window_label: &str,
focused: bool,
) {
scheduler.set_focus(format!("{TAURI_CLIENT_PREFIX}{window_label}"), focused);
}

/// Report a client's window focus. With no client focused, periodic polling
/// pauses (an explicit `refresh_now` still fetches).
#[tauri::command(rename_all = "camelCase")]
Expand Down
2 changes: 1 addition & 1 deletion apps/staged/src-tauri/src/session_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,7 @@ pub struct ActiveSessionInfo {
/// sessions (pr/push) link no artifact, so their branch comes from the
/// session row's own `branch_id` and their type falls back to prompt
/// inference.
fn project_active_session(store: &Store, session: &store::Session) -> ActiveSessionInfo {
pub(crate) fn project_active_session(store: &Store, session: &store::Session) -> ActiveSessionInfo {
let project_note = store
.get_project_note_by_session(&session.id)
.ok()
Expand Down
67 changes: 67 additions & 0 deletions apps/staged/src-tauri/src/session_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,44 @@ impl SessionRegistry {
self.inner.lock().unwrap().running.contains_key(session_id)
}

/// Ids of every session this process is currently running.
///
/// The shutdown path uses this to cancel them all: the registry, not the DB,
/// is what says which running rows belong to *this* process's threads.
pub fn running_session_ids(&self) -> Vec<String> {
self.inner.lock().unwrap().running.keys().cloned().collect()
}

/// Wait until none of `session_ids` are registered as running, or until
/// `timeout` elapses. Returns `true` if they all deregistered in time.
///
/// Modelled on `ActionExecutor::wait_for_executions`: session threads
/// deregister themselves as they exit, so polling the registry is how the
/// shutdown path learns a cancelled session's agent is actually gone rather
/// than exiting out from under it.
pub fn wait_for_sessions(&self, session_ids: &[String], timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;

loop {
let all_stopped = {
let inner = self.inner.lock().unwrap();
session_ids
.iter()
.all(|session_id| !inner.running.contains_key(session_id))
};

if all_stopped {
return true;
}

if std::time::Instant::now() >= deadline {
return false;
}

std::thread::sleep(Duration::from_millis(25));
}
}

/// Register a session whose work is driven outside `start_session` (e.g. a
/// pikchr diagram child session run by a `generate_pikchr` worker thread),
/// so a user cancel reaches the actual work instead of taking
Expand Down Expand Up @@ -3579,6 +3617,35 @@ mod tests {
assert_eq!(failed.completion_reason, Some(CompletionReason::Crashed));
}

#[test]
fn wait_for_sessions_returns_once_every_session_deregisters() {
let registry = Arc::new(SessionRegistry::new());
registry.register("session-1");
registry.register("session-2");
let session_ids = registry.running_session_ids();
assert_eq!(session_ids.len(), 2);

let deregistering = Arc::clone(&registry);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
deregistering.deregister("session-1");
deregistering.deregister("session-2");
});

assert!(registry.wait_for_sessions(&session_ids, Duration::from_secs(2)));
assert!(registry.running_session_ids().is_empty());
}

#[test]
fn wait_for_sessions_times_out_while_a_session_is_still_running() {
let registry = SessionRegistry::new();
registry.register("session-1");

assert!(!registry.wait_for_sessions(&["session-1".to_string()], Duration::from_millis(50)));
// Unknown ids count as stopped, so a stale snapshot can't block a quit.
assert!(registry.wait_for_sessions(&["gone".to_string()], Duration::from_millis(50)));
}

#[test]
fn running_project_session_cancellation_records_completion_reason_override() {
let registry = SessionRegistry::new();
Expand Down
9 changes: 9 additions & 0 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,15 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val
Err("claim_updater_ownership is not supported in web mode".to_string())
}

// =====================================================================
// App lifecycle
// =====================================================================
// Refused rather than proxied: a browser client must not be able to
// terminate the desktop host every other client is connected to. The
// confirmation this gates on is a native alert on the host anyway, with
// no browser client to answer it.
"quit_app" => Err(format!("{command} is not available in web mode")),

// =====================================================================
// Projects
// =====================================================================
Expand Down
4 changes: 3 additions & 1 deletion apps/staged/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -586,8 +586,10 @@
}
}

// Quits rather than closing the window: closing the last window only hides
// it, and there is no usable app behind this screen to come back to.
function handleClose() {
getWindowSync().close();
void commands.quitApp().catch((e) => console.error('Failed to quit:', e));
}
</script>

Expand Down
Loading