From ddf37d9fa931bf9b6ddcd31846e76ef28eb45ec9 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 17:44:36 -0700 Subject: [PATCH 1/2] feat(desktop): add prevent sleep preference --- Cargo.toml | 1 + src/apps/cli/src/peer_host/deny.rs | 2 + src/apps/desktop/Cargo.toml | 1 + src/apps/desktop/src/api/peer_host_invoke.rs | 2 + .../src/api/remote_workspace_policy.rs | 8 + src/apps/desktop/src/lib.rs | 5 + src/apps/desktop/src/sleep_prevention.rs | 210 ++++++++++++++++++ .../assembly/core/src/service/config/types.rs | 14 +- .../api/adapters/peer-device-adapter.test.ts | 5 + .../api/adapters/peer-device-adapter.ts | 2 + .../api/service-api/SystemAPI.test.ts | 37 +++ .../api/service-api/SystemAPI.ts | 22 ++ .../config/components/BasicsConfig.tsx | 95 ++++++++ .../src/locales/en-US/settings/basics.json | 16 +- .../src/locales/zh-CN/settings/basics.json | 16 +- .../src/locales/zh-TW/settings/basics.json | 16 +- 16 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 src/apps/desktop/src/sleep_prevention.rs create mode 100644 src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts diff --git a/Cargo.toml b/Cargo.toml index ceb507b529..c4233fd55e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 570a5125fb..80af64a157 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -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", diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index f73a5c560c..e133e394f7 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -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 } diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index f954143a74..11d41d5175 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -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", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index d92cbfefd3..1492a5e694 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -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), @@ -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, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 2cdf40dcf4..61c13c1d5f 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -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; @@ -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) @@ -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(); @@ -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, diff --git a/src/apps/desktop/src/sleep_prevention.rs b/src/apps/desktop/src/sleep_prevention.rs new file mode 100644 index 0000000000..498c86bd3c --- /dev/null +++ b/src/apps/desktop/src/sleep_prevention.rs @@ -0,0 +1,210 @@ +//! 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; + +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>, + }, +} + +#[derive(Clone)] +pub struct SleepPreventionState { + sender: mpsc::Sender, +} + +impl SleepPreventionState { + async fn set_enabled(&self, enabled: bool) -> Result<(), String> { + let (response, result) = tokio::sync::oneshot::channel(); + self.sender + .send(SleepPreventionRequest::SetEnabled { enabled, response }) + .map_err(|_| "Sleep-prevention worker is unavailable".to_string())?; + result + .await + .map_err(|_| "Sleep-prevention worker stopped unexpectedly".to_string())? + } +} + +impl Default for SleepPreventionState { + fn default() -> Self { + let (sender, receiver) = mpsc::channel(); + std::thread::Builder::new() + .name("bitfun-sleep-prevention".to_string()) + .spawn(move || run_worker(receiver)) + .expect("failed to start sleep-prevention worker"); + Self { sender } + } +} + +fn run_worker(receiver: mpsc::Receiver) { + 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, + 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| format!("Failed to prevent system sleep: {}", error))?; + *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 { + config_service + .get_config::(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::().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 { + 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::SleepPreventionState; + + #[tokio::test] + async fn disabling_an_inactive_inhibitor_is_idempotent() { + let state = SleepPreventionState::default(); + state.set_enabled(false).await.unwrap(); + state.set_enabled(false).await.unwrap(); + } +} diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 2cce8c55ed..e274e3b736 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -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, @@ -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 { @@ -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(); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 4a1b53667b..8b73f06b21 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -18,6 +18,11 @@ describe('isPeerLocalOnlyCommand', () => { expect(isPeerLocalOnlyCommand('speech_append_audio_chunk')).toBe(true); expect(isPeerLocalOnlyCommand('speech_finish_input_session')).toBe(true); }); + + it('keeps sleep-prevention controls on the controller computer', () => { + expect(isPeerLocalOnlyCommand('get_prevent_sleep_enabled')).toBe(true); + expect(isPeerLocalOnlyCommand('set_prevent_sleep_enabled')).toBe(true); + }); }); describe('peerInvokePriorityFor', () => { diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 38908fbc81..308022cd08 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -20,6 +20,8 @@ const LOCAL_ONLY_COMMANDS = new Set([ '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', diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts new file mode 100644 index 0000000000..ad376da009 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SystemAPI } from './SystemAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { + invoke: invokeMock, + }, +})); + +describe('SystemAPI sleep prevention', () => { + let systemAPI: SystemAPI; + + beforeEach(() => { + systemAPI = new SystemAPI(); + invokeMock.mockReset(); + }); + + it('reads the persisted desktop preference', async () => { + invokeMock.mockResolvedValueOnce(false); + + await expect(systemAPI.getPreventSleepEnabled()).resolves.toBe(false); + expect(invokeMock).toHaveBeenCalledWith('get_prevent_sleep_enabled', { + request: {}, + }); + }); + + it('sends the requested app-wide state', async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await expect(systemAPI.setPreventSleepEnabled(true)).resolves.toBeUndefined(); + expect(invokeMock).toHaveBeenCalledWith('set_prevent_sleep_enabled', { + request: { enabled: true }, + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index e9e5fac13a..9e3b818782 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -219,6 +219,28 @@ export class SystemAPI { } } + /** Desktop only: whether BitFun should keep the local computer awake. */ + async getPreventSleepEnabled(): Promise { + try { + return await api.invoke('get_prevent_sleep_enabled', { + request: {} + }); + } catch (error) { + throw createTauriCommandError('get_prevent_sleep_enabled', error); + } + } + + /** Desktop only: apply and persist the app-wide sleep-prevention preference. */ + async setPreventSleepEnabled(enabled: boolean): Promise { + try { + await api.invoke('set_prevent_sleep_enabled', { + request: { enabled } + }); + } catch (error) { + throw createTauriCommandError('set_prevent_sleep_enabled', error, { enabled }); + } + } + // ─── Window / Tray behavior ──────────────────────────────────────────────── /** Desktop only: immediately quit the application. */ diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index 1cbb139ed7..f13f47ab89 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -240,6 +240,100 @@ function BasicsAutoUpdateSection() { ); } +function BasicsPreventSleepSection() { + const { t } = useTranslation('settings/basics'); + const isTauri = typeof window !== 'undefined' && '__TAURI__' in window; + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + const showMessage = useCallback((type: 'success' | 'error' | 'info', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }, []); + + useEffect(() => { + if (!isTauri) { + setLoading(false); + return; + } + + let cancelled = false; + void (async () => { + try { + setLoading(true); + const value = await systemAPI.getPreventSleepEnabled(); + if (!cancelled) { + setEnabled(value); + } + } catch (error) { + log.error('Failed to load prevent-sleep preference', error); + if (!cancelled) { + showMessage('error', t('preventSleep.messages.loadFailed')); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [isTauri, showMessage, t]); + + const handleToggle = useCallback( + async (next: boolean) => { + const previous = enabled; + setEnabled(next); + setSaving(true); + try { + await systemAPI.setPreventSleepEnabled(next); + showMessage('success', t('preventSleep.messages.saved')); + } catch (error) { + setEnabled(previous); + log.error('Failed to set prevent-sleep preference', { next, error }); + showMessage('error', t('preventSleep.messages.saveFailed')); + } finally { + setSaving(false); + } + }, + [enabled, showMessage, t] + ); + + if (!isTauri) { + return null; + } + + if (loading) { + return ; + } + + return ( + + + + { + void handleToggle(event.target.checked); + }} + disabled={saving} + /> + + + ); +} + function BasicsLoggingSection() { const { t } = useTranslation('settings/basics'); const [configLevel, setConfigLevel] = useState('info'); @@ -875,6 +969,7 @@ const BasicsConfig: React.FC = () => { + diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json index fa0f21e561..e88c72f457 100644 --- a/src/web-ui/src/locales/en-US/settings/basics.json +++ b/src/web-ui/src/locales/en-US/settings/basics.json @@ -1,6 +1,6 @@ { "title": "Basics", - "subtitle": "Logging, terminal, notifications, and launch at login", + "subtitle": "Power, logging, terminal, notifications, and launch at login", "appearance": { "title": "Appearance", "hint": "Interface language and visual theme", @@ -100,6 +100,20 @@ "saveFailed": "Failed to update launch-at-login setting" } }, + "preventSleep": { + "sections": { + "title": "Power", + "hint": "Computer sleep behavior while BitFun is running" + }, + "toggleLabel": "Prevent sleep", + "toggleDescription": "Keep the computer awake whenever BitFun is running, whether or not a task is active. The display may still turn off, and explicit sleep or closing a laptop lid follows system policy.", + "messages": { + "loading": "Loading…", + "loadFailed": "Failed to read the prevent-sleep setting", + "saveFailed": "Failed to update the prevent-sleep setting", + "saved": "Prevent-sleep setting saved" + } + }, "logging": { "sections": { "logging": "Logging", diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json index 6539b6ce78..8fc408d93c 100644 --- a/src/web-ui/src/locales/zh-CN/settings/basics.json +++ b/src/web-ui/src/locales/zh-CN/settings/basics.json @@ -1,6 +1,6 @@ { "title": "基础", - "subtitle": "日志、终端、通知与登录时启动", + "subtitle": "电源、日志、终端、通知与登录时启动", "appearance": { "title": "外观", "hint": "界面语言与视觉主题", @@ -100,6 +100,20 @@ "saveFailed": "无法更新开机启动设置" } }, + "preventSleep": { + "sections": { + "title": "电源", + "hint": "BitFun 运行时的电脑睡眠行为" + }, + "toggleLabel": "阻止电脑睡眠", + "toggleDescription": "只要 BitFun 正在运行,就保持电脑唤醒,无论是否有任务在执行。显示器仍可关闭;主动睡眠或合上笔记本电脑由系统策略决定。", + "messages": { + "loading": "加载中…", + "loadFailed": "无法读取阻止睡眠设置", + "saveFailed": "无法更新阻止睡眠设置", + "saved": "阻止睡眠设置已保存" + } + }, "logging": { "sections": { "logging": "日志", diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json index ee256be543..9915d25e15 100644 --- a/src/web-ui/src/locales/zh-TW/settings/basics.json +++ b/src/web-ui/src/locales/zh-TW/settings/basics.json @@ -1,6 +1,6 @@ { "title": "基礎", - "subtitle": "日誌、終端、通知與登入時啟動", + "subtitle": "電源、日誌、終端、通知與登入時啟動", "appearance": { "title": "外觀", "hint": "界面語言與視覺主題", @@ -86,6 +86,20 @@ "saveFailed": "無法更新開機啟動設置" } }, + "preventSleep": { + "sections": { + "title": "電源", + "hint": "BitFun 執行時的電腦睡眠行為" + }, + "toggleLabel": "防止電腦睡眠", + "toggleDescription": "只要 BitFun 正在執行,就保持電腦喚醒,無論是否有任務正在執行。顯示器仍可關閉;主動睡眠或闔上筆記型電腦由系統原則決定。", + "messages": { + "loading": "載入中…", + "loadFailed": "無法讀取防止睡眠設定", + "saveFailed": "無法更新防止睡眠設定", + "saved": "防止睡眠設定已儲存" + } + }, "logging": { "sections": { "logging": "日誌", From d16b453c0ec2272292aa49a565f3ed9903c8f1a9 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 18:15:01 -0700 Subject: [PATCH 2/2] fix(desktop): harden sleep prevention lifecycle --- src/apps/desktop/src/sleep_prevention.rs | 91 +++++++++++++++++++----- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/apps/desktop/src/sleep_prevention.rs b/src/apps/desktop/src/sleep_prevention.rs index 498c86bd3c..471d1f89b6 100644 --- a/src/apps/desktop/src/sleep_prevention.rs +++ b/src/apps/desktop/src/sleep_prevention.rs @@ -4,7 +4,7 @@ //! enabled, the inhibitor lives for the desktop process and is released when //! the preference is disabled or the process exits. -use std::sync::mpsc; +use std::sync::{mpsc, Arc}; use bitfun_core::service::config::{subscribe_config_updates, ConfigService, ConfigUpdateEvent}; use serde::Deserialize; @@ -23,32 +23,63 @@ enum SleepPreventionRequest { #[derive(Clone)] pub struct SleepPreventionState { - sender: mpsc::Sender, + worker: Arc>>>, } 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(); - self.sender - .send(SleepPreventionRequest::SetEnabled { enabled, response }) - .map_err(|_| "Sleep-prevention worker is unavailable".to_string())?; - result + 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())? + .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 { - let (sender, receiver) = mpsc::channel(); - std::thread::Builder::new() - .name("bitfun-sleep-prevention".to_string()) - .spawn(move || run_worker(receiver)) - .expect("failed to start sleep-prevention worker"); - Self { sender } + Self { + worker: Arc::new(tokio::sync::Mutex::new(None)), + } } } +fn start_worker() -> Result, 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) { let mut inhibitor = None; @@ -73,7 +104,21 @@ fn set_inhibitor_enabled( .app_name("BitFun") .app_reverse_domain("com.bitfun.desktop") .create() - .map_err(|error| format!("Failed to prevent system sleep: {}", error))?; + .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"); } @@ -199,12 +244,26 @@ pub async fn set_prevent_sleep_enabled( #[cfg(test)] mod tests { - use super::SleepPreventionState; + use super::{start_worker, SleepPreventionState}; #[tokio::test] - async fn disabling_an_inactive_inhibitor_is_idempotent() { + 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()); } }