diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 86235afbb..fedc4acbe 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -62,8 +62,8 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_U muda = { version = "=0.21.1", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } -# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd. -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] } +# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] } block2 = "0.6" # The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR # Apache-2.0 OR Zlib, already in the build through eframe and winit. diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index 529100513..e163ffb95 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -67,10 +67,31 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }; eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}"); + #[cfg(target_os = "macos")] + keep_answering(); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } +/// The activity the control channel holds on macOS: work the user asked for, which may let the +/// system sleep when idle, with full timer precision. Without it, App Nap lowers every thread to +/// the lowest priority within about a minute of the window no longer being visible. +#[cfg(target_os = "macos")] +fn activity_options() -> objc2_foundation::NSActivityOptions { + use objc2_foundation::NSActivityOptions; + NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical +} + +/// Begins the control channel's activity. It lasts until the process ends. +#[cfg(target_os = "macos")] +fn keep_answering() { + use objc2_foundation::{NSProcessInfo, NSString}; + let reason = NSString::from_str("Answering control requests"); + let activity = NSProcessInfo::processInfo().beginActivityWithOptions_reason(activity_options(), &reason); + // The token is never released or passed to `endActivity`, so the activity lasts for the process's lifetime. + std::mem::forget(activity); +} + fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming().flatten() { @@ -642,4 +663,17 @@ mod tests { assert!(line.contains("too many control connections"), "{line}"); drop(held); } + + // A test process is not an app that App Nap applies to, so this checks the activity the + // channel asks for; the throttling itself shows only in a running app with its window hidden. + #[cfg(target_os = "macos")] + #[test] + fn the_control_channel_keeps_the_app_at_full_priority_on_macos() { + use objc2_foundation::NSActivityOptions; + let options = activity_options(); + assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20"); + assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep)); + assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake"); + keep_answering(); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0ec4cfd87..3f60aae80 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,8 @@ A request the app hasn't answered after **60 s** gets `{"ok": false, "error": "t app had not started it (it was busy, or its window wasn't being drawn), the request is withdrawn and never runs, and the error says "will not run": retrying is safe. If the app was already running it, the error says "still running": it may yet complete, so check the document before -retrying. +retrying. On macOS the app keeps itself out of App Nap while the control channel is on, so it keeps +answering with its window hidden, minimized or covered, or with the screen locked. ## Confining file access