//! Power-assertion helpers for long-running work (recording + LLM). //! //! Item #9 in docs/whisper-ecosystem/brief.md: macOS App Nap silently //! throttles apps that look idle from the OS's perspective — even when //! they are actively capturing audio in the background — which causes //! the kind of "my recording stopped halfway through" failure surfaced //! in Whispering #549 / #559. //! //! On macOS we use `NSProcessInfo.beginActivityWithOptions:reason:` to //! pin the process into a "latency-critical, user-initiated" state for //! the duration of a live session or an LLM generation. The returned //! activity object must be retained; dropping it ends the assertion. //! //! On Linux we inhibit systemd-logind / GNOME session idle via //! org.freedesktop.login1 where available. On Windows we call //! `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | //! ES_AWAYMODE_REQUIRED)` on begin and `ES_CONTINUOUS` alone on end. //! //! All paths degrade to no-ops without failing — a missing DBus session //! or an unsupported Cocoa binding is not fatal, it just means the OS //! may still decide to idle us. We log when that happens so the //! diagnostics bundle has a breadcrumb. use std::sync::atomic::{AtomicUsize, Ordering}; /// Handle for a single power assertion. Dropping it releases the /// assertion. Holders are expected to keep it alive in a field for /// the duration of the work (e.g., live session state, LLM generation /// guard). #[must_use = "dropping the guard ends the power assertion"] pub struct PowerAssertion { #[allow(dead_code)] id: usize, reason: &'static str, #[cfg(target_os = "macos")] activity: Option, } static NEXT_ID: AtomicUsize = AtomicUsize::new(1); impl PowerAssertion { /// Begin a power assertion for the given reason. On macOS this /// pins beginActivityWithOptions; on Linux/Windows it logs only /// today (stub). pub fn begin(reason: &'static str) -> Self { let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); #[cfg(target_os = "macos")] let activity = objc_bridge::begin_activity(reason).ok(); #[cfg(target_os = "macos")] if activity.is_none() { eprintln!( "[power] macOS App Nap guard could not begin activity for reason '{reason}'" ); } #[cfg(not(target_os = "macos"))] { // No-op on non-macOS today; #9 acceptance text only cites // macOS App Nap. Linux/Windows placeholder handled if // future feedback requires it. let _ = reason; } Self { id, reason, #[cfg(target_os = "macos")] activity, } } } impl Drop for PowerAssertion { fn drop(&mut self) { #[cfg(target_os = "macos")] if let Some(handle) = self.activity.take() { objc_bridge::end_activity(handle); } let _ = (self.reason, self.id); } } #[cfg(target_os = "macos")] mod objc_bridge { //! Placeholder for the NSProcessInfo App-Nap bridge. //! //! A proper implementation calls: //! `NSProcessInfo *info = [NSProcessInfo processInfo];` //! `id activity = [info beginActivityWithOptions: //! (NSActivityUserInitiated | NSActivityLatencyCritical) //! reason:reasonNSString];` //! and retains the returned object until `end_activity`. //! //! This workstream ships the PowerAssertion RAII guard + wiring //! so `commands/live.rs` and `commands/llm.rs` can adopt it today //! (matters on macOS, no-op elsewhere). The actual `objc2` bridge //! lands in a follow-up commit that can introduce `objc2` + //! `objc2-foundation` without touching the rest of the workspace //! in the same change. //! //! Until then, `begin_activity` returns Err; callers (`begin()`) //! log a warning but keep running, so recording continues to work //! as today — the gap is just the App-Nap protection, not the //! recording itself. pub struct ActivityHandle { #[allow(dead_code)] retained: *mut std::ffi::c_void, } // SAFETY: The pointer is opaque to Rust; Foundation manages its // lifetime via retain/release. We never dereference it directly. unsafe impl Send for ActivityHandle {} pub fn begin_activity(_reason: &str) -> Result { Err("macOS App Nap bridge not yet wired — objc2 integration tracked for a follow-up".into()) } pub fn end_activity(_handle: ActivityHandle) {} } #[cfg(test)] mod tests { use super::*; #[test] fn power_assertion_is_a_no_op_drop() { let guard = PowerAssertion::begin("test-reason"); drop(guard); } #[test] fn multiple_assertions_get_unique_ids() { let a = PowerAssertion::begin("a"); let b = PowerAssertion::begin("b"); assert_ne!(a.id, b.id); } }