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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ tauri-plugin-global-shortcut = "2.3"
tauri-plugin-single-instance = "2.4"
tauri-plugin-window-state = "2.4"
tauri-build = { version = "2.6", features = [] }
keepawake = "0.6.0"

# Desktop support
screenshots = "0.8"
Expand Down
2 changes: 2 additions & 0 deletions src/apps/cli/src/peer_host/deny.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"initialize_tray_after_startup",
"startup_window_control",
"toggle_main_window_fullscreen",
"get_prevent_sleep_enabled",
"set_prevent_sleep_enabled",
"restart_app",
"check_for_updates",
"install_update",
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ tauri-plugin-updater = { workspace = true }
tauri-plugin-global-shortcut = { workspace = true }
tauri-plugin-single-instance = { workspace = true }
tauri-plugin-window-state = { workspace = true }
keepawake = { workspace = true }
# Keep Tauri's transitive time resolution on the known-good release in CI,
# where the root Cargo.lock is intentionally ignored.
time = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/api/peer_host_invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"initialize_tray_after_startup",
"startup_window_control",
"toggle_main_window_fullscreen",
"get_prevent_sleep_enabled",
"set_prevent_sleep_enabled",
"restart_app",
"check_for_updates",
"install_update",
Expand Down
8 changes: 8 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"get_subscription_login_status",
RemoteWorkspacePolicy::LocalOnly,
),
(
"get_prevent_sleep_enabled",
RemoteWorkspacePolicy::LocalOnly,
),
("get_system_info", RemoteWorkspacePolicy::WorkspaceAgnostic),
("get_tool_info", RemoteWorkspacePolicy::LegacyUnaudited),
("get_turn_files", RemoteWorkspacePolicy::LegacyUnaudited),
Expand Down Expand Up @@ -1500,6 +1504,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::RemoteUnsupported,
),
("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly),
(
"set_prevent_sleep_enabled",
RemoteWorkspacePolicy::LocalOnly,
),
(
"set_miniapp_draft_storage",
RemoteWorkspacePolicy::LegacyUnaudited,
Expand Down
5 changes: 5 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod embedded_relay_host;
pub mod logging;
pub mod macos_menubar;
pub mod runtime;
pub mod sleep_prevention;
pub mod startup_trace;
pub mod theme;
pub mod tray;
Expand Down Expand Up @@ -470,6 +471,7 @@ pub async fn run() {
.build(),
)
.manage(app_state)
.manage(sleep_prevention::SleepPreventionState::default())
.manage(desktop_runtime)
.manage(coordinator_state)
.manage(scheduler_state)
Expand Down Expand Up @@ -831,6 +833,7 @@ pub async fn run() {
let step_started = Instant::now();
init_services(app_handle.clone(), startup_log_level);
api::remote_connect_api::set_account_app_handle(app_handle.clone());
sleep_prevention::spawn_config_listener(app_handle.clone());
startup_trace.record_elapsed_step("native_setup", "init_services", step_started);

let step_started = Instant::now();
Expand Down Expand Up @@ -1338,6 +1341,8 @@ pub async fn run() {
api::system_api::initialize_tray_after_startup,
api::system_api::startup_window_control,
api::system_api::toggle_main_window_fullscreen,
sleep_prevention::get_prevent_sleep_enabled,
sleep_prevention::set_prevent_sleep_enabled,
check_command_exists,
check_commands_exist,
run_system_command,
Expand Down
269 changes: 269 additions & 0 deletions src/apps/desktop/src/sleep_prevention.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
//! Desktop host integration for the app-wide sleep-prevention preference.
//!
//! The preference is intentionally independent of agent/session activity. While
//! enabled, the inhibitor lives for the desktop process and is released when
//! the preference is disabled or the process exits.

use std::sync::{mpsc, Arc};

use bitfun_core::service::config::{subscribe_config_updates, ConfigService, ConfigUpdateEvent};
use serde::Deserialize;
use tauri::{AppHandle, Manager, State};

use crate::api::app_state::AppState;

const PREVENT_SLEEP_CONFIG_PATH: &str = "app.prevent_sleep";

enum SleepPreventionRequest {
SetEnabled {
enabled: bool,
response: tokio::sync::oneshot::Sender<Result<(), String>>,
},
}

#[derive(Clone)]
pub struct SleepPreventionState {
worker: Arc<tokio::sync::Mutex<Option<mpsc::Sender<SleepPreventionRequest>>>>,
}

impl SleepPreventionState {
async fn set_enabled(&self, enabled: bool) -> Result<(), String> {
let mut worker = self.worker.lock().await;
if !enabled && worker.is_none() {
return Ok(());
}

if worker.is_none() {
*worker = Some(start_worker()?);
}

let (response, result) = tokio::sync::oneshot::channel();
let send_result = worker
.as_ref()
.ok_or_else(|| "Sleep-prevention worker failed to initialize".to_string())?
.send(SleepPreventionRequest::SetEnabled { enabled, response });
if send_result.is_err() {
worker.take();
return Err("Sleep-prevention worker is unavailable".to_string());
}

let outcome = result
.await
.map_err(|_| "Sleep-prevention worker stopped unexpectedly".to_string())
.and_then(|outcome| outcome);

// An inactive or failed worker owns no useful resources. Dropping the
// final sender lets the thread exit instead of keeping one alive for
// the entire application lifetime while the preference is off.
if !enabled || outcome.is_err() {
worker.take();
}

outcome
}
}

impl Default for SleepPreventionState {
fn default() -> Self {
Self {
worker: Arc::new(tokio::sync::Mutex::new(None)),
}
}
}

fn start_worker() -> Result<mpsc::Sender<SleepPreventionRequest>, String> {
let (sender, receiver) = mpsc::channel();
std::thread::Builder::new()
.name("bitfun-sleep-prevention".to_string())
.spawn(move || run_worker(receiver))
.map_err(|error| format!("Failed to start sleep-prevention worker: {}", error))?;
Ok(sender)
}

fn run_worker(receiver: mpsc::Receiver<SleepPreventionRequest>) {
let mut inhibitor = None;

while let Ok(request) = receiver.recv() {
match request {
SleepPreventionRequest::SetEnabled { enabled, response } => {
let _ = response.send(set_inhibitor_enabled(&mut inhibitor, enabled));
}
}
}
}

fn set_inhibitor_enabled(
inhibitor: &mut Option<keepawake::KeepAwake>,
enabled: bool,
) -> Result<(), String> {
if enabled {
if inhibitor.is_none() {
let guard = keepawake::Builder::default()
.idle(true)
.reason("Prevent sleep is enabled in BitFun")
.app_name("BitFun")
.app_reverse_domain("com.bitfun.desktop")
.create()
.map_err(|error| {
let message = format!("Failed to prevent system sleep: {}", error);
#[cfg(target_os = "linux")]
{
format!(
"{}. Linux sleep prevention requires the system D-Bus and \
org.freedesktop.login1",
message
)
}
#[cfg(not(target_os = "linux"))]
{
message
}
})?;
*inhibitor = Some(guard);
log::info!("System sleep prevention enabled");
}
} else if inhibitor.take().is_some() {
log::info!("System sleep prevention disabled");
}

Ok(())
}

async fn configured_enabled(config_service: &ConfigService) -> Result<bool, String> {
config_service
.get_config::<bool>(Some(PREVENT_SLEEP_CONFIG_PATH))
.await
.map_err(|error| format!("Failed to read prevent-sleep preference: {}", error))
}

async fn sync_from_config(config_service: &ConfigService, sleep_prevention: &SleepPreventionState) {
match configured_enabled(config_service).await {
Ok(enabled) => {
if let Err(error) = sleep_prevention.set_enabled(enabled).await {
log::warn!(
"Failed to apply prevent-sleep preference: enabled={}, error={}",
enabled,
error
);
}
}
Err(error) => {
log::warn!("{}", error);
}
}
}

/// Applies the saved preference at startup and after config imports/reloads.
pub fn spawn_config_listener(app: AppHandle) {
let app_state: State<'_, AppState> = app.state();
let config_service = app_state.config_service.clone();
let sleep_prevention = app.state::<SleepPreventionState>().inner().clone();

tokio::spawn(async move {
let Some(mut receiver) = subscribe_config_updates() else {
log::warn!("Config update subscription unavailable for sleep-prevention listener");
sync_from_config(&config_service, &sleep_prevention).await;
return;
};

sync_from_config(&config_service, &sleep_prevention).await;

loop {
match receiver.recv().await {
Ok(ConfigUpdateEvent::AppUpdated) | Ok(ConfigUpdateEvent::ConfigReloaded) => {
sync_from_config(&config_service, &sleep_prevention).await;
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log::warn!("Sleep-prevention config listener channel closed");
break;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
log::warn!(
"Sleep-prevention config listener lagged by {} messages",
count
);
sync_from_config(&config_service, &sleep_prevention).await;
}
}
}
});
}

#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct GetPreventSleepEnabledRequest {}

#[tauri::command]
pub async fn get_prevent_sleep_enabled(
app_state: State<'_, AppState>,
sleep_prevention: State<'_, SleepPreventionState>,
request: GetPreventSleepEnabledRequest,
) -> Result<bool, String> {
let _ = request;
let enabled = configured_enabled(&app_state.config_service).await?;
sleep_prevention.set_enabled(enabled).await?;
Ok(enabled)
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPreventSleepEnabledRequest {
pub enabled: bool,
}

#[tauri::command]
pub async fn set_prevent_sleep_enabled(
app_state: State<'_, AppState>,
sleep_prevention: State<'_, SleepPreventionState>,
request: SetPreventSleepEnabledRequest,
) -> Result<(), String> {
let previous = configured_enabled(&app_state.config_service).await?;
sleep_prevention.set_enabled(request.enabled).await?;

if let Err(error) = app_state
.config_service
.set_config(PREVENT_SLEEP_CONFIG_PATH, request.enabled)
.await
{
if let Err(rollback_error) = sleep_prevention.set_enabled(previous).await {
return Err(format!(
"Failed to save prevent-sleep preference: {}; runtime rollback also failed: {}",
error, rollback_error
));
}
return Err(format!(
"Failed to save prevent-sleep preference: {}",
error
));
}

crate::api::remote_connect_api::notify_settings_changed();
Ok(())
}

#[cfg(test)]
mod tests {
use super::{start_worker, SleepPreventionState};

#[tokio::test]
async fn inactive_state_does_not_start_a_worker() {
let state = SleepPreventionState::default();
assert!(state.worker.lock().await.is_none());

state.set_enabled(false).await.unwrap();
state.set_enabled(false).await.unwrap();

assert!(state.worker.lock().await.is_none());
}

#[tokio::test]
async fn disabling_stops_an_existing_worker() {
let state = SleepPreventionState::default();
*state.worker.lock().await = Some(start_worker().unwrap());

state.set_enabled(false).await.unwrap();

assert!(state.worker.lock().await.is_none());
}
}
14 changes: 13 additions & 1 deletion src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ pub struct AppConfig {
pub startup_behavior: String,
pub confirm_on_exit: bool,
pub restore_windows: bool,
/// Keep the local computer awake while the desktop application is running.
pub prevent_sleep: bool,
pub zoom_level: f64,
#[serde(default)]
pub logging: AppLoggingConfig,
Expand Down Expand Up @@ -1614,6 +1616,7 @@ impl Default for AppConfig {
startup_behavior: "lastWorkspace".to_string(),
confirm_on_exit: true,
restore_windows: true,
prevent_sleep: false,
zoom_level: 1.0,
logging: AppLoggingConfig::default(),
sidebar: SidebarConfig {
Expand Down Expand Up @@ -2028,12 +2031,21 @@ impl AIModelConfig {
mod tests {
use super::{
AIConfig, AIExperienceConfig, AIModelConfig, AgentModelDefaultsConfig, AgentProfileConfig,
AgentProfileView, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy,
AgentProfileView, AppConfig, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy,
ModelExchangeTracingMode, ReasoningMode, SubagentBatchExecutionPolicy,
SubagentModelSelection, UserSkillGroupsConfig, UserToolGroupsConfig,
};
use bitfun_runtime_ports::ToolPermissionConfig;

#[test]
fn prevent_sleep_defaults_to_disabled() {
assert!(!AppConfig::default().prevent_sleep);

let config: AppConfig =
serde_json::from_value(serde_json::json!({})).expect("empty app config should default");
assert!(!config.prevent_sleep);
}

#[test]
fn agent_profile_defaults_keep_all_collections_empty() {
let config = AgentProfileConfig::default();
Expand Down
Loading