资讯动态

Cherry Studio WindowManager 使用指南:窗口注册、打开与生命周期事件驱动的领域服务集成

发布时间:2026/9/13 10:32:25 来源:尧图企业网站定制
Cherry Studio WindowManager 使用指南窗口注册、打开与生命周期事件驱动的领域服务集成【免费下载链接】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本文是一份面向消费方consumer代码的 WindowManager 实战指南完整覆盖从「新增一个窗口类型」到「在主进程领域服务中注入窗口行为、在渲染进程中消费初始化数据」的完整链路。文中所有示例均可直接对照 Cherry Studio 仓库中 src/main/core/window 目录下的真实实现与测试验证读完你将掌握open()/close()消费方 API 与onWindowCreatedByType事件钩子的正确用法理解绝不直接内联挂监听器的设计约束并能用useWindowInitData无闪烁地驱动任意受管窗口的渲染层。快速开始四个步骤接入一个新窗口类型在 Cherry Studio 中一个受 WindowManager 管理的窗口需要依次完成类型声明、注册表登记、打开调用与行为注入四步。下面以自定义的MyWindow为例走通全流程。1. 在WindowType枚举中新增类型值打开 src/main/core/window/types.ts向枚举追加成员。当前仓库已登记的类型包括Main、Print、QuickAssistant、SubWindow、SelectionToolbar、SelectionAction、McpBrowser、Screenshot等新类型直接在末尾追加即可export enum WindowType { Main main, // ... existing types MyWindow myWindow, // -- add your new type }类型值同时会进入VALID_WINDOW_TYPEStypes.ts中由Object.values(WindowType)派生用于运行时校验因此枚举是唯一事实来源改动后会自动同步。2. 在窗口注册表中登记元数据在 src/main/core/window/windowRegistry.ts 中向WINDOW_TYPE_REGISTRY写入该类型对应的WindowTypeMetadata。元数据是一个以lifecycle为判别字段的联合类型见 types.tsdefault/singleton/pooled三种模式各自拥有专属配置字段poolConfig/singletonConfig。WINDOW_TYPE_REGISTRY[WindowType.MyWindow] { type: WindowType.MyWindow, lifecycle: singleton, htmlPath: windows/myWindow/index.html, // preload omitted → defaults to preload.js // showMode omitted → defaults to auto windowOptions: { ...DEFAULT_WINDOW_CONFIG, width: 800, height: 600, minWidth: 600, minHeight: 400, }, }关键字段说明依据 types.ts 的WindowTypeMetadataBase字段默认值语义htmlPath必填HTML 文件相对 renderer 根目录的路径设为表示消费者加载窗口见下文Consumer-loaded windowspreloadpreload.jspreload 脚本文件名位于src/preload/设为表示无 preload适用于nodeIntegration: true的窗口其余值会被 WM 自动拼接../preload/前缀showModeautoautoWM 管理显隐——新建时隐藏、ready-to-show后显示复用路径则立即显示immediate构造即显示manual消费者自行控制显隐WM 在任何路径都不会调用show()rememberBoundsfalse仅 singleton 有效跨启动持久化并恢复窗口位置/尺寸恢复到上次所在显示器在非 singleton 类型上声明会被忽略并给出 dev 警告behavior—声明式行为层hideOnBlur、alwaysOnTop、visibleOnAllWorkspaces、macShowInDockquirks—平台特定 OS hackmacOS 焦点恢复、置顶重应用等仓库中DEFAULT_WINDOW_CONFIGwindowRegistry.ts为1100×720、autoHideMenuBar: true、nodeIntegration: falsecontextIsolation: true是所有窗口的基准配置。mergeWindowOptions()同文件 L652-L681负责合并优先级注册表windowOptions→ 注册表platformOverrides→ 调用方options→ 调用方platformOverrides且webPreferences按同样顺序深合并最终剥离platformOverrides字段后再交给new BrowserWindow(...)。3. 通过application.get(WindowManager)打开窗口import { application } from application import { WindowType } from main/core/window/types const wm application.get(WindowManager) // open() is lifecycle-aware — handles singleton reuse, pool recycle, etc. const windowId wm.open(WindowType.MyWindow)open()是消费方唯一的取窗入口它会依据注册表中的lifecycle自动决定是新建、复用 singleton 还是从池中回收调用方不需要也不应该感知这些差异。4. 用onWindowCreatedByType注入领域行为// In your domain services onInit(): const wm application.get(WindowManager) wm.onWindowCreatedByType(WindowType.MyWindow, ({ window, id }) { // Store the windowId for later use this.myWindowId id // Attach event listeners BEFORE content loads window.on(closed, () { this.myWindowId undefined }) })上面的示例使用了解构写法。当回调体较长或需要频繁访问多个字段时也可以使用mw简写ManagedWindow的首字母缩写wm.onWindowCreatedByType(WindowType.MyWindow, (mw) { this.myWindowId mw.id mw.window.on(closed, () { this.myWindowId undefined }) })两种写法都合法选择依据见下文回调风格小节。领域服务集成onWindowCreated是规范化钩子onWindowCreated事件是领域服务注入窗口专属行为的规范化canonical挂载点它与wm.open()/wm.close()构成消费方的通用 API 对。对于最常见的只关心单一窗口类型的订阅场景应优先使用带类型过滤的便捷变体onWindowCreatedByType/onWindowDestroyedByType——它们替你完成了类型过滤回调体无需再以if (managed.type ! X) return开头通用的onWindowCreated/onWindowDestroyed留给需要观察所有窗口的少数场景。完整模式一个典型的受管窗口服务Injectable(MyWindowService) ServicePhase(Phase.WhenReady) export class MyWindowService extends BaseService { private myWindowId: string | undefined protected override onInit(): void { const wm application.get(WindowManager) wm.onWindowCreatedByType(WindowType.MyWindow, ({ window, id }) { // 1. Store the windowId this.myWindowId id // 2. Attach listeners BEFORE content loads window.once(ready-to-show, () { this.sendInitialData(window) }) window.on(closed, () { this.myWindowId undefined }) }) wm.onWindowDestroyedByType(WindowType.MyWindow, () { this.myWindowId undefined }) } }注意onInit()中同时订阅了创建与销毁两个事件前者负责注入行为、记录 id后者负责清理本地状态使其与 WindowManager 内部的closed追踪保持同步。onWindowCreated免费提供的能力从 WindowManager.ts 的createWindow()实现5 步执行序见 window-manager-overview.md可以确认该事件契约的四个核心保证每个全新BrowserWindow恰好触发一次。Singleton 重开与池回收不会重复触发——因此挂在这里的监听器永远不会累积重复open()无论走哪条复用路径都是安全的。这一点由 WindowManager.test.ts 中onWindowCreated fires only on fresh creation一类的用例直接验证例如recycles idle window without firing Reused相关分组。一次订阅覆盖所有open()调用点。主路径、崩溃恢复、测试夹具以及未来新增的任何入口都会流经同一个事件不存在忘记给新路径接线的可能。触发先于loadURL。因此setFocusableLinux Wayland、setContentProtection、webContentssession 配置等首次绘制前的设置都能及时生效。依据createWindow()的执行序new BrowserWindow→setupWindowListeners()→windows.set()→_onWindowCreated.fire()→loadWindowContent()见 WindowManager.ts 中createWindow步骤注释。对池化窗口同样适用。resized、closed等按实例挂载的监听器必须在这里挂——回收路径不会重发该事件若在open()调用点挂载要么错过被回收的实例要么在重开时累积重复。仓库注册表中有大量实践佐证这一模式例如SelectionToolbar的焦点策略是运行时在onWindowCreated回调里调用setFocusable(isLinuxWaylandDisplay)见 windowRegistry.ts 中 SelectionToolbar 条目注释因为 Wayland 检测只有在原生模块加载后才可用SubWindow的backgroundThrottling: false也是为流式 LLM 响应与 WebSocket 心跳服务的显式声明。反模式在open()调用点按 ID 直接挂监听器拿到 id 后顺势内联挂监听器看起来更简洁const id wm.open(WindowType.MyWindow) const window wm.getWindow(id)! window.on(blur, this.hideIfUnpinned) window.once(closed, () { this.windowId null })但这一写法隐藏着三个代价迫使你脱离open()。若窗口被复用singleton 重开或池回收这些监听器会在已挂载它们的窗口上第二次挂载。要让它安全就得改用create()——而它是内部原语而非消费方 API见下文API 分层。多个入口路径静默解耦。崩溃恢复、测试夹具、未来任何新增的open()调用点都需要各自记得执行设置逻辑一个onWindowCreated订阅即可在一处覆盖全部。与注册表配置隐式耦合。如果监听器安全性依赖某个具体的showMode/paintWhenInitiallyHidden等取值例如仅当showMode: manual时才成立的 pre-showsetFocusable时机后续注册表改动会在无任何编译期信号的情况下破坏正确性。若确实被这种写法吸引请改用onWindowCreatedByType(type, listener)——只多一行三个代价全部消失。回调风格解构 vsmw简写onWindowCreatedByType/onWindowDestroyedByType的监听器收到的是ManagedWindow结构见 types.tsid、type、window、metadata、createdAt与通用变体完全一致。两种惯用访问方式解构推荐默认短回调wm.onWindowCreatedByType(WindowType.MyWindow, ({ window, id }) { this.myWindowId id window.on(closed, () { this.myWindowId undefined }) })只解出需要的字段即可——{ window }、{ window, id }、{ window, id, metadata }。自文档化且避免了mw.window.on(...)的视觉噪音。mw简写含内部闭包或多次访问的回调wm.onWindowCreatedByType(WindowType.SelectionAction, (mw) { // Inner closure reads mw.windows methods repeatedly — keeping the whole // record under one short name reads better than re-destructuring. mw.window.on(resized, () { if (mw.window.isDestroyed()) return this.saveBounds(mw.id, mw.window.getBounds()) }) })mw是ManagedWindow的缩写——简短、具体且不会像参数命名为window那样与.window字段冲突。选哪种以可读性为准。跨文件甚至同一服务内混用都完全没问题——参数名是唯一的区别。窗口 API 分层消费方 vs 内部WindowManager 暴露四个生命周期方法分为两层完整签名见 window-manager-api-reference.md层方法语义何时调用消费方open(type, args?)生命周期感知按注册表lifecycle决定新建、singleton 复用或池回收任何时候获取窗口消费方close(windowId)生命周期感知非池化窗口销毁池化窗口释放回池任何时候释放窗口内部create(type, args?)强制全新创建singleton 已存在时抛错防御性断言——消费方代码不应需要内部destroy(windowId)强制销毁绕过池回收消费方代码不需要见下文消费方代码只应调用open()和close()。注册表中的lifecycle声明是这两个方法行为的唯一事实来源调用点无需按窗口类型分支。为什么create()不是消费方 API。每个想用create()的常见动机都有更干净的open()方案动机解法我只想在全新窗口上执行设置订阅onWindowCreatedByType——它只在新建时触发复用时绝不触发我要确保不存在重复的 singleton注册表lifecycle: singleton已保证这一点open()返回既有实例我服务里的本地windowId必须与 WindowManager 一致订阅onWindowDestroyedByType清理本地状态与 WM 的closed追踪同步为什么destroy()不是消费方 API。对非池化窗口default 与 singletonclose()最终都会落到同一个destroyWindow()调用两者行为无差别对池化窗口destroy()会绕过池——这几乎从不是消费方真正想要的。停掉整个池的正确 API 是suspendPool(type)它销毁空闲窗口并阻止继续回收同时不触碰使用中的窗口。相关语义在 window-manager-warmup-mechanics.md 有完整说明测试覆盖见 WindowManager.test.ts 的suspend / resume分组如open()during suspension creates non-pooled windows。例外情形关闭策略写在close监听器里的窗口。因为两条路径最终都走window.destroy()wm.close()不会触发原生的close事件。如果一个窗口在close监听器中自行决定关闭行为——隐藏到托盘、退出应用、未保存更改提示——它会被wm.close()静默绕过。这类窗口必须由持有它的服务负责关闭对已持有的BrowserWindow调用win.close()并把它暴露为方法例如MainWindowService.requestClose(windowId)window.closeIPC 路由会先咨询它再回退到wm.close()见 src/main/ipc/handlers/window.ts。追踪不受影响WM 的closed监听器仍会触发onWindowDestroyed并完成清理singleton 的 bounds 持久化监听器也在close时运行。Consumer-loaded 窗口htmlPath: 注册表条目若将htmlPath设为则该窗口为消费者加载WM 负责完成窗口的接线preload、行为、bounds、生命周期但不加载任何内容——由领域服务在open()之后自行加载。典型场景是隐藏的一次性表面渲染生成内容打印 / PDF、离屏渲染。仓库中的Print与McpBrowser正是这种模式见 windowRegistry.ts 中的 Print / McpBrowser 条目htmlPath: preload: showMode: manual。const id wm.open(WindowType.MyPrintSurface) // WM wires; loads nothing const win wm.getWindow(id) // the sanctioned handle to load into await win?.webContents.loadURL(generatedHtmlDataUrl) // consumer owns content show close() // ... await did-finish-load, e.g. webContents.printToPDF(), then wm.close(id)getWindow(id)是消费方只调用open()/close()原则的唯一例外——仅用于webContents加载负载编码是消费方的事。主进程发起的loadURL/loadFile不受 WM 导航守卫拦截那些守卫只拦截渲染进程发起的导航。createWindow()的 5 步执行序中htmlPath为空时会跳过内容加载这一步见 window-manager-overview.md 的 Guarantees 一节测试用例skips content loading when htmlPath is empty (consumer-loaded window)亦覆盖此行为。领域键到 WindowId 的映射对于以领域数据为键的窗口类型例如某个话题专属窗口领域服务自行维护映射表并结合 initData 完成复用或新建的决策// Domain service tracks which topic is shown in which window private topicWindows new Mapstring, string() // topicId - windowId wm.onWindowCreatedByType(WindowType.TopicView, ({ id }) { const topicId wm.getInitData(id) as string this.topicWindows.set(topicId, id) }) // Open a topic — reuse existing or create new openTopic(topicId: string): void { const existingId this.topicWindows.get(topicId) if (existingId) { wm.show(existingId) wm.focus(existingId) return } const windowId wm.open(WindowType.TopicView, { initData: topicId }) }这里的关键是open(type, { initData })会把载荷原子地写入 init-data 存储在方法返回前完成从而保证渲染进程后续的getInitData调用总能读到最新值若走复用路径池回收 / singleton 重开同一载荷还会作为window.reusedIpcApi 事件的 payload 推送到渲染进程详见 window-manager-api-reference.md 的 Timing contract。渲染进程useWindowInitData钩子src/renderer/hooks/useWindowInitData.ts 提供了任何受管窗口消费其 init data 的规范入口统一处理两条创建路径import { useWindowInitData } from renderer/hooks/useWindowInitData const MyWindowApp: FC () { const data useWindowInitDataMyInitData() if (!data) return null return ControlledContent data{data} / }实现要点对照 useWindowInitData.ts 源码冷启动路径mount 时拉取窗口首次挂载时singleton 首次打开、池化窗口新建、default 创建或任何create()路径主进程已在返回窗口之前把 initData 同步写入存储钩子在 mount 时通过ipcApi.request(window.get_init_data)拉取一次。之所以冷启动必须用 PULLwebContents.send是 fire-and-forget 的不会缓冲渲染进程注册监听器之前发送的消息——这正是全新窗口无法用 PUSH 的原因。复用路径推送零往返窗口被复用池回收 / singleton 重开且调用方提供了新的 initData 时主进程通过window.reusedIpcApi 事件推送载荷钩子经useIpcOn(window.reused, ...)直接更新 state无 IPC 往返。由于事件只在复用且提供了 initData时触发不存在空 Reused事件钩子永远不需要 fallback 到第二次请求。每个会话的状态重置应放在子组件内用useEffect([data.someStableId], …)驱动——这样 DOM 在回收过程中保持连续。切勿用key{resetKey}强制重挂载那会重新引入本契约设计要消除的闪烁。这正是 useWindowInitData.ts 头部注释中DO NOT key{…}警示的含义。该钩子已被仓库内多个窗口复用例如useMainWindowNavigation.ts中以useWindowInitDataMainWindowInitData()消费主窗口初始化数据见 src/renderer/hooks/tab/useMainWindowNavigation.ts渲染层测试见 useMainWindowNavigation.test.tsx 的 mock 用法。复用路径的细节契约关于冷启动 vs 复用的完整时序契约见 window-manager-api-reference.md 的 Init Data 一节此处提炼要点冷启动全新创建createWindow在返回前同步写入 initData因此渲染进程React 挂载后发起的任何getInitData调用都能读到新值。复用池回收 / singleton 重开open()同时写入存储并以相同载荷发送window.reuseduseWindowInitData直接从事件载荷更新 state零往返。复用但未提供 initData事件不触发——钩子因此永远不需要 fallback 请求。实时更新窗口已打开主进程任意服务可调用pushInitData/pushInitDataToType两条路径复用window.reused事件useWindowInitData原地接收新载荷、不重挂载——适合免close()open()闪烁地切换可见窗口上下文。与复用路径不同它们禁止undefined载荷推送空没有语义。池化窗口的数据边界池化复用open()未带 initData 时该窗口先前存储的 initData 会被清除池是多消费者陈旧载荷泄漏是隐患singleton 隐藏→显示复用未带新 initData 时存储条目被保留singleton 是单消费者仍是同一会话意味着渲染进程可能合法地要回上次载荷例如隐藏期间 devtools 重载后的window.get_init_data。事件仍只在传入新 initData 时触发。补充阅读与源码索引架构背景与事件时序契约window-manager-overview.md完整方法签名表window-manager-api-reference.md池化 / singleton 预热状态机、GC 定时器、suspend/resumewindow-manager-warmup-mechanics.md平台配置与 OS quirkswindow-manager-platform.md直接BrowserWindow用法的迁移指南window-manager-migration-guide.md核心实现src/main/core/window/WindowManager.ts、src/main/core/window/types.ts、src/main/core/window/windowRegistry.ts测试覆盖src/main/core/window/tests/WindowManager.test.tsdefault / singleton / pooled 三生命周期、warmup、suspend、standby 等全量用例渲染钩子src/renderer/hooks/useWindowInitData.ts渲染进程 IPC 表面src/main/ipc/handlers/window.ts 与 src/shared/ipc/schemas/window.ts【免费下载链接】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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价