Skip to content

Commit ddf37d9

Browse files
committed
feat(desktop): add prevent sleep preference
1 parent 7f43d7c commit ddf37d9

16 files changed

Lines changed: 448 additions & 4 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ tauri-plugin-global-shortcut = "2.3"
191191
tauri-plugin-single-instance = "2.4"
192192
tauri-plugin-window-state = "2.4"
193193
tauri-build = { version = "2.6", features = [] }
194+
keepawake = "0.6.0"
194195

195196
# Desktop support
196197
screenshots = "0.8"

src/apps/cli/src/peer_host/deny.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
1515
"initialize_tray_after_startup",
1616
"startup_window_control",
1717
"toggle_main_window_fullscreen",
18+
"get_prevent_sleep_enabled",
19+
"set_prevent_sleep_enabled",
1820
"restart_app",
1921
"check_for_updates",
2022
"install_update",

src/apps/desktop/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ tauri-plugin-updater = { workspace = true }
4444
tauri-plugin-global-shortcut = { workspace = true }
4545
tauri-plugin-single-instance = { workspace = true }
4646
tauri-plugin-window-state = { workspace = true }
47+
keepawake = { workspace = true }
4748
# Keep Tauri's transitive time resolution on the known-good release in CI,
4849
# where the root Cargo.lock is intentionally ignored.
4950
time = { workspace = true }

src/apps/desktop/src/api/peer_host_invoke.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
3737
"initialize_tray_after_startup",
3838
"startup_window_control",
3939
"toggle_main_window_fullscreen",
40+
"get_prevent_sleep_enabled",
41+
"set_prevent_sleep_enabled",
4042
"restart_app",
4143
"check_for_updates",
4244
"install_update",

src/apps/desktop/src/api/remote_workspace_policy.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
610610
"get_subscription_login_status",
611611
RemoteWorkspacePolicy::LocalOnly,
612612
),
613+
(
614+
"get_prevent_sleep_enabled",
615+
RemoteWorkspacePolicy::LocalOnly,
616+
),
613617
("get_system_info", RemoteWorkspacePolicy::WorkspaceAgnostic),
614618
("get_tool_info", RemoteWorkspacePolicy::LegacyUnaudited),
615619
("get_turn_files", RemoteWorkspacePolicy::LegacyUnaudited),
@@ -1500,6 +1504,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
15001504
RemoteWorkspacePolicy::RemoteUnsupported,
15011505
),
15021506
("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly),
1507+
(
1508+
"set_prevent_sleep_enabled",
1509+
RemoteWorkspacePolicy::LocalOnly,
1510+
),
15031511
(
15041512
"set_miniapp_draft_storage",
15051513
RemoteWorkspacePolicy::LegacyUnaudited,

src/apps/desktop/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod embedded_relay_host;
1212
pub mod logging;
1313
pub mod macos_menubar;
1414
pub mod runtime;
15+
pub mod sleep_prevention;
1516
pub mod startup_trace;
1617
pub mod theme;
1718
pub mod tray;
@@ -470,6 +471,7 @@ pub async fn run() {
470471
.build(),
471472
)
472473
.manage(app_state)
474+
.manage(sleep_prevention::SleepPreventionState::default())
473475
.manage(desktop_runtime)
474476
.manage(coordinator_state)
475477
.manage(scheduler_state)
@@ -831,6 +833,7 @@ pub async fn run() {
831833
let step_started = Instant::now();
832834
init_services(app_handle.clone(), startup_log_level);
833835
api::remote_connect_api::set_account_app_handle(app_handle.clone());
836+
sleep_prevention::spawn_config_listener(app_handle.clone());
834837
startup_trace.record_elapsed_step("native_setup", "init_services", step_started);
835838

836839
let step_started = Instant::now();
@@ -1338,6 +1341,8 @@ pub async fn run() {
13381341
api::system_api::initialize_tray_after_startup,
13391342
api::system_api::startup_window_control,
13401343
api::system_api::toggle_main_window_fullscreen,
1344+
sleep_prevention::get_prevent_sleep_enabled,
1345+
sleep_prevention::set_prevent_sleep_enabled,
13411346
check_command_exists,
13421347
check_commands_exist,
13431348
run_system_command,
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
//! Desktop host integration for the app-wide sleep-prevention preference.
2+
//!
3+
//! The preference is intentionally independent of agent/session activity. While
4+
//! enabled, the inhibitor lives for the desktop process and is released when
5+
//! the preference is disabled or the process exits.
6+
7+
use std::sync::mpsc;
8+
9+
use bitfun_core::service::config::{subscribe_config_updates, ConfigService, ConfigUpdateEvent};
10+
use serde::Deserialize;
11+
use tauri::{AppHandle, Manager, State};
12+
13+
use crate::api::app_state::AppState;
14+
15+
const PREVENT_SLEEP_CONFIG_PATH: &str = "app.prevent_sleep";
16+
17+
enum SleepPreventionRequest {
18+
SetEnabled {
19+
enabled: bool,
20+
response: tokio::sync::oneshot::Sender<Result<(), String>>,
21+
},
22+
}
23+
24+
#[derive(Clone)]
25+
pub struct SleepPreventionState {
26+
sender: mpsc::Sender<SleepPreventionRequest>,
27+
}
28+
29+
impl SleepPreventionState {
30+
async fn set_enabled(&self, enabled: bool) -> Result<(), String> {
31+
let (response, result) = tokio::sync::oneshot::channel();
32+
self.sender
33+
.send(SleepPreventionRequest::SetEnabled { enabled, response })
34+
.map_err(|_| "Sleep-prevention worker is unavailable".to_string())?;
35+
result
36+
.await
37+
.map_err(|_| "Sleep-prevention worker stopped unexpectedly".to_string())?
38+
}
39+
}
40+
41+
impl Default for SleepPreventionState {
42+
fn default() -> Self {
43+
let (sender, receiver) = mpsc::channel();
44+
std::thread::Builder::new()
45+
.name("bitfun-sleep-prevention".to_string())
46+
.spawn(move || run_worker(receiver))
47+
.expect("failed to start sleep-prevention worker");
48+
Self { sender }
49+
}
50+
}
51+
52+
fn run_worker(receiver: mpsc::Receiver<SleepPreventionRequest>) {
53+
let mut inhibitor = None;
54+
55+
while let Ok(request) = receiver.recv() {
56+
match request {
57+
SleepPreventionRequest::SetEnabled { enabled, response } => {
58+
let _ = response.send(set_inhibitor_enabled(&mut inhibitor, enabled));
59+
}
60+
}
61+
}
62+
}
63+
64+
fn set_inhibitor_enabled(
65+
inhibitor: &mut Option<keepawake::KeepAwake>,
66+
enabled: bool,
67+
) -> Result<(), String> {
68+
if enabled {
69+
if inhibitor.is_none() {
70+
let guard = keepawake::Builder::default()
71+
.idle(true)
72+
.reason("Prevent sleep is enabled in BitFun")
73+
.app_name("BitFun")
74+
.app_reverse_domain("com.bitfun.desktop")
75+
.create()
76+
.map_err(|error| format!("Failed to prevent system sleep: {}", error))?;
77+
*inhibitor = Some(guard);
78+
log::info!("System sleep prevention enabled");
79+
}
80+
} else if inhibitor.take().is_some() {
81+
log::info!("System sleep prevention disabled");
82+
}
83+
84+
Ok(())
85+
}
86+
87+
async fn configured_enabled(config_service: &ConfigService) -> Result<bool, String> {
88+
config_service
89+
.get_config::<bool>(Some(PREVENT_SLEEP_CONFIG_PATH))
90+
.await
91+
.map_err(|error| format!("Failed to read prevent-sleep preference: {}", error))
92+
}
93+
94+
async fn sync_from_config(config_service: &ConfigService, sleep_prevention: &SleepPreventionState) {
95+
match configured_enabled(config_service).await {
96+
Ok(enabled) => {
97+
if let Err(error) = sleep_prevention.set_enabled(enabled).await {
98+
log::warn!(
99+
"Failed to apply prevent-sleep preference: enabled={}, error={}",
100+
enabled,
101+
error
102+
);
103+
}
104+
}
105+
Err(error) => {
106+
log::warn!("{}", error);
107+
}
108+
}
109+
}
110+
111+
/// Applies the saved preference at startup and after config imports/reloads.
112+
pub fn spawn_config_listener(app: AppHandle) {
113+
let app_state: State<'_, AppState> = app.state();
114+
let config_service = app_state.config_service.clone();
115+
let sleep_prevention = app.state::<SleepPreventionState>().inner().clone();
116+
117+
tokio::spawn(async move {
118+
let Some(mut receiver) = subscribe_config_updates() else {
119+
log::warn!("Config update subscription unavailable for sleep-prevention listener");
120+
sync_from_config(&config_service, &sleep_prevention).await;
121+
return;
122+
};
123+
124+
sync_from_config(&config_service, &sleep_prevention).await;
125+
126+
loop {
127+
match receiver.recv().await {
128+
Ok(ConfigUpdateEvent::AppUpdated) | Ok(ConfigUpdateEvent::ConfigReloaded) => {
129+
sync_from_config(&config_service, &sleep_prevention).await;
130+
}
131+
Ok(_) => {}
132+
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
133+
log::warn!("Sleep-prevention config listener channel closed");
134+
break;
135+
}
136+
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
137+
log::warn!(
138+
"Sleep-prevention config listener lagged by {} messages",
139+
count
140+
);
141+
sync_from_config(&config_service, &sleep_prevention).await;
142+
}
143+
}
144+
}
145+
});
146+
}
147+
148+
#[derive(Debug, Deserialize, Default)]
149+
#[serde(rename_all = "camelCase")]
150+
pub struct GetPreventSleepEnabledRequest {}
151+
152+
#[tauri::command]
153+
pub async fn get_prevent_sleep_enabled(
154+
app_state: State<'_, AppState>,
155+
sleep_prevention: State<'_, SleepPreventionState>,
156+
request: GetPreventSleepEnabledRequest,
157+
) -> Result<bool, String> {
158+
let _ = request;
159+
let enabled = configured_enabled(&app_state.config_service).await?;
160+
sleep_prevention.set_enabled(enabled).await?;
161+
Ok(enabled)
162+
}
163+
164+
#[derive(Debug, Deserialize)]
165+
#[serde(rename_all = "camelCase")]
166+
pub struct SetPreventSleepEnabledRequest {
167+
pub enabled: bool,
168+
}
169+
170+
#[tauri::command]
171+
pub async fn set_prevent_sleep_enabled(
172+
app_state: State<'_, AppState>,
173+
sleep_prevention: State<'_, SleepPreventionState>,
174+
request: SetPreventSleepEnabledRequest,
175+
) -> Result<(), String> {
176+
let previous = configured_enabled(&app_state.config_service).await?;
177+
sleep_prevention.set_enabled(request.enabled).await?;
178+
179+
if let Err(error) = app_state
180+
.config_service
181+
.set_config(PREVENT_SLEEP_CONFIG_PATH, request.enabled)
182+
.await
183+
{
184+
if let Err(rollback_error) = sleep_prevention.set_enabled(previous).await {
185+
return Err(format!(
186+
"Failed to save prevent-sleep preference: {}; runtime rollback also failed: {}",
187+
error, rollback_error
188+
));
189+
}
190+
return Err(format!(
191+
"Failed to save prevent-sleep preference: {}",
192+
error
193+
));
194+
}
195+
196+
crate::api::remote_connect_api::notify_settings_changed();
197+
Ok(())
198+
}
199+
200+
#[cfg(test)]
201+
mod tests {
202+
use super::SleepPreventionState;
203+
204+
#[tokio::test]
205+
async fn disabling_an_inactive_inhibitor_is_idempotent() {
206+
let state = SleepPreventionState::default();
207+
state.set_enabled(false).await.unwrap();
208+
state.set_enabled(false).await.unwrap();
209+
}
210+
}

src/crates/assembly/core/src/service/config/types.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ pub struct AppConfig {
110110
pub startup_behavior: String,
111111
pub confirm_on_exit: bool,
112112
pub restore_windows: bool,
113+
/// Keep the local computer awake while the desktop application is running.
114+
pub prevent_sleep: bool,
113115
pub zoom_level: f64,
114116
#[serde(default)]
115117
pub logging: AppLoggingConfig,
@@ -1614,6 +1616,7 @@ impl Default for AppConfig {
16141616
startup_behavior: "lastWorkspace".to_string(),
16151617
confirm_on_exit: true,
16161618
restore_windows: true,
1619+
prevent_sleep: false,
16171620
zoom_level: 1.0,
16181621
logging: AppLoggingConfig::default(),
16191622
sidebar: SidebarConfig {
@@ -2028,12 +2031,21 @@ impl AIModelConfig {
20282031
mod tests {
20292032
use super::{
20302033
AIConfig, AIExperienceConfig, AIModelConfig, AgentModelDefaultsConfig, AgentProfileConfig,
2031-
AgentProfileView, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy,
2034+
AgentProfileView, AppConfig, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy,
20322035
ModelExchangeTracingMode, ReasoningMode, SubagentBatchExecutionPolicy,
20332036
SubagentModelSelection, UserSkillGroupsConfig, UserToolGroupsConfig,
20342037
};
20352038
use bitfun_runtime_ports::ToolPermissionConfig;
20362039

2040+
#[test]
2041+
fn prevent_sleep_defaults_to_disabled() {
2042+
assert!(!AppConfig::default().prevent_sleep);
2043+
2044+
let config: AppConfig =
2045+
serde_json::from_value(serde_json::json!({})).expect("empty app config should default");
2046+
assert!(!config.prevent_sleep);
2047+
}
2048+
20372049
#[test]
20382050
fn agent_profile_defaults_keep_all_collections_empty() {
20392051
let config = AgentProfileConfig::default();

src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ describe('isPeerLocalOnlyCommand', () => {
1818
expect(isPeerLocalOnlyCommand('speech_append_audio_chunk')).toBe(true);
1919
expect(isPeerLocalOnlyCommand('speech_finish_input_session')).toBe(true);
2020
});
21+
22+
it('keeps sleep-prevention controls on the controller computer', () => {
23+
expect(isPeerLocalOnlyCommand('get_prevent_sleep_enabled')).toBe(true);
24+
expect(isPeerLocalOnlyCommand('set_prevent_sleep_enabled')).toBe(true);
25+
});
2126
});
2227

2328
describe('peerInvokePriorityFor', () => {

src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const LOCAL_ONLY_COMMANDS = new Set([
2020
'initialize_tray_after_startup',
2121
'startup_window_control',
2222
'toggle_main_window_fullscreen',
23+
'get_prevent_sleep_enabled',
24+
'set_prevent_sleep_enabled',
2325
'restart_app',
2426
'check_for_updates',
2527
'install_update',

0 commit comments

Comments
 (0)