//! 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. //! Runtime verification on Apple Silicon against actual idle-throttling //! is still pending. See `KNOWN-ISSUES.md` (KI-01). //! //! On Linux (KI-02) we call `org.freedesktop.login1.Manager.Inhibit` via //! D-Bus (zbus blocking API). The returned file descriptor is the inhibit //! lock; closing it releases the lock. A global `OnceLock>>` //! holds the descriptor for the duration of recording. //! //! On Windows (KI-03) we call //! `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` to prevent //! the system from sleeping. `ES_CONTINUOUS` alone resets that on release. //! //! All paths return `Ok(())` on failure — errors are logged but never block //! recording. The workarounds in `KNOWN-ISSUES.md` remain valid for edge cases //! (non-systemd Linux containers, policy-locked Windows images). use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; // --------------------------------------------------------------------------- // Shared snapshot registry (all platforms) // --------------------------------------------------------------------------- #[derive(Debug, Clone)] pub struct PowerAssertionSnapshot { pub id: usize, pub reason: &'static str, pub backend: &'static str, pub acquired: bool, } /// 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, backend: &'static str, acquired: bool, #[cfg(target_os = "macos")] activity: Option, } static NEXT_ID: AtomicUsize = AtomicUsize::new(1); fn assertion_registry() -> &'static Mutex> { static REGISTRY: OnceLock>> = OnceLock::new(); REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } pub fn active_assertions_snapshot() -> Vec { let mut snapshots = assertion_registry() .lock() .unwrap() .values() .cloned() .collect::>(); snapshots.sort_by_key(|snapshot| snapshot.id); snapshots } impl PowerAssertion { /// Begin a power assertion for the given reason. On macOS this /// pins beginActivityWithOptions; on Linux/Windows it logs only /// (the OS-level inhibit is managed separately via the /// `acquire_idle_inhibit` / `release_idle_inhibit` Tauri commands). 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")] let backend = "macos"; #[cfg(target_os = "macos")] let acquired = activity.is_some(); #[cfg(target_os = "macos")] if acquired { tracing::info!(assertion_id = id, reason, "began macOS App Nap guard"); } else { tracing::warn!(reason, "macOS App Nap guard could not begin activity"); } #[cfg(target_os = "linux")] let backend = "linux-logind"; #[cfg(target_os = "linux")] let acquired = true; // actual inhibit is managed by acquire_idle_inhibit command #[cfg(target_os = "windows")] let backend = "windows-ste"; #[cfg(target_os = "windows")] let acquired = true; // actual STE call is managed by acquire_idle_inhibit command #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] let backend = "noop"; #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] let acquired = false; #[cfg(not(target_os = "macos"))] let _ = reason; assertion_registry().lock().unwrap().insert( id, PowerAssertionSnapshot { id, reason, backend, acquired, }, ); Self { id, reason, backend, acquired, #[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); tracing::info!( assertion_id = self.id, reason = self.reason, "ended macOS App Nap guard" ); } assertion_registry().lock().unwrap().remove(&self.id); let _ = (self.reason, self.id); let _ = (self.backend, self.acquired); } } // --------------------------------------------------------------------------- // macOS: NSProcessInfo activity (unchanged — KI-01, not touched here) // --------------------------------------------------------------------------- #[cfg(target_os = "macos")] mod objc_bridge { use objc2::rc::Retained; use objc2::runtime::ProtocolObject; use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString}; pub struct ActivityHandle { activity: Retained>, } unsafe impl Send for ActivityHandle {} pub fn begin_activity(reason: &str) -> Result { let process_info = NSProcessInfo::processInfo(); let reason = NSString::from_str(reason); let options = NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical; let activity = process_info.beginActivityWithOptions_reason(options, &reason); Ok(ActivityHandle { activity }) } pub fn end_activity(handle: ActivityHandle) { let process_info = NSProcessInfo::processInfo(); unsafe { process_info.endActivity(&handle.activity); } } } // --------------------------------------------------------------------------- // Linux: systemd-logind D-Bus inhibit (KI-02) // --------------------------------------------------------------------------- /// The global inhibit lock file descriptor. `Some(fd)` while recording; /// `None` when not inhibiting. Dropping the inner `OwnedFd` releases the /// systemd-logind inhibit lock automatically. #[cfg(target_os = "linux")] fn linux_inhibit_lock() -> &'static Mutex> { static LOCK: OnceLock>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(None)) } #[cfg(target_os = "linux")] mod linux_inhibit { use std::os::unix::io::OwnedFd; use zbus::blocking::Connection; use zbus::zvariant::OwnedFd as ZOwnedFd; /// Acquires a systemd-logind idle+sleep inhibit lock. /// Returns the file descriptor whose lifetime IS the lock. /// Drops (closes) the fd to release. pub fn acquire() -> Result { let conn = Connection::system().map_err(|e| format!("zbus: connect to system bus failed: {e}"))?; let reply = conn .call_method( Some("org.freedesktop.login1"), "/org/freedesktop/login1", Some("org.freedesktop.login1.Manager"), "Inhibit", &( "idle:sleep:handle-lid-switch", "Lumotia", "Active dictation in progress", "block", ), ) .map_err(|e| format!("zbus: Inhibit call failed: {e}"))?; let fd: ZOwnedFd = reply .body() .deserialize() .map_err(|e| format!("zbus: Inhibit reply deserialize failed: {e}"))?; // Convert zvariant's OwnedFd to std's OwnedFd Ok(fd.into()) } } // --------------------------------------------------------------------------- // Windows: SetThreadExecutionState (KI-03) // --------------------------------------------------------------------------- #[cfg(target_os = "windows")] mod windows_inhibit { use windows::Win32::System::Power::{ SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED, }; /// Prevents the system from sleeping by setting ES_CONTINUOUS | ES_SYSTEM_REQUIRED. /// Display sleep is intentionally NOT blocked — the user is dictating, not watching. pub fn acquire() { unsafe { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } } /// Releases the sleep prevention by resetting to ES_CONTINUOUS alone. pub fn release() { unsafe { SetThreadExecutionState(ES_CONTINUOUS); } } } // --------------------------------------------------------------------------- // Tauri commands: acquire_idle_inhibit / release_idle_inhibit // --------------------------------------------------------------------------- /// Acquire an OS-level idle/sleep inhibit lock for the duration of recording. /// /// - Linux: calls `org.freedesktop.login1.Manager.Inhibit` and holds the fd. /// - Windows: calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`. /// - macOS: no-op here; App Nap is handled by `PowerAssertion::begin`. /// - Other: no-op. /// /// Errors are logged and swallowed — recording must never be blocked by a /// failed power assertion. #[tauri::command] pub async fn acquire_idle_inhibit() -> Result<(), String> { #[cfg(target_os = "linux")] { // The zbus blocking call must not run on the Tokio executor thread. let result = tokio::task::spawn_blocking(linux_inhibit::acquire) .await .unwrap_or_else(|e| Err(format!("spawn_blocking panic: {e}"))); match result { Ok(fd) => { *linux_inhibit_lock().lock().unwrap() = Some(fd); tracing::info!("Linux idle inhibit acquired (logind block)"); } Err(e) => { tracing::warn!( error = %e, "Linux idle inhibit not acquired — recording continues unaffected" ); } } Ok(()) } #[cfg(target_os = "windows")] { windows_inhibit::acquire(); tracing::info!("Windows sleep prevention engaged (ES_CONTINUOUS | ES_SYSTEM_REQUIRED)"); Ok(()) } // macOS: App Nap guard is managed by PowerAssertion in the live session path. // Other platforms: no-op. #[cfg(not(any(target_os = "linux", target_os = "windows")))] Ok(()) } /// Release the OS-level idle/sleep inhibit lock acquired during recording. /// /// Safe to call even if no lock was held (idempotent). #[tauri::command] pub async fn release_idle_inhibit() -> Result<(), String> { #[cfg(target_os = "linux")] { // Dropping the OwnedFd closes the file descriptor, which releases // the systemd-logind inhibit lock. let prev = linux_inhibit_lock().lock().unwrap().take(); if prev.is_some() { tracing::info!("Linux idle inhibit released (fd closed)"); } Ok(()) } #[cfg(target_os = "windows")] { windows_inhibit::release(); tracing::info!("Windows sleep prevention released (ES_CONTINUOUS)"); Ok(()) } #[cfg(not(any(target_os = "linux", target_os = "windows")))] Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::sync::{Mutex, MutexGuard, OnceLock}; fn power_test_guard() -> MutexGuard<'static, ()> { static TEST_GUARD: OnceLock> = OnceLock::new(); TEST_GUARD.get_or_init(|| Mutex::new(())).lock().unwrap() } fn clear_assertion_registry() { assertion_registry().lock().unwrap().clear(); } #[test] fn power_assertion_is_a_no_op_drop() { let _guard = power_test_guard(); clear_assertion_registry(); let guard = PowerAssertion::begin("test-reason"); let snapshots = active_assertions_snapshot(); assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id)); drop(guard); assert!(active_assertions_snapshot().is_empty()); } #[test] fn multiple_assertions_get_unique_ids() { let _guard = power_test_guard(); clear_assertion_registry(); let a = PowerAssertion::begin("a"); let b = PowerAssertion::begin("b"); assert_ne!(a.id, b.id); let snapshots = active_assertions_snapshot(); assert_eq!(snapshots.len(), 2); assert_eq!(snapshots[0].reason, "a"); assert_eq!(snapshots[1].reason, "b"); drop(a); drop(b); assert!(active_assertions_snapshot().is_empty()); } }