From 78b9e4423c8675ddb47dc4dfbb46eeb10c5fb0a0 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 5 May 2026 12:59:11 -0700 Subject: [PATCH] feat(desktop): add "keep awake while agents are active" setting Prevents macOS idle sleep while local managed agents are running via an IOKit PreventUserIdleSystemSleep power assertion. Safety rails: auto-release when agents stop, 4-hour hard cap with frontend notification, and cleanup on app exit. Toggle defaults to OFF. No-op on non-macOS. Co-Authored-By: Claude Opus 4.6 --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/app_state.rs | 7 +- desktop/src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/prevent_sleep.rs | 15 + desktop/src-tauri/src/lib.rs | 3 + desktop/src-tauri/src/prevent_sleep.rs | 156 +++++++ desktop/src/app/AppShell.tsx | 417 +++++++++--------- .../src/features/agents/usePreventSleep.ts | 90 ++++ .../settings/ui/PreventSleepSettingsCard.tsx | 80 ++++ .../features/settings/ui/SettingsPanels.tsx | 10 + desktop/src/shared/api/tauri.ts | 3 + 11 files changed, 577 insertions(+), 208 deletions(-) create mode 100644 desktop/src-tauri/src/commands/prevent_sleep.rs create mode 100644 desktop/src-tauri/src/prevent_sleep.rs create mode 100644 desktop/src/features/agents/usePreventSleep.ts create mode 100644 desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3fd04524..41ea3a51 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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) { diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94f1403b..c070c1f4 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, io::Write, - sync::{atomic::AtomicU16, Mutex}, + sync::{atomic::AtomicU16, Arc, Mutex}, }; use nostr::{Keys, ToBech32}; @@ -30,6 +30,8 @@ pub struct AppState { pub audio_output_device: Mutex>, /// 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>, } pub fn build_app_state() -> AppState { @@ -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(), + )), } } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c5f5598b..511b61d5 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ mod media; mod messages; pub mod pairing; mod personas; +mod prevent_sleep; mod profile; mod relay_members; mod social; @@ -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::*; diff --git a/desktop/src-tauri/src/commands/prevent_sleep.rs b/desktop/src-tauri/src/commands/prevent_sleep.rs new file mode 100644 index 00000000..8e04d7f1 --- /dev/null +++ b/desktop/src-tauri/src/commands/prevent_sleep.rs @@ -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(()) + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fb257ef7..2ffbfd88 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod media_proxy; mod migration; mod models; pub mod nostr_convert; +mod prevent_sleep; mod relay; mod util; @@ -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"); @@ -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::().prevent_sleep); if let Err(error) = shutdown_managed_agents(app_handle) { eprintln!("sprout-desktop: failed to stop managed agents: {error}"); } diff --git a/desktop/src-tauri/src/prevent_sleep.rs b/desktop/src-tauri/src/prevent_sleep.rs new file mode 100644 index 00000000..61e51f3f --- /dev/null +++ b/desktop/src-tauri/src/prevent_sleep.rs @@ -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, + timer_abort: Option, +} + +// ── 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>, + 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>) { + 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>) -> bool { + state + .lock() + .map(|g| g.assertion_id.is_some()) + .unwrap_or(false) +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 60274e77..e841701b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -27,6 +27,7 @@ import { setDesktopAppBadgeCount, type DesktopNotificationTarget, } from "@/features/notifications/lib/desktop"; +import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { usePresenceSession, usePresenceSubscription, @@ -462,217 +463,221 @@ export function AppShell() { }, [handleCloseSettings, handleOpenSettings, settingsOpen]); return ( - - { - setIsChannelManagementOpen(true); - }, - }} - > - -
- -
- - - -
-
- handleOpenSettings("updates")} - /> -
- { - const id = workspacesHook.addWorkspace(workspace); - workspacesHook.switchWorkspace(id); - }} - onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} - onNewDmOpenChange={setIsNewDmOpen} - onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} - onUpdateWorkspace={workspacesHook.updateWorkspace} - onRemoveWorkspace={workspacesHook.removeWorkspace} - onSwitchWorkspace={workspacesHook.switchWorkspace} - selfPresenceStatus={presenceSession.currentStatus} - workspaces={workspacesHook.workspaces} - onCreateChannel={async ({ - description, - name, - visibility, - ttlSeconds, - }) => { - const createdChannel = - await createChannelMutation.mutateAsync({ + + + { + setIsChannelManagementOpen(true); + }, + }} + > + +
+ +
+ + + +
+
+ handleOpenSettings("updates")} + /> +
+ { + const id = workspacesHook.addWorkspace(workspace); + workspacesHook.switchWorkspace(id); + }} + onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} + onNewDmOpenChange={setIsNewDmOpen} + onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} + onUpdateWorkspace={workspacesHook.updateWorkspace} + onRemoveWorkspace={workspacesHook.removeWorkspace} + onSwitchWorkspace={workspacesHook.switchWorkspace} + selfPresenceStatus={presenceSession.currentStatus} + workspaces={workspacesHook.workspaces} + onCreateChannel={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { + const createdChannel = + await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + ttlSeconds, + }); + + await goChannel(createdChannel.id); + }} + onCreateForum={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { + const createdForum = await createForumMutation.mutateAsync({ name, description, - channelType: "stream", + channelType: "forum", visibility, ttlSeconds, }); - await goChannel(createdChannel.id); - }} - onCreateForum={async ({ - description, - name, - visibility, - ttlSeconds, - }) => { - const createdForum = await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - ttlSeconds, - }); - - await goChannel(createdForum.id); - }} - onHideDm={handleHideDm} - onOpenBrowseChannels={handleOpenBrowseChannels} - onOpenBrowseForums={handleOpenBrowseForums} - onOpenDm={async ({ pubkeys }) => { - const directMessage = await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onOpenSearch={handleOpenSearch} - onSelectAgents={() => { - void goAgents(); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - onSelectHome={() => { - void goHome(); - }} - onSelectPulse={() => { - void goPulse(); - }} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => { - void goWorkflows(); - }} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ text: "", emoji: "" }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[deferredPubkey.toLowerCase()] ?? - undefined) - : undefined - } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - /> - - - - - - { - setIsChannelManagementOpen(false); - void goHome({ replace: true }); - }} - onOpenSearchResult={handleOpenSearchResult} - onSearchOpenChange={setIsSearchOpen} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - - {settingsOpen ? ( - - - - ) : null} -
- -
-
-
-
+ await goChannel(createdForum.id); + }} + onHideDm={handleHideDm} + onOpenBrowseChannels={handleOpenBrowseChannels} + onOpenBrowseForums={handleOpenBrowseForums} + onOpenDm={async ({ pubkeys }) => { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onOpenSearch={handleOpenSearch} + onSelectAgents={() => { + void goAgents(); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + onSelectHome={() => { + void goHome(); + }} + onSelectPulse={() => { + void goPulse(); + }} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => { + void goWorkflows(); + }} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) + } + onClearUserStatus={() => + setUserStatusMutation.mutate({ text: "", emoji: "" }) + } + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[deferredPubkey.toLowerCase()] ?? + undefined) + : undefined + } + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + /> + + + + + + { + setIsChannelManagementOpen(false); + void goHome({ replace: true }); + }} + onOpenSearchResult={handleOpenSearchResult} + onSearchOpenChange={setIsSearchOpen} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + + {settingsOpen ? ( + + + + ) : null} +
+ +
+
+
+
+ ); } diff --git a/desktop/src/features/agents/usePreventSleep.ts b/desktop/src/features/agents/usePreventSleep.ts new file mode 100644 index 00000000..1a4e11a3 --- /dev/null +++ b/desktop/src/features/agents/usePreventSleep.ts @@ -0,0 +1,90 @@ +import * as React from "react"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { setPreventSleepActive } from "@/shared/api/tauri"; +import { listen } from "@tauri-apps/api/event"; + +// Intentionally not scoped per-pubkey — multi-user desktop is rare and the +// setting applies to the machine's sleep behavior regardless of account. +const STORAGE_KEY = "sprout-prevent-sleep"; + +function readPreference(): boolean { + return window.localStorage.getItem(STORAGE_KEY) === "true"; +} + +function writePreference(enabled: boolean) { + window.localStorage.setItem(STORAGE_KEY, String(enabled)); +} + +interface PreventSleepValue { + enabled: boolean; + setEnabled: (value: boolean) => void; + active: boolean; + hasRunningAgents: boolean; + expired: boolean; + clearExpired: () => void; +} + +const PreventSleepContext = React.createContext(null); + +export function PreventSleepProvider({ + children, +}: { + children: React.ReactNode; +}) { + const value = usePreventSleepInternal(); + return React.createElement(PreventSleepContext.Provider, { value }, children); +} + +export function usePreventSleepContext(): PreventSleepValue { + const ctx = React.useContext(PreventSleepContext); + if (!ctx) { + throw new Error( + "usePreventSleepContext must be used within a PreventSleepProvider", + ); + } + return ctx; +} + +function usePreventSleepInternal() { + const [enabled, setEnabledState] = React.useState(readPreference); + const { data: agents } = useManagedAgentsQuery(); + + // Only local "running" agents need sleep prevention. Remote "deployed" + // agents run on provider infrastructure and are unaffected by local sleep. + const hasRunningAgents = React.useMemo( + () => agents?.some((agent) => agent.status === "running") ?? false, + [agents], + ); + + const active = enabled && hasRunningAgents; + + const setEnabled = React.useCallback((value: boolean) => { + writePreference(value); + setEnabledState(value); + }, []); + + // Sync active state to backend + React.useEffect(() => { + void setPreventSleepActive(active); + }, [active]); + + // Listen for 4-hour expiry + const [expired, setExpired] = React.useState(false); + React.useEffect(() => { + const unlisten = listen("prevent-sleep-expired", () => { + setExpired(true); + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, []); + + return { + enabled, + setEnabled, + active, + hasRunningAgents, + expired, + clearExpired: () => setExpired(false), + }; +} diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx new file mode 100644 index 00000000..97332f58 --- /dev/null +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -0,0 +1,80 @@ +import { Shield } from "lucide-react"; +import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; +import { cn } from "@/shared/lib/cn"; + +export function PreventSleepSettingsCard() { + const { + enabled, + setEnabled, + active, + hasRunningAgents, + expired, + clearExpired, + } = usePreventSleepContext(); + + return ( +
+
+

Agents

+

+ Settings that affect how local managed agents run on this machine. +

+
+ + + +
+ + {active ? "Active" : "Inactive"} + + {enabled && !hasRunningAgents && ( + + Waiting for agents to start + + )} +
+ + {expired && ( +

+ Sleep prevention expired after 4 hours. Toggle off and on to + re-enable. +

+ )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 946aa7d1..eba24096 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useRef } from "react"; import { BellRing, + Bot, Check, Download, Keyboard, @@ -28,12 +29,14 @@ import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; +import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; export type SettingsSection = | "profile" | "notifications" + | "agents" | "appearance" | "shortcuts" | "tokens" @@ -74,6 +77,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Notifications", icon: BellRing, }, + { + value: "agents", + label: "Agents", + icon: Bot, + }, { value: "appearance", label: "Appearance", @@ -256,6 +264,8 @@ export function renderSettingsSection( } /> ); + case "agents": + return ; case "appearance": return ; case "shortcuts": diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index ad63893b..910f0f13 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1206,3 +1206,6 @@ export async function applyWorkspace( token: token ?? null, }); } + +export const setPreventSleepActive = (active: boolean) => + invokeTauri("set_prevent_sleep_active", { active });