diff --git a/Cargo.lock b/Cargo.lock index 773b4e5..150f362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2710,8 +2710,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "local-dev-proxy" -version = "0.0.17" +version = "0.0.18" dependencies = [ + "block2 0.6.2", "bytes", "chrono", "dirs", @@ -2725,6 +2726,7 @@ dependencies = [ "libc", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "sha2", "slint", "slint-build", diff --git a/Cargo.toml b/Cargo.toml index b6c5f22..4afffef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "local-dev-proxy" -version = "0.0.17" +version = "0.0.18" edition = "2024" description = "Local development process orchestration with a built-in reverse proxy" authors = ["Andrew Chen "] @@ -37,6 +37,8 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } +block2 = "0.6.2" [dev-dependencies] futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] } diff --git a/README.md b/README.md index 647b0dc..667e604 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,9 @@ similar locations resolve even when the binary is started outside a terminal. it from the tray icon's **Open Manager** (or by launching the application again). On macOS the Dock icon is shown only while the window is open. Without a tray icon, closing the window quits. -- **Quit** (the window's button, the tray menu, or Ctrl/Cmd-Q) → stops the proxy, stops - every managed service together with its child processes, and exits. +- **Quit** (the window's button, the tray menu, or Ctrl/Cmd-Q; on macOS also the app + menu, the Dock, and logging out) → stops the proxy, stops every managed service + together with its child processes, and exits. - **SIGTERM, SIGINT (Ctrl-C), or SIGHUP** (Ctrl-C, Ctrl-Break, or closing the console window on Windows) → the same as Quit. - **A managed service exits on its own** → it is reported as `crashed` with its exit diff --git a/src/desktop.rs b/src/desktop.rs index 888d92c..5a4e1e3 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -53,10 +53,16 @@ pub struct Desktop; impl Frontend for Desktop { fn run(self: Box, manager: Manager, events: Receiver) -> ExitCode { let manager = Arc::new(Mutex::new(manager)); + // AppKit's own quit paths (the app menu's Quit and its Cmd-Q, the + // Dock's Quit, logging out) exit the process from inside the event + // loop, so this never regains control after them. + let _terminate = app_exit::on_terminate({ + let manager = Arc::clone(&manager); + move || shut_down(&manager) + }); let result = run_event_loop(Arc::clone(&manager), events); // However the event loop ended, the window is gone; stop everything. - tracing::info!("Shutting down"); - lock(&manager).stop(); + shut_down(&manager); match result { Ok(()) => ExitCode::SUCCESS, Err(err) => { @@ -115,6 +121,11 @@ fn forward_app_events( Ok(()) } +fn shut_down(manager: &Mutex) { + tracing::info!("Shutting down"); + lock(manager).stop(); +} + fn lock(manager: &Mutex) -> MutexGuard<'_, Manager> { manager.lock().unwrap_or_else(PoisonError::into_inner) } @@ -770,6 +781,56 @@ mod dock { pub fn set_icon_visible(_visible: bool) {} } +mod app_exit { + /// Keeps the terminate handler registered while alive. + pub struct Registration { + #[cfg(target_os = "macos")] + token: objc2::rc::Retained< + objc2::runtime::ProtocolObject, + >, + } + + /// Run `handler` when AppKit is about to exit the process (`terminate:`), + /// which it does without returning from the event loop. + #[cfg(target_os = "macos")] + pub fn on_terminate(handler: impl Fn() + Send + 'static) -> Registration { + use std::ptr::NonNull; + + use block2::RcBlock; + use objc2_app_kit::NSApplicationWillTerminateNotification; + use objc2_foundation::{NSNotification, NSNotificationCenter}; + + let block = RcBlock::new(move |_: NonNull| handler()); + let center = NSNotificationCenter::defaultCenter(); + // SAFETY: the name is an AppKit constant; with no queue the block + // runs synchronously on the posting (main) thread, and it is `Send`. + let token = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(NSApplicationWillTerminateNotification), + None, + None, + &block, + ) + }; + Registration { token } + } + + #[cfg(not(target_os = "macos"))] + pub fn on_terminate(_handler: impl Fn() + Send + 'static) -> Registration { + Registration {} + } + + #[cfg(target_os = "macos")] + impl Drop for Registration { + fn drop(&mut self) { + use objc2_foundation::NSNotificationCenter; + + // SAFETY: `token` came from this center's `addObserverForName`. + unsafe { NSNotificationCenter::defaultCenter().removeObserver(self.token.as_ref()) }; + } + } +} + fn install_toml_syntax(window: &ManagerWindow) { let syntax = window.global::(); syntax.on_tokenize(|text| {