From 32da5eb3584b757cdc2745f35dec2e0fb9ecdc8d Mon Sep 17 00:00:00 2001 From: Yaroslav Volovich Date: Fri, 13 Feb 2026 18:31:39 +0000 Subject: [PATCH] feat(tui): prevent macOS idle sleep while turns run (#11711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a shared `codex-core` sleep inhibitor that uses native macOS IOKit assertions (`IOPMAssertionCreateWithName` / `IOPMAssertionRelease`) instead of spawning `caffeinate` - wire sleep inhibition to turn lifecycle in `tui` (`TurnStarted` enables; `TurnComplete` and abort/error finalization disable) - gate this behavior behind a `/experimental` feature toggle (`[features].prevent_idle_sleep`) instead of a dedicated `[tui]` config flag - expose the toggle in `/experimental` on macOS; keep it under development on other platforms - keep behavior no-op on non-macOS targets image ## Testing - `cargo check -p codex-core -p codex-tui` - `cargo test -p codex-core sleep_inhibitor::tests -- --nocapture` - `cargo test -p codex-core tui_config_missing_notifications_field_defaults_to_enabled -- --nocapture` - `cargo test -p codex-core prevent_idle_sleep_is_ -- --nocapture` ## Semantics and API references - This PR targets `caffeinate -i` semantics: prevent *idle system sleep* while allowing display idle sleep. - `caffeinate -i` mapping in Apple open source (`assertionMap`): - `kIdleAssertionFlag -> kIOPMAssertionTypePreventUserIdleSystemSleep` - Source: https://github.com/apple-oss-distributions/PowerManagement/blob/PowerManagement-1846.60.12/caffeinate/caffeinate.c#L52-L54 - Apple IOKit docs for assertion types and API: - https://developer.apple.com/documentation/iokit/iopmlib_h/iopmassertiontypes - https://developer.apple.com/documentation/iokit/1557092-iopmassertioncreatewithname - https://developer.apple.com/library/archive/qa/qa1340/_index.html ## Codex Electron vs this PR (full stack path) - Codex Electron app requests sleep blocking with `powerSaveBlocker.start("prevent-app-suspension")`: - https://github.com/openai/codex/blob/main/codex/codex-vscode/electron/src/electron-message-handler.ts - Electron maps that string to Chromium wake lock type `kPreventAppSuspension`: - https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_power_save_blocker.cc - Chromium macOS backend maps wake lock types to IOKit assertion constants and calls IOKit: - `kPreventAppSuspension -> kIOPMAssertionTypeNoIdleSleep` - `kPreventDisplaySleep / kPreventDisplaySleepAllowDimming -> kIOPMAssertionTypeNoDisplaySleep` - https://github.com/chromium/chromium/blob/main/services/device/wake_lock/power_save_blocker/power_save_blocker_mac.cc ## Why this PR uses a different macOS constant name - This PR uses `"PreventUserIdleSystemSleep"` directly, via `IOPMAssertionCreateWithName`, in `codex-rs/core/src/sleep_inhibitor.rs`. - Apple’s IOKit header documents `kIOPMAssertionTypeNoIdleSleep` as deprecated and recommends `kIOPMAssertPreventUserIdleSystemSleep` / `kIOPMAssertionTypePreventUserIdleSystemSleep`: - https://github.com/apple-oss-distributions/IOKitUser/blob/IOKitUser-100222.60.2/pwr_mgt.subproj/IOPMLib.h#L1000-L1030 - So Chromium and this PR are using different constant names, but semantically equivalent idle-system-sleep prevention behavior. ## Future platform support The architecture is intentionally set up for multi-platform extensions: - UI code (`tui`) only calls `SleepInhibitor::set_turn_running(...)` on turn lifecycle boundaries. - Platform-specific behavior is isolated in `codex-rs/core/src/sleep_inhibitor.rs` behind `cfg(...)` blocks. - Feature exposure is centralized in `core/src/features.rs` and surfaced via `/experimental`. - Adding new OS backends should not require additional TUI wiring; only the backend internals and feature stage metadata need to change. Potential follow-up implementations: - Windows: - Add a backend using Win32 power APIs (`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` as baseline). - Optionally move to `PowerCreateRequest` / `PowerSetRequest` / `PowerClearRequest` for richer assertion semantics. - Linux: - Add a backend using logind inhibitors over D-Bus (`org.freedesktop.login1.Manager.Inhibit` with `what="sleep"`). - Keep a no-op fallback where logind/D-Bus is unavailable. This PR keeps the cross-platform API surface minimal so future PRs can add Windows/Linux support incrementally with low churn. --------- Co-authored-by: jif-oai --- codex-rs/Cargo.lock | 10 + codex-rs/Cargo.toml | 2 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/features.rs | 16 ++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/chatwidget.rs | 16 ++ codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/utils/sleep-inhibitor/BUILD.bazel | 6 + codex-rs/utils/sleep-inhibitor/Cargo.toml | 13 ++ codex-rs/utils/sleep-inhibitor/src/dummy.rs | 16 ++ codex-rs/utils/sleep-inhibitor/src/lib.rs | 94 +++++++++ .../sleep-inhibitor/src/macos_inhibitor.rs | 192 ++++++++++++++++++ 12 files changed, 374 insertions(+) create mode 100644 codex-rs/utils/sleep-inhibitor/BUILD.bazel create mode 100644 codex-rs/utils/sleep-inhibitor/Cargo.toml create mode 100644 codex-rs/utils/sleep-inhibitor/src/dummy.rs create mode 100644 codex-rs/utils/sleep-inhibitor/src/lib.rs create mode 100644 codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b8c431de1..224502c38 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2297,6 +2297,7 @@ dependencies = [ "codex-utils-oss", "codex-utils-pty", "codex-utils-sandbox-summary", + "codex-utils-sleep-inhibitor", "codex-windows-sandbox", "color-eyre", "crossterm", @@ -2496,6 +2497,15 @@ dependencies = [ "regex", ] +[[package]] +name = "codex-utils-sleep-inhibitor" +version = "0.0.0" +dependencies = [ + "core-foundation 0.9.4", + "libc", + "tracing", +] + [[package]] name = "codex-utils-string" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 8f76c64ed..75b3d18d3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -54,6 +54,7 @@ members = [ "utils/elapsed", "utils/sandbox-summary", "utils/sanitizer", + "utils/sleep-inhibitor", "utils/approval-presets", "utils/oss", "utils/fuzzy-match", @@ -131,6 +132,7 @@ codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sanitizer = { path = "utils/sanitizer" } +codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c712d51fa..7a1c40a0a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -239,6 +239,9 @@ "powershell_utf8": { "type": "boolean" }, + "prevent_idle_sleep": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -1366,6 +1369,9 @@ "powershell_utf8": { "type": "boolean" }, + "prevent_idle_sleep": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 005fb4c87..33801c47c 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -133,6 +133,8 @@ pub enum Feature { CollaborationModes, /// Enable personality selection in the TUI. Personality, + /// Prevent idle system sleep while a turn is actively running. + PreventIdleSleep, /// Use the Responses API WebSocket transport for OpenAI by default. ResponsesWebsockets, /// Enable Responses API websocket v2 mode. @@ -604,6 +606,20 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::PreventIdleSleep, + key: "prevent_idle_sleep", + stage: if cfg!(target_os = "macos") { + Stage::Experimental { + name: "Prevent sleep while running", + menu_description: "Keep your computer awake while Codex is running a thread.", + announcement: "NEW: Prevent sleep while running is now available in /experimental.", + } + } else { + Stage::UnderDevelopment + }, + default_enabled: false, + }, FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9f268fbd4..f791a3fc9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -46,6 +46,7 @@ codex-utils-elapsed = { workspace = true } codex-utils-fuzzy-match = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-sandbox-summary = { workspace = true } +codex-utils-sleep-inhibitor = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } derive_more = { workspace = true, features = ["is_variant"] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b2b064378..af79a136a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -131,6 +131,7 @@ use codex_protocol::parse_command::ParsedCommand; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -520,6 +521,7 @@ pub(crate) struct ChatWidget { skills_initial_state: Option>, last_unified_wait: Option, unified_exec_wait_streak: Option, + turn_sleep_inhibitor: SleepInhibitor, task_complete_pending: bool, unified_exec_processes: Vec, /// Tracks whether codex-core currently considers an agent turn to be in progress. @@ -1275,6 +1277,7 @@ impl ChatWidget { fn on_task_started(&mut self) { self.agent_turn_running = true; + self.turn_sleep_inhibitor.set_turn_running(true); self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.plan_delta_buffer.clear(); @@ -1332,6 +1335,7 @@ impl ChatWidget { // Mark task stopped and request redraw now that all content is in history. self.pending_status_indicator_restore = false; self.agent_turn_running = false; + self.turn_sleep_inhibitor.set_turn_running(false); self.update_task_running_state(); self.running_commands.clear(); self.suppressed_exec_calls.clear(); @@ -1568,6 +1572,7 @@ impl ChatWidget { self.finalize_active_cell_as_failed(); // Reset running state and clear streaming buffers. self.agent_turn_running = false; + self.turn_sleep_inhibitor.set_turn_running(false); self.update_task_running_state(); self.running_commands.clear(); self.suppressed_exec_calls.clear(); @@ -2544,6 +2549,7 @@ impl ChatWidget { let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; config.model = model.clone(); + let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); @@ -2611,6 +2617,7 @@ impl ChatWidget { suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, + turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, @@ -2710,6 +2717,7 @@ impl ChatWidget { let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; config.model = model.clone(); + let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); @@ -2776,6 +2784,7 @@ impl ChatWidget { suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, + turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, @@ -2862,6 +2871,7 @@ impl ChatWidget { otel_manager, } = common; let model = model.filter(|m| !m.trim().is_empty()); + let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); @@ -2930,6 +2940,7 @@ impl ChatWidget { suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, + turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, @@ -5950,6 +5961,11 @@ impl ChatWidget { if feature == Feature::Personality { self.sync_personality_command_enabled(); } + if feature == Feature::PreventIdleSleep { + self.turn_sleep_inhibitor = SleepInhibitor::new(enabled); + self.turn_sleep_inhibitor + .set_turn_running(self.agent_turn_running); + } #[cfg(target_os = "windows")] if matches!( feature, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1ff7fc14c..f7b8e3304 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1030,6 +1030,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } + let prevent_idle_sleep = cfg.features.enabled(Feature::PreventIdleSleep); let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), @@ -1086,6 +1087,7 @@ async fn make_chatwidget_manual( skills_initial_state: None, last_unified_wait: None, unified_exec_wait_streak: None, + turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, diff --git a/codex-rs/utils/sleep-inhibitor/BUILD.bazel b/codex-rs/utils/sleep-inhibitor/BUILD.bazel new file mode 100644 index 000000000..4884593ef --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "sleep-inhibitor", + crate_name = "codex_utils_sleep_inhibitor", +) diff --git a/codex-rs/utils/sleep-inhibitor/Cargo.toml b/codex-rs/utils/sleep-inhibitor/Cargo.toml new file mode 100644 index 000000000..703433d81 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "codex-utils-sleep-inhibitor" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" +libc = { workspace = true } +tracing = { workspace = true } diff --git a/codex-rs/utils/sleep-inhibitor/src/dummy.rs b/codex-rs/utils/sleep-inhibitor/src/dummy.rs new file mode 100644 index 000000000..767091ea9 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/dummy.rs @@ -0,0 +1,16 @@ +use crate::PlatformSleepInhibitor; + +#[derive(Debug, Default)] +pub(crate) struct DummySleepInhibitor; + +impl DummySleepInhibitor { + pub(crate) fn new() -> Self { + Self + } +} + +impl PlatformSleepInhibitor for DummySleepInhibitor { + fn acquire(&mut self) {} + + fn release(&mut self) {} +} diff --git a/codex-rs/utils/sleep-inhibitor/src/lib.rs b/codex-rs/utils/sleep-inhibitor/src/lib.rs new file mode 100644 index 000000000..0f2f32df6 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -0,0 +1,94 @@ +//! Cross-platform helper for preventing idle sleep while a turn is running. +//! +//! On macOS this uses native IOKit power assertions instead of spawning +//! `caffeinate`, so assertion lifecycle is tied directly to Rust object lifetime. + +#[cfg(not(target_os = "macos"))] +mod dummy; +#[cfg(target_os = "macos")] +mod macos_inhibitor; + +use std::fmt::Debug; + +/// Keeps the machine awake while a turn is in progress when enabled. +#[derive(Debug)] +pub struct SleepInhibitor { + enabled: bool, + platform: Box, +} + +pub(crate) trait PlatformSleepInhibitor: Debug { + fn acquire(&mut self); + fn release(&mut self); +} + +impl SleepInhibitor { + pub fn new(enabled: bool) -> Self { + #[cfg(target_os = "macos")] + let platform: Box = + Box::new(macos_inhibitor::MacOsSleepInhibitor::new()); + #[cfg(not(target_os = "macos"))] + let platform: Box = Box::new(dummy::DummySleepInhibitor::new()); + + Self { enabled, platform } + } + + /// Update the active turn state; turns sleep prevention on/off as needed. + pub fn set_turn_running(&mut self, turn_running: bool) { + if !self.enabled { + self.release(); + return; + } + + if turn_running { + self.acquire(); + } else { + self.release(); + } + } + + fn acquire(&mut self) { + self.platform.acquire(); + } + + fn release(&mut self) { + self.platform.release(); + } +} + +#[cfg(test)] +mod tests { + use super::SleepInhibitor; + + #[test] + fn sleep_inhibitor_toggles_without_panicking() { + let mut inhibitor = SleepInhibitor::new(true); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(false); + } + + #[test] + fn sleep_inhibitor_disabled_does_not_panic() { + let mut inhibitor = SleepInhibitor::new(false); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(false); + } + + #[test] + fn sleep_inhibitor_multiple_true_calls_are_idempotent() { + let mut inhibitor = SleepInhibitor::new(true); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(false); + } + + #[test] + fn sleep_inhibitor_can_toggle_multiple_times() { + let mut inhibitor = SleepInhibitor::new(true); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(false); + inhibitor.set_turn_running(true); + inhibitor.set_turn_running(false); + } +} diff --git a/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs new file mode 100644 index 000000000..e788705e1 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs @@ -0,0 +1,192 @@ +use crate::PlatformSleepInhibitor; +use core_foundation::base::TCFType; +use core_foundation::string::CFString; +use core_foundation::string::CFStringRef; +use std::fmt::Debug; +use std::sync::OnceLock; +use tracing::warn; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; +const MACOS_IDLE_SLEEP_ASSERTION_TYPE: &str = "PreventUserIdleSystemSleep"; +const IOKIT_FRAMEWORK_BINARY: &[u8] = b"/System/Library/Frameworks/IOKit.framework/IOKit\0"; +const IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL: &[u8] = b"IOPMAssertionCreateWithName\0"; +const IOPM_ASSERTION_RELEASE_SYMBOL: &[u8] = b"IOPMAssertionRelease\0"; +const IOKIT_ASSERTION_API_UNAVAILABLE: &str = "IOKit power assertion APIs are unavailable"; + +type IOPMAssertionReleaseFn = unsafe extern "C" fn(assertion_id: IOPMAssertionID) -> IOReturn; +type IOPMAssertionID = u32; +type IOPMAssertionLevel = u32; +type IOReturn = i32; + +const K_IOPM_ASSERTION_LEVEL_ON: IOPMAssertionLevel = 255; +const K_IORETURN_SUCCESS: IOReturn = 0; + +#[derive(Debug, Default)] +pub(crate) struct MacOsSleepInhibitor { + assertion: Option, +} + +impl MacOsSleepInhibitor { + pub(crate) fn new() -> Self { + Self { + ..Default::default() + } + } +} + +impl PlatformSleepInhibitor for MacOsSleepInhibitor { + fn acquire(&mut self) { + if self.assertion.is_some() { + return; + } + + match MacSleepAssertion::create(ASSERTION_REASON) { + Ok(assertion) => { + self.assertion = Some(assertion); + } + Err(error) => match error { + MacSleepAssertionError::ApiUnavailable(reason) => { + warn!(reason, "Failed to create macOS sleep-prevention assertion"); + } + MacSleepAssertionError::Iokit(code) => { + warn!( + iokit_error = code, + "Failed to create macOS sleep-prevention assertion" + ); + } + }, + } + } + + fn release(&mut self) { + self.assertion = None; + } +} + +#[derive(Debug)] +struct MacSleepAssertion { + id: IOPMAssertionID, +} + +impl MacSleepAssertion { + fn create(name: &str) -> Result { + let Some(api) = MacSleepApi::get() else { + return Err(MacSleepAssertionError::ApiUnavailable( + IOKIT_ASSERTION_API_UNAVAILABLE, + )); + }; + + let assertion_type = CFString::new(MACOS_IDLE_SLEEP_ASSERTION_TYPE); + let assertion_name = CFString::new(name); + let mut id: IOPMAssertionID = 0; + let result = unsafe { + (api.create_with_name)( + assertion_type.as_concrete_TypeRef(), + K_IOPM_ASSERTION_LEVEL_ON, + assertion_name.as_concrete_TypeRef(), + &mut id, + ) + }; + if result == K_IORETURN_SUCCESS { + Ok(Self { id }) + } else { + Err(MacSleepAssertionError::Iokit(result)) + } + } +} + +impl Drop for MacSleepAssertion { + fn drop(&mut self) { + let Some(api) = MacSleepApi::get() else { + warn!( + reason = IOKIT_ASSERTION_API_UNAVAILABLE, + "Failed to release macOS sleep-prevention assertion" + ); + return; + }; + + let result = unsafe { (api.release)(self.id) }; + if result != K_IORETURN_SUCCESS { + warn!( + iokit_error = result, + "Failed to release macOS sleep-prevention assertion" + ); + } + } +} + +#[derive(Debug, Clone, Copy)] +enum MacSleepAssertionError { + ApiUnavailable(&'static str), + Iokit(IOReturn), +} + +type IOPMAssertionCreateWithNameFn = unsafe extern "C" fn( + assertion_type: CFStringRef, + assertion_level: IOPMAssertionLevel, + assertion_name: CFStringRef, + assertion_id: *mut IOPMAssertionID, +) -> IOReturn; + +struct MacSleepApi { + // Keep the dlopen handle alive for the lifetime of the loaded symbols. + // This prevents accidental dlclose while function pointers are in use. + _iokit_handle: usize, + create_with_name: IOPMAssertionCreateWithNameFn, + release: IOPMAssertionReleaseFn, +} + +impl MacSleepApi { + fn get() -> Option<&'static Self> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(Self::load).as_ref() + } + + fn load() -> Option { + let handle = unsafe { + libc::dlopen( + IOKIT_FRAMEWORK_BINARY.as_ptr().cast(), + libc::RTLD_LOCAL | libc::RTLD_LAZY, + ) + }; + if handle.is_null() { + warn!(framework = "IOKit", "Failed to open IOKit framework"); + return None; + } + + let create_with_name = unsafe { + libc::dlsym( + handle, + IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL.as_ptr().cast(), + ) + }; + if create_with_name.is_null() { + warn!( + symbol = "IOPMAssertionCreateWithName", + "Failed to load IOKit symbol" + ); + let _ = unsafe { libc::dlclose(handle) }; + return None; + } + + let release = unsafe { libc::dlsym(handle, IOPM_ASSERTION_RELEASE_SYMBOL.as_ptr().cast()) }; + if release.is_null() { + warn!( + symbol = "IOPMAssertionRelease", + "Failed to load IOKit symbol" + ); + let _ = unsafe { libc::dlclose(handle) }; + return None; + } + + let create_with_name: IOPMAssertionCreateWithNameFn = + unsafe { std::mem::transmute(create_with_name) }; + let release: IOPMAssertionReleaseFn = unsafe { std::mem::transmute(release) }; + + Some(Self { + _iokit_handle: handle as usize, + create_with_name, + release, + }) + } +}