From 214345df137dec07adaafe931f03bc0afbed181a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 20:49:29 -0400 Subject: [PATCH] control: keep channel responsive when window is covered (disable App Nap); fail fast on occluded screenshots Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 ++++++++ apps/effectcraft/Cargo.toml | 2 ++ apps/effectcraft/src/main.rs | 15 +++++++++++++++ crates/ui-egui/src/lib.rs | 6 ++++++ 4 files changed, 31 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b29b9181..ae0a711f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "effectcraft-ui-egui", "eframe", "egui", + "objc2-foundation 0.3.2", "rfd", "serde_json", "winit", @@ -5465,6 +5466,13 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 1759d4c0..3240c78d 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -21,3 +21,5 @@ rfd = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). winit = { version = "0.30", default-features = false } +# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling). +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 6ee51c56..a2b3fedd 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -70,6 +70,7 @@ fn main() -> eframe::Result { app.hooks.pick_open_project = Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); if let Some(port) = control_port { + disable_app_nap(); let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); } @@ -91,3 +92,17 @@ fn agent_event_loop(agent: bool) -> Option { let _ = agent; None } + +/// Agents drive the app while its window is covered or on another Space. macOS App Nap would +/// throttle the event loop then, stalling the control channel; opt out for `--control` runs. +fn disable_app_nap() { + #[cfg(target_os = "macos")] + { + use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; + let info = NSProcessInfo::processInfo(); + let reason = NSString::from_str("EffectCraft control channel"); + let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical; + // Leaked on purpose: the activity lasts for the life of the process. + std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d2..6b7f089f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -313,6 +313,12 @@ impl EffectcraftApp { } control::Outcome::AfterInput => self.input_waiters.push(reply), control::Outcome::Screenshot { path, crop } => { + // A covered or minimized window presents no frames (macOS skips its redraws), so + // a screenshot would never arrive and nothing would tick its timeout. + if ctx.input(|i| i.viewport().occluded == Some(true) || i.viewport().minimized == Some(true)) { + let _ = reply.send(json!({"ok": false, "error": "window is covered or minimized: call ui.focus first (render.frame renders comp pixels without the window)"})); + continue; + } let token = self.next_token; self.next_token += 1; let settle = now + 0.35;