From 1ef0234f9b95fdc75edca1bef3275402a6b6e74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=94=E4=BB=99=E5=A0=A1?= Date: Wed, 12 Aug 2026 10:30:54 +0800 Subject: [PATCH 1/2] feat(electron): add idle mode with display-off and sleep prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「挂机模式(息屏 + 防休眠)」:开启后立即熄灭显示器 (macOS pmset displaysleepnow / Windows SendMessage SC_MONITORPOWER / Linux xset dpms force off),并通过 powerSaveBlocker ('prevent-app-suspension') 阻止系统休眠但允许息屏,适合挂机跑任务。 设置页新增开关,应用启动时同步、退出时自动释放。 Made-with: Proma --- apps/electron/src/main/index.ts | 6 + apps/electron/src/main/ipc.ts | 7 + .../src/main/lib/idle-mode-electron.ts | 36 +++ apps/electron/src/main/lib/idle-mode.test.ts | 250 ++++++++++++++++++ apps/electron/src/main/lib/idle-mode.ts | 130 +++++++++ .../electron/src/main/lib/settings-service.ts | 11 +- .../components/settings/GeneralSettings.tsx | 28 ++ apps/electron/src/types/settings.ts | 8 + 8 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 apps/electron/src/main/lib/idle-mode-electron.ts create mode 100644 apps/electron/src/main/lib/idle-mode.test.ts create mode 100644 apps/electron/src/main/lib/idle-mode.ts diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 42a69f0ae3..ba3a323289 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -106,6 +106,7 @@ import { startPlanningNativeSyncCoordinator, stopPlanningNativeSyncCoordinator } import { feishuBridgeManager } from './lib/feishu-bridge-manager' import { getFeishuMultiBotConfig } from './lib/feishu-config' import { stopFeishuSyncSleepBlocker, syncFeishuSyncSleepBlocker } from './lib/feishu-sleep-blocker' +import { stopIdleMode, syncIdleMode } from './lib/idle-mode-electron' import { getPersistableMainWindowState, hideMacMainWindowAfterClose } from './lib/main-window-lifecycle' import { dingtalkBridgeManager } from './lib/dingtalk-bridge-manager' import { getDingTalkMultiBotConfig } from './lib/dingtalk-config' @@ -702,6 +703,9 @@ async function bootstrap(): Promise { // 飞书实时同步开启时,默认阻止系统自动休眠,保证远程群内继续可用。 safeRun('syncFeishuSyncSleepBlocker', () => syncFeishuSyncSleepBlocker(getSettings())) + // 挂机模式开启时,立即熄灭显示器并阻止系统休眠,保证挂机任务持续运行。 + safeRun('syncIdleMode', () => syncIdleMode(getSettings())) + // 注册全局快捷键 safeRun('registerGlobalShortcut:quick-task', () => registerGlobalShortcut('quick-task', toggleQuickTaskWindow), @@ -823,6 +827,8 @@ app.on('before-quit', () => { stopPlanningNativeSyncCoordinator() // 释放飞书同步防休眠 stopFeishuSyncSleepBlocker() + // 释放挂机模式(息屏 + 防休眠) + stopIdleMode() // 注销全局快捷键 unregisterAllGlobalShortcuts() // 销毁辅助窗口 diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 78353bf098..68623b5ebc 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -376,6 +376,7 @@ import { } from './lib/feishu-config' import { feishuBridgeManager } from './lib/feishu-bridge-manager' import { syncFeishuSyncSleepBlocker } from './lib/feishu-sleep-blocker' +import { syncIdleMode } from './lib/idle-mode-electron' import { presenceService } from './lib/feishu-presence' import { getDingTalkConfig, saveDingTalkConfig, getDecryptedClientSecret, getDingTalkMultiBotConfig, saveDingTalkBotConfig, removeDingTalkBot, getDecryptedBotClientSecret } from './lib/dingtalk-config' import { listShallowDirectory } from './lib/directory-listing' @@ -1758,6 +1759,9 @@ export function registerIpcHandlers(): void { if (updates.feishuSessionMirror !== undefined) { syncFeishuSyncSleepBlocker(result) } + if (updates.idleMode !== undefined) { + syncIdleMode(result) + } if (updates.agentIsland !== undefined) { refreshAgentIslandConfiguration() } @@ -1790,6 +1794,9 @@ export function registerIpcHandlers(): void { if (updates.feishuSessionMirror !== undefined) { syncFeishuSyncSleepBlocker(result) } + if (updates.idleMode !== undefined) { + syncIdleMode(result) + } if (updates.agentIsland !== undefined) { refreshAgentIslandConfiguration() } diff --git a/apps/electron/src/main/lib/idle-mode-electron.ts b/apps/electron/src/main/lib/idle-mode-electron.ts new file mode 100644 index 0000000000..d51c23da6e --- /dev/null +++ b/apps/electron/src/main/lib/idle-mode-electron.ts @@ -0,0 +1,36 @@ +import { powerSaveBlocker } from 'electron' +import type { AppSettings } from '../../types' +import { IdleModeManager, type SleepBlockerAdapter, type SleepBlockerType } from './idle-mode' + +/** + * 挂机模式(息屏 + 防休眠)— electron 接线层 + * + * 核心逻辑见 idle-mode.ts;此处负责把 powerSaveBlocker 注入为 + * SleepBlockerAdapter,并导出供主进程使用的 sync/stop 入口。 + */ + +const electronSleepBlocker: SleepBlockerAdapter = { + start: (type: SleepBlockerType): number => powerSaveBlocker.start(type), + stop: (id: number): void => { + powerSaveBlocker.stop(id) + }, + isStarted: (id: number): boolean => powerSaveBlocker.isStarted(id), +} + +const manager = new IdleModeManager(electronSleepBlocker) + +export function syncIdleMode(settings: Pick): void { + try { + manager.sync(settings) + } catch (error) { + console.error('[挂机模式] 同步状态失败:', error) + } +} + +export function stopIdleMode(): void { + try { + manager.stop() + } catch (error) { + console.error('[挂机模式] 关闭失败:', error) + } +} diff --git a/apps/electron/src/main/lib/idle-mode.test.ts b/apps/electron/src/main/lib/idle-mode.test.ts new file mode 100644 index 0000000000..9fc8844cfc --- /dev/null +++ b/apps/electron/src/main/lib/idle-mode.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from 'bun:test' +import { + IdleModeManager, + buildDisplayOffCommand, + shouldEnableIdleMode, + type DisplayOffRunner, + type SleepBlockerAdapter, + type SleepBlockerType, +} from './idle-mode' + +class FakeSleepBlocker implements SleepBlockerAdapter { + readonly startedTypes: SleepBlockerType[] = [] + readonly stoppedIds: number[] = [] + private nextId = 1 + private activeIds = new Set() + + start(type: SleepBlockerType): number { + const id = this.nextId + this.nextId += 1 + this.startedTypes.push(type) + this.activeIds.add(id) + return id + } + + stop(id: number): void { + this.stoppedIds.push(id) + this.activeIds.delete(id) + } + + isStarted(id: number): boolean { + return this.activeIds.has(id) + } + + markStopped(id: number): void { + this.activeIds.delete(id) + } +} + +class FakeDisplayOffRunner implements DisplayOffRunner { + readonly calls: NodeJS.Platform[] = [] + failNext = false + /** 置为 true 后,run 返回的 promise 由测试手动控制(模拟慢命令在途场景) */ + deferMode = false + private deferredResolve: (() => void) | null = null + private deferredReject: ((error: Error) => void) | null = null + + async run(platform: NodeJS.Platform): Promise { + this.calls.push(platform) + if (this.failNext) { + this.failNext = false + throw new Error('mock display off failure') + } + if (this.deferMode) { + await new Promise((resolve, reject) => { + this.deferredResolve = resolve + this.deferredReject = reject + }) + } + } + + /** 手动完成在途命令 */ + completeDeferred(): void { + const resolve = this.deferredResolve + this.deferredResolve = null + this.deferredReject = null + resolve?.() + } + + /** 手动让在途命令失败 */ + failDeferred(): void { + const reject = this.deferredReject + this.deferredResolve = null + this.deferredReject = null + reject?.(new Error('mock deferred failure')) + } +} + +describe('平台息屏命令', () => { + test('macOS 使用 pmset displaysleepnow', () => { + expect(buildDisplayOffCommand('darwin')).toBe('pmset displaysleepnow') + }) + + test('Windows 使用 EncodedCommand Base64 传递 PS 脚本(避免引号嵌套被剥离)', () => { + const command = buildDisplayOffCommand('win32') + if (command === null) { + throw new Error('win32 平台应返回关屏命令') + } + expect(command.startsWith('powershell -NoProfile -EncodedCommand ')).toBe(true) + expect(command).not.toContain('\"user32.dll\"') + // 解码 Base64,确认脚本内容完整(不依赖外部引号解析) + const encoded = command.slice('powershell -NoProfile -EncodedCommand '.length) + const script = Buffer.from(encoded, 'base64').toString('utf16le') + expect(script).toContain('SendMessage') + expect(script).toContain('0xF170') + expect(script).toContain('MonitorOff') + expect(script).toContain('"user32.dll"') + // 确保输出中没有裸引号嵌套问题:整条命令不含双引号 + expect(command).not.toContain('"') + }) + + test('Linux 使用 xset dpms force off', () => { + expect(buildDisplayOffCommand('linux')).toBe('xset dpms force off') + }) + + test('不支持的平台返回 null(仅防休眠)', () => { + expect(buildDisplayOffCommand('freebsd')).toBeNull() + }) +}) + +describe('挂机模式', () => { + test('Given 挂机模式已开启 When 同步挂机模式 Then 启用系统级防休眠并执行息屏命令', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + // 等待息屏命令异步完成 + await Promise.resolve() + + expect(adapter.startedTypes).toEqual(['prevent-app-suspension']) + expect(adapter.isStarted(1)).toBe(true) + expect(displayOff.calls).toHaveLength(1) + }) + + test('Given 防休眠已启用 When 重复同步开启状态 Then 不重复创建 blocker 也不重复息屏', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + await Promise.resolve() + manager.sync({ idleMode: { enabled: true } }) + await Promise.resolve() + + expect(adapter.startedTypes).toHaveLength(1) + expect(displayOff.calls).toHaveLength(1) + }) + + test('Given 挂机模式从开启切到关闭 When 同步挂机模式 Then 释放系统防休眠', () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + manager.sync({ idleMode: { enabled: false } }) + + expect(adapter.stoppedIds).toEqual([1]) + expect(adapter.isStarted(1)).toBe(false) + }) + + test('Given 系统中的 blocker 已失效 When 挂机模式仍开启 Then 重新启用防休眠并再次息屏', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + await Promise.resolve() + adapter.markStopped(1) + manager.sync({ idleMode: { enabled: true } }) + await Promise.resolve() + + expect(adapter.startedTypes).toHaveLength(2) + expect(displayOff.calls).toHaveLength(2) + }) + + test('Given 未开启挂机模式 When 判断是否需要 Then 不启用', () => { + expect(shouldEnableIdleMode({})).toBe(false) + expect(shouldEnableIdleMode({ idleMode: { enabled: false } })).toBe(false) + expect(shouldEnableIdleMode({ idleMode: { enabled: true } })).toBe(true) + }) + + test('Given 开启后立即关闭 When 在途息屏命令完成 Then 不产生误导反馈且状态一致', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + displayOff.deferMode = true + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + // 命令在途时关闭 + manager.sync({ idleMode: { enabled: false } }) + expect(adapter.isStarted(1)).toBe(false) + + // 在途命令此时才完成:不应抛错、不应复活 blocker + displayOff.completeDeferred() + await Promise.resolve() + await Promise.resolve() + + expect(adapter.isStarted(1)).toBe(false) + expect(adapter.stoppedIds).toEqual([1]) + }) + + test('Given 开启后立即关闭且命令失败 When 在途命令失败 Then 不产生未处理 rejection', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + displayOff.deferMode = true + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + manager.sync({ idleMode: { enabled: false } }) + + displayOff.failDeferred() + await Promise.resolve() + await Promise.resolve() + + expect(adapter.isStarted(1)).toBe(false) + }) + + test('Given 开启期间命令在途 When 再次开启 Then 新 blocker 独立生效', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + displayOff.deferMode = true + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + manager.sync({ idleMode: { enabled: false } }) + manager.sync({ idleMode: { enabled: true } }) + expect(adapter.startedTypes).toHaveLength(2) + + // 第一批在途命令完成:不产生任何异常(generation 已过期) + displayOff.completeDeferred() + await Promise.resolve() + await Promise.resolve() + + expect(adapter.isStarted(2)).toBe(true) + }) + + test('Given 息屏命令失败 When 挂机模式开启 Then 防休眠仍然生效', async () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + displayOff.failNext = true + const manager = new IdleModeManager(adapter, displayOff) + + manager.sync({ idleMode: { enabled: true } }) + await Promise.resolve() + + expect(adapter.isStarted(1)).toBe(true) + expect(displayOff.calls).toHaveLength(1) + }) + + test('Given 从未开启 When stop Then 无操作', () => { + const adapter = new FakeSleepBlocker() + const displayOff = new FakeDisplayOffRunner() + const manager = new IdleModeManager(adapter, displayOff) + + manager.stop() + + expect(adapter.stoppedIds).toHaveLength(0) + expect(displayOff.calls).toHaveLength(0) + }) +}) diff --git a/apps/electron/src/main/lib/idle-mode.ts b/apps/electron/src/main/lib/idle-mode.ts new file mode 100644 index 0000000000..fdd3f3e580 --- /dev/null +++ b/apps/electron/src/main/lib/idle-mode.ts @@ -0,0 +1,130 @@ +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import type { AppSettings } from '../../types' + +const execAsync = promisify(exec) + +/** + * 挂机模式(息屏 + 防休眠)核心逻辑 + * + * 开启后: + * 1. 通过系统命令立即熄灭显示器(macOS pmset displaysleepnow / + * Windows SendMessage SC_MONITORPOWER / Linux xset dpms force off); + * 2. 通过 powerSaveBlocker('prevent-app-suspension') 阻止系统休眠(允许息屏), + * 保证挂机任务持续运行;移动鼠标或按任意键即可唤醒屏幕。 + * + * 本文件保持纯逻辑、不依赖 electron,便于单元测试; + * electron 接线见 idle-mode-electron.ts。 + */ + +/** 构建 Windows 关屏命令:用 -EncodedCommand 传 Base64 编码的 PS 脚本, + * 绕开 cmd.exe/CommandLineToArgvW 对嵌套双引号的剥离(直接拼 -Command 会 + * 导致 Add-Type 收到非法 C#,关屏静默失效)。 */ +export function buildWindowsDisplayOffCommand(): string { + const script = [ + 'Add-Type -TypeDefinition \'', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public class MonitorOff { [DllImport("user32.dll")] public static extern int SendMessage(IntPtr hWnd, int msg, int wParam, int lParam); }', + '\';', + '[MonitorOff]::SendMessage([IntPtr]0xffff, 0x0112, 0xF170, 2)', + ].join('') + const encoded = Buffer.from(script, 'utf16le').toString('base64') + return `powershell -NoProfile -EncodedCommand ${encoded}` +} + +/** 各平台立即熄灭显示器的命令;不支持返回 null(尽力而为,失败仅记录) */ +export function buildDisplayOffCommand(platform: NodeJS.Platform): string | null { + switch (platform) { + case 'darwin': + return 'pmset displaysleepnow' + case 'win32': + return buildWindowsDisplayOffCommand() + case 'linux': + return 'xset dpms force off' + default: + return null + } +} + +export type SleepBlockerType = 'prevent-app-suspension' + +export interface SleepBlockerAdapter { + start(type: SleepBlockerType): number + stop(id: number): void + isStarted(id: number): boolean +} + +export interface DisplayOffRunner { + run(platform: NodeJS.Platform): Promise +} + +export const defaultDisplayOffRunner: DisplayOffRunner = { + async run(platform: NodeJS.Platform): Promise { + const command = buildDisplayOffCommand(platform) + if (command === null) { + console.warn(`[挂机模式] 当前平台 ${platform} 暂不支持自动息屏命令,仅启用防休眠`) + return + } + await execAsync(command) + }, +} + +export function shouldEnableIdleMode(settings: Pick): boolean { + return settings.idleMode?.enabled === true +} + +export class IdleModeManager { + private activeBlockerId: number | null = null + /** 每次 stop 自增,使在途的息屏命令完成后不再产生误导反馈 */ + private generation = 0 + + constructor( + private readonly adapter: SleepBlockerAdapter, + private readonly displayOff: DisplayOffRunner = defaultDisplayOffRunner, + ) {} + + sync(settings: Pick): void { + if (shouldEnableIdleMode(settings)) { + this.start() + return + } + + this.stop() + } + + stop(): void { + this.generation += 1 + if (this.activeBlockerId === null) return + + const blockerId = this.activeBlockerId + this.activeBlockerId = null + + if (this.adapter.isStarted(blockerId)) { + this.adapter.stop(blockerId) + console.log('[挂机模式] 已关闭,恢复系统休眠策略') + } + } + + private start(): void { + if (this.activeBlockerId !== null) { + if (this.adapter.isStarted(this.activeBlockerId)) return + this.activeBlockerId = null + } + + const generation = this.generation + this.activeBlockerId = this.adapter.start('prevent-app-suspension') + console.log('[挂机模式] 已启用:阻止系统休眠(允许息屏),正在熄灭显示器…') + void this.displayOff + .run(process.platform) + .then(() => { + // 期间已关闭:放弃反馈,避免误导 + if (this.generation !== generation) return + console.log('[挂机模式] 显示器已熄灭(移动鼠标或按任意键可唤醒)') + }) + .catch((error) => { + if (this.generation !== generation) return + console.error('[挂机模式] 熄灭显示器失败(防休眠仍生效):', error) + }) + } +} diff --git a/apps/electron/src/main/lib/settings-service.ts b/apps/electron/src/main/lib/settings-service.ts index fe4914a09a..04b33a8ef5 100644 --- a/apps/electron/src/main/lib/settings-service.ts +++ b/apps/electron/src/main/lib/settings-service.ts @@ -8,7 +8,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { getSettingsPath } from './config-paths' import { DEFAULT_INTERFACE_VARIANT, DEFAULT_THEME_MODE } from '../../types' -import type { AgentIslandSettings, AppSettings } from '../../types' +import type { AgentIslandSettings, AppSettings, IdleModeSettings } from '../../types' function sanitizeAgentIslandSettings(input: unknown): AgentIslandSettings | undefined { if (!input || typeof input !== 'object') return undefined @@ -16,6 +16,12 @@ function sanitizeAgentIslandSettings(input: unknown): AgentIslandSettings | unde return typeof raw.enabled === 'boolean' ? { enabled: raw.enabled } : undefined } +function sanitizeIdleModeSettings(input: unknown): IdleModeSettings | undefined { + if (!input || typeof input !== 'object') return undefined + const raw = input as { enabled?: unknown } + return typeof raw.enabled === 'boolean' ? { enabled: raw.enabled } : undefined +} + /** * 获取应用设置 * @@ -34,6 +40,7 @@ export function getSettings(): AppSettings { longTextPasteAsAttachmentEnabled: false, richTextRenderingEnabled: false, feishuSessionMirror: { mode: 'off' }, + idleMode: { enabled: false }, visionRelay: { enabled: false }, builtinMcpDisabledIds: [], windowsShellPreference: 'auto', @@ -66,6 +73,7 @@ export function getSettings(): AppSettings { longTextPasteAsAttachmentEnabled: data.longTextPasteAsAttachmentEnabled ?? false, richTextRenderingEnabled: data.richTextRenderingEnabled ?? false, feishuSessionMirror: data.feishuSessionMirror ?? { mode: 'off' }, + idleMode: sanitizeIdleModeSettings(data.idleMode) ?? { enabled: false }, visionRelay: data.visionRelay ?? { enabled: false }, builtinMcpDisabledIds: settings.builtinMcpDisabledIds ?? [], windowsShellPreference: settings.windowsShellPreference ?? 'auto', @@ -86,6 +94,7 @@ export function getSettings(): AppSettings { longTextPasteAsAttachmentEnabled: false, richTextRenderingEnabled: false, feishuSessionMirror: { mode: 'off' }, + idleMode: { enabled: false }, visionRelay: { enabled: false }, builtinMcpDisabledIds: [], windowsShellPreference: 'auto', diff --git a/apps/electron/src/renderer/components/settings/GeneralSettings.tsx b/apps/electron/src/renderer/components/settings/GeneralSettings.tsx index 2f797957b1..c26eada06c 100644 --- a/apps/electron/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/electron/src/renderer/components/settings/GeneralSettings.tsx @@ -78,6 +78,9 @@ export function GeneralSettings(): React.ReactElement { /** Git/PR 推广标识:默认开启 */ const [gitAttributionEnabled, setGitAttributionEnabled] = React.useState(true) const [agentIslandEnabled, setAgentIslandEnabled] = React.useState(true) + const [idleModeEnabled, setIdleModeEnabled] = React.useState(false) + /** 挂机模式开关请求在途标记:防止快速连点导致 IPC 乱序、物理熄屏状态与 UI 相反 */ + const [idleModePending, setIdleModePending] = React.useState(false) const isMac = React.useMemo(() => detectIsMac(), []) const fileInputRef = React.useRef(null) @@ -87,6 +90,7 @@ export function GeneralSettings(): React.ReactElement { setArchiveAfterDays(settings.archiveAfterDays ?? 7) setGitAttributionEnabled(settings.gitAttributionEnabled ?? true) setAgentIslandEnabled(settings.agentIsland?.enabled ?? true) + setIdleModeEnabled(settings.idleMode?.enabled ?? false) }).catch(console.error) }, []) @@ -112,6 +116,21 @@ export function GeneralSettings(): React.ReactElement { } } + /** 更新挂机模式开关(立即息屏 + 防休眠),串行化防连点乱序 */ + const handleIdleModeChange = async (checked: boolean): Promise => { + if (idleModePending) return + setIdleModePending(true) + setIdleModeEnabled(checked) + try { + await window.electronAPI.updateSettings({ idleMode: { enabled: checked } }) + } catch (error) { + console.error('[通用设置] 更新挂机模式失败:', error) + setIdleModeEnabled(!checked) + } finally { + setIdleModePending(false) + } + } + /** 更新归档天数 */ const handleArchiveDaysChange = async (value: string): Promise => { const days = parseInt(value, 10) @@ -404,6 +423,15 @@ export function GeneralSettings(): React.ReactElement { }} /> )} + { + void handleIdleModeChange(checked) + }} + /> Date: Wed, 12 Aug 2026 10:51:44 +0800 Subject: [PATCH 2/2] feat(electron): keep system awake while agent tasks are running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「任务防休眠」(默认开启):只要 Proma 有任务在跑(Agent run / 自动化任务 / 飞书桥接任务),通过 powerSaveBlocker ('prevent-app-suspension') 阻止系统休眠 —— 用户锁屏、息屏甚至合上 笔记本盖都不会中断任务;任务全部结束后自动恢复系统默认电源策略。 - task-sleep-guard.ts:token 集合跟踪活跃任务,纯逻辑可单测; 设置关闭时立即释放,重新开启时若仍有任务在跑则恢复 - agent-service.ts:runAgent / runAgentHeadless 挂钩 onRunStarted begin / onComplete / onError / catch end,覆盖所有 run 入口 - 设置页新增开关(默认开启),与手动「挂机模式」相互独立 - macOS 合盖实测:任务运行期间合盖无睡眠事件,任务不中断 Made-with: Proma --- apps/electron/src/main/index.ts | 6 + apps/electron/src/main/ipc.ts | 7 + apps/electron/src/main/lib/agent-service.ts | 15 ++ .../electron/src/main/lib/settings-service.ts | 11 +- .../src/main/lib/task-sleep-guard-electron.ts | 36 ++++ .../src/main/lib/task-sleep-guard.test.ts | 166 ++++++++++++++++++ .../electron/src/main/lib/task-sleep-guard.ts | 97 ++++++++++ .../components/settings/GeneralSettings.tsx | 28 +++ apps/electron/src/types/settings.ts | 8 + 9 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 apps/electron/src/main/lib/task-sleep-guard-electron.ts create mode 100644 apps/electron/src/main/lib/task-sleep-guard.test.ts create mode 100644 apps/electron/src/main/lib/task-sleep-guard.ts diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index ba3a323289..6cba8b8972 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -107,6 +107,7 @@ import { feishuBridgeManager } from './lib/feishu-bridge-manager' import { getFeishuMultiBotConfig } from './lib/feishu-config' import { stopFeishuSyncSleepBlocker, syncFeishuSyncSleepBlocker } from './lib/feishu-sleep-blocker' import { stopIdleMode, syncIdleMode } from './lib/idle-mode-electron' +import { stopTaskSleepGuard, syncTaskSleepGuard } from './lib/task-sleep-guard-electron' import { getPersistableMainWindowState, hideMacMainWindowAfterClose } from './lib/main-window-lifecycle' import { dingtalkBridgeManager } from './lib/dingtalk-bridge-manager' import { getDingTalkMultiBotConfig } from './lib/dingtalk-config' @@ -706,6 +707,9 @@ async function bootstrap(): Promise { // 挂机模式开启时,立即熄灭显示器并阻止系统休眠,保证挂机任务持续运行。 safeRun('syncIdleMode', () => syncIdleMode(getSettings())) + // 任务防休眠(默认开启):任务运行期间阻止系统休眠,锁屏/息屏/合盖不中断任务。 + safeRun('syncTaskSleepGuard', () => syncTaskSleepGuard(getSettings())) + // 注册全局快捷键 safeRun('registerGlobalShortcut:quick-task', () => registerGlobalShortcut('quick-task', toggleQuickTaskWindow), @@ -829,6 +833,8 @@ app.on('before-quit', () => { stopFeishuSyncSleepBlocker() // 释放挂机模式(息屏 + 防休眠) stopIdleMode() + // 释放任务防休眠 + stopTaskSleepGuard() // 注销全局快捷键 unregisterAllGlobalShortcuts() // 销毁辅助窗口 diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 68623b5ebc..d89c1919ad 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -377,6 +377,7 @@ import { import { feishuBridgeManager } from './lib/feishu-bridge-manager' import { syncFeishuSyncSleepBlocker } from './lib/feishu-sleep-blocker' import { syncIdleMode } from './lib/idle-mode-electron' +import { syncTaskSleepGuard } from './lib/task-sleep-guard-electron' import { presenceService } from './lib/feishu-presence' import { getDingTalkConfig, saveDingTalkConfig, getDecryptedClientSecret, getDingTalkMultiBotConfig, saveDingTalkBotConfig, removeDingTalkBot, getDecryptedBotClientSecret } from './lib/dingtalk-config' import { listShallowDirectory } from './lib/directory-listing' @@ -1762,6 +1763,9 @@ export function registerIpcHandlers(): void { if (updates.idleMode !== undefined) { syncIdleMode(result) } + if (updates.taskSleepGuard !== undefined) { + syncTaskSleepGuard(result) + } if (updates.agentIsland !== undefined) { refreshAgentIslandConfiguration() } @@ -1797,6 +1801,9 @@ export function registerIpcHandlers(): void { if (updates.idleMode !== undefined) { syncIdleMode(result) } + if (updates.taskSleepGuard !== undefined) { + syncTaskSleepGuard(result) + } if (updates.agentIsland !== undefined) { refreshAgentIslandConfiguration() } diff --git a/apps/electron/src/main/lib/agent-service.ts b/apps/electron/src/main/lib/agent-service.ts index 7cbe5b4d33..f1e4cf2e70 100644 --- a/apps/electron/src/main/lib/agent-service.ts +++ b/apps/electron/src/main/lib/agent-service.ts @@ -38,6 +38,7 @@ import { getAgentSessionMeta, updateAgentSessionMeta } from './agent-session-man import { setAgentStopper, setHeadlessAgentRunner } from './agent-headless-runner-registry' import { getHeadlessAgentRunTarget } from './agent-headless-run-target' import { sendAgentStreamComplete } from './agent-completion-payload' +import { taskSleepGuard } from './task-sleep-guard-electron' // ===== 实例创建 ===== @@ -181,9 +182,13 @@ export async function runAgent( } } catch { /* 新会话可能尚未写入索引 */ } } + // 任务防休眠:本次 run 的 token,onRunStarted 开始防休眠,onComplete/onError/异常时释放。 + // 声明在函数级作用域,确保 catch 分支可访问。 + const sleepGuardToken = {} try { await orchestrator.sendMessage(input, { onError: (error) => { + taskSleepGuard.end(sleepGuardToken) if (!webContents.isDestroyed()) { webContents.send(AGENT_IPC_CHANNELS.STREAM_ERROR, { sessionId: input.sessionId, @@ -192,6 +197,7 @@ export async function runAgent( } }, onComplete: (messages, opts) => { + taskSleepGuard.end(sleepGuardToken) publishRunStopped(input.sessionId, opts?.stoppedByUser, opts?.startedAt) if (!webContents.isDestroyed()) { sendAgentStreamComplete(webContents, input, { @@ -207,6 +213,7 @@ export async function runAgent( } }, onRunStarted: ({ startedAt }) => { + taskSleepGuard.begin(sleepGuardToken) eventBus.emit(input.sessionId, { kind: 'proma_event', event: { type: 'run_started', startedAt }, @@ -228,6 +235,7 @@ export async function runAgent( } catch (err) { console.error('[Agent 服务] runAgent 未处理异常:', err) const errorMessage = err instanceof Error ? err.message : '未知错误' + taskSleepGuard.end(sleepGuardToken) if (!webContents.isDestroyed()) { webContents.send(AGENT_IPC_CHANNELS.STREAM_ERROR, { sessionId: input.sessionId, @@ -276,9 +284,13 @@ export async function runAgentHeadless( registerWebContents(runInput.sessionId, wc) } + // 任务防休眠:本次 run 的 token,onRunStarted 开始防休眠,onComplete/onError/异常时释放。 + // 声明在函数级作用域,确保 catch 分支可访问。 + const sleepGuardToken = {} try { await orchestrator.sendMessage(runInput, { onError: (error) => { + taskSleepGuard.end(sleepGuardToken) callbacks.onError(error) // 同步到渲染进程 if (wc && !wc.isDestroyed()) { @@ -289,6 +301,7 @@ export async function runAgentHeadless( } }, onComplete: (messages, opts) => { + taskSleepGuard.end(sleepGuardToken) callbacks.onComplete(messages) publishRunStopped(runInput.sessionId, opts?.stoppedByUser, opts?.startedAt) // 同步到渲染进程 @@ -320,6 +333,7 @@ export async function runAgentHeadless( } }, onRunStarted: ({ startedAt: persistedStartedAt }) => { + taskSleepGuard.begin(sleepGuardToken) const session = getAgentSessionMeta(runInput.sessionId) eventBus.emit(runInput.sessionId, { kind: 'proma_event', @@ -339,6 +353,7 @@ export async function runAgentHeadless( } catch (err) { console.error('[Agent 服务] runAgentHeadless 未处理异常:', err) const errorMessage = err instanceof Error ? err.message : '未知错误' + taskSleepGuard.end(sleepGuardToken) callbacks.onError(errorMessage) callbacks.onComplete() if (wc && !wc.isDestroyed()) { diff --git a/apps/electron/src/main/lib/settings-service.ts b/apps/electron/src/main/lib/settings-service.ts index 04b33a8ef5..494199e9f2 100644 --- a/apps/electron/src/main/lib/settings-service.ts +++ b/apps/electron/src/main/lib/settings-service.ts @@ -8,7 +8,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { getSettingsPath } from './config-paths' import { DEFAULT_INTERFACE_VARIANT, DEFAULT_THEME_MODE } from '../../types' -import type { AgentIslandSettings, AppSettings, IdleModeSettings } from '../../types' +import type { AgentIslandSettings, AppSettings, IdleModeSettings, TaskSleepGuardSettings } from '../../types' function sanitizeAgentIslandSettings(input: unknown): AgentIslandSettings | undefined { if (!input || typeof input !== 'object') return undefined @@ -22,6 +22,12 @@ function sanitizeIdleModeSettings(input: unknown): IdleModeSettings | undefined return typeof raw.enabled === 'boolean' ? { enabled: raw.enabled } : undefined } +function sanitizeTaskSleepGuardSettings(input: unknown): TaskSleepGuardSettings | undefined { + if (!input || typeof input !== 'object') return undefined + const raw = input as { enabled?: unknown } + return typeof raw.enabled === 'boolean' ? { enabled: raw.enabled } : undefined +} + /** * 获取应用设置 * @@ -41,6 +47,7 @@ export function getSettings(): AppSettings { richTextRenderingEnabled: false, feishuSessionMirror: { mode: 'off' }, idleMode: { enabled: false }, + taskSleepGuard: { enabled: true }, visionRelay: { enabled: false }, builtinMcpDisabledIds: [], windowsShellPreference: 'auto', @@ -74,6 +81,7 @@ export function getSettings(): AppSettings { richTextRenderingEnabled: data.richTextRenderingEnabled ?? false, feishuSessionMirror: data.feishuSessionMirror ?? { mode: 'off' }, idleMode: sanitizeIdleModeSettings(data.idleMode) ?? { enabled: false }, + taskSleepGuard: sanitizeTaskSleepGuardSettings(data.taskSleepGuard) ?? { enabled: true }, visionRelay: data.visionRelay ?? { enabled: false }, builtinMcpDisabledIds: settings.builtinMcpDisabledIds ?? [], windowsShellPreference: settings.windowsShellPreference ?? 'auto', @@ -95,6 +103,7 @@ export function getSettings(): AppSettings { richTextRenderingEnabled: false, feishuSessionMirror: { mode: 'off' }, idleMode: { enabled: false }, + taskSleepGuard: { enabled: true }, visionRelay: { enabled: false }, builtinMcpDisabledIds: [], windowsShellPreference: 'auto', diff --git a/apps/electron/src/main/lib/task-sleep-guard-electron.ts b/apps/electron/src/main/lib/task-sleep-guard-electron.ts new file mode 100644 index 0000000000..031761ada1 --- /dev/null +++ b/apps/electron/src/main/lib/task-sleep-guard-electron.ts @@ -0,0 +1,36 @@ +import { powerSaveBlocker } from 'electron' +import type { AppSettings } from '../../types' +import { TaskSleepGuard, type SleepBlockerAdapter, type SleepBlockerType } from './task-sleep-guard' + +/** + * 任务防休眠 — electron 接线层 + * + * 核心逻辑见 task-sleep-guard.ts;此处注入 powerSaveBlocker, + * 并导出主进程使用的单例与 sync/stop 入口。 + */ + +const electronSleepBlocker: SleepBlockerAdapter = { + start: (type: SleepBlockerType): number => powerSaveBlocker.start(type), + stop: (id: number): void => { + powerSaveBlocker.stop(id) + }, + isStarted: (id: number): boolean => powerSaveBlocker.isStarted(id), +} + +export const taskSleepGuard = new TaskSleepGuard(electronSleepBlocker) + +export function syncTaskSleepGuard(settings: Pick): void { + try { + taskSleepGuard.syncSettings(settings) + } catch (error) { + console.error('[任务防休眠] 同步设置失败:', error) + } +} + +export function stopTaskSleepGuard(): void { + try { + taskSleepGuard.stop() + } catch (error) { + console.error('[任务防休眠] 停止失败:', error) + } +} diff --git a/apps/electron/src/main/lib/task-sleep-guard.test.ts b/apps/electron/src/main/lib/task-sleep-guard.test.ts new file mode 100644 index 0000000000..5f47d0658c --- /dev/null +++ b/apps/electron/src/main/lib/task-sleep-guard.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from 'bun:test' +import { + TaskSleepGuard, + shouldEnableTaskSleepGuard, + type SleepBlockerAdapter, + type SleepBlockerType, +} from './task-sleep-guard' + +class FakeSleepBlocker implements SleepBlockerAdapter { + readonly startedTypes: SleepBlockerType[] = [] + readonly stoppedIds: number[] = [] + private nextId = 1 + private activeIds = new Set() + + start(type: SleepBlockerType): number { + const id = this.nextId + this.nextId += 1 + this.startedTypes.push(type) + this.activeIds.add(id) + return id + } + + stop(id: number): void { + this.stoppedIds.push(id) + this.activeIds.delete(id) + } + + isStarted(id: number): boolean { + return this.activeIds.has(id) + } +} + +describe('任务防休眠默认开启判断', () => { + test('未配置时默认开启(用户要求默认开启)', () => { + expect(shouldEnableTaskSleepGuard({})).toBe(true) + }) + + test('enabled true 开启,enabled false 关闭', () => { + expect(shouldEnableTaskSleepGuard({ taskSleepGuard: { enabled: true } })).toBe(true) + expect(shouldEnableTaskSleepGuard({ taskSleepGuard: { enabled: false } })).toBe(false) + }) +}) + +describe('任务防休眠', () => { + test('Given 默认设置 When 第一个任务开始 Then 启动防休眠', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({}) + + guard.begin({}) + + expect(adapter.startedTypes).toEqual(['prevent-app-suspension']) + expect(guard.isActive).toBe(true) + expect(guard.runningTaskCount).toBe(1) + }) + + test('Given 多个任务并发 When 全部开始 Then 只启动一个 blocker', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({}) + + guard.begin({ id: 1 }) + guard.begin({ id: 2 }) + guard.begin({ id: 3 }) + + expect(adapter.startedTypes).toHaveLength(1) + expect(guard.runningTaskCount).toBe(3) + expect(guard.isActive).toBe(true) + }) + + test('Given 多个任务并发 When 部分结束 Then 防休眠保持,全部结束才释放', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({}) + + const token1 = { id: 1 } + const token2 = { id: 2 } + guard.begin(token1) + guard.begin(token2) + guard.end(token1) + + expect(guard.isActive).toBe(true) + + guard.end(token2) + + expect(guard.isActive).toBe(false) + expect(adapter.stoppedIds).toHaveLength(1) + expect(guard.runningTaskCount).toBe(0) + }) + + test('Given 重复结束同一 token When end 多次 Then 不影响其他任务与计数', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({}) + + const token = { id: 1 } + guard.begin(token) + guard.end(token) + guard.end(token) + + expect(guard.runningTaskCount).toBe(0) + expect(guard.isActive).toBe(false) + }) + + test('Given 设置被关闭 When 仍有任务运行 Then 立即释放防休眠', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({ taskSleepGuard: { enabled: true } }) + + guard.begin({ id: 1 }) + expect(guard.isActive).toBe(true) + + guard.syncSettings({ taskSleepGuard: { enabled: false } }) + + expect(guard.isActive).toBe(false) + expect(adapter.stoppedIds).toHaveLength(1) + }) + + test('Given 设置关闭后 When 新任务开始 Then 不启动防休眠', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({ taskSleepGuard: { enabled: false } }) + + guard.begin({ id: 1 }) + + expect(adapter.startedTypes).toHaveLength(0) + expect(guard.isActive).toBe(false) + }) + + test('Given 设置从关闭切回开启 When 已有任务在跑 Then 重新启用防休眠', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({ taskSleepGuard: { enabled: false } }) + guard.begin({ id: 1 }) + + guard.syncSettings({ taskSleepGuard: { enabled: true } }) + + expect(adapter.startedTypes).toHaveLength(1) + expect(guard.isActive).toBe(true) + }) + + test('Given 应用退出 When stop Then 清空所有任务并释放', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({}) + + guard.begin({ id: 1 }) + guard.begin({ id: 2 }) + + guard.stop() + + expect(guard.runningTaskCount).toBe(0) + expect(guard.isActive).toBe(false) + }) + + test('Given 从未有任务 When stop Then 无副作用', () => { + const adapter = new FakeSleepBlocker() + const guard = new TaskSleepGuard(adapter) + guard.syncSettings({}) + + guard.stop() + + expect(adapter.startedTypes).toHaveLength(0) + expect(adapter.stoppedIds).toHaveLength(0) + }) +}) diff --git a/apps/electron/src/main/lib/task-sleep-guard.ts b/apps/electron/src/main/lib/task-sleep-guard.ts new file mode 100644 index 0000000000..3fd427239f --- /dev/null +++ b/apps/electron/src/main/lib/task-sleep-guard.ts @@ -0,0 +1,97 @@ +import type { AppSettings } from '../../types' + +/** + * 任务防休眠(Task Sleep Guard)核心逻辑 + * + * 需求:只要 Proma 有任务在跑(Agent run / 自动化任务 / 飞书桥接任务), + * 无论用户锁屏、息屏还是合上笔记本盖,系统都不应休眠中断任务; + * 任务全部结束后恢复系统默认电源策略。设置默认开启。 + * + * 实现:token 集合跟踪活跃任务 —— 每个正在运行的任务持有一个 token, + * 第一个任务开始时启动 powerSaveBlocker('prevent-app-suspension'), + * 最后一个任务结束时释放。与手动「挂机模式」(idle-mode)相互独立。 + * + * 本文件保持纯逻辑、不依赖 electron,便于单元测试; + * electron 接线见 task-sleep-guard-electron.ts。 + */ + +export type SleepBlockerType = 'prevent-app-suspension' + +export interface SleepBlockerAdapter { + start(type: SleepBlockerType): number + stop(id: number): void + isStarted(id: number): boolean +} + +/** 默认开启:未配置或 enabled 非 false 都视为开启(用户要求默认开启) */ +export function shouldEnableTaskSleepGuard(settings: Pick): boolean { + return settings.taskSleepGuard?.enabled !== false +} + +export class TaskSleepGuard { + private activeBlockerId: number | null = null + private enabled = true + private activeTokens = new Set() + + constructor(private readonly adapter: SleepBlockerAdapter) {} + + /** 设置变更时同步:关闭时立即释放 blocker;重新开启时若有任务在跑则恢复防休眠 */ + syncSettings(settings: Pick): void { + this.enabled = shouldEnableTaskSleepGuard(settings) + if (this.enabled) { + this.startBlockerIfNeeded() + } else { + this.releaseIfNeeded() + } + } + + /** 任务开始:始终记录 token(任务确实在跑);启用状态且第一个任务时启动防休眠 */ + begin(token: object): void { + this.activeTokens.add(token) + if (!this.enabled) return + this.startBlockerIfNeeded() + } + + /** 任务结束:释放 token,最后一个任务结束时释放防休眠 */ + end(token: object): void { + this.activeTokens.delete(token) + this.releaseIfNeeded() + } + + /** 应用退出等场景:清空所有 token 并释放 */ + stop(): void { + this.activeTokens.clear() + this.releaseIfNeeded() + } + + /** 当前是否有活跃任务 */ + get runningTaskCount(): number { + return this.activeTokens.size + } + + /** 当前防休眠是否生效 */ + get isActive(): boolean { + return this.activeBlockerId !== null && this.adapter.isStarted(this.activeBlockerId) + } + + private startBlockerIfNeeded(): void { + if (this.activeBlockerId !== null) { + if (this.adapter.isStarted(this.activeBlockerId)) return + // blocker 已被系统回收:置空以便重建 + this.activeBlockerId = null + } + if (this.activeTokens.size === 0) return + this.activeBlockerId = this.adapter.start('prevent-app-suspension') + console.log('[任务防休眠] 任务运行中:阻止系统休眠(锁屏/息屏/合盖不中断任务)') + } + + private releaseIfNeeded(): void { + if (this.activeBlockerId === null) return + if (this.enabled && this.activeTokens.size > 0) return + if (this.adapter.isStarted(this.activeBlockerId)) { + this.adapter.stop(this.activeBlockerId) + } + this.activeBlockerId = null + console.log('[任务防休眠] 已释放,恢复系统休眠策略') + } +} diff --git a/apps/electron/src/renderer/components/settings/GeneralSettings.tsx b/apps/electron/src/renderer/components/settings/GeneralSettings.tsx index c26eada06c..785e94c4a5 100644 --- a/apps/electron/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/electron/src/renderer/components/settings/GeneralSettings.tsx @@ -81,6 +81,9 @@ export function GeneralSettings(): React.ReactElement { const [idleModeEnabled, setIdleModeEnabled] = React.useState(false) /** 挂机模式开关请求在途标记:防止快速连点导致 IPC 乱序、物理熄屏状态与 UI 相反 */ const [idleModePending, setIdleModePending] = React.useState(false) + /** 任务防休眠:默认开启 */ + const [taskSleepGuardEnabled, setTaskSleepGuardEnabled] = React.useState(true) + const [taskSleepGuardPending, setTaskSleepGuardPending] = React.useState(false) const isMac = React.useMemo(() => detectIsMac(), []) const fileInputRef = React.useRef(null) @@ -91,6 +94,7 @@ export function GeneralSettings(): React.ReactElement { setGitAttributionEnabled(settings.gitAttributionEnabled ?? true) setAgentIslandEnabled(settings.agentIsland?.enabled ?? true) setIdleModeEnabled(settings.idleMode?.enabled ?? false) + setTaskSleepGuardEnabled(settings.taskSleepGuard?.enabled ?? true) }).catch(console.error) }, []) @@ -131,6 +135,21 @@ export function GeneralSettings(): React.ReactElement { } } + /** 更新任务防休眠开关(默认开启,任务运行期间阻止系统休眠) */ + const handleTaskSleepGuardChange = async (checked: boolean): Promise => { + if (taskSleepGuardPending) return + setTaskSleepGuardPending(true) + setTaskSleepGuardEnabled(checked) + try { + await window.electronAPI.updateSettings({ taskSleepGuard: { enabled: checked } }) + } catch (error) { + console.error('[通用设置] 更新任务防休眠失败:', error) + setTaskSleepGuardEnabled(!checked) + } finally { + setTaskSleepGuardPending(false) + } + } + /** 更新归档天数 */ const handleArchiveDaysChange = async (value: string): Promise => { const days = parseInt(value, 10) @@ -432,6 +451,15 @@ export function GeneralSettings(): React.ReactElement { void handleIdleModeChange(checked) }} /> + { + void handleTaskSleepGuardChange(checked) + }} + />