From 4d52bb287c01653ee7c852a618bdcde1cb905f1d Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 18:21:43 -0700 Subject: [PATCH 1/2] Stop services when AppKit terminates the app on macOS The app menu's Quit (and its Cmd-Q), the Dock's Quit, and logging out go through NSApplication terminate:, which exits the process from inside the event loop, so Desktop::run never reached its shutdown and managed services were left running. Stop the manager from an NSApplicationWillTerminateNotification observer as well. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 ++ Cargo.toml | 2 ++ README.md | 5 ++-- src/desktop.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 773b4e5..fe4b79e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2712,6 +2712,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" name = "local-dev-proxy" version = "0.0.17" 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..e43f687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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| { From aeea1d8aaebee83c8546a5b9988c5c07d525a9f9 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 18:26:35 -0700 Subject: [PATCH 2/2] Bump version to 0.0.18 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe4b79e..150f362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2710,7 +2710,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "local-dev-proxy" -version = "0.0.17" +version = "0.0.18" dependencies = [ "block2 0.6.2", "bytes", diff --git a/Cargo.toml b/Cargo.toml index e43f687..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 "]