Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const overrides = new Map([
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
["src/shared/api/tauri.ts", 1210], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role)
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep
]);

async function walkFiles(directory) {
Expand Down
7 changes: 6 additions & 1 deletion desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::{
collections::HashMap,
io::Write,
sync::{atomic::AtomicU16, Mutex},
sync::{atomic::AtomicU16, Arc, Mutex},
};

use nostr::{Keys, ToBech32};
Expand Down Expand Up @@ -30,6 +30,8 @@ pub struct AppState {
pub audio_output_device: Mutex<Option<String>>,
/// Port of the localhost media streaming proxy (set during setup).
pub media_proxy_port: AtomicU16,
/// IOKit power assertion state — prevents idle sleep while agents run.
pub prevent_sleep: Arc<Mutex<crate::prevent_sleep::PreventSleepState>>,
}

pub fn build_app_state() -> AppState {
Expand Down Expand Up @@ -71,6 +73,9 @@ pub fn build_app_state() -> AppState {
app_handle: Mutex::new(None),
audio_output_device: Mutex::new(None),
media_proxy_port: AtomicU16::new(0),
prevent_sleep: Arc::new(Mutex::new(
crate::prevent_sleep::PreventSleepState::default(),
)),
}
}

Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod media;
mod messages;
pub mod pairing;
mod personas;
mod prevent_sleep;
mod profile;
mod relay_members;
mod social;
Expand All @@ -30,6 +31,7 @@ pub use media::*;
pub use messages::*;
pub use pairing::*;
pub use personas::*;
pub use prevent_sleep::*;
pub use profile::*;
pub use relay_members::*;
pub use social::*;
Expand Down
15 changes: 15 additions & 0 deletions desktop/src-tauri/src/commands/prevent_sleep.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use crate::app_state::AppState;

#[tauri::command]
pub fn set_prevent_sleep_active(
active: bool,
state: tauri::State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
if active {
crate::prevent_sleep::acquire(&state.prevent_sleep, &app_handle)
} else {
crate::prevent_sleep::release(&state.prevent_sleep);
Ok(())
}
}
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod media_proxy;
mod migration;
mod models;
pub mod nostr_convert;
mod prevent_sleep;
mod relay;
mod util;

Expand Down Expand Up @@ -565,6 +566,7 @@ pub fn run() {
cancel_pairing,
apply_workspace,
get_active_workspace,
set_prevent_sleep_active,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
Expand All @@ -574,6 +576,7 @@ pub fn run() {
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
shutdown_started.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
prevent_sleep::release(&app_handle.state::<AppState>().prevent_sleep);
if let Err(error) = shutdown_managed_agents(app_handle) {
eprintln!("sprout-desktop: failed to stop managed agents: {error}");
}
Expand Down
156 changes: 156 additions & 0 deletions desktop/src-tauri/src/prevent_sleep.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
use std::sync::{Arc, Mutex};

use tauri::{AppHandle, Emitter};

/// Tracks the macOS IOKit power assertion that prevents idle sleep
/// while local managed agents are running.
#[derive(Default)]
pub struct PreventSleepState {
assertion_id: Option<u32>,
timer_abort: Option<tokio::task::AbortHandle>,
}

// ── macOS implementation ────────────────────────────────────────────────────

#[cfg(target_os = "macos")]
mod macos {
#[link(name = "IOKit", kind = "framework")]
extern "C" {
pub fn IOPMAssertionCreateWithName(
assertion_type: *const std::ffi::c_void, // CFStringRef
level: u32, // IOPMAssertionLevel
name: *const std::ffi::c_void, // CFStringRef
assertion_id: *mut u32, // IOPMAssertionID
) -> i32; // IOReturn

pub fn IOPMAssertionRelease(assertion_id: u32) -> i32;
}

#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
pub fn CFStringCreateWithCString(
alloc: *const std::ffi::c_void,
c_str: *const std::ffi::c_char,
encoding: u32,
) -> *const std::ffi::c_void;
pub fn CFRelease(cf: *const std::ffi::c_void);
}
}

#[cfg(target_os = "macos")]
const K_IOPM_ASSERTION_LEVEL_ON: u32 = 255;

#[cfg(target_os = "macos")]
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;

/// 4-hour cap in seconds.
const CAP_SECONDS: u64 = 4 * 3600;

/// Create a `PreventUserIdleSystemSleep` assertion if not already held.
/// Starts a 4-hour timer that auto-releases and emits `prevent-sleep-expired`.
pub fn acquire(
state: &Arc<Mutex<PreventSleepState>>,
app_handle: &AppHandle,
) -> Result<(), String> {
let mut guard = state.lock().map_err(|e| e.to_string())?;

// Idempotent — already held.
if guard.assertion_id.is_some() {
return Ok(());
}

#[cfg(target_os = "macos")]
{
let assertion_type = b"PreventUserIdleSystemSleep\0".as_ptr() as *const std::ffi::c_char;
let reason = b"Sprout \xe2\x80\x94 agents are active\0".as_ptr() as *const std::ffi::c_char;

unsafe {
let cf_type = macos::CFStringCreateWithCString(
std::ptr::null(),
assertion_type,
K_CF_STRING_ENCODING_UTF8,
);
let cf_reason = macos::CFStringCreateWithCString(
std::ptr::null(),
reason,
K_CF_STRING_ENCODING_UTF8,
);

if cf_type.is_null() || cf_reason.is_null() {
if !cf_type.is_null() {
macos::CFRelease(cf_type);
}
if !cf_reason.is_null() {
macos::CFRelease(cf_reason);
}
return Err("Failed to create CFString for IOKit assertion".into());
}

let mut assertion_id: u32 = 0;
let ret = macos::IOPMAssertionCreateWithName(
cf_type,
K_IOPM_ASSERTION_LEVEL_ON,
cf_reason,
&mut assertion_id,
);

macos::CFRelease(cf_type);
macos::CFRelease(cf_reason);

if ret != 0 {
return Err(format!(
"IOPMAssertionCreateWithName failed with IOReturn {ret}"
));
}

guard.assertion_id = Some(assertion_id);
}
}

// Start the 4-hour cap timer only if an assertion was actually created.
if guard.assertion_id.is_some() {
let handle = app_handle.clone();
let timer_state = Arc::clone(state);
let timer_task = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(CAP_SECONDS)).await;
release(&timer_state);
let _ = handle.emit("prevent-sleep-expired", ());
});
guard.timer_abort = Some(timer_task.abort_handle());
}

Ok(())
}

/// Release the power assertion if held. Cancel the cap timer.
pub fn release(state: &Arc<Mutex<PreventSleepState>>) {
let mut guard = match state.lock() {
Ok(g) => g,
Err(_) => return,
};

if let Some(abort) = guard.timer_abort.take() {
abort.abort();
}

#[cfg(target_os = "macos")]
if let Some(id) = guard.assertion_id.take() {
unsafe {
macos::IOPMAssertionRelease(id);
}
}

#[cfg(not(target_os = "macos"))]
{
guard.assertion_id = None;
}
}

/// Returns `true` if a power assertion is currently held.
#[allow(dead_code)]
pub fn is_held(state: &Arc<Mutex<PreventSleepState>>) -> bool {
state
.lock()
.map(|g| g.assertion_id.is_some())
.unwrap_or(false)
}
Loading
Loading