Cherry Studio 电源中枢 PowerService:Electron 电源事件、关机屏障与防睡眠机制完全指南
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
导读
本文深入解析 Cherry Studio 主进程中统一的系统电源枢纽——PowerService(位于 src/main/core/power/PowerService.ts)。它是整个应用唯一直接接触 ElectronpowerMonitor/powerSaveBlocker的入口,集中提供电源通知事件、跨平台关机清理屏障、基于引用计数的防睡眠机制与电平触发式电源查询。读完本文,你将掌握这套电源管理的设计模式,并能直接用文中 API 为自己的模块接入“机器挂起/恢复通知”“关机前清理”与“保持机器唤醒”三类能力。
为什么需要“电源中枢”:一个服务收编所有电源关注点
在 Electron 应用中,powerMonitor与powerSaveBlocker是全局性的系统级 API。若每个模块各自直接监听suspend、resume、shutdown事件或各自调用powerSaveBlocker.start(),会带来三个典型问题:
- 监听与释放责任分散:谁监听、谁移除、生命周期如何与模块同生共死,难以统一管理;
- 事件重复消费:macOS 上
suspend/resume会被系统触发两次,若无统一去重,订阅方会收到重复通知; - 防睡眠冲突:多个模块同时想阻止睡眠时,各自 start/stop 会互相覆盖,无法收敛到“最后一个离开才释放”的语义。
PowerService的解法是把这些关注点收编为一个生命周期管理的单例服务,其余代码一律不直接触碰 Electron 电源 API,而是通过服务的类型化接口间接使用。这与 Cherry Studio 主进程依赖注入(@Injectable装饰器 +application.get(...)容器)的设计一脉相承。
其职责可以用一张表概括:
| 领域 | 提供的能力 |
|---|---|
| 通知事件 | 类型化的Emitter→Event,覆盖onSuspend/onResume/onLockScreen/onUnlockScreen/onPowerSourceChange。suspend/resume 与电源来源均基于内部状态去重(macOS 会双触发,见 Electron 上游 issue #24803);lock/unlock 则直接透传 |
| 关机屏障 | registerShutdownHandler(fn)返回Disposable。系统关机时,处理器按序串行执行,并受硬超时约束,随后应用退出。跨平台实现:macOS/Linux 使用powerMonitor的shutdown事件 +preventDefault();Windows 使用@paymoapp/electron-shutdown-handler原生插件 |
| 防睡眠 | preventSleep(reason?)返回Disposable。基于引用计数(ref-counted holds);操作系统阻塞器(prevent-app-suspension)仅在“存在至少一个 hold 且用户已在app.power.prevent_sleep_when_busy中开启”时才激活。isPreventingSleep()报告生效状态 |
| 查询 | getPowerPhase()/getPowerSource()/isOnBatteryPower()/getSystemIdleTime()/getSystemIdleState(thresholdSec)——采用电平触发语义,迟到的调用者可直接对账当前状态,无需观察事件边沿 |
服务定位与生命周期:WhenReady 阶段的单例
PowerService的类声明清晰交代了它在应用生命周期中的位置:
@Injectable('PowerService') @ServicePhase(Phase.WhenReady) export class PowerService extends BaseService {- 通过
@Injectable('PowerService')注册进依赖容器,其他模块用application.get('PowerService')获取; - 通过
@ServicePhase(Phase.WhenReady)声明在应用“已就绪”阶段初始化。此时app.whenReady()已经完成,powerSaveBlocker与BrowserWindow可以直接使用,无需再做 whenReady 体操; - 继承 BaseService(位于 src/main/core/lifecycle/BaseService.ts),获得统一的
onInit()/onStop()钩子与registerDisposable()资源注册能力——凡是registerDisposable()注册的监听器、事件订阅都会在服务停止时自动清理。
初始化时,onInit()依次执行三件事(PowerService.ts):
protected onInit(): void { this.initPowerEvents() // 电源通知事件 + 去重状态机 this.initShutdownBarrier() // 跨平台关机屏障 this.initSleepPrevention() // 防睡眠 + 偏好门控 logger.info('PowerService initialized', { platform: process.platform }) }onStop()则会清空关机处理器列表、停止正在运行的防睡眠阻塞器(powerSaveBlocker.stop)并清空 holds 表(PowerService.ts),保证服务停止后不留任何系统级副作用。
电源通知事件:类型化 Emitter 与去重状态机
事件面(Event Surface)
服务对外暴露五个事件,全部是“私有Emitter+ 公有只读Event”的经典封装模式:
public readonly onSuspend: Event<void> public readonly onResume: Event<void> public readonly onLockScreen: Event<void> public readonly onUnlockScreen: Event<void> public readonly onPowerSourceChange: Event<PowerSource>调用方只拿到只读的Event(订阅/退订能力),无法直接 fire,保证事件只能由服务内部产生。
去重逻辑:两种不同的策略
初始化时,服务先用当前状态播种电源来源,保证第一次查询/订阅即正确:
this.powerSource = powerMonitor.onBatteryPower ? 'battery' : 'ac'随后注册六个底层监听器。其中suspend/resume 走状态机去重,因为 macOS 存在双触发问题(Electron 上游 issue #24803):
const onSuspend = () => { if (this.powerPhase === 'suspended') return // 已挂起,忽略重复触发 this.powerPhase = 'suspended' this._onSuspend.fire() } const onResume = () => { if (this.powerPhase === 'active') return this.powerPhase = 'active' this._onResume.fire() }核心思路是维护powerPhase: 'active' | 'suspended'内部状态,只有发生真实的“active → suspended”或“suspended → active”状态迁移时才对外触发一次事件,重复事件被内部状态直接吞掉。
lock/unlock 则直接透传——锁屏/解锁没有可去重的状态机,也不需要:
const onLockScreen = () => this._onLockScreen.fire() const onUnlockScreen = () => this._onUnlockScreen.fire()电源来源(AC/电池)同样按“变化才触发”去重:updatePowerSource(source)只有在来源真正变化时才更新内部状态并 fireonPowerSourceChange。所有底层powerMonitor监听器都通过registerDisposable注册,服务停止时统一removeListener。
测试 PowerService.test.ts 对上述行为做了精确断言:
- 连续两次
suspend事件只 fire 一次onSuspend;resume后又suspend会再次触发; - 未挂起时收到
resume不会触发onResume; lock-screen触发两次则onLockScreen收到两次(透传语义);- 初始化时
onBatteryPower=false则getPowerSource()返回'ac';重复on-battery不重复 fire。
关机屏障:串行执行、错误隔离、硬超时
系统关机时,应用往往还有未落盘的状态(消息、任务元数据、缓存)。PowerService提供registerShutdownHandler(fn)让各模块登记清理逻辑,返回的Disposable可随时注销,服务停止时也会清空全部处理器:
public registerShutdownHandler(handler: ShutdownHandler): Disposable { this.shutdownHandlers.push(handler) return { dispose: () => { const idx = this.shutdownHandlers.indexOf(handler) if (idx !== -1) this.shutdownHandlers.splice(idx, 1) } } }执行语义:串行 + 错误隔离 + 5 秒硬超时
executeShutdownHandlers()是屏障的核心(PowerService.ts):
- 串行执行:for 循环依次
await每个 handler; - 错误隔离:单个 handler 抛错只记录日志,不影响后续 handler 执行;
- 硬超时兜底:
Promise.race([run, timeout]),其中超时上限由常量SHUTDOWN_HANDLER_TIMEOUT_MS = 5000(5 秒)控制。一旦超时,记录警告并直接放行退出,一个卡死的 handler 永远无法阻止用户关机。
跨平台接入:macOS/Linux 与 Windows 两条路径
initShutdownBarrier()按平台分流:
macOS/Linux ——powerMonitor的shutdown事件 +preventDefault()
const shutdownListener = async (event?: Electron.Event) => { event?.preventDefault() try { await this.executeShutdownHandlers() } finally { application.quit() } } powerMonitor.on('shutdown', shutdownListener)注意一个实现细节:Electron 的类型定义中shutdown监听器签名是() => void(省略了事件参数),但运行时确实会传入带preventDefault的事件对象。源码将事件参数声明为可选,既保持与类型重载的兼容,又能在运行时调用preventDefault()推迟关机,为处理器争取执行窗口。
Windows ——@paymoapp/electron-shutdown-handler原生插件
Windows 路径更复杂,关键点在于:
- 插件钩住 Windows 关机消息(
WM_QUERYENDSESSION),需要一个原生窗口句柄(HWND)。服务刻意创建自己的隐藏窗口,而非复用主窗口——主窗口是单例,可能被销毁重建(HWND 会变化)、初始化时可能还不存在,复用会把手伸进主窗口生命周期,造成耦合; - 隐藏窗口采用
show: false+paintWhenInitiallyHidden: false+skipTaskbar: true的极简配置。由于不加载任何内容且禁止渲染器激活/绘制,Electron 不会为该窗口派生独立的渲染进程——这才是控制内存的关键杠杆(窗口尺寸并不影响内存,width/height: 0也只会被钳制到平台最小值); - 源码注释给出了实测边际成本:在窗口子系统已被主窗口初始化的前提下,约 0.7 MB RSS、零新增进程;
- 必须显式调用
blockShutdown(...)才能真正阻止 Windows 关机,否则插件只“观察”事件而不持有系统——这是该路径能成为真正屏障的关键,必须在监听器挂上之后再调用; - 收到关机信号后的流程为:
blockShutdown→ 执行处理器 →releaseShutdown()释放阻塞 →application.quit()。
ElectronShutdownHandler.setWindowHandle(shutdownHookWindow.getNativeWindowHandle()) ElectronShutdownHandler.on('shutdown', async () => { try { await this.executeShutdownHandlers() } finally { ElectronShutdownHandler.releaseShutdown() application.quit() } }) ElectronShutdownHandler.blockShutdown('Cherry Studio is finishing background work')关机走应用正常退出流程的意义
无论哪条平台路径,最终都调用application.quit()(而非裸app.quit())。这会维持应用内部的_isQuitting记账状态,因此操作系统发起的关机与用户主动退出走同一条before-quit流程——若存在活跃的Application.preventQuithold(例如正在进行数据迁移),同样会闸门 OS 关机,最终由 5 秒硬超时兜底,因为 OS 不可能被无限期阻塞。
测试覆盖了关键场景(PowerService.test.ts):shutdown 事件触发 preventDefault 并执行 handler 后 quit;某个 handler 抛错不影响其他 handler 与 quit;handler 永不 resolve 时 5 秒假时钟推进后强制 quit;Disposable注销后 handler 不再执行;Windows 路径验证setWindowHandle、blockShutdown、releaseShutdown与 quit 的完整调用链。
防睡眠机制:引用计数 holds + 偏好门控
preventSleep()是“让机器保持唤醒”的统一入口,语义与Application.preventQuit(reason)的 hold 惯用法对称——这是一个请求,而非硬保证:
public preventSleep(reason?: string): Disposable { const token = Symbol(reason ?? 'sleep-prevention') this.holds.set(token, { reason, since: Date.now() }) this.applyBlockerState() return { dispose: () => { if (this.holds.delete(token)) this.applyBlockerState() } } }为什么用 Map 而不是计数器
holds 是一张Map<symbol, { reason?: string; since: number }>,而非简单的数字计数。好处有二:
- dispose 幂等:
Map.delete对重复调用返回false,第二次 dispose 不会再次触发状态收敛,天然幂等; - 可枚举诊断:每个 hold 记录
reason与since(持有时间戳),一旦出现“机器始终不睡”的疑似泄漏,可以枚举出“谁在持有、从何时开始”。
单一幂等收敛点:applyBlockerState
OS 阻塞器的启停全部收敛到applyBlockerState()这一个幂等函数:
private applyBlockerState(): void { const shouldBlock = this.preventEnabled && this.holds.size > 0 try { if (shouldBlock && this.blockerId === null) { this.blockerId = powerSaveBlocker.start('prevent-app-suspension') } else if (!shouldBlock && this.blockerId !== null) { powerSaveBlocker.stop(this.blockerId) this.blockerId = null } } catch (err) { logger.warn('powerSaveBlocker state change failed; ...') } }- 激活条件 = 偏好开启 且 至少一个 hold(与门逻辑),二者缺一不可;
- 阻塞类型选
'prevent-app-suspension':保持系统运行但允许显示器休眠——正好契合后台任务(任务/下载)场景;'prevent-display-sleep'则会让显示器也常亮,不适用; - 永不抛出:任何
powerSaveBlocker失败都被记录并吞掉。这意味着preventSleep()永远返回可用的Disposable,调用方无需任何防御性 try/catch——优雅降级收敛在服务内部,而不是散落在每个调用点。失败后果是暂时性的:下一次 preventSleep/dispose/偏好变更会重新执行收敛并可能恢复; isPreventingSleep()返回“偏好开启 且 holds 非空”的合成状态,即当前是否真正在阻止睡眠。
测试对此有完整覆盖:偏好关闭时即使有 hold 也不 start;偏好开启后首个 hold 触发 start(参数确认为'prevent-app-suspension');多个 hold 只维护一个 blocker,最后一个释放才 stop;dispose 幂等;powerSaveBlocker.start抛错时 preventSleep 不抛且仍返回可用 hold;偏好中途关闭立即 stop blocker;偏好中途开启且已有 hold 则立即 start。
偏好门控:配置定义与设置项
偏好键app.power.prevent_sleep_when_busy定义在 scripts/data-classify/data/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)" }要点:类型boolean,默认false(即默认不干预系统睡眠,把控制权交给用户)。服务通过PreferenceService自读该键并在变更时订阅响应(subscribeChange),从而在“用户正在设置里切换开关”时也能即时启停 blocker。服务初始化时读取的偏好门控逻辑,与TrayService/ThemeService/ProxyService的自读模式一致。
对应的用户可见开关位于设置 → 通用(Settings → General),实现见 GeneralSettings.tsx(setting-general-prevent-sleep-when-busy设置行),通过usePreference('app.power.prevent_sleep_when_busy')与偏好存储双向绑定。
查询 API:电平触发,迟到订阅者也能对账
服务提供五个查询方法,全部直接透传powerMonitor或返回内部状态:
getPowerPhase(): PowerPhase // 'active' | 'suspended'(内部状态机) getPowerSource(): PowerSource // 'ac' | 'battery' | 'unknown'(内部状态) isOnBatteryPower(): boolean // 透传 powerMonitor.onBatteryPower getSystemIdleTime(): number // 透传 powerMonitor.getSystemIdleTime(),单位秒 getSystemIdleState(idleThresholdSec: number): SystemIdleState // 'active' | 'idle' | 'locked' | 'unknown'设计上它们是电平触发(level-triggered)而非边沿触发:查询返回的是“当前状态”,而不是“刚刚发生了什么”。一个迟到的订阅者无需亲历事件边沿,直接查询即可与当前系统状态对账。测试验证了 idle 查询参数正确透传(getSystemIdleState(60)→'idle')与isOnBatteryPower转发。源码注释还透露了后续用途:空闲查询将为 Job 系统的after-idle追赶策略解锁能力。
快速上手:三段式接入模板
以下完整代码直接取自原文档 Quick Start,是接入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()))三条使用建议:
- always 配对释放:
preventSleep的 hold 必须与工作代码块生命周期一致,惯用法是try/finally包裹,finally中dispose(); - 事件订阅用 registerDisposable:
onSuspend/onResume等订阅返回的Disposable应交给BaseService.registerDisposable,随宿主服务停止自动退订; - 无需防御性守卫:
preventSleep永不抛出、永远返回可用 Disposable,调用方不需要 try/catch 包裹获取过程。
第一个注册者:Job 系统如何持有防睡眠 hold
防睡眠是一个通用注册表:任何需要机器保持唤醒的 worker 都来注册一个 hold,而“是否允许”的闸门(用户偏好)正交地由本服务持有。Job 系统是第一个注册者。在 JobManager.ts 的任务执行体中可以看到实际调用:
const sleepHold = application.get('PowerService').preventSleep(`job:${row.type}:${row.id}`) try { const output = await handler.execute(ctx) ... } catch (err) { ... }实现细节值得留意:
- hold 的 reason 携带任务类型与 ID(
job:${row.type}:${row.id}),便于按任务诊断; - 该 hold 声明在任务 IIFE 作用域内,
finally分支统一 dispose——每次尝试(attempt)独立持有:重试之间任务处于delayed(非工作中)状态,不应持续阻止睡眠; - 注释明确:
preventSleep永不抛出、总是返回 Disposable(提供方内部降级),因此这里无需守卫代码。
这一注册时机也印证了原文档 Notes 中的规划:流式(streaming)与其他 worker 后续将通过同一 API 自注册。
工程要点与设计启示
原文档 Notes 部分集中了本服务最值得借鉴的工程决策,逐一展开:
- WhenReady 阶段直接使用系统 API。应用已就绪,
powerSaveBlocker/BrowserWindow直接可用,不需要app.whenReady()体操;偏好门控采用自读模式,与TrayService/ThemeService/ProxyService一致。 - 防睡眠是通用注册表。任何 worker 都可注册 hold;用户偏好这道闸门与本服务正交且归属本服务所有。Job 系统是首个注册者,流式与其他 worker 后续自注册。
preventSleep()尽力而为、永不抛出。永远返回可用Disposable;powerSaveBlocker的任何失败都被内部记录并吞掉。调用方因此无需在获取处做防御性 try/catch——优雅降级住在提供方,而不是散落在每个调用点。- OS 关机走应用正常退出流程。macOS/Linux 为
event.preventDefault()→application.quit();Windows 为blockShutdown→ handlers →releaseShutdown→application.quit()。因为退出经过before-quit,活跃的Application.preventQuithold(如数据迁移)会像拦截用户退出一样拦截 OS 关机——并以硬超时兜底,OS 不能被无限期阻塞。 - 用户开关位置:设置 → 通用;偏好定义在 contenteditable="false">【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs
项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考