Cherry Studio 电源中枢 PowerService 深度解析:Electron powerMonitor 与电源管理的统一封装
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
Cherry Studio 的PowerService(源码)是系统电源管理的统一中枢,一个由应用生命周期托管(lifecycle-managed)的单例服务,它独占所有 ElectronpowerMonitor/powerSaveBlocker相关的职责,使应用其余部分永远不需要直接触碰这些底层 API。本文基于仓库中的 power 模块说明文档 并结合源码实现,完整讲解它的五大职责:电源通知事件、关机屏障、防睡眠、状态查询以及生命周期集成,读者读完后将掌握在 Cherry Studio 中如何安全地监听系统挂起/恢复、在系统关机前执行清理、以及为长任务申请"阻止系统睡眠"的完整实战方案。
为什么需要 PowerService:集中管理电源职责
Electron 桌面应用常常需要感知系统电源状态:笔记本合盖挂起、屏幕锁定、电源从电池切换到交流电、系统即将关机、长时间后台任务需要阻止睡眠等。如果每个模块都各自直接调用powerMonitor/powerSaveBlocker,会带来三个典型问题:
- 事件去重缺失:macOS 上
suspend/resume事件会触发两次(见 electron/electron#24803),各调用方需要各自实现去重逻辑; - 关机清理不完整:系统关机时如果直接退进程,正在进行的文件写入、状态持久化会被截断;
- 阻止睡眠失控:多个模块同时申请阻止睡眠时,没有引用计数,很难知道"谁在阻止睡眠、何时可以安全释放"。
PowerService 正是为解决这些问题而生:它把上述所有电源关注点收敛到单一服务之后,对外暴露类型化事件、关机屏障、引用计数的防睡眠持有(hold)和电平触发(level-triggered)的查询 API。
职责总览
| 职责领域 | 提供能力 |
|---|---|
| 通知事件 | 类型化Emitter→Event:onSuspend/onResume/onLockScreen/onUnlockScreen/onPowerSourceChange。其中 suspend/resume 与电源来源(power-source)会基于内部状态去重(应对 macOS 双重触发,electron/electron#24803);lock/unlock 为透传转发 |
| 关机屏障 | registerShutdownHandler(fn)→Disposable。系统关机时,处理器串行执行并被硬超时约束,随后应用退出。跨平台:macOS/Linux 使用powerMonitor的shutdown事件 +preventDefault,Windows 使用@paymoapp/electron-shutdown-handler |
| 防睡眠 | preventSleep(reason?)→Disposable。基于引用计数持有;只有至少存在一个持有 AND 用户通过app.power.prevent_sleep_when_busy偏好开启时,OS 阻塞器(prevent-app-suspension)才处于激活状态。isPreventingSleep()返回有效状态 |
| 查询 | getPowerPhase()/getPowerSource()/isOnBatteryPower()/getSystemIdleTime()/getSystemIdleState(thresholdSec)—— 电平触发式,迟到的调用者无需观察过边沿事件即可对账当前状态 |
快速上手:三类典型用法
按照 README 的 Quick Start,以下是完整可用的入门代码。PowerService已注册进应用容器,通过application.get('PowerService')即可获取:
import { application } from '@application' const power = application.get('PowerService') // 1. 在某个工作的存续期内保持机器唤醒 // (仅当用户开启了 `app.power.prevent_sleep_when_busy` 时真正生效): const hold = power.preventSleep('job:export') try { await doWork() } finally { hold.dispose() // 幂等,可重复调用 } // 2. 响应系统挂起/恢复: this.registerDisposable(power.onSuspend(() => pauseLongPoll())) this.registerDisposable(power.onResume(() => resumeLongPoll())) // 3. 在 OS 关机前执行清理: this.registerDisposable(power.registerShutdownHandler(() => flushCriticalState()))三个要点值得注意:
preventSleep返回的Disposable与事件订阅、关机处理器注册返回的Disposable都能安全地交给生命周期基类(如BaseService的registerDisposable)统一管理,服务停止时自动释放;registerShutdownHandler的处理器可以是同步函数,也可以是返回Promise的异步函数(类型为() => void | Promise<void>),串行执行、错误隔离;- 所有 API 都遵循"绝不抛出、总是返回可用对象"的约定,调用方无需防御式
try/catch。
电源通知事件:类型化 Emitter 与去重机制
PowerService 通过类型化Emitter对外暴露五个事件,内部状态为powerPhase('active' | 'suspended')与powerSource('ac' | 'battery' | 'unknown'),见 PowerService.ts 事件定义与初始化:
- suspend / resume 去重:内部维护
powerPhase状态机。收到suspend时,若当前已是'suspended'则直接忽略,否则置状态并触发onSuspend;resume同理。这正是针对 macOS 双重触发(electron/electron#24803)的防御; - power-source 去重:
updatePowerSource()仅在来源真正变化时才更新状态并触发onPowerSourceChange,重复的on-ac/on-battery事件不会重复广播; - lock / unlock 透传:屏幕锁定/解锁没有需要去重的状态机,直接转发;
- 初始状态播种:服务初始化时即从
powerMonitor.onBatteryPower读取当前电源来源并写入powerSource,保证第一个查询/事件就是正确的(见 PowerService.ts#L118)。
单元测试 PowerService.test.ts 中的事件用例完整覆盖了这些行为:重复suspend只触发一次回调、未挂起时收到resume不触发、lock-screen 透传两次、电源来源仅在真实变化时广播。
关机屏障:在系统关机前完成清理
有界串行执行
registerShutdownHandler(handler)将处理器推入内部数组并返回可注销的Disposable(按索引移除)。当系统报告即将关机时,executeShutdownHandlers()会:
- 串行依次
await每个处理器; - 错误隔离:单个处理器抛错仅记录日志,不影响后续处理器执行;
- 硬超时兜底:整体执行受
SHUTDOWN_HANDLER_TIMEOUT_MS = 5000(5 秒)约束,通过Promise.race([run, timeout])实现——即使某个处理器永久挂起,5 秒后也会强制进入退出流程,绝不拖住用户机器的关机操作(见 PowerService.ts#L23 与 #L188-L213)。
测试中的 "force-quits when a handler hangs past the timeout" 用例用假定时器验证了这一点:一个永不 resolve 的处理器不会阻塞退出。
macOS / Linux:preventDefault → 执行处理器 → quit
在 macOS/Linux 上,powerMonitor.on('shutdown', ...)监听器收到事件后先调用event.preventDefault()推迟系统关机,然后串行执行所有关机处理器,最后调用application.quit()(而不是裸app.quit())走应用正常的退出流程(见 PowerService.ts#L215-L235)。
实现细节:Electron 类型定义中
shutdown监听器签名为() => void,但运行时确实会传入带preventDefault的事件对象。源码将事件参数声明为可选(event?: Electron.Event),既兼容类型重载,又能在运行时调用preventDefault。
Windows:原生关机消息钩子 + 隐藏窗口
Windows 路径使用@paymoapp/electron-shutdown-handler原生插件,通过WM_QUERYENDSESSION钩住系统关机消息(见 PowerService.ts#L237-L287),流程为:blockShutdown()→ 执行处理器 →releaseShutdown()→application.quit()。
源码注释中披露了几个关键的工程决策:
- 自建隐藏窗口而非复用主窗口:主窗口是单例、可能在服务初始化时尚未存在、且销毁重建会更换 HWND,而插件需要稳定的 HWND 挂接关机消息。因此服务自建一个
show: false+paintWhenInitiallyHidden: false+skipTaskbar: true的BrowserWindow,仅作为原生句柄载体、从不加载任何内容; - 零渲染进程开销:由于不加载内容且
paintWhenInitiallyHidden: false阻止激活/绘制,Electron 不会为该窗口派生独立渲染进程;在窗口子系统已初始化的前提下实测边际成本仅约 0.7 MB RSS,可视为近乎免费; - 必须显式调用
blockShutdown():若只挂监听器而不调用blockShutdown,插件只会"观察"到关机事件而不会真正按住系统——这一调用是 Windows 路径成为真正屏障的关键,且必须在监听器挂载之后调用。
对应测试用例断言了setWindowHandle、on('shutdown')、blockShutdown均被调用,回调执行后releaseShutdown与quit各调用一次。
退出流程的一致性
无论哪个平台,最终都经由application.quit()走before-quit流程。这意味着如果应用存在活动的Application.preventQuit持有(例如正在进行数据迁移),它会像拦截用户主动退出一样拦截 OS 发起的关机——受关机处理器硬超时的限制,因为 OS 不能被无限期阻塞(见 README Notes)。
防睡眠:引用计数持有 + 用户偏好门控
设计模型
防睡眠采用"通用注册表 + 正交门控"模型(见 PowerService.ts#L293-L356):
- 持有注册表:任何需要机器保持唤醒的 worker 调用
preventSleep(reason?)注册一个持有,返回Disposable,工作结束时dispose()释放; - 偏好门控:
preventEnabled来自用户偏好app.power.prevent_sleep_when_busy,由服务内部自行读取(自读模式,与TrayService/ThemeService/ProxyService一致),并通过pref.subscribeChange(...)实时订阅变化; - 有效状态 = 门控开启 AND 持有数 > 0:
applyBlockerState()是唯一的幂等收敛点,任何路径(获取持有、释放持有、偏好变更、服务停止)都汇聚到这里,决定启动/停止 OS 阻塞器。
持有的数据结构是Map<symbol, { reason?: string; since: number }>而非裸计数器:dispose()通过Map.delete天然幂等(第二次调用返回false即不再动作),且持有可枚举,便于排查"谁在持有、从何时开始"的泄漏问题(见 PowerService.ts#L77)。
prevent-app-suspension 的选择
阻塞器使用powerSaveBlocker.start('prevent-app-suspension')(见 PowerService.ts#L316):该模式保持系统运行但允许显示器休眠——对后台工作(任务、下载)而言是正确选择,不同于会阻止显示器休眠的prevent-display-sleep。
尽力而为、绝不抛出
applyBlockerState()将powerSaveBlocker.start/stop的任何失败记录日志并吞掉,因此:
preventSleep()永远返回一个可用的Disposable,即使 OS 阻塞器启动失败也不影响调用方;- 调用方无需任何防御式
try/catch,优雅降级内聚在 provider 内部,而不是散落在每个调用点; - 失败后的自动恢复:下一次
preventSleep/dispose/ 偏好变更会重新运行收敛逻辑,可纠正当前不一致的阻塞状态(见 PowerService.ts#L310-L330)。
测试对此有专门用例:powerSaveBlocker.start抛出异常时,preventSleep不抛、仍返回可用且可幂等释放的持有。
引用计数行为
测试验证了完整语义(见 PowerService.test.ts):
- 偏好关闭时,即使有持有也不启动阻塞器;
- 偏好开启且无持有时不启动;持有到达时启动一次
prevent-app-suspension; - 多个持有共享同一个阻塞器,只有最后一个释放后才停止;
- 持有期间用户关闭偏好,阻塞器立即释放;
- 持有期间用户开启偏好,阻塞器立即启动。
查询 API:电平触发式状态读取
查询类 API 均为电平触发(level-triggered)设计,迟到的调用者无需观察到边沿事件即可对账当前状态(见 PowerService.ts#L358-L381):
getPowerPhase(): 'active' | 'suspended'—— 返回内部状态机当前值;getPowerSource(): 'ac' | 'battery' | 'unknown'—— 返回已去重的当前电源来源;isOnBatteryPower(): boolean—— 直接转发powerMonitor.onBatteryPower;getSystemIdleTime(): number—— 系统空闲秒数,将服务于 Job 系统的after-idle追赶策略(见 catchUp.ts 中的预留注释);getSystemIdleState(thresholdSec): 'active' | 'idle' | 'locked' | 'unknown'—— 按阈值判定空闲状态。
测试验证了这些 API 正确转发到底层powerMonitor并透传阈值参数。
生命周期集成:WhenReady 阶段的自足服务
PowerService声明为@Injectable('PowerService')且@ServicePhase(Phase.WhenReady)(见 PowerService.ts#L52-L54),即:
- 在应用 WhenReady 阶段初始化——此时应用已经 ready,
powerSaveBlocker/BrowserWindow可直接使用,无需app.whenReady()体操; onInit()依次初始化电源事件、关机屏障、防睡眠三个子系统;onStop()清理:清空关机处理器、停止活动阻塞器、清空全部持有。
它注册于应用服务注册表(见 serviceRegistry.ts),与TrayService/ThemeService/ProxyService一样采用自读偏好模式。
实战:Job 系统如何接入防睡眠
Job 系统是preventSleep的首个注册者。在 JobManager.ts#L1798-L1832 中,每个任务尝试(attempt)开始时获取持有,finally中释放:
const task = (async () => { // 尽力而为,受用户 `app.power.prevent_sleep_when_busy` 偏好门控。 // preventSleep 绝不抛出、总是返回 Disposable,因此这里无需防御。 const sleepHold = application.get('PowerService').preventSleep(`job:${row.type}:${row.id}`) try { const output = await handler.execute(ctx) // ... 完成/失败分类、重试调度 } finally { // ... 释放 } })()值得注意的细节是按尝试(per-attempt)持有:任务在两次重试之间处于delayed状态(非工作状态),不应持有机器唤醒——注释明确指出这一设计意图(见 JobManager.ts#L1802-L1803)。流式传输等后续 worker 可通过同一 API 自行注册。
用户偏好与开关位置
防睡眠的用户开关位于设置 → 通用(Settings → General)。偏好定义位于数据分类配置 target-key-definitions.json:
{ "targetKey": "app.power.prevent_sleep_when_busy", "type": "boolean", "defaultValue": false, "status": "classified", "description": "Prevent system sleep while the app has active work (v2 new feature, no v1 source)" }关键事实:默认值为false(用户未开启时,即使有 worker 请求,系统也不会被阻止睡眠);该偏好为 v2 新增特性,无 v1 来源。
总结
Cherry Studio 的PowerService展示了如何以单一生命周期托管服务封装 Electron 电源 API 的全部复杂性:类型化且去重的通知事件、跨平台有界关机屏障、引用计数 + 偏好门控的防睡眠注册表、电平触发的状态查询,以及自读偏好与 WhenReady 阶段的优雅集成。对于需要与操作系统电源生命周期打交道的 Electron 应用,这是一份值得借鉴的完整参考实现——进一步深入可阅读 PowerService 源码、单元测试 以及 Job 系统调用示例。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考