feat: improve windows (#110)

* feat(desktop): improve Windows native experience

* feat(desktop): improve Windows attention indicators

* docs: simplify Windows changesets

* fix(desktop): address Windows lifecycle edge cases

* fix(desktop): address Windows review feedback

* fix(desktop): preserve Windows launch and shutdown state

* fix(desktop): complete open-in app catalog

* fix(desktop): queue early second-instance launches

* fix(desktop): avoid flashing restored attention
This commit is contained in:
liruifengv 2026-07-27 13:03:11 +08:00 committed by GitHub
parent 41970aa227
commit c13cef5811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2067 additions and 106 deletions

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复 Windows 重复启动应用时打开多个实例的问题。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
Windows 任务栏右键菜单支持新建会话和打开最近工作区。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
「用其他应用打开」支持 Windows。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复 Windows 下的通知功能。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
优化 Windows 托盘功能。

View file

@ -28,7 +28,7 @@
## 目录地图
- `apps/desktop`Electron 壳(`kimi-code-app`)。`src/main/index.ts` 主进程入口(只做引导:先装 `log.ts` 的文件日志 + 崩溃守卫——写 `~/.kimi-code/logs/kimi-code-desktop.log`——再动态加载 `app.ts`;编排在 `app.ts`:窗口 `window.ts`、菜单 `menu.ts`(含 App 菜单「设置…」项accelerator 跟随 renderer 经 `kimi:menu-shortcut` 推送的用户绑定;编辑菜单不用 editMenu 角色而是手工拼装——原生 Select All 角色的加速键会先于 renderer 截获按键并整页选中,自定义「全选」项保留加速键、经 `kimi:menu-action` 转发 renderer 的作用域全选)、快捷键 `shortcuts.ts`、系统托盘 `tray.ts`、IPC `ipc.ts` + channel 常量 `ipc-channels.ts`、server 连接 `connect.ts`、启动失败页 `screens.ts`、自动更新 `updater.ts`electron-updater generic feed 轮询 `https://code.kimi.com/kimi-code/desktop/` 的 latest*.yml状态经 `kimi:update-status` 推给 renderer 的 UpdateIndicator——侧栏 header 最右端黄 pill + §09 规范弹窗dev 未打包时整体 no-opdev 下手动把 Dock 图标设为 `build/icon.png`,打包态由 electron-builder 处理);`src/main/server.ts` 内嵌 server`startDesktopServer`:回环 + 临时端口 + 注册进 `<home>/server/instances/` 实例表;`server_version` 传 tsdown 注入的 `__KIMI_CORE_VERSION__`,即 submodule CLI 版本,见 `apps/desktop/scripts/kimi-core-version.mjs``src/main/connect-target.ts` 外部 server 模式解析(`KIMI_SERVER_URL`,纯函数);`src/main/protocol.ts` `app://renderer` 协议映射(带 `..` 越界防护)。`pnpm dev``scripts/dev.mjs`:起 renderer 的 Vite dev server默认 `http://127.0.0.1:5174`)并把实际端口经 `KIMI_RENDERER_DEV_URL` 传给主进程,`connect.ts` 据此加载 dev serverrenderer HMR并把该 origin 加进内嵌 server 的 CORS 白名单;主进程改动需重启 dev。主进程测试在 `tests/main/`renderer 测试在 `tests/renderer/`DOM composable 等;早期用例仍有与源码同目录的,新测试一律进 `tests/renderer/`)。自定义键盘快捷键为 desktop-only`src/renderer/lib/keymap.ts`action 注册表 + 绑定原语)+ `src/renderer/composables/useShortcuts.ts`localStorage 覆盖表 `kimi-web.shortcut-overrides`+ `App.vue` 全局 dispatcher + 设置页 `components/settings/ShortcutsPanel.vue`web 保持硬编码键位,分叉清单见 `apps/desktop/docs/native-todos.md`。细则见 `apps/desktop/README.md`
- `apps/desktop`Electron 壳(`kimi-code-app`)。`src/main/index.ts` 主进程入口(只做引导:先装 `log.ts` 的文件日志 + 崩溃守卫——写 `~/.kimi-code/logs/kimi-code-desktop.log`——再动态加载 `app.ts`;编排在 `app.ts`:窗口 `window.ts`、菜单 `menu.ts`(含 App 菜单「设置…」项accelerator 跟随 renderer 经 `kimi:menu-shortcut` 推送的用户绑定;编辑菜单不用 editMenu 角色而是手工拼装——原生 Select All 角色的加速键会先于 renderer 截获按键并整页选中,自定义「全选」项保留加速键、经 `kimi:menu-action` 转发 renderer 的作用域全选)、快捷键 `shortcuts.ts`、系统托盘 `tray.ts`Windows 侧左键显示窗口,待办的任务栏角标/闪动在 `taskbar.ts`、Windows Jump List `jump-list.ts`(工作区条目推送 + `--workspace`/`--new-chat` argv 解析路由)、IPC `ipc.ts` + channel 常量 `ipc-channels.ts`、server 连接 `connect.ts`、启动失败页 `screens.ts`、自动更新 `updater.ts`electron-updater generic feed 轮询 `https://code.kimi.com/kimi-code/desktop/` 的 latest*.yml状态经 `kimi:update-status` 推给 renderer 的 UpdateIndicator——侧栏 header 最右端黄 pill + §09 规范弹窗dev 未打包时整体 no-opdev 下手动把 Dock 图标设为 `build/icon.png`,打包态由 electron-builder 处理);`src/main/server.ts` 内嵌 server`startDesktopServer`:回环 + 临时端口 + 注册进 `<home>/server/instances/` 实例表;`server_version` 传 tsdown 注入的 `__KIMI_CORE_VERSION__`,即 submodule CLI 版本,见 `apps/desktop/scripts/kimi-core-version.mjs``src/main/connect-target.ts` 外部 server 模式解析(`KIMI_SERVER_URL`,纯函数);`src/main/protocol.ts` `app://renderer` 协议映射(带 `..` 越界防护)。`pnpm dev``scripts/dev.mjs`:起 renderer 的 Vite dev server默认 `http://127.0.0.1:5174`)并把实际端口经 `KIMI_RENDERER_DEV_URL` 传给主进程,`connect.ts` 据此加载 dev serverrenderer HMR并把该 origin 加进内嵌 server 的 CORS 白名单;主进程改动需重启 dev。主进程测试在 `tests/main/`renderer 测试在 `tests/renderer/`DOM composable 等;早期用例仍有与源码同目录的,新测试一律进 `tests/renderer/`)。自定义键盘快捷键为 desktop-only`src/renderer/lib/keymap.ts`action 注册表 + 绑定原语)+ `src/renderer/composables/useShortcuts.ts`localStorage 覆盖表 `kimi-web.shortcut-overrides`+ `App.vue` 全局 dispatcher + 设置页 `components/settings/ShortcutsPanel.vue`web 保持硬编码键位,分叉清单见 `apps/desktop/docs/native-todos.md`。细则见 `apps/desktop/README.md`
- `apps/web`:浏览器 Web UI`kimi-code-web`Vue 3 + Vite + vue-i18n。dev 时 Vite 把 `/api/v1`REST + WS代理到 `KIMI_SERVER_URL`(默认 `http://127.0.0.1:58627`)。
- `packages/*``@moonshot-ai/{web-core,web-i18n,web-markdown,web-ui}` + `vite-preset`exports→src被 apps/web 与 desktop renderer 复用);`web-ui/src/assets/fonts` 保存字体许可证与本地生成gitignored的两端共用字体产物install 的 root postinstall 以及 dev/build 前会由 `scripts/prepare-fonts.mjs` 下载、校验并转换,再由 Vite 打入最终产物。
- `kimi-code/`git submodule核心仓`kimi-code/packages/*` 提供 `kap-server``agent-core-v2``kimi-code-sdk` 等源码。

View file

@ -35,8 +35,13 @@ Electron 桌面客户端(产品名 **Kimi Code**workspace 包 `kimi-code-ap
- `src/main/window.ts` — 窗口创建、window-state 持久化、`sendToRenderer()`
- `src/main/menu.ts` / `shortcuts.ts` / `screens.ts` — 原生菜单、全局快捷键、启动失败页。
- `src/main/tray.ts` — 系统托盘macOS 菜单栏 / Windows 通知区):图标、上下文菜单、待处理
badge菜单栏计数 + 托盘菜单按会话跳转);主进程原生界面文案的 en/zh 字符串表与
`kimi:locale` 语言同步也在这里。
badgemacOS 菜单栏计数 + 托盘菜单按会话跳转Windows 的任务栏角标/闪动在 `taskbar.ts`
Windows 左键 = 显示主窗口);主进程原生界面文案的 en/zh 字符串表与 `kimi:locale` 语言
同步也在这里。macOS 与 Windows 均为关窗 = 隐藏驻留(`window.ts` `shouldHideOnClose`
托盘「退出」为显式退出入口;打包版启动有单实例锁,二次启动聚焦已有窗口并路由其 argv。
- `src/main/jump-list.ts` — Windows Jump List任务栏右键「新建会话」task + renderer
推送的最近工作区(`kimi:jump-list`),条目共用 `--new-chat` / `--workspace="<root>"`
argv`parseLaunchArgs`),经 `window.ts` 的 renderer 就绪队列下发 `kimi:launch-action`
- `src/main/ipc.ts` / `ipc-channels.ts` — IPC handler 注册、channel 常量与 payload 类型。
- `src/main/server.ts``startDesktopServer`:进程内起 server写入 CORS allowlist`server_version`
经 tsdown 注入的 `__KIMI_CORE_VERSION__``scripts/kimi-core-version.mjs` 读 submodule 的 CLI 版本)

View file

@ -38,7 +38,7 @@
- 测试:`tests/main/open-in.test.ts`13 用例平台门控、检测、argv 构造、失败回传);`tests/main/preload.test.ts` 白名单;`src/renderer/lib/nativeOpenIn.test.ts`16 用例:桥探测、列表过滤、打开回传、默认目标持久化、快捷解析优先级)。
- 遗留:打开失败暂无 UI 反馈(静默);「打开文件 / 在 Finder 显示」FilePreview 的 `openWorkspaceFile`/`revealWorkspaceFile`)仍走 daemon REST未原生化。
- 图标:彩色官方图标从本机 app bundle 的 `.icns` 提取为 128px PNG`src/renderer/assets/app-icons/`11 个),经 `lib/nativeOpenIn.ts``openInAppIcon(id)` 映射;菜单项与快捷按钮用 `<img>` 渲染nominative use设置页 Select 靠 web-ui `Select` 新增的 `option.icon` 字段可选、向后兼容web 同步受益)。
- 已知限制:第一版仅 macOSWindows/Linux 返回空目录并隐藏入口。
- 已知限制:仅 macOS / Windows2026-07 已补 Windows 侧VS Code / VS Code Insiders / Cursor / Zed 走 LOCALAPPDATA/ProgramFiles 固定路径探测File Explorer 恒有Terminal 优先 `wt.exe`、不可用时回退 Windows PowerShellGit Bash 走 Git for Windows 的 LOCALAPPDATA/ProgramFiles 路径探测;启动全部 detached spawn——explorer.exe 成功也常返回非零退出码,不看 exit code图标为从 exe / 安装包资源提取的官方图标);Linux 返回空目录并隐藏入口。
- [x] **文件导出走保存对话框**已完成desktop 专属)
- 实现:新增 `src/main/downloads.ts`——主进程 `will-download` 统一接管所有下载(会话导出 zip、trace 日志、未来任何下载),`dialog.showSaveDialogSync` 弹系统保存框(预选"上次目录 + 建议文件名",首次 `~/Downloads`),确认才 `setSavePath` 落盘、取消 `item.cancel()` 不落盘;`WeakSet` 防窗口重建重复注册renderer 零改动、零 web 分叉,未走 `kimi:dialog-save` IPC
@ -67,9 +67,11 @@
- **web 刻意不改**:浏览器无 globalShortcut`summonApp``SHORTCUT_ACTIONS` 中 scope 为 global 但不进 `App.vue` 的 renderer dispatcher主进程直接处理renderer 无需消费)。
- 测试:`tests/main/shortcuts.test.ts`14 用例:推送前不注册/推送注册/回调 showMainWindow/重绑/清空/注册失败/重绑失败保旧值(含返回值断言)/重绑失败后挂起循环恢复 committed/deferred 失败回落 committed 并返回 false/恢复回执 true+false/挂起中取消分配/挂起-恢复/相同绑定 no-op/卸载全清);`tests/main/preload.test.ts` 白名单 + 两全局快捷键通道 invoke 回执校验;`tests/renderer/keymap.test.ts` 补 OS-global 跨 scope 双向查重、`isAcceleratorExpressible`(单引号拒绝)、`isAltGrShapedBinding`平台分流、OS-global 默认值可转换+带修饰键 sanity`tests/renderer/useShortcuts.test.ts`3 用例:拒绝置位/成功清除/重绑推送/无桥静默)。
- [ ] **最近工作区接入 OS**
- 现状:全靠 localStorage + server `recentRoots`
- 做法:`app.addRecentDocument`macOS dock 最近文档)/ Windows Jump List。
- [x] **最近工作区接入 OS**Windows 侧已完成desktop 专属macOS `app.addRecentDocument` 仍待做)
- 实现Windows Jump List2026-07主进程 `src/main/jump-list.ts`——renderer 经新 IPC `kimi:jump-list` 推送最近工作区name + root上限 9 条,`asJumpListWorkspaces` 结构校验),主进程 `app.setJumpList` 建 custom「最近/Recent」+ tasks「新建会话/New Chat」两段双语跟随 `kimi:locale` 推送OS 语言兜底);条目为 `type: 'task'` + `program: process.execPath` + `args: --workspace="<root>"` / `--new-chat`**不用 `type: 'file'`**——目录没有文件关联点击行为不可靠root 带空格必须引号包裹。argv 解析 `parseLaunchArgs`(兼容引号/非引号、剥空值),两个入口:首实例 `process.argv``second-instance` 的 argv配合单实例锁统一 `forwardLaunchArgs``window.ts` `sendLaunchAction`——与托盘会话跳转共用同一个 renderer 就绪队列(`did-finish-load` 落定冲刷),新 renderer event channel `kimi:launch-action`。renderer 侧 `composables/useJumpList.ts`desktop-only无桥 no-op照 useTrayAttention 模式,`initialized` 门控首推(不抹掉加载窗口的旧菜单)、结构去重防重复推;`App.vue` 订阅 launch actiondesktop 分叉块new-chat → `handleCreateSession()`open-workspace → `openWorkspaceByRoot`(已注册则选中,未注册走 `addWorkspace` 标准流程并选中)。`second-instance` 回调在 app.ts单实例锁的配套
- **web 无对应物**:浏览器没有 Jump List`useJumpList.ts` 不同步 apps/web同 useTrayAttention 先例);`App.vue` 分叉块import + useJumpList 调用 + openWorkspaceByRoot整目录 re-copy 时需保留。
- 测试:`tests/main/jump-list.test.ts`parseLaunchArgs 各形态/引号/空值、payload 校验与截断、分类模板双语);`tests/renderer/useJumpList.test.ts`(去重、门控、截断、路由、无桥);`tests/main/preload.test.ts` 白名单 + 两通道校验。
- 验证注意Jump List 只对打包版的 exe 路径生效dev 下 `process.execPath` 是 electron 二进制dev 可用命令行 `--workspace=...` 验证 argv 路由。
- [ ] **server token 改走 IPC**
- 现状:`renderer/lib/serverAuth.ts` 从 URL `#token=` hash 读 token 再镜像 localStorage7 天 TTL
@ -129,6 +131,17 @@
- TODO代码内 `TODO(help-menu)` 注释同步What's Newchangelog、Send Feedback、Start Performance Trace性能录制三项后续再加。
- 测试:`tests/main/menu.test.ts` +1 用例Help 为最末菜单、两项双语 label、已接线 vs File 展示项)。
- [x] **Windows 关窗驻留托盘 + 单实例锁**已完成desktop 专属)
- 实现2026-07`window.ts` `shouldHideOnClose` 平台门控从仅 darwin 扩到 darwin/win32——Windows 点 X 也改为隐藏窗口(内嵌 server / WS / 托盘全保活),真退出走托盘「退出」/ 更新器安装(`before-quit` / `markQuitting` 既有闭环不变macOS 的全屏先退再藏分支 Windows 同样适用。`app.ts``app.requestSingleInstanceLock()`(拿不到锁直接 quit不起 server 不建窗dev 与打包版 userData 目录不同、锁互不影响,同机调试能力保留)+ `second-instance``showMainWindow()`(并路由 argv见 Jump List 条目)。窗口位置恢复新增 `clampBoundsToWorkArea`:存档 bounds 不在当前任何显示器 workArea 内时 clamp 回边缘(至少 100px 可见、标题栏不出顶边),修拔外接屏后窗口恢复到屏幕外的问题。`window-all-closed` 非 darwin `app.quit()` 保留为真销毁兜底。
- **web 无对应物**`shouldHideOnClose` / `clampBoundsToWorkArea` 为纯函数。
- 测试:`tests/main/window.test.ts`win32 hide-on-close 两态、linux 仍销毁clamp 各方向/副屏 origin/无坐标)。
- 验证注意Windows 用户旧习惯「点 X = 退出」改变,托盘菜单「退出」是唯一显式退出入口(更新器安装不受影响)。
- [x] **Windows 托盘交互 + 任务栏待办角标/闪动**已完成desktop 专属)
- 实现2026-07`tray.ts` win32 左键/双击改为 `showMainWindow()`(右键菜单是 `setContextMenu` 后的系统默认行为,旧行为是左键弹菜单,反 Windows 惯例)。待处理提醒新增 Windows 落点 `src/main/taskbar.ts``tray.ts setTrayAttention` 同步调 `setTaskbarAttention(total, summary)`——`win.setOverlayIcon` 数字角标(运行时 `nativeImage.createFromBitmap` 生成紧凑红色圆/胶囊 + 3×5 白色点阵数字199 显示数字、超过 99 显示 `99+`,带 1x/2x representation无资产文件、不动 extraResources生成失败降级为只闪动+ total **增大**且窗口未聚焦时 `flashFrame(true)`0→N 的启动恢复不闪——窗口聚焦;未变/变小不闪focus / 归零停闪。控制器按窗口实例缓存、角标按显示文本缓存窗口重建后重挂。macOS 仍走菜单栏计数 + Dock badge`setTaskbarAttention` 非 win32 恒 no-op。
- **web 无对应物**(主进程-only 改动renderer 零改动)。
- 测试:`tests/main/taskbar.test.ts`badgePixels 像素格式、overlay 设置/清除、闪动触发条件全集、缺资产降级、窗口销毁 no-op
- [x] **拖文件夹到侧边栏创建工作区**已完成desktop 专属)
- 实现preload 新增 `getPathForFile(file)``webUtils.getPathForFile`——sandboxed renderer 拿拖放文件绝对路径的唯一官方姿势,`File.path` 早已移除;空串/异常归 `null`,零新 IPC channelwebUtils 就在 preload 进程内。renderer 新增 `src/renderer/lib/nativeWorkspaceDrop.ts``canDropWorkspaceFolders()` 桥探测(桥缺 `getPathForFile` 方法即 false旧桥自动降级`looksLikeFolderDrag()` dragover 启发式(拖拽处于 protected mode只有 `kind`/`type` 可读,文件夹 = `kind:'file'` + 空 MIME误报无害——drop 会权威复核);`extractDroppedFolderPaths()` drop 权威提取(`webkitGetAsEntry().isDirectory` 过滤、桥解析路径、去重,解析失败跳过)。`Sidebar.vue``.col` 上挂 dragenter/dragover/dragleave/drop启发式命中才 `preventDefault + stopPropagation`(后者压住 `useAttachmentUpload` document 级处理composer 的全窗口附件 overlay 不为文件夹拖亮起;高亮用计数器跟踪嵌套 enter/leave照抄同款模式dragleave 刻意不 stopPropagationdocument 计数 floor 0 兜底drop 提取到 ≥1 个路径才拦截并 `emit('addWorkspacePaths', paths)`,否则原样冒泡回落附件流程——普通文件拖放行为两端零变化;内部工作区排序拖是 `text/plain` payload天然不命中启发式。高亮 UI 为 `.col` 内绝对定位 overlaypointer-events:none、纯 CSS show/hide 不用 Transition虚线 accent 卡片复用 composer drop-overlay 视觉语言)。`App.vue`desktop 分叉块,同 requestAddWorkspace 区块)`@add-workspace-paths` 顺序循环复用 `addWorkspace()`(自动选中最后一个、接上 pending 首条消息daemon 拒绝走 `addWorkspaceError` + 回退 dialog同选择器失败的报错面。多文件夹一次拖入 = 顺序逐个创建。
- **web 无桥恒不触发**`Sidebar.vue``nativeWorkspaceDrop.ts` 两端同步、文件保持一致(仅既有的文件头 + 日志前缀 2 行品牌分叉web 端「拖文件到侧边栏 = 附件」行为不变。拖到非侧边栏区域保持现状(用户明确决定:文件夹落聊天区仍走附件流程,不特判)。

View file

@ -9,17 +9,17 @@
"main": "out/main.cjs",
"scripts": {
"prepare:fonts": "node ../../scripts/prepare-fonts.mjs",
"predev": "npm run prepare:fonts",
"prebuild:renderer": "npm run prepare:fonts",
"build": "tsdown",
"build:renderer": "vite build --config vite.renderer.config.ts",
"prebuild": "npm run build:renderer",
"start": "electron .",
"predev": "npm run prepare:fonts",
"dev": "node scripts/dev.mjs",
"typecheck": "tsc --noEmit && npm run typecheck:renderer",
"typecheck:renderer": "vue-tsc --noEmit -p tsconfig.renderer.json",
"test": "vitest run",
"postinstall": "electron-rebuild",
"postinstall": "node scripts/rebuild-native.mjs",
"dist": "npm run build:renderer && tsdown && electron-builder --config electron-builder.config.cjs"
},
"dependencies": {

View file

@ -18,7 +18,13 @@ const desktopDir = fileURLToPath(new URL('..', import.meta.url));
function run(cmd, args) {
return new Promise((resolvePromise, reject) => {
const child = spawn(cmd, args, { cwd: desktopDir, stdio: 'inherit' });
// On Windows pnpm is a .cmd shim: CreateProcess can't execute batch
// files, so spawn must go through the shell. Single-string form (args are
// fixed literals) — an args array with shell:true trips Node's DEP0190.
const child =
process.platform === 'win32'
? spawn(`${cmd} ${args.join(' ')}`, { cwd: desktopDir, stdio: 'inherit', shell: true })
: spawn(cmd, args, { cwd: desktopDir, stdio: 'inherit' });
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolvePromise();
@ -53,7 +59,7 @@ async function shutdown(code) {
try {
await run('pnpm', ['exec', 'tsdown']);
} catch (error) {
process.stderr.write(`[dev] tsdown build failed: ${error}\n`);
process.stderr.write(`[dev] tsdown build failed: ${String(error)}\n`);
await shutdown(1);
}

View file

@ -0,0 +1,30 @@
#!/usr/bin/env node
// Fall back to node-pty's shipped prebuild when a local rebuild is unavailable.
import { existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const desktopDir = fileURLToPath(new URL('..', import.meta.url));
const prebuild = `${desktopDir}/node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/pty.node`;
const result = spawnSync('electron-rebuild', {
cwd: desktopDir,
stdio: 'inherit',
// Windows command shims require a shell.
shell: process.platform === 'win32',
});
if (result.status === 0) {
process.exit(0);
}
if (existsSync(prebuild)) {
console.warn(
`[rebuild-native] electron-rebuild failed (no build toolchain?), but node-pty ships a ` +
`${process.platform}-${process.arch} prebuild — continuing with it.`,
);
process.exit(0);
}
process.exit(result.status ?? 1);

View file

@ -2,18 +2,57 @@ import { app } from 'electron';
import { registerRendererScheme, registerRendererProtocol } from './protocol';
import { rendererDistRoot, closeServerHandle } from './connect';
import { createWindow, selectSessionInRenderer, showMainWindow } from './window';
import { createWindow, selectSessionInRenderer, sendLaunchAction, showMainWindow } from './window';
import { createTray, destroyTray } from './tray';
import { initDockIcon } from './dock-icon';
import { buildMenu } from './menu';
import { unregisterGlobalShortcuts } from './shortcuts';
import { registerIpcHandlers } from './ipc';
import { initAutoUpdater } from './updater';
import { parseLaunchArgs } from './jump-list';
// --- app lifecycle ------------------------------------------------------------
/** Route the launch flags (Jump List items, CLI relaunch) into the renderer:
new-chat opens a draft, open-workspace selects (or registers) the root. */
function forwardLaunchArgs(argv: readonly string[]): void {
const launch = parseLaunchArgs(argv);
if (launch.newChat) {
sendLaunchAction({ action: 'new-chat' });
}
if (launch.workspace !== undefined) {
sendLaunchAction({ action: 'open-workspace', root: launch.workspace });
}
}
export function main(): void {
// Windows Toast notifications are grouped and activated by AppUserModelID.
// Keep this exactly aligned with electron-builder.config.cjs `appId`, whose
// NSIS shortcut supplies the matching Start Menu identity in packaged builds.
if (process.platform === 'win32') {
app.setAppUserModelId('com.kimi.code.desktop');
}
registerRendererScheme();
// Packaged launches stay single-instance. Dev intentionally skips this lock
// because it shares userData with the installed app and must run alongside it.
if (app.isPackaged && !app.requestSingleInstanceLock()) {
app.quit();
return;
}
const pendingSecondInstanceArgv: string[][] = [];
let launchRoutingReady = false;
app.on('second-instance', (_event, argv) => {
if (!launchRoutingReady) {
pendingSecondInstanceArgv.push(argv);
return;
}
showMainWindow();
forwardLaunchArgs(argv);
});
registerIpcHandlers();
app.on('before-quit', () => {
@ -51,6 +90,14 @@ export function main(): void {
// After the window exists: update statuses push to the renderer. No-op in
// dev (unpackaged); the packaged app checks on a delay + 4h cadence.
initAutoUpdater();
// Launch flags from the very first invocation (Jump List item click).
forwardLaunchArgs(process.argv);
const hadPendingSecondInstance = pendingSecondInstanceArgv.length > 0;
for (const argv of pendingSecondInstanceArgv.splice(0)) {
forwardLaunchArgs(argv);
}
launchRoutingReady = true;
if (hadPendingSecondInstance) showMainWindow();
app.on('activate', () => {
// macOS Dock click: un-hide the window (hide-on-close leaves it alive
// but hidden), or recreate it after a real destroy.

View file

@ -35,10 +35,16 @@ export const IPC = {
vibrancy: 'kimi:vibrancy',
getVibrancy: 'kimi:get-vibrancy',
showWindow: 'kimi:show-window',
jumpList: 'kimi:jump-list',
launchAction: 'kimi:launch-action',
} as const;
export type ColorScheme = 'light' | 'dark' | 'system';
/** Launch intent parsed from argv (--new-chat / --workspace=<root>) or a
Jump List item click, forwarded to the renderer once it is ready. */
export type LaunchActionPayload = { action: 'new-chat' } | { action: 'open-workspace'; root: string };
// Channels that carry main → renderer events (see window.ts sendToRenderer).
export type RendererEventChannel =
| typeof IPC.menuAction
@ -46,4 +52,5 @@ export type RendererEventChannel =
| typeof IPC.fullscreenChanged
| typeof IPC.updateStatus
| typeof IPC.traySelectSession
| typeof IPC.launchAction
| typeof IPC.osAppearanceChanged;

View file

@ -13,6 +13,7 @@ import {
setUpdateAutoDownload,
} from './updater';
import { asTrayAttention, setTrayAttention, setTrayLocale } from './tray';
import { asJumpListWorkspaces, setJumpListLocale, updateJumpList } from './jump-list';
import { setMenuLocale, setMenuShortcuts, setMenuSuspended } from './menu';
import { setGlobalShortcut, setGlobalShortcutSuspended } from './shortcuts';
import { isVibrancyEnabled, markOnboarded, setVibrancyEnabled } from './ui-state';
@ -100,6 +101,15 @@ export function registerIpcHandlers(): void {
if (locale === 'en' || locale === 'zh') {
setTrayLocale(locale);
setMenuLocale(locale);
setJumpListLocale(locale);
}
});
// Windows Jump List: the renderer pushes its recent workspaces (name +
// root) whenever they change (useJumpList.ts); malformed payloads drop.
ipcMain.on(IPC.jumpList, (_event, payload: unknown) => {
const workspaces = asJumpListWorkspaces(payload);
if (workspaces !== null) {
updateJumpList(workspaces);
}
});
// The renderer's customizable shortcut bindings (canonical keymap format,
@ -152,7 +162,7 @@ export function registerIpcHandlers(): void {
return setGlobalShortcutSuspended(suspended);
});
// Renderer-initiated "bring the window back" (notification clicks): with
// macOS hide-on-close the window may be alive but hidden, and the web
// hide-on-close the window may be alive but hidden, and the web
// window.focus() can't un-hide it — only the main process can.
ipcMain.on(IPC.showWindow, () => {
showMainWindow();

View file

@ -0,0 +1,159 @@
import { app } from 'electron';
import type { JumpListCategory } from 'electron';
import type { TrayLocale } from './tray';
export interface JumpListWorkspace {
name: string;
root: string;
}
// Leave one of Windows' roughly 10 visible slots for the task category.
const MAX_WORKSPACES = 9;
const MAX_DESCRIPTION_LENGTH = 260;
export function asJumpListWorkspaces(value: unknown): JumpListWorkspace[] | null {
if (!Array.isArray(value)) return null;
const workspaces: JumpListWorkspace[] = [];
for (const raw of value.slice(0, MAX_WORKSPACES)) {
if (typeof raw !== 'object' || raw === null) return null;
const candidate = raw as { name?: unknown; root?: unknown };
if (
typeof candidate.name !== 'string' ||
typeof candidate.root !== 'string' ||
candidate.root === ''
) {
return null;
}
workspaces.push({ name: candidate.name, root: candidate.root });
}
return workspaces;
}
export interface LaunchAction {
newChat: boolean;
workspace?: string;
}
/** Apply Windows CommandLineToArgvW backslash escaping. */
export function quoteWindowsCommandLineArg(value: string): string {
const escaped = value
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
.replace(/(\\+)$/, '$1$1');
return `"${escaped}"`;
}
function workspaceArgs(root: string): string {
return `--workspace=${quoteWindowsCommandLineArg(root)}`;
}
export function filterRemovedJumpListWorkspaces(
workspaces: readonly JumpListWorkspace[],
removedItems: readonly { args?: string }[],
): JumpListWorkspace[] {
const removedArgs = new Set(
removedItems
.map((item) => item.args)
.filter((args): args is string => args !== undefined),
);
return workspaces.filter((workspace) => !removedArgs.has(workspaceArgs(workspace.root)));
}
function jumpListDescription(root: string): string {
return root.length <= MAX_DESCRIPTION_LENGTH
? root
: `${root.slice(0, MAX_DESCRIPTION_LENGTH - 1)}`;
}
export function parseLaunchArgs(argv: readonly string[]): LaunchAction {
let newChat = false;
let workspace: string | undefined;
for (const arg of argv) {
if (arg === '--new-chat') {
newChat = true;
} else if (arg.startsWith('--workspace=')) {
let value = arg.slice('--workspace='.length);
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
if (value !== '') workspace = value;
}
}
return workspace === undefined ? { newChat } : { newChat, workspace };
}
const JUMP_LIST_STRINGS: Record<TrayLocale, { newChat: string; recent: string }> = {
zh: { newChat: '新建会话', recent: '最近' },
en: { newChat: 'New Chat', recent: 'Recent' },
};
export function buildJumpListCategories(
workspaces: JumpListWorkspace[],
locale: TrayLocale,
execPath: string,
): JumpListCategory[] {
const strings = JUMP_LIST_STRINGS[locale];
const categories: JumpListCategory[] = [];
if (workspaces.length > 0) {
categories.push({
type: 'custom',
name: strings.recent,
items: workspaces.map((workspace) => ({
type: 'task' as const,
program: execPath,
args: workspaceArgs(workspace.root),
title: workspace.name,
description: jumpListDescription(workspace.root),
iconPath: execPath,
iconIndex: 0,
})),
});
}
categories.push({
type: 'tasks',
items: [
{
type: 'task' as const,
program: execPath,
args: '--new-chat',
title: strings.newChat,
iconPath: execPath,
iconIndex: 0,
},
],
});
return categories;
}
let lastWorkspaces: JumpListWorkspace[] = [];
let jumpListLocale: TrayLocale | null = null;
function applyJumpList(): void {
if (process.platform !== 'win32') return;
let locale = jumpListLocale;
if (locale === null) {
try {
locale = app.getLocale().toLowerCase().startsWith('zh') ? 'zh' : 'en';
} catch {
locale = 'en';
}
}
try {
const workspaces = filterRemovedJumpListWorkspaces(
lastWorkspaces,
app.getJumpListSettings().removedItems,
);
app.setJumpList(buildJumpListCategories(workspaces, locale, process.execPath));
} catch {}
}
export function updateJumpList(workspaces: JumpListWorkspace[]): void {
lastWorkspaces = workspaces;
applyJumpList();
}
export function setJumpListLocale(locale: TrayLocale): void {
if (locale === jumpListLocale) return;
jumpListLocale = locale;
applyJumpList();
}

View file

@ -238,7 +238,7 @@ export function bindingToAccelerator(binding: string | null): string | undefined
// hides the `available` state).
async function runMenuUpdateCheck(): Promise<void> {
const strings = MENU_STRINGS[effectiveMenuLocale()];
// Parent the dialog to a visible window (macOS hide-on-close may leave it
// Parent the dialog to a visible window (hide-on-close may leave it
// hidden, and a sheet on a hidden window never appears).
showMainWindow();
const result = await requestUpdateCheck();
@ -333,7 +333,7 @@ export function menuTemplate(
accelerator: bindingToAccelerator(menuBinding(shortcutOverrides, 'openSettings')),
click: () => {
// The dialog lives in the renderer; the window may be hidden
// (macOS hide-on-close), so surface it before forwarding.
// (hide-on-close), so surface it before forwarding.
showMainWindow();
sendToRenderer(IPC.menuAction, 'open-settings');
},

View file

@ -1,11 +1,11 @@
// Desktop-native "open this workspace in <editor/terminal>". The app catalog
// is detected in the main process and launching goes through `open(1)` — each
// app's own Info.plist folder registration (verified against the installed
// bundles) turns `open -a <App> <dir>` into "a window at this directory".
// macOS only for now; other platforms return an empty catalog so the renderer
// hides the entry entirely (this is a desktop-only feature by design).
// Desktop-native "open this workspace in <editor/terminal>". On macOS the app
// catalog is detected from /Applications bundles and launching goes through
// `open(1)`; on Windows it's detected from the well-known per-user / system
// install locations and launched detached. Other platforms return an empty
// catalog so the renderer hides the entry entirely (this is a desktop-only
// feature by design).
//
// Pure + dependency-injected (fs/spawn/platform/home) so tests need no Electron.
// Pure + dependency-injected (fs/spawn/platform/home/env) so tests need no Electron.
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
@ -23,7 +23,10 @@ export type OpenInAppId =
| 'ghostty'
| 'warp'
| 'kitty'
| 'xcode';
| 'xcode'
| 'explorer'
| 'windows-terminal'
| 'git-bash';
export interface OpenInAppInfo {
id: OpenInAppId;
@ -57,12 +60,12 @@ const APP_SPECS: readonly OpenInAppSpec[] = [
{ id: 'xcode', label: 'Xcode', bundleName: 'Xcode.app' },
];
export const OPEN_IN_APP_IDS: readonly OpenInAppId[] = APP_SPECS.map((spec) => spec.id);
export interface OpenInDetectDeps {
platform?: NodeJS.Platform;
home?: string;
exists?: (path: string) => boolean;
/** Environment for install-location lookups (LOCALAPPDATA / ProgramFiles). */
env?: NodeJS.ProcessEnv;
}
/** Resolves the on-disk path of a ".app" bundle, or null when not installed. */
@ -86,10 +89,18 @@ function isSystemProvided(spec: OpenInAppSpec): boolean {
/**
* Apps currently installed (or system-provided) that can open a directory.
* Returns [] off macOS the renderer treats "empty catalog" as "hide".
* Returns [] off macOS/Windows the renderer treats "empty catalog" as "hide".
*/
export function listAvailableOpenInApps(deps: OpenInDetectDeps = {}): OpenInAppInfo[] {
const platform = deps.platform ?? process.platform;
if (platform === 'win32') {
const env = deps.env ?? process.env;
const exists = deps.exists ?? existsSync;
return WINDOWS_APP_SPECS.filter((spec) => spec.resolve(env, exists) !== null).map((spec) => ({
id: spec.id,
label: spec.label,
}));
}
if (platform !== 'darwin') return [];
const exists = deps.exists ?? existsSync;
const home = deps.home ?? homedir();
@ -102,6 +113,135 @@ export function listAvailableOpenInApps(deps: OpenInDetectDeps = {}): OpenInAppI
return available;
}
// --- Windows --------------------------------------------------------------------
//
// Editors install per-user by default (LOCALAPPDATA\Programs); the system-wide
// Program Files variants are covered too. File Explorer and a terminal are
// always present (Windows Terminal when its `wt.exe` alias is detectable,
// otherwise Windows PowerShell); Git Bash appears when Git for Windows is
// installed. Everything launches detached — a GUI app outlives this process,
// and explorer.exe in particular exits non-zero even on a successful open, so
// the exit code is meaningless.
interface WindowsAppSpec {
id: OpenInAppId;
label: string;
/** Launch command: an absolute exe path, or a name spawn resolves through
PATH. null = not installed. */
resolve(env: NodeJS.ProcessEnv, exists: (path: string) => boolean): string | null;
args(dir: string, command: string): string[];
}
function firstExisting(paths: string[], exists: (path: string) => boolean): string | null {
for (const candidate of paths) {
if (exists(candidate)) return candidate;
}
return null;
}
/** Per-user + system-wide install candidates for one editor directory name. */
function editorExeCandidates(env: NodeJS.ProcessEnv, ...suffixes: string[]): string[] {
const roots = [
env['LOCALAPPDATA'] === undefined ? undefined : join(env['LOCALAPPDATA'], 'Programs'),
env['ProgramFiles'],
env['ProgramFiles(x86)'],
].filter((root): root is string => root !== undefined);
const candidates: string[] = [];
for (const suffix of suffixes) {
for (const root of roots) {
candidates.push(join(root, suffix));
}
}
return candidates;
}
function pathExeCandidates(env: NodeJS.ProcessEnv, executable: string): string[] {
const path = env['Path'] ?? env['PATH'] ?? '';
return path
.split(';')
.filter((entry) => entry !== '')
.map((entry) => join(entry, executable));
}
function terminalCommand(env: NodeJS.ProcessEnv, exists: (path: string) => boolean): string {
const candidates = [
...(env['LOCALAPPDATA'] === undefined
? []
: [join(env['LOCALAPPDATA'], 'Microsoft', 'WindowsApps', 'wt.exe')]),
...pathExeCandidates(env, 'wt.exe'),
];
return firstExisting(candidates, exists) ?? 'powershell.exe';
}
function powershellSetLocationArgs(dir: string): string[] {
const literalPath = dir.replace(/'/g, "''");
const command = `Set-Location -LiteralPath '${literalPath}'`;
return ['-NoExit', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')];
}
// Menu order: editors first, then the file manager, then terminals.
const WINDOWS_APP_SPECS: readonly WindowsAppSpec[] = [
{
id: 'vscode',
label: 'VS Code',
resolve: (env, exists) =>
firstExisting(editorExeCandidates(env, join('Microsoft VS Code', 'Code.exe')), exists),
args: (dir) => [dir],
},
{
id: 'vscode-insiders',
label: 'VS Code Insiders',
resolve: (env, exists) =>
firstExisting(
editorExeCandidates(env, join('Microsoft VS Code Insiders', 'Code - Insiders.exe')),
exists,
),
args: (dir) => [dir],
},
{
id: 'cursor',
label: 'Cursor',
resolve: (env, exists) =>
firstExisting(
editorExeCandidates(env, join('cursor', 'Cursor.exe'), join('Cursor', 'Cursor.exe')),
exists,
),
args: (dir) => [dir],
},
{
id: 'zed',
label: 'Zed',
resolve: (env, exists) => firstExisting(editorExeCandidates(env, join('Zed', 'Zed.exe')), exists),
args: (dir) => [dir],
},
{
id: 'explorer',
label: 'File Explorer',
resolve: () => 'explorer.exe',
args: (dir) => [dir],
},
{
id: 'windows-terminal',
label: 'Terminal',
resolve: terminalCommand,
args: (dir, command) =>
command.toLowerCase().endsWith('wt.exe')
? ['-d', dir]
: powershellSetLocationArgs(dir),
},
{
id: 'git-bash',
label: 'Git Bash',
resolve: (env, exists) =>
firstExisting(editorExeCandidates(env, join('Git', 'git-bash.exe')), exists),
args: (dir) => [`--cd=${dir}`],
},
];
export const OPEN_IN_APP_IDS: readonly OpenInAppId[] = [
...new Set([...APP_SPECS, ...WINDOWS_APP_SPECS].map((spec) => spec.id)),
];
/** Builds the `open(1)` argv for a spec, or null when it cannot launch here. */
function buildOpenArgs(
spec: OpenInAppSpec,
@ -120,6 +260,8 @@ function buildOpenArgs(
export interface OpenInRunDeps extends OpenInDetectDeps {
/** Command runner, injected by tests. Defaults to spawning the real binary. */
run?: (command: string, args: string[]) => Promise<{ code: number | null; stderr: string }>;
/** Detached GUI launcher (Windows), injected by tests. */
runDetached?: (command: string, args: string[]) => Promise<{ error: string | null }>;
}
function defaultRun(command: string, args: string[]): Promise<{ code: number | null; stderr: string }> {
@ -136,9 +278,43 @@ function defaultRun(command: string, args: string[]): Promise<{ code: number | n
export type OpenInResult = { ok: true } | { ok: false; error: string };
/** Windows launch: detached spawn, resolved on the 'spawn' event GUI apps
outlive the caller, and explorer.exe exits non-zero even on a successful
open, so the exit code is never consulted. */
function runDetached(command: string, args: string[]): Promise<{ error: string | null }> {
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, args, { detached: true, stdio: 'ignore' });
} catch (error) {
resolve({ error: error instanceof Error ? error.message : String(error) });
return;
}
child.once('error', (error: Error) => resolve({ error: error.message }));
child.once('spawn', () => {
child.unref();
resolve({ error: null });
});
});
}
async function openInAppWindows(
appId: string,
targetPath: string,
deps: OpenInRunDeps,
): Promise<OpenInResult> {
const spec = WINDOWS_APP_SPECS.find((candidate) => candidate.id === appId);
if (spec === undefined) return { ok: false, error: `unknown open-in app: ${appId}` };
const command = spec.resolve(deps.env ?? process.env, deps.exists ?? existsSync);
if (command === null) return { ok: false, error: `${spec.label} is not installed` };
const run = deps.runDetached ?? runDetached;
const { error } = await run(command, spec.args(targetPath, command));
return error === null ? { ok: true } : { ok: false, error };
}
/**
* Opens `targetPath` in the given app. Never throws: every failure (unknown
* app, unsupported platform, app not installed, `open` failing) comes back as
* app, unsupported platform, app not installed, spawn failing) comes back as
* `{ ok: false, error }` so the IPC handler can forward it verbatim.
*/
export async function openInApp(
@ -146,10 +322,11 @@ export async function openInApp(
targetPath: string,
deps: OpenInRunDeps = {},
): Promise<OpenInResult> {
const platform = deps.platform ?? process.platform;
if (platform === 'win32') return openInAppWindows(appId, targetPath, deps);
const spec = APP_SPECS.find((candidate) => candidate.id === appId);
if (!spec) return { ok: false, error: `unknown open-in app: ${appId}` };
const platform = deps.platform ?? process.platform;
if (platform !== 'darwin') return { ok: false, error: 'open-in is only supported on macOS' };
if (platform !== 'darwin') return { ok: false, error: 'open-in is only supported on macOS and Windows' };
const exists = deps.exists ?? existsSync;
const home = deps.home ?? homedir();
const args = buildOpenArgs(spec, targetPath, exists, home);

View file

@ -160,6 +160,41 @@ function asUpdateCheckResult(value: unknown): UpdateCheckResult | null {
}
}
/** One recent workspace shown in the Windows Jump List (main/jump-list.ts). */
export type JumpListWorkspace = { name: string; root: string };
function asJumpListWorkspaces(value: unknown): value is JumpListWorkspace[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
typeof item === 'object' &&
item !== null &&
typeof (item as { name?: unknown }).name === 'string' &&
typeof (item as { root?: unknown }).root === 'string' &&
(item as { root: string }).root !== '',
)
);
}
/** Launch intent forwarded main renderer (Jump List item, second-instance
argv; main/ipc-channels.ts LaunchActionPayload, structurally duplicated). */
export type LaunchActionPayload = { action: 'new-chat' } | { action: 'open-workspace'; root: string };
function asLaunchActionPayload(value: unknown): LaunchActionPayload | null {
if (typeof value !== 'object' || value === null) {
return null;
}
const candidate = value as { action?: unknown; root?: unknown };
if (candidate.action === 'new-chat') {
return { action: 'new-chat' };
}
if (candidate.action === 'open-workspace' && typeof candidate.root === 'string' && candidate.root !== '') {
return { action: 'open-workspace', root: candidate.root };
}
return null;
}
export type KimiDesktopApi = {
setTheme: (scheme: 'light' | 'dark' | 'system') => void;
/** Dock tile preference ('light'|'dark'|'auto'); the main process swaps the
@ -237,9 +272,15 @@ export type KimiDesktopApi = {
* working shortcut was restored), so the panel can roll back. */
setGlobalShortcutSuspended: (suspended: boolean) => Promise<boolean>;
/** Bring the native window back on screen (notification clicks): with
* macOS hide-on-close it may be alive but hidden, and the renderer's own
* hide-on-close it may be alive but hidden, and the renderer's own
* window.focus() can't un-hide it. */
showWindow: () => void;
/** Push the recent workspace list for the Windows Jump List (taskbar
* right-click menu). No-op semantics elsewhere (main/jump-list.ts). */
setJumpList: (workspaces: JumpListWorkspace[]) => void;
/** Main renderer push of a launch intent (Jump List item click or
* second-instance argv: open a draft / open a workspace by root). */
onLaunchAction: (cb: (payload: LaunchActionPayload) => void) => () => void;
/** macOS frosted-sidebar material toggle (settings appearance). Persisted
* main-side so window creation applies it before the renderer boots. */
setVibrancy: (enabled: boolean) => void;
@ -383,6 +424,21 @@ export const api: KimiDesktopApi = {
showWindow: () => {
ipcRenderer.send('kimi:show-window');
},
setJumpList: (workspaces) => {
if (asJumpListWorkspaces(workspaces)) {
ipcRenderer.send('kimi:jump-list', workspaces);
}
},
onLaunchAction: (cb) => {
const listener = (_event: unknown, payload: unknown) => {
const action = asLaunchActionPayload(payload);
if (action !== null) {
cb(action);
}
};
ipcRenderer.on('kimi:launch-action', listener);
return () => ipcRenderer.removeListener('kimi:launch-action', listener);
},
setVibrancy: (enabled) => {
if (typeof enabled === 'boolean') {
ipcRenderer.send('kimi:vibrancy', enabled);

View file

@ -0,0 +1,191 @@
// Windows taskbar attention: an overlay badge on the taskbar button plus
// flash-frame while new attention arrives — the Windows counterpart of the
// macOS menu-bar count + Dock badge (Tray.setTitle and app.dock don't exist
// on Windows). Fed from tray.ts's setTrayAttention (the renderer's
// kimi:tray-attention pushes); no-op on other platforms.
//
// The badge pixels are generated at runtime (nativeImage.createFromBitmap),
// so there is no asset to ship and nothing for extraResources to miss.
import { nativeImage } from 'electron';
import type { BrowserWindow, NativeImage } from 'electron';
import { getMainWindow } from './window';
// --- overlay badge ------------------------------------------------------------
// Attention red; written BGRA premultiplied by coverage below.
const BADGE_RGB = { r: 0xe5, g: 0x48, b: 0x4d };
const BADGE_GLYPHS: Record<string, readonly string[]> = {
'0': ['111', '101', '101', '101', '111'],
'1': ['010', '110', '010', '010', '111'],
'2': ['111', '001', '111', '100', '111'],
'3': ['111', '001', '111', '001', '111'],
'4': ['101', '101', '111', '001', '001'],
'5': ['111', '100', '111', '001', '111'],
'6': ['111', '100', '111', '101', '111'],
'7': ['111', '001', '010', '010', '010'],
'8': ['111', '101', '111', '101', '111'],
'9': ['111', '101', '111', '001', '111'],
'+': ['000', '010', '111', '010', '000'],
};
export function badgeText(total: number): string {
if (total <= 0) return '';
return total > 99 ? '99+' : String(Math.floor(total));
}
function roundedRectCoverage(
pixelX: number,
pixelY: number,
box: { x: number; y: number; width: number; height: number; radius: number },
scale: number,
): number {
const centerX = Math.min(Math.max(pixelX, box.x + box.radius), box.x + box.width - box.radius);
const centerY = Math.min(Math.max(pixelY, box.y + box.radius), box.y + box.height - box.radius);
const distance = Math.hypot(pixelX - centerX, pixelY - centerY);
return Math.max(0, Math.min(1, (box.radius - distance) * scale + 0.5));
}
/** Numeric badge pixels: a compact red circle/pill on a transparent 16px
logical canvas, with a dependency-free 3x5 white bitmap font. Counts cap
visually at 99+ while the tooltip retains the exact breakdown. */
export function badgePixels(size: number, total: number): Buffer {
const pixels = Buffer.alloc(size * size * 4, 0);
const text = badgeText(total);
if (text === '') return pixels;
const scale = size / 16;
const textWidth = text.length * 3 + text.length - 1;
const boxWidth = Math.max(10, textWidth + 4);
const box = {
x: (16 - boxWidth) / 2,
y: 3,
width: boxWidth,
height: 10,
radius: 5,
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const coverage = roundedRectCoverage((x + 0.5) / scale, (y + 0.5) / scale, box, scale);
if (coverage === 0) continue;
const offset = (y * size + x) * 4;
pixels[offset] = Math.round(BADGE_RGB.b * coverage);
pixels[offset + 1] = Math.round(BADGE_RGB.g * coverage);
pixels[offset + 2] = Math.round(BADGE_RGB.r * coverage);
pixels[offset + 3] = Math.round(255 * coverage);
}
}
const textX = Math.floor((16 - textWidth) / 2);
const textY = 5;
for (let charIndex = 0; charIndex < text.length; charIndex++) {
const glyph = BADGE_GLYPHS[text[charIndex]!];
if (glyph === undefined) continue;
for (let glyphY = 0; glyphY < glyph.length; glyphY++) {
for (let glyphX = 0; glyphX < 3; glyphX++) {
if (glyph[glyphY]![glyphX] !== '1') continue;
const startX = (textX + charIndex * 4 + glyphX) * scale;
const startY = (textY + glyphY) * scale;
for (let py = startY; py < startY + scale; py++) {
for (let px = startX; px < startX + scale; px++) {
const offset = (py * size + px) * 4;
pixels[offset] = 255;
pixels[offset + 1] = 255;
pixels[offset + 2] = 255;
pixels[offset + 3] = 255;
}
}
}
}
}
return pixels;
}
// The overlay slot is 16px logical; the 2x representation keeps it sharp on
// high-DPI displays.
function createBadgeImage(total: number): NativeImage | null {
try {
const image = nativeImage.createFromBitmap(badgePixels(16, total), { width: 16, height: 16 });
image.addRepresentation({ scaleFactor: 2, width: 32, height: 32, buffer: badgePixels(32, total) });
return image.isEmpty() ? null : image;
} catch {
return null;
}
}
// --- state machine --------------------------------------------------------------
export interface TaskbarWindowLike {
isDestroyed(): boolean;
isFocused(): boolean;
setOverlayIcon(overlay: NativeImage | null, description: string): void;
flashFrame(flag: boolean): void;
on(event: 'focus', listener: () => void): void;
}
export interface TaskbarAttentionController {
update(total: number, description: string): void;
}
/** Per-window attention state: badge while anything pends; flash only when
the total GROWS while unfocused (re-pushing an unchanged count, e.g. the
boot-time restore of last-known state, must not flash), stop flashing on
focus or when caught up. A failed badge render degrades to flash-only. */
export function createTaskbarAttention(
win: TaskbarWindowLike,
badgeForTotal: (total: number) => NativeImage | null,
): TaskbarAttentionController {
let lastTotal: number | null = null;
win.on('focus', () => win.flashFrame(false));
return {
update(total: number, description: string): void {
if (win.isDestroyed()) return;
if (total > 0) {
const badge = badgeForTotal(total);
if (badge !== null) {
win.setOverlayIcon(badge, description);
} else {
win.setOverlayIcon(null, '');
}
} else {
win.setOverlayIcon(null, '');
}
if (lastTotal !== null && total > lastTotal && !win.isFocused()) win.flashFrame(true);
if (total === 0) win.flashFrame(false);
lastTotal = total;
},
};
}
// --- production wiring ----------------------------------------------------------
const cachedBadges = new Map<string, NativeImage | null>();
let badgeWarningShown = false;
let controller: TaskbarAttentionController | null = null;
let controllerWindow: BrowserWindow | null = null;
function badgeForTotal(total: number): NativeImage | null {
const key = badgeText(total);
if (!cachedBadges.has(key)) {
cachedBadges.set(key, createBadgeImage(total));
}
const badge = cachedBadges.get(key) ?? null;
if (badge === null && !badgeWarningShown) {
badgeWarningShown = true;
console.warn('[taskbar] badge image creation failed, overlay icon disabled');
}
return badge;
}
/** Entry point for tray.ts: Windows-only, resolves the (possibly recreated)
main window lazily and rebuilds the per-window controller when it changes. */
export function setTaskbarAttention(total: number, description: string): void {
if (process.platform !== 'win32') return;
const win = getMainWindow();
if (win === null || win.isDestroyed()) return;
if (controllerWindow !== win || controller === null) {
controller = createTaskbarAttention(win, badgeForTotal);
controllerWindow = win;
}
controller.update(total, description);
}

View file

@ -3,23 +3,28 @@ import { join } from 'node:path';
import { app, Menu, nativeImage, Tray } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { setTaskbarAttention } from './taskbar';
// System tray (macOS menu-bar / Windows notification area). Desktop-only — the
// web client has no equivalent surface. A single context menu covers both
// interactions: on macOS a plain click on a status item with a context menu
// opens the menu; on Windows left-click is wired to the same menu below.
// web client has no equivalent surface. Click behaviour: on macOS a plain
// click on a status item with a context menu opens the menu; on Windows the
// menu opens on right-click by default once set, so left-click (and
// double-click) is wired to surface the main window below.
//
// The tray also renders the pending-attention badge: the renderer pushes
// {unread, approvals, questions, items} over `IPC.trayAttention` whenever they
// change (see renderer composables/useTrayAttention.ts), and `setTrayAttention`
// shows the bare total next to the macOS menu-bar icon (Tray.setTitle is
// macOS-only), the per-kind breakdown in the tooltip, and the attention
// macOS-only — the Windows counterpart is the taskbar overlay + flash in
// taskbar.ts), the per-kind breakdown in the tooltip, and the attention
// sessions as clickable entries at the top of the dropdown menu (click → show
// the window and jump to that session). On macOS the window hides instead of
// closing (window.ts shouldHideOnClose), so the renderer keeps reporting while
// hidden and the badge stays live; the last-known state only has to survive
// real quits/reloads — unread flags persist in localStorage and pending items
// live server-side, so entries stay meaningful (and clickable, via the
// window.ts queue while booting/reloading) until the next push.
// the window and jump to that session). On macOS and Windows the window hides
// instead of closing (window.ts shouldHideOnClose), so the renderer keeps
// reporting while hidden and the badge stays live; the last-known state only
// has to survive real quits/reloads — unread flags persist in localStorage
// and pending items live server-side, so entries stay meaningful (and
// clickable, via the window.ts queue while booting/reloading) until the next
// push.
export interface TrayIconEnv {
platform: NodeJS.Platform;
@ -338,7 +343,10 @@ export function createTray(actions: TrayActions): Tray | null {
lastAttention = ZERO_ATTENTION;
renderTray();
if (process.platform === 'win32') {
tray.on('click', () => tray?.popUpContextMenu());
// Windows convention: left-click / double-click surfaces the window; the
// context menu opens on right-click without any handler.
tray.on('click', () => actions.showMainWindow());
tray.on('double-click', () => actions.showMainWindow());
}
return tray;
}
@ -368,6 +376,7 @@ function renderTray(): void {
export function setTrayAttention(attention: TrayAttention): void {
lastAttention = attention;
renderTray();
syncTaskbarAttention();
}
/** Follow the renderer's in-app language (IPC.locale): re-render the tooltip
@ -378,6 +387,12 @@ export function setTrayLocale(locale: TrayLocale): void {
}
trayLocale = locale;
renderTray();
syncTaskbarAttention();
}
function syncTaskbarAttention(): void {
const total = lastAttention.unread + lastAttention.approvals + lastAttention.questions;
setTaskbarAttention(total, trayAttentionSummary(lastAttention, effectiveTrayLocale()));
}
/** Tear the tray down on quit and drop the module references. */

View file

@ -6,7 +6,7 @@ import { app, BrowserWindow, dialog, screen, shell } from 'electron';
import { connect } from './connect';
import { installDownloadHandler } from './downloads';
import { installExternalLinkGuard } from './external-links';
import { IPC, type RendererEventChannel } from './ipc-channels';
import { IPC, type LaunchActionPayload, type RendererEventChannel } from './ipc-channels';
import { log, redactUrlForLog } from './log';
import { isVibrancyEnabled } from './ui-state';
@ -34,11 +34,11 @@ export function showMainWindow(): void {
// --- window lifecycle ---------------------------------------------------------
// macOS hide-on-close (the tray-resident model, like Slack/Discord): closing
// the window only hides it — the renderer, its session state, WS, and the
// tray-select subscription all stay alive, so re-showing (Dock click, tray)
// is instant and tray jumps deliver immediately without the boot/reload
// queue. Real quits (Cmd+Q, tray 退出, updater install) go through
// macOS/Windows hide-on-close (the tray-resident model, like Slack/Discord):
// closing the window only hides it — the renderer, its session state, WS, and
// the tray-select subscription all stay alive, so re-showing (Dock click,
// tray, taskbar) is instant and tray jumps deliver immediately without the
// boot/reload queue. Real quits (Cmd+Q, tray 退出, updater install) go through
// before-quit, which fires before any window close event and flips this
// flag, letting the close proceed to destruction. The listener installs
// lazily from createWindow: module scope must stay Electron-free for tests.
@ -68,9 +68,25 @@ export function markQuitting(): void {
// cancels the stale close intent.
let pendingFullscreenHide = false;
/** Close-button policy: hide instead of destroy on macOS, unless quitting. */
/** Close-button policy: hide instead of destroy on macOS/Windows, unless quitting. */
export function shouldHideOnClose(platform: NodeJS.Platform, quitting: boolean): boolean {
return platform === 'darwin' && !quitting;
return (platform === 'darwin' || platform === 'win32') && !quitting;
}
interface SessionEndWindowLike {
on(event: 'session-end', listener: () => void): unknown;
}
/** Windows does not emit app.before-quit for shutdown, restart, or logoff.
session-end is final (unlike query-session-end, which can be cancelled),
so it is safe to let the following close destroy the window. */
export function installWindowsSessionEndWatch(
platform: NodeJS.Platform,
win: SessionEndWindowLike,
markEnding: () => void,
): void {
if (platform !== 'win32') return;
win.on('session-end', markEnding);
}
// --- tray "jump to session" routing -------------------------------------------
@ -81,7 +97,7 @@ export function shouldHideOnClose(platform: NodeJS.Platform, quitting: boolean):
// `onTraySelectSession` subscription is in place (module scripts run before
// the load event) — so clicks before that queue up and flush when the load
// settles (did-finish-load, or did-fail-load: a failed/aborted load leaves
// the old, still-subscribed page displayed). With macOS hide-on-close
// the old, still-subscribed page displayed). With hide-on-close
// (shouldHideOnClose) the renderer otherwise stays alive for the app's
// lifetime, so clicks deliver immediately and the queue only covers boot and
// reload.
@ -106,6 +122,23 @@ export function selectSessionInRenderer(sessionId: string): void {
}
}
let pendingLaunchActions: LaunchActionPayload[] = [];
export function drainLaunchActions(actions: LaunchActionPayload[]): LaunchActionPayload[] {
return actions.splice(0);
}
/** Forward a launch action (Jump List item, second-instance argv) to a live,
loaded renderer; queue behind the same readiness gate as tray clicks and
flush together with them when the load settles. */
export function sendLaunchAction(action: LaunchActionPayload): void {
if (mainWindow !== null && !mainWindow.isDestroyed() && rendererReady) {
sendToRenderer(IPC.launchAction, action);
} else {
pendingLaunchActions.push(action);
}
}
// --- renderer event channels (menu / shortcut) -------------------------------
//
// Native menu items and global shortcuts forward to the renderer over the
@ -140,6 +173,29 @@ export function looksMaximizedBounds(
return bounds.width >= workArea.width * 0.95 && bounds.height >= workArea.height * 0.95;
}
/** Clamp saved bounds into the matched display's work area. Display layouts
change between runs (laptop undocked, monitor re-arranged), and an
unreachable window title bar off every screen is unrecoverable without
editing the state file. At least MIN_VISIBLE px stay on screen; the top
edge never goes above the work area (that's where the drag handle is). */
const MIN_VISIBLE_PX = 100;
export function clampBoundsToWorkArea(
bounds: WindowBounds,
workArea: { x: number; y: number; width: number; height: number },
): WindowBounds {
if (bounds.x === undefined || bounds.y === undefined) return bounds;
const x = Math.min(
Math.max(bounds.x, workArea.x - bounds.width + MIN_VISIBLE_PX),
workArea.x + workArea.width - MIN_VISIBLE_PX,
);
const y = Math.min(
Math.max(bounds.y, workArea.y),
workArea.y + workArea.height - MIN_VISIBLE_PX,
);
return x === bounds.x && y === bounds.y ? bounds : { ...bounds, x, y };
}
function stateFile(): string {
return join(app.getPath('userData'), 'window-state.json');
}
@ -164,7 +220,7 @@ function loadBounds(): WindowBounds {
height: bounds.height,
})
).workArea;
if (!looksMaximizedBounds(bounds, workArea)) return bounds;
if (!looksMaximizedBounds(bounds, workArea)) return clampBoundsToWorkArea(bounds, workArea);
}
} catch {
// No saved state yet, or it is unreadable — fall back to defaults.
@ -318,6 +374,7 @@ export function createWindow(): void {
};
win.on('enter-full-screen', notifyFullscreen);
win.on('leave-full-screen', notifyFullscreen);
installWindowsSessionEndWatch(process.platform, win, markQuitting);
win.on('close', (event) => {
saveBounds(win);
if (shouldHideOnClose(process.platform, isQuitting)) {
@ -375,6 +432,9 @@ export function createWindow(): void {
sendToRenderer(IPC.traySelectSession, pendingTraySessionSelect);
pendingTraySessionSelect = null;
}
for (const action of drainLaunchActions(pendingLaunchActions)) {
sendToRenderer(IPC.launchAction, action);
}
};
win.webContents.on('did-fail-load', (_event, _code, _desc, _url, isMainFrame) => {
settleRendererReady(isMainFrame);

View file

@ -50,8 +50,9 @@ import { Icon, IconButton } from '@moonshot-ai/web-ui';
import { isMacosDesktop } from './lib/desktopFlag';
import { selectContentsOf } from './lib/transcriptSelectAll';
import { useFullscreen } from './composables/useFullscreen';
import { runWhenInitialized, useTrayAttention } from './composables/useTrayAttention';
import { useJumpList } from './composables/useJumpList';
import { useVibrancy } from './composables/useVibrancy';
import { useTrayAttention } from './composables/useTrayAttention';
import { matchShortcutAction } from './composables/useShortcuts';
import { shortcutActionById } from './lib/keymap';
import {
@ -111,6 +112,19 @@ const { vibrancy } = useVibrancy();
// tray tooltip/menu breakdown. No-op without the desktop bridge (web).
useTrayAttention(client);
// Push the recent workspaces to the Windows Jump List (taskbar right-click),
// and route launch actions (Jump List clicks, second-instance argv) back
// into the app. No-op without the desktop bridge (web).
useJumpList(client, (payload) => {
runWhenInitialized(client.initialized, () => {
if (payload.action === 'new-chat') {
handleCreateSession();
} else {
void openWorkspaceByRoot(payload.root);
}
});
});
// Mobile sheet visibility
const showMobileSwitcher = ref(false);
const showMobileSettings = ref(false);
@ -980,6 +994,23 @@ async function handleDropWorkspacePaths(paths: string[]): Promise<void> {
}
}
// Launch-action "open this workspace" (Jump List item / second-instance
// argv): select it when already registered, otherwise add it through the
// standard flow (which also selects it). Part of the desktop-only
// add-workspace block keep on webdesktop re-copies (docs/native-todos.md).
async function openWorkspaceByRoot(root: string): Promise<void> {
const existing = client.workspacesView.value.find((workspace) => workspace.root === root);
if (existing) {
client.openWorkspace(existing.id);
return;
}
const added = await addWorkspace(root);
if (!added) {
addWorkspaceError.value = t('workspace.addFailed');
showAddWorkspace.value = true;
}
}
function focusComposerAfterDraft(): void {
void nextTick(() => {
conversationPaneRef.value?.focusComposer();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

View file

@ -247,7 +247,6 @@ onMounted(async () => {
});
const showOpenIn = computed(() => openInApps.value.length > 0 && Boolean(props.workspaceRoot));
const openInAppIds = computed(() => openInApps.value.map((app) => app.id));
async function onOpenInApp(appId: string): Promise<void> {
if (!props.workspaceRoot) return;
@ -343,7 +342,7 @@ async function onOpenInApp(appId: string): Promise<void> {
<OpenInMenu
v-if="showOpenIn"
:work-dir="workspaceRoot"
:available-apps="openInAppIds"
:available-apps="openInApps"
@open-in-app="onOpenInApp"
/>

View file

@ -16,28 +16,15 @@ const { t } = useI18n();
const props = defineProps<{
/** Absolute path of the workspace to open; the control is disabled without it. */
workDir?: string;
/** Installed app IDs from the main process; unset/empty shows the full catalog. */
availableApps?: string[];
/** Installed app catalog from the main process; unset/empty shows the fallback catalog. */
availableApps?: Array<{ id: string; label: string }>;
}>();
const emit = defineEmits<{
openInApp: [appId: string];
}>();
type TargetId =
| 'vscode'
| 'vscode-insiders'
| 'cursor'
| 'zed'
| 'finder'
| 'terminal'
| 'iterm'
| 'ghostty'
| 'warp'
| 'kitty'
| 'xcode';
const TARGETS: Array<{ id: TargetId; label: string }> = [
const TARGETS: Array<{ id: string; label: string }> = [
{ id: 'vscode', label: 'VS Code' },
{ id: 'vscode-insiders', label: 'VS Code Insiders' },
{ id: 'cursor', label: 'Cursor' },
@ -55,8 +42,7 @@ const hasWorkDir = computed(() => Boolean(props.workDir && props.workDir.trim().
const visibleTargets = computed(() => {
if (!props.availableApps || props.availableApps.length === 0) return TARGETS;
const available = new Set(props.availableApps);
return TARGETS.filter((target) => available.has(target.id));
return props.availableApps;
});
// ---------------------------------------------------------------------------
@ -84,7 +70,7 @@ const quickTooltipText = computed(() =>
: t('header.openInApp', { app: quickTargetLabel.value }),
);
function handleOpenTarget(id: TargetId): void {
function handleOpenTarget(id: string): void {
// Picking an item both opens with it and selects it the same key the
// settings dropdown writes, so the pill and settings stay in sync.
saveDefaultOpenInTarget(id);

View file

@ -0,0 +1,110 @@
// Desktop-only: push the recent workspace list to the native Jump List
// (Windows taskbar right-click menu; main/jump-list.ts), and route launch
// actions (Jump List item clicks, second-instance argv) back into the app.
// The list is pushed, not polled: the main process has no workspace data.
//
// With no bridge — plain web, tests — both sides are safe no-ops (per
// native-todos.md).
import { computed, watch, type ComputedRef, type Ref } from 'vue';
export interface JumpListWorkspaceEntry {
name: string;
root: string;
}
// The OS caps Jump List lists around 10 visible entries; headroom lives on
// the main side too (jump-list.ts MAX_WORKSPACES).
const MAX_ENTRIES = 9;
export type LaunchActionPayload = { action: 'new-chat' } | { action: 'open-workspace'; root: string };
interface JumpListBridge {
setJumpList?: (workspaces: JumpListWorkspaceEntry[]) => void;
onLaunchAction?: (cb: (payload: LaunchActionPayload) => void) => () => void;
}
function bridge(): JumpListBridge | undefined {
return (window as { kimiDesktop?: JumpListBridge }).kimiDesktop;
}
export function jumpListEntriesEqual(
a: JumpListWorkspaceEntry[],
b: JumpListWorkspaceEntry[],
): boolean {
return a.length === b.length && a.every((entry, i) => entry.name === b[i]!.name && entry.root === b[i]!.root);
}
/** Watch the entry list and push every change to the native Jump List. A null
value (client state not loaded yet) is NOT pushed at setup the workspace
list is still empty, and wiping the OS menu for the whole load window is
worse than showing slightly stale entries (same gating as the tray
attention reporter). Identical successive lists are pushed once. Returns
the stop handle; a missing bridge yields a no-op reporter. */
export function createJumpListReporter(
reporter: { setJumpList: (workspaces: JumpListWorkspaceEntry[]) => void } | undefined,
entries: ComputedRef<JumpListWorkspaceEntry[] | null>,
): () => void {
if (reporter === undefined) {
return () => {};
}
let lastPushed: JumpListWorkspaceEntry[] | null = null;
return watch(
entries,
(value) => {
if (value === null) return;
if (lastPushed !== null && jumpListEntriesEqual(value, lastPushed)) {
return;
}
lastPushed = value;
reporter.setJumpList(value);
},
{ immediate: true },
);
}
/** Subscribe to launch actions (main renderer) and route them to the
handler. Returns the unsubscribe; no-op without the bridge method. */
export function createLaunchActionRouter(
subscriber: {
onLaunchAction: (cb: (payload: LaunchActionPayload) => void) => () => void;
} | undefined,
handler: (payload: LaunchActionPayload) => void,
): () => void {
if (subscriber === undefined) {
return () => {};
}
return subscriber.onLaunchAction(handler);
}
interface JumpListSource {
/** Sidebar workspace view (display order = recency or the user's manual order). */
workspacesView: ComputedRef<ReadonlyArray<{ name: string; root: string }>>;
/** False until the client's first load() settles (see useWorkspaceState). */
initialized: Ref<boolean>;
}
/** App.vue wiring: report the workspace list to the native Jump List, and
route launch actions (Jump List clicks, second-instance argv) to the
handler. Each bridge method degrades independently (an old bridge may lack
either). Lives for the app's lifetime. */
export function useJumpList(
client: JumpListSource,
onLaunchAction: (payload: LaunchActionPayload) => void,
): void {
const b = bridge();
const setJumpList = b?.setJumpList;
if (typeof setJumpList === 'function') {
const entries = computed<JumpListWorkspaceEntry[] | null>(() => {
if (!client.initialized.value) return null;
return client.workspacesView.value
.slice(0, MAX_ENTRIES)
.map((workspace) => ({ name: workspace.name, root: workspace.root }));
});
createJumpListReporter({ setJumpList }, entries);
}
const subscribe = b?.onLaunchAction;
if (typeof subscribe === 'function') {
createLaunchActionRouter({ onLaunchAction: subscribe }, onLaunchAction);
}
}

View file

@ -11,7 +11,7 @@
// process only knows what it is told. The reload recovery and the keep-last-
// known-across-reload behaviour live on the main side (window.ts / the
// immediate push); the reverse direction (tray click → selectSession) queues
// in window.ts while the renderer is (re)loading — with macOS hide-on-close
// in window.ts while the renderer is (re)loading — with hide-on-close
// it otherwise delivers immediately.
//
// With no bridge — plain web, tests — the reporter never starts, so this file

View file

@ -9,9 +9,10 @@
import { safeGetString, safeRemove, safeSetString, STORAGE_KEYS } from './storage';
import { ref, type Ref } from 'vue';
// Colored app icons, extracted from the apps' own .icns bundles (the
// "open in <app>" menu is nominative use of the trademarks). PNG URLs are
// bundled by Vite; keyed by the main-process app id (src/main/open-in.ts).
// Colored app icons, extracted from the apps' own bundles (macOS .icns /
// Windows exe resources; the "open in <app>" menu is nominative use of the
// trademarks). PNG URLs are bundled by Vite; keyed by the main-process app id
// (src/main/open-in.ts).
import iconVscode from '../assets/app-icons/vscode.png';
import iconVscodeInsiders from '../assets/app-icons/vscode-insiders.png';
import iconCursor from '../assets/app-icons/cursor.png';
@ -23,6 +24,9 @@ import iconGhostty from '../assets/app-icons/ghostty.png';
import iconWarp from '../assets/app-icons/warp.png';
import iconKitty from '../assets/app-icons/kitty.png';
import iconXcode from '../assets/app-icons/xcode.png';
import iconExplorer from '../assets/app-icons/explorer.png';
import iconWindowsTerminal from '../assets/app-icons/windows-terminal.png';
import iconGitBash from '../assets/app-icons/git-bash.png';
const APP_ICONS: Record<string, string> = {
vscode: iconVscode,
@ -36,6 +40,9 @@ const APP_ICONS: Record<string, string> = {
warp: iconWarp,
kitty: iconKitty,
xcode: iconXcode,
explorer: iconExplorer,
'windows-terminal': iconWindowsTerminal,
'git-bash': iconGitBash,
};
/** Bundled PNG URL for an app id; '' for unknown ids (caller renders no img). */

View file

@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const listeners = new Map<string, (...args: unknown[]) => void>();
let resolveReady = (): void => {};
const app = {
isPackaged: true,
setAppUserModelId: vi.fn(),
requestSingleInstanceLock: vi.fn(() => true),
quit: vi.fn(),
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
listeners.set(event, listener);
}),
whenReady: vi.fn(
() =>
new Promise<void>((resolve) => {
resolveReady = resolve;
}),
),
};
return {
app,
listeners,
ready: () => resolveReady(),
createWindow: vi.fn(),
showMainWindow: vi.fn(),
sendLaunchAction: vi.fn(),
};
});
vi.mock('electron', () => ({ app: mocks.app }));
vi.mock('../../src/main/protocol', () => ({
registerRendererScheme: vi.fn(),
registerRendererProtocol: vi.fn(),
}));
vi.mock('../../src/main/connect', () => ({
rendererDistRoot: '/renderer',
closeServerHandle: vi.fn(),
}));
vi.mock('../../src/main/window', () => ({
createWindow: mocks.createWindow,
selectSessionInRenderer: vi.fn(),
sendLaunchAction: mocks.sendLaunchAction,
showMainWindow: mocks.showMainWindow,
}));
vi.mock('../../src/main/tray', () => ({
createTray: vi.fn(),
destroyTray: vi.fn(),
}));
vi.mock('../../src/main/dock-icon', () => ({ initDockIcon: vi.fn() }));
vi.mock('../../src/main/menu', () => ({ buildMenu: vi.fn() }));
vi.mock('../../src/main/shortcuts', () => ({ unregisterGlobalShortcuts: vi.fn() }));
vi.mock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn() }));
vi.mock('../../src/main/updater', () => ({ initAutoUpdater: vi.fn() }));
import { main } from '../../src/main/app';
describe('app second-instance routing', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listeners.clear();
});
it('registers immediately and replays launches received before the window is ready', async () => {
main();
const onSecondInstance = mocks.listeners.get('second-instance');
expect(onSecondInstance).toBeTypeOf('function');
onSecondInstance?.({}, ['electron.exe', '--new-chat']);
expect(mocks.sendLaunchAction).not.toHaveBeenCalled();
mocks.ready();
await vi.waitFor(() => expect(mocks.createWindow).toHaveBeenCalledOnce());
expect(mocks.sendLaunchAction).toHaveBeenCalledWith({ action: 'new-chat' });
expect(mocks.showMainWindow).toHaveBeenCalledOnce();
});
});

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { BrowserWindow } from 'electron';
import { join } from 'node:path';
import type { DesktopServerHandle } from '../../src/main/server';
@ -86,7 +87,7 @@ describe('connect', () => {
expect(mocks.startDesktopServer).toHaveBeenCalledTimes(1);
expect(mocks.startDesktopServer).toHaveBeenCalledWith({
webAssetsDir: '/resources/desktop-dist',
webAssetsDir: join('/resources', 'desktop-dist'),
identity: { userAgentProduct: 'kimi-code-desktop', version: '1.2.3' },
extraCorsOrigins: [],
});
@ -158,7 +159,7 @@ describe('connect', () => {
await connect(win1 as unknown as BrowserWindow);
expect(mocks.errorHtml).toHaveBeenCalledWith(
'server already running (pid=1, port=2, started=x)',
'/tmp/kimi-test/server/server.log',
join('/tmp/kimi-test', 'server', 'server.log'),
);
expect(mocks.dataUrl).toHaveBeenCalledWith('<error>');
expect(win1.loadURL).toHaveBeenCalledWith('error-url');

View file

@ -1,4 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { join } from 'node:path';
import { installDownloadHandler } from '../../src/main/downloads';
@ -46,7 +47,7 @@ describe('installDownloadHandler', () => {
installDownloadHandler(session as any, deps);
fireDownload(fakeItem('kimi-session.zip'));
expect(deps.showSaveDialog).toHaveBeenCalledWith({
defaultPath: '/Users/x/Downloads/kimi-session.zip',
defaultPath: join('/Users/x/Downloads', 'kimi-session.zip'),
});
});
@ -80,7 +81,7 @@ describe('installDownloadHandler', () => {
fireDownload(fakeItem('a.zip'));
fireDownload(fakeItem('trace.jsonl'));
expect(deps.showSaveDialog).toHaveBeenLastCalledWith({
defaultPath: '/Users/x/Desktop/trace.jsonl',
defaultPath: join('/Users/x/Desktop', 'trace.jsonl'),
});
});
@ -98,7 +99,7 @@ describe('installDownloadHandler', () => {
fireDownload(fakeItem('c.zip'));
// A cancelled dialog must not clobber the remembered directory.
expect(deps.showSaveDialog).toHaveBeenLastCalledWith({
defaultPath: '/Users/x/Desktop/c.zip',
defaultPath: join('/Users/x/Desktop', 'c.zip'),
});
});

View file

@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import {
asJumpListWorkspaces,
buildJumpListCategories,
filterRemovedJumpListWorkspaces,
parseLaunchArgs,
quoteWindowsCommandLineArg,
} from '../../src/main/jump-list';
describe('parseLaunchArgs', () => {
it('returns no action for a plain launch', () => {
expect(parseLaunchArgs(['C:\\Apps\\Kimi Code\\Kimi Code.exe'])).toEqual({ newChat: false });
expect(parseLaunchArgs(['electron', '.'])).toEqual({ newChat: false });
});
it('parses --new-chat', () => {
expect(parseLaunchArgs(['app', '--new-chat'])).toEqual({ newChat: true });
});
it('parses --workspace with a quoted root (Jump List args quote paths with spaces)', () => {
expect(parseLaunchArgs(['app', '--workspace="D:\\My Projects\\kimi"'])).toEqual({
newChat: false,
workspace: 'D:\\My Projects\\kimi',
});
});
it('also accepts an unquoted root (hand-typed)', () => {
expect(parseLaunchArgs(['app', '--workspace=/work/kimi'])).toEqual({
newChat: false,
workspace: '/work/kimi',
});
});
it('ignores an empty workspace value', () => {
expect(parseLaunchArgs(['app', '--workspace=', '--new-chat'])).toEqual({ newChat: true });
expect(parseLaunchArgs(['app', '--workspace=""'])).toEqual({ newChat: false });
});
it('parses both flags together', () => {
expect(parseLaunchArgs(['app', '--new-chat', '--workspace=/work/kimi'])).toEqual({
newChat: true,
workspace: '/work/kimi',
});
});
});
describe('asJumpListWorkspaces', () => {
it('accepts a well-formed list', () => {
expect(
asJumpListWorkspaces([
{ name: 'kimi', root: '/work/kimi' },
{ name: 'app', root: '/work/app' },
]),
).toEqual([
{ name: 'kimi', root: '/work/kimi' },
{ name: 'app', root: '/work/app' },
]);
});
it('drops the whole payload on any malformed entry (tray-attention policy)', () => {
expect(asJumpListWorkspaces('nope')).toBeNull();
expect(asJumpListWorkspaces([{ name: 'kimi' }])).toBeNull();
expect(asJumpListWorkspaces([{ name: 'kimi', root: '' }])).toBeNull();
expect(asJumpListWorkspaces([{ name: 1, root: '/work/kimi' }])).toBeNull();
expect(asJumpListWorkspaces([null])).toBeNull();
});
it('truncates beyond the OS-visible cap instead of rejecting', () => {
const many = Array.from({ length: 12 }, (_, i) => ({ name: `w${i}`, root: `/work/w${i}` }));
expect(asJumpListWorkspaces(many)).toHaveLength(9);
});
it('strips unknown extra fields', () => {
expect(asJumpListWorkspaces([{ name: 'kimi', root: '/work/kimi', branch: 'main' }])).toEqual([
{ name: 'kimi', root: '/work/kimi' },
]);
});
});
describe('quoteWindowsCommandLineArg', () => {
it('doubles trailing backslashes so they cannot escape the closing quote', () => {
expect(quoteWindowsCommandLineArg('C:\\')).toBe('"C:\\\\"');
expect(quoteWindowsCommandLineArg('D:\\Projects\\')).toBe('"D:\\Projects\\\\"');
});
it('quotes spaces without changing ordinary path separators', () => {
expect(quoteWindowsCommandLineArg('D:\\My Projects\\kimi')).toBe(
'"D:\\My Projects\\kimi"',
);
});
});
describe('buildJumpListCategories', () => {
const exec = 'C:\\Apps\\Kimi Code\\Kimi Code.exe';
it('always carries the New Chat task, even with no workspaces', () => {
expect(buildJumpListCategories([], 'en', exec)).toEqual([
{
type: 'tasks',
items: [
{ type: 'task', program: exec, args: '--new-chat', title: 'New Chat', iconPath: exec, iconIndex: 0 },
],
},
]);
});
it('lists workspaces in a custom category ahead of the tasks, quoting roots', () => {
const categories = buildJumpListCategories(
[{ name: 'kimi', root: 'D:\\My Projects\\kimi' }],
'zh',
exec,
);
expect(categories).toHaveLength(2);
expect(categories[0]).toMatchObject({ type: 'custom', name: '最近' });
expect(categories[0]!.items).toEqual([
{
type: 'task',
program: exec,
args: '--workspace="D:\\My Projects\\kimi"',
title: 'kimi',
description: 'D:\\My Projects\\kimi',
iconPath: exec,
iconIndex: 0,
},
]);
expect(categories[1]).toMatchObject({ type: 'tasks' });
expect(categories[1]!.items![0]).toMatchObject({ title: '新建会话', args: '--new-chat' });
});
it('escapes a drive-root workspace for Windows command-line parsing', () => {
const categories = buildJumpListCategories([{ name: 'C drive', root: 'C:\\' }], 'en', exec);
expect(categories[0]!.items![0]).toMatchObject({
args: '--workspace="C:\\\\"',
description: 'C:\\',
});
});
it('caps workspace descriptions at the Windows limit', () => {
const root = `C:\\${'nested\\'.repeat(50)}`;
const categories = buildJumpListCategories([{ name: 'deep', root }], 'en', exec);
const description = categories[0]!.items![0]!.description;
expect(description).toHaveLength(260);
expect(description?.endsWith('…')).toBe(true);
expect(categories[0]!.items![0]!.args).toBe(
`--workspace=${quoteWindowsCommandLineArg(root)}`,
);
});
});
describe('filterRemovedJumpListWorkspaces', () => {
it('does not re-add workspace tasks removed by the user', () => {
const workspaces = [
{ name: 'keep', root: 'D:\\keep' },
{ name: 'removed', root: 'C:\\' },
];
expect(
filterRemovedJumpListWorkspaces(workspaces, [
{ args: `--workspace=${quoteWindowsCommandLineArg('C:\\')}` },
{ args: '--new-chat' },
]),
).toEqual([{ name: 'keep', root: 'D:\\keep' }]);
});
});

View file

@ -95,7 +95,7 @@ describe('defaultMainLogPath', () => {
const prev = process.env['KIMI_CODE_HOME'];
process.env['KIMI_CODE_HOME'] = '/tmp/kimi-home-test';
try {
expect(defaultMainLogPath()).toBe('/tmp/kimi-home-test/logs/kimi-code-desktop.log');
expect(defaultMainLogPath()).toBe(join('/tmp/kimi-home-test', 'logs', 'kimi-code-desktop.log'));
} finally {
if (prev === undefined) delete process.env['KIMI_CODE_HOME'];
else process.env['KIMI_CODE_HOME'] = prev;

View file

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import { listAvailableOpenInApps, openInApp, OPEN_IN_APP_IDS } from '../../src/main/open-in';
@ -14,9 +15,8 @@ function fakeExists(paths: string[]): (p: string) => boolean {
}
describe('listAvailableOpenInApps', () => {
it('returns an empty catalog off macOS (renderer hides the entry)', () => {
it('returns an empty catalog on Linux (renderer hides the entry)', () => {
expect(listAvailableOpenInApps({ platform: 'linux' })).toEqual([]);
expect(listAvailableOpenInApps({ platform: 'win32' })).toEqual([]);
});
it('always includes Finder and Terminal on macOS, even with nothing installed', () => {
@ -28,10 +28,9 @@ describe('listAvailableOpenInApps', () => {
const apps = listAvailableOpenInApps({
platform: 'darwin',
home: HOME,
exists: fakeExists([
'/Applications/Ghostty.app',
`${HOME}/Applications/Zed.app`,
]),
// Built with join(): the implementation joins its candidates the same
// way, so the fake stays consistent on Windows (backslash separators).
exists: fakeExists([join('/Applications', 'Ghostty.app'), join(HOME, 'Applications', 'Zed.app')]),
});
expect(apps.map((a) => a.id)).toEqual(['zed', 'finder', 'terminal', 'ghostty']);
});
@ -42,7 +41,19 @@ describe('listAvailableOpenInApps', () => {
home: HOME,
exists: () => true,
});
expect(everything.map((a) => a.id)).toEqual([...OPEN_IN_APP_IDS]);
expect(everything.map((a) => a.id)).toEqual([
'vscode',
'vscode-insiders',
'cursor',
'zed',
'finder',
'terminal',
'iterm',
'ghostty',
'warp',
'kitty',
'xcode',
]);
expect(everything).toContainEqual({ id: 'vscode', label: 'VS Code' });
expect(everything).toContainEqual({ id: 'vscode-insiders', label: 'VS Code Insiders' });
expect(everything).toContainEqual({ id: 'kitty', label: 'kitty' });
@ -58,10 +69,10 @@ describe('openInApp', () => {
expect(run).not.toHaveBeenCalled();
});
it('rejects off macOS without spawning', async () => {
it('rejects unsupported platforms without spawning', async () => {
const run = vi.fn();
const result = await openInApp('vscode', '/work/dir', { platform: 'linux', run });
expect(result).toEqual({ ok: false, error: 'open-in is only supported on macOS' });
expect(result).toEqual({ ok: false, error: 'open-in is only supported on macOS and Windows' });
expect(run).not.toHaveBeenCalled();
});
@ -82,11 +93,11 @@ describe('openInApp', () => {
const result = await openInApp('ghostty', '/work/dir', {
platform: 'darwin',
home: HOME,
exists: fakeExists([`${HOME}/Applications/Ghostty.app`]),
exists: fakeExists([join(HOME, 'Applications', 'Ghostty.app')]),
run,
});
expect(result).toEqual({ ok: true });
expect(run).toHaveBeenCalledWith('open', ['-a', `${HOME}/Applications/Ghostty.app`, '/work/dir']);
expect(run).toHaveBeenCalledWith('open', ['-a', join(HOME, 'Applications', 'Ghostty.app'), '/work/dir']);
});
it('opens directories in Finder with a bare `open <dir>` (default handler)', async () => {
@ -119,3 +130,168 @@ describe('openInApp', () => {
expect(result).toEqual({ ok: false, error: 'spawn open ENOENT' });
});
});
// --- Windows -------------------------------------------------------------------
const LOCALAPPDATA = '/Users/test/AppData/Local';
const PROGRAM_FILES = '/Program Files';
const WIN_ENV = { LOCALAPPDATA, ProgramFiles: PROGRAM_FILES } as NodeJS.ProcessEnv;
const VSCODE_EXE = join(LOCALAPPDATA, 'Programs', 'Microsoft VS Code', 'Code.exe');
const WT_ALIAS = join(LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'wt.exe');
const GIT_BASH_EXE = join(PROGRAM_FILES, 'Git', 'git-bash.exe');
describe('listAvailableOpenInApps (win32)', () => {
it('exports every app id supported on macOS or Windows', () => {
const macIds = listAvailableOpenInApps({
platform: 'darwin',
home: HOME,
exists: () => true,
}).map((app) => app.id);
const windowsIds = listAvailableOpenInApps({
platform: 'win32',
env: WIN_ENV,
exists: () => true,
}).map((app) => app.id);
expect(OPEN_IN_APP_IDS).toEqual([...new Set([...macIds, ...windowsIds])]);
});
it('always includes File Explorer and a PowerShell-backed Terminal on Windows', () => {
const apps = listAvailableOpenInApps({ platform: 'win32', env: WIN_ENV, exists: () => false });
expect(apps).toEqual([
{ id: 'explorer', label: 'File Explorer' },
{ id: 'windows-terminal', label: 'Terminal' },
]);
});
it('detects per-user editor installs, Windows Terminal, and Git Bash', () => {
const apps = listAvailableOpenInApps({
platform: 'win32',
env: WIN_ENV,
exists: fakeExists([VSCODE_EXE, WT_ALIAS, GIT_BASH_EXE]),
});
expect(apps.map((a) => a.id)).toEqual([
'vscode',
'explorer',
'windows-terminal',
'git-bash',
]);
});
it('falls back to Program Files for system-wide installs', () => {
const systemCursor = join(PROGRAM_FILES, 'Cursor', 'Cursor.exe');
const apps = listAvailableOpenInApps({
platform: 'win32',
env: WIN_ENV,
exists: fakeExists([systemCursor]),
});
expect(apps.map((a) => a.id)).toEqual(['cursor', 'explorer', 'windows-terminal']);
});
it('keeps the catalog order: editors, file manager, terminals', () => {
const apps = listAvailableOpenInApps({ platform: 'win32', env: WIN_ENV, exists: () => true });
expect(apps.map((a) => a.id)).toEqual([
'vscode',
'vscode-insiders',
'cursor',
'zed',
'explorer',
'windows-terminal',
'git-bash',
]);
});
});
describe('openInApp (win32)', () => {
it('rejects unknown app ids (including macOS-only ones) without spawning', async () => {
const runDetached = vi.fn();
expect(await openInApp('emacs', '/work/dir', { platform: 'win32', runDetached })).toEqual({
ok: false,
error: 'unknown open-in app: emacs',
});
expect(await openInApp('finder', '/work/dir', { platform: 'win32', runDetached })).toEqual({
ok: false,
error: 'unknown open-in app: finder',
});
expect(runDetached).not.toHaveBeenCalled();
});
it('reports a clear error when the app is not installed', async () => {
const runDetached = vi.fn();
const result = await openInApp('zed', '/work/dir', {
platform: 'win32',
env: WIN_ENV,
exists: () => false,
runDetached,
});
expect(result).toEqual({ ok: false, error: 'Zed is not installed' });
expect(runDetached).not.toHaveBeenCalled();
});
it('launches editors detached with the directory as the single argument', async () => {
const runDetached = vi.fn().mockResolvedValue({ error: null });
const result = await openInApp('vscode', '/work/dir', {
platform: 'win32',
env: WIN_ENV,
exists: fakeExists([VSCODE_EXE]),
runDetached,
});
expect(result).toEqual({ ok: true });
expect(runDetached).toHaveBeenCalledWith(VSCODE_EXE, ['/work/dir']);
});
it('launches Windows Terminal via its alias with -d <dir>', async () => {
const runDetached = vi.fn().mockResolvedValue({ error: null });
const result = await openInApp('windows-terminal', '/work/dir', {
platform: 'win32',
env: WIN_ENV,
exists: fakeExists([WT_ALIAS]),
runDetached,
});
expect(result).toEqual({ ok: true });
expect(runDetached).toHaveBeenCalledWith(WT_ALIAS, ['-d', '/work/dir']);
});
it('falls back to Windows PowerShell when the Windows Terminal alias is unavailable', async () => {
const runDetached = vi.fn().mockResolvedValue({ error: null });
const targetPath = "C:\\work; Write-Output 'unsafe'";
const result = await openInApp('windows-terminal', targetPath, {
platform: 'win32',
env: WIN_ENV,
exists: () => false,
runDetached,
});
expect(result).toEqual({ ok: true });
const args = runDetached.mock.calls[0]![1] as string[];
expect(args.slice(0, 2)).toEqual(['-NoExit', '-EncodedCommand']);
expect(Buffer.from(args[2]!, 'base64').toString('utf16le')).toBe(
"Set-Location -LiteralPath 'C:\\work; Write-Output ''unsafe'''",
);
});
it('launches Git Bash at the workspace directory', async () => {
const runDetached = vi.fn().mockResolvedValue({ error: null });
const result = await openInApp('git-bash', '/work/dir', {
platform: 'win32',
env: WIN_ENV,
exists: fakeExists([GIT_BASH_EXE]),
runDetached,
});
expect(result).toEqual({ ok: true });
expect(runDetached).toHaveBeenCalledWith(GIT_BASH_EXE, ['--cd=/work/dir']);
});
it('launches Explorer through PATH resolution', async () => {
const runDetached = vi.fn().mockResolvedValue({ error: null });
const result = await openInApp('explorer', '/work/dir', { platform: 'win32', runDetached });
expect(result).toEqual({ ok: true });
expect(runDetached).toHaveBeenCalledWith('explorer.exe', ['/work/dir']);
});
it('surfaces launcher errors as a result instead of throwing', async () => {
const runDetached = vi.fn().mockResolvedValue({ error: 'spawn ENOENT' });
const result = await openInApp('explorer', '/work/dir', { platform: 'win32', runDetached });
expect(result).toEqual({ ok: false, error: 'spawn ENOENT' });
});
});

View file

@ -31,6 +31,7 @@ const WHITELIST = [
'isFullscreen',
'listOpenInApps',
'onFullscreenChanged',
'onLaunchAction',
'onMenu',
'onMenuAction',
'onShortcut',
@ -43,6 +44,7 @@ const WHITELIST = [
'setDockIconChoice',
'setGlobalShortcut',
'setGlobalShortcutSuspended',
'setJumpList',
'setLocale',
'setMenuShortcuts',
'setMenuSuspended',
@ -172,6 +174,14 @@ describe('kimiDesktop preload bridge', () => {
offOs();
expect(removeListener).toHaveBeenCalledWith('kimi:os-appearance-changed', expect.any(Function));
// Jump List: validated workspace lists forward; junk is ignored.
const workspaces = [{ name: 'kimi', root: '/work/kimi' }];
exposed.setJumpList(workspaces);
expect(send).toHaveBeenCalledWith('kimi:jump-list', workspaces);
exposed.setJumpList([{ name: 'x', root: '' }]); // empty root ignored
exposed.setJumpList('nope'); // junk ignored
expect(send).toHaveBeenCalledTimes(9);
const offMenu = exposed.onMenu(() => {});
expect(on).toHaveBeenCalledWith('kimi:menu', expect.any(Function));
offMenu();
@ -202,6 +212,11 @@ describe('kimiDesktop preload bridge', () => {
offTraySelect();
expect(removeListener).toHaveBeenCalledWith('kimi:tray-select-session', expect.any(Function));
const offLaunchAction = exposed.onLaunchAction(() => {});
expect(on).toHaveBeenCalledWith('kimi:launch-action', expect.any(Function));
offLaunchAction();
expect(removeListener).toHaveBeenCalledWith('kimi:launch-action', expect.any(Function));
await exposed.openExternal('https://example.com');
expect(invoke).toHaveBeenCalledWith('kimi:open-external', 'https://example.com');
@ -241,7 +256,7 @@ describe('kimiDesktop preload bridge', () => {
exposed.setVibrancy(false);
expect(send).toHaveBeenCalledWith('kimi:vibrancy', false);
exposed.setVibrancy('yes'); // junk ignored
expect(send).toHaveBeenCalledTimes(9);
expect(send).toHaveBeenCalledTimes(10);
// getVibrancy: only an explicit false from the main process disables.
invoke.mockResolvedValueOnce(false);
@ -319,6 +334,18 @@ describe('kimiDesktop preload bridge', () => {
listeners.get('kimi:update-status')?.({}, { state: 'bogus' });
listeners.get('kimi:update-status')?.({}, 'available');
expect(updateCb).toHaveBeenCalledTimes(1);
// Launch actions forward after structural validation; junk is dropped.
const launchCb = vi.fn();
exposed.onLaunchAction(launchCb);
listeners.get('kimi:launch-action')?.({}, { action: 'new-chat' });
expect(launchCb).toHaveBeenCalledWith({ action: 'new-chat' });
listeners.get('kimi:launch-action')?.({}, { action: 'open-workspace', root: '/work/kimi' });
expect(launchCb).toHaveBeenCalledWith({ action: 'open-workspace', root: '/work/kimi' });
listeners.get('kimi:launch-action')?.({}, { action: 'open-workspace' }); // no root
listeners.get('kimi:launch-action')?.({}, { action: 'bogus' });
listeners.get('kimi:launch-action')?.({}, 'new-chat');
expect(launchCb).toHaveBeenCalledTimes(2);
});
it('passes release notes through field-wise validation, dropping junk note fields only', async () => {

View file

@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest';
import type { NativeImage } from 'electron';
import {
badgePixels,
badgeText,
createTaskbarAttention,
type TaskbarWindowLike,
} from '../../src/main/taskbar';
// Sentinel stand-in for a NativeImage — the controller only passes it through
// to setOverlayIcon.
const BADGE = { id: 'badge' } as unknown as NativeImage;
const badgeFactory = () => BADGE;
interface MockWindow {
win: TaskbarWindowLike;
calls: {
overlays: Array<{ icon: NativeImage | null; description: string }>;
flashes: boolean[];
focusListeners: Array<() => void>;
};
state: { focused: boolean; destroyed: boolean };
}
function mockWindow(): MockWindow {
const state = { focused: true, destroyed: false };
const calls: MockWindow['calls'] = { overlays: [], flashes: [], focusListeners: [] };
return {
state,
calls,
win: {
isDestroyed: () => state.destroyed,
isFocused: () => state.focused,
setOverlayIcon: (icon, description) => {
calls.overlays.push({ icon, description });
},
flashFrame: (flag) => {
calls.flashes.push(flag);
},
on: (_event, listener) => {
calls.focusListeners.push(listener);
},
},
};
}
describe('badgePixels', () => {
it('returns a size*size*4 BGRA buffer', () => {
expect(badgePixels(16, 1).length).toBe(16 * 16 * 4);
});
it('paints an opaque red background and opaque white numeral', () => {
const pixels = badgePixels(16, 1);
const red = (4 * 16 + 8) * 4;
expect([...pixels.subarray(red, red + 4)]).toEqual([0x4d, 0x48, 0xe5, 255]);
const white = (5 * 16 + 7) * 4;
expect([...pixels.subarray(white, white + 4)]).toEqual([255, 255, 255, 255]);
});
it('leaves the corners fully transparent', () => {
const pixels = badgePixels(16, 1);
expect(pixels[3]).toBe(0); // top-left alpha
const topRight = 15 * 4;
expect(pixels[topRight + 3]).toBe(0);
const bottomLeft = (15 * 16) * 4;
expect(pixels[bottomLeft + 3]).toBe(0);
});
it('renders distinct numeric bitmaps and keeps zero fully transparent', () => {
expect(badgePixels(16, 2).equals(badgePixels(16, 8))).toBe(false);
expect(badgePixels(16, 0).every((byte) => byte === 0)).toBe(true);
});
});
describe('badgeText', () => {
it('formats taskbar counts in the compact 1-99 / 99+ range', () => {
expect(badgeText(0)).toBe('');
expect(badgeText(1)).toBe('1');
expect(badgeText(99)).toBe('99');
expect(badgeText(100)).toBe('99+');
});
});
describe('createTaskbarAttention', () => {
it('sets the overlay badge with the description while attention pends', () => {
const { win, calls } = mockWindow();
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(3, '3 unread');
expect(calls.overlays).toEqual([{ icon: BADGE, description: '3 unread' }]);
});
it('refreshes the overlay description without flashing when only the locale changes', () => {
const { win, calls, state } = mockWindow();
state.focused = false;
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(3, '3 unread');
controller.update(3, '3 条未读');
expect(calls.overlays).toEqual([
{ icon: BADGE, description: '3 unread' },
{ icon: BADGE, description: '3 条未读' },
]);
expect(calls.flashes).toEqual([]);
});
it('clears the overlay when caught up', () => {
const { win, calls } = mockWindow();
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(3, '3 unread');
controller.update(0, '');
expect(calls.overlays[1]).toEqual({ icon: null, description: '' });
});
it('clears any stale overlay when badge rendering fails (flash-only degrade)', () => {
const { win, calls } = mockWindow();
const controller = createTaskbarAttention(win, () => null);
controller.update(3, '3 unread');
controller.update(0, '');
expect(calls.overlays).toEqual([
{ icon: null, description: '' },
{ icon: null, description: '' },
]);
});
it('uses the first update as a baseline without flashing', () => {
const { win, calls, state } = mockWindow();
state.focused = false;
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(1, '1 unread');
expect(calls.flashes).toEqual([]);
});
it('flashes when the total grows after the baseline while unfocused', () => {
const { win, calls, state } = mockWindow();
state.focused = false;
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(1, '1 unread');
controller.update(2, '2 unread');
expect(calls.flashes).toEqual([true]);
});
it('does not flash while the window is focused', () => {
const { win, calls } = mockWindow();
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(1, '1 unread');
controller.update(2, '2 unread');
expect(calls.flashes).toEqual([]);
});
it('does not re-flash on an unchanged or shrinking total', () => {
const { win, calls, state } = mockWindow();
state.focused = false;
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(2, '2 unread');
controller.update(2, '2 unread');
controller.update(1, '1 unread');
expect(calls.flashes).toEqual([]);
});
it('stops flashing when the total reaches zero', () => {
const { win, calls, state } = mockWindow();
state.focused = false;
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(1, '1 unread');
controller.update(2, '2 unread');
controller.update(0, '');
expect(calls.flashes).toEqual([true, false]);
});
it('stops flashing on window focus', () => {
const { win, calls, state } = mockWindow();
state.focused = false;
const controller = createTaskbarAttention(win, badgeFactory);
controller.update(1, '1 unread');
controller.update(2, '2 unread');
calls.focusListeners.forEach((listener) => listener());
expect(calls.flashes).toEqual([true, false]);
});
it('ignores updates after the window is destroyed', () => {
const { win, calls, state } = mockWindow();
const controller = createTaskbarAttention(win, badgeFactory);
state.destroyed = true;
controller.update(1, '1 unread');
expect(calls.overlays).toEqual([]);
expect(calls.flashes).toEqual([]);
});
});

View file

@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { isAppRendererUrl, looksMaximizedBounds, shouldHideOnClose, shouldPersistBounds, vibrancyWindowOptions } from '../../src/main/window';
import {
clampBoundsToWorkArea,
drainLaunchActions,
installWindowsSessionEndWatch,
isAppRendererUrl,
looksMaximizedBounds,
shouldHideOnClose,
shouldPersistBounds,
vibrancyWindowOptions,
} from '../../src/main/window';
describe('isAppRendererUrl', () => {
it('accepts the packaged renderer protocol and the dev-server http URL', () => {
@ -18,20 +27,57 @@ describe('isAppRendererUrl', () => {
});
describe('shouldHideOnClose', () => {
it('hides instead of destroying on macOS (tray-resident model)', () => {
it('hides instead of destroying on macOS and Windows (tray-resident model)', () => {
expect(shouldHideOnClose('darwin', false)).toBe(true);
expect(shouldHideOnClose('win32', false)).toBe(true);
});
it('lets real quits destroy the window', () => {
expect(shouldHideOnClose('darwin', true)).toBe(false);
expect(shouldHideOnClose('win32', true)).toBe(false);
});
it('keeps destroy-on-close on other platforms', () => {
expect(shouldHideOnClose('win32', false)).toBe(false);
expect(shouldHideOnClose('linux', false)).toBe(false);
});
});
describe('installWindowsSessionEndWatch', () => {
it('marks only the final Windows session-end event as quitting', () => {
const listeners = new Map<string, () => void>();
const markEnding = vi.fn();
installWindowsSessionEndWatch(
'win32',
{ on: (event, listener) => listeners.set(event, listener) },
markEnding,
);
expect(listeners.has('query-session-end')).toBe(false);
listeners.get('session-end')?.();
expect(markEnding).toHaveBeenCalledOnce();
});
it('does not install Windows session listeners on other platforms', () => {
const on = vi.fn();
installWindowsSessionEndWatch('darwin', { on }, vi.fn());
expect(on).not.toHaveBeenCalled();
});
});
describe('drainLaunchActions', () => {
it('preserves every queued launch action in order and empties the queue', () => {
const actions = [
{ action: 'new-chat' as const },
{ action: 'open-workspace' as const, root: 'C:\\workspace' },
];
expect(drainLaunchActions(actions)).toEqual([
{ action: 'new-chat' },
{ action: 'open-workspace', root: 'C:\\workspace' },
]);
expect(actions).toEqual([]);
});
});
describe('shouldPersistBounds', () => {
it('persists normal window bounds', () => {
expect(shouldPersistBounds(false, false)).toBe(true);
@ -58,6 +104,47 @@ describe('looksMaximizedBounds', () => {
});
});
describe('clampBoundsToWorkArea', () => {
const workArea = { x: 0, y: 0, width: 1920, height: 1080 };
it('leaves on-screen bounds untouched (same reference)', () => {
const bounds = { width: 1280, height: 860, x: 100, y: 80 };
expect(clampBoundsToWorkArea(bounds, workArea)).toBe(bounds);
});
it('leaves position-less bounds untouched', () => {
const bounds = { width: 1280, height: 860 };
expect(clampBoundsToWorkArea(bounds, workArea)).toBe(bounds);
});
it('pulls a fully off-screen window (unplugged monitor) back onto the work area', () => {
const clamped = clampBoundsToWorkArea({ width: 1280, height: 860, x: 3000, y: 400 }, workArea);
expect(clamped.x).toBe(1920 - 100);
expect(clamped.y).toBe(400);
});
it('pulls a window parked left of the work area back (keeps 100px visible)', () => {
const clamped = clampBoundsToWorkArea({ width: 1280, height: 860, x: -2000, y: 100 }, workArea);
expect(clamped.x).toBe(-1280 + 100);
});
it('never lets the title bar go above the work area', () => {
const clamped = clampBoundsToWorkArea({ width: 1280, height: 860, x: 200, y: -300 }, workArea);
expect(clamped.y).toBe(0);
});
it('clamps a window sunk below the work area', () => {
const clamped = clampBoundsToWorkArea({ width: 1280, height: 860, x: 200, y: 2000 }, workArea);
expect(clamped.y).toBe(1080 - 100);
});
it('respects a non-zero work area origin (secondary display)', () => {
const secondary = { x: -2560, y: 30, width: 2560, height: 1410 };
const bounds = { width: 1280, height: 860, x: -2400, y: 100 };
expect(clampBoundsToWorkArea(bounds, secondary)).toBe(bounds);
});
});
describe('vibrancyWindowOptions', () => {
it('always passes the pinned flat material + transparent flash on macOS (an opt-out launch removes it right after creation)', () => {
expect(vibrancyWindowOptions('darwin')).toEqual({

View file

@ -0,0 +1,129 @@
import { computed, nextTick, ref } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createJumpListReporter,
createLaunchActionRouter,
jumpListEntriesEqual,
useJumpList,
type JumpListWorkspaceEntry,
type LaunchActionPayload,
} from '../../src/renderer/composables/useJumpList';
function entries(...roots: string[]): JumpListWorkspaceEntry[] {
return roots.map((root) => ({ name: root.split('/').pop() ?? root, root }));
}
describe('jumpListEntriesEqual', () => {
it('compares entry lists structurally', () => {
expect(jumpListEntriesEqual(entries('/a', '/b'), entries('/a', '/b'))).toBe(true);
expect(jumpListEntriesEqual(entries('/a'), entries('/a', '/b'))).toBe(false);
expect(jumpListEntriesEqual(entries('/a'), entries('/b'))).toBe(false);
expect(jumpListEntriesEqual([{ name: 'x', root: '/a' }], [{ name: 'y', root: '/a' }])).toBe(false);
});
});
describe('createJumpListReporter', () => {
it('is a no-op without the bridge', () => {
const source = ref<JumpListWorkspaceEntry[] | null>(entries('/a'));
expect(() => createJumpListReporter(undefined, computed(() => source.value))).not.toThrow();
});
it('does not push null (client still loading) and pushes once loaded', async () => {
const setJumpList = vi.fn();
const source = ref<JumpListWorkspaceEntry[] | null>(null);
createJumpListReporter({ setJumpList }, computed(() => source.value));
expect(setJumpList).not.toHaveBeenCalled();
source.value = entries('/a', '/b');
await nextTick();
expect(setJumpList).toHaveBeenCalledWith(entries('/a', '/b'));
});
it('pushes identical successive lists only once', async () => {
const setJumpList = vi.fn();
const source = ref<JumpListWorkspaceEntry[] | null>(entries('/a'));
createJumpListReporter({ setJumpList }, computed(() => source.value));
// A re-render producing a fresh but identical array must not re-push.
source.value = entries('/a');
await nextTick();
source.value = entries('/a', '/b');
await nextTick();
expect(setJumpList).toHaveBeenCalledTimes(2);
expect(setJumpList).toHaveBeenLastCalledWith(entries('/a', '/b'));
});
});
describe('createLaunchActionRouter', () => {
it('is a no-op without the bridge method', () => {
expect(() => createLaunchActionRouter(undefined, () => {})).not.toThrow();
});
it('forwards payloads to the handler and unsubscribes', () => {
const listeners = new Set<(payload: LaunchActionPayload) => void>();
const bridge = {
onLaunchAction: vi.fn((cb: (payload: LaunchActionPayload) => void) => {
listeners.add(cb);
return () => listeners.delete(cb);
}),
};
const handler = vi.fn();
const stop = createLaunchActionRouter(bridge, handler);
listeners.forEach((cb) => cb({ action: 'new-chat' }));
expect(handler).toHaveBeenCalledWith({ action: 'new-chat' });
stop();
listeners.forEach((cb) => cb({ action: 'new-chat' }));
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe('useJumpList', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function client(initialized: boolean, roots: string[]) {
return {
workspacesView: computed(() =>
roots.map((root, i) => ({ name: `w${i}`, root, id: `id${i}` })),
),
initialized: ref(initialized),
};
}
it('is a no-op without the bridge', () => {
vi.stubGlobal('window', {});
expect(() => useJumpList(client(true, ['/a']), () => {})).not.toThrow();
});
it('pushes the workspace list (capped at 9) once initialized', () => {
const setJumpList = vi.fn();
vi.stubGlobal('window', { kimiDesktop: { setJumpList } });
const many = Array.from({ length: 12 }, (_, i) => `/w${i}`);
useJumpList(client(true, many), () => {});
expect(setJumpList).toHaveBeenCalledTimes(1);
expect(setJumpList.mock.calls[0]![0]).toHaveLength(9);
});
it('holds the first push until the client initializes', () => {
const setJumpList = vi.fn();
vi.stubGlobal('window', { kimiDesktop: { setJumpList } });
useJumpList(client(false, ['/a']), () => {});
expect(setJumpList).not.toHaveBeenCalled();
});
it('routes launch actions when the bridge method exists', () => {
const listeners = new Set<(payload: LaunchActionPayload) => void>();
vi.stubGlobal('window', {
kimiDesktop: {
onLaunchAction: (cb: (payload: LaunchActionPayload) => void) => {
listeners.add(cb);
return () => {};
},
},
});
const handler = vi.fn();
useJumpList(client(true, []), handler);
listeners.forEach((cb) => cb({ action: 'open-workspace', root: '/a' }));
expect(handler).toHaveBeenCalledWith({ action: 'open-workspace', root: '/a' });
});
});

View file

@ -0,0 +1,135 @@
# Windows 体验原生化的第一轮 实施计划
> 交给执行者的实施计划。本文档自包含,不需要其他上下文。
> 现状调研结论见 §1含文件:行号,执行时不必重查)。全部改动都在 `apps/desktop`(主进程为主),`apps/web` 不涉及;`kimi-code/` submodule 不动。
> 自定义标题栏frameless + titleBarOverlay用户明确放到最后单独立项**不在本计划范围**。
## 1. 背景Windows 现状(调研结论)
desktop 是 Electron 壳(`apps/desktop`,包名 `kimi-code-app`),主进程在进程内起 kap-serverrenderer 经 `app://renderer` 自定义协议加载 `desktop-dist`。macOS 已做了一批原生能力隐藏标题栏、菜单栏计数、Dock badge、hide-on-close 驻留、OpenIn、桌宠Windows 侧现状:
1. **关窗即退出**`window.ts:71` `shouldHideOnClose` 只放行 darwin`app.ts:27-31` `window-all-closed` 在非 darwin 直接 `app.quit()`。Windows 上点 X = 整个应用(含内嵌 server、托盘消失。托盘 attention 菜单(`tray.ts`、OS 级唤起快捷键(`shortcuts.ts`都依赖应用驻留才有意义Windows 上形同虚设。
2. **无单实例锁**:双击图标起第二个完整实例(第二个内嵌 server、第二个托盘、第二个窗口`app.ts` `main()` 里没有 `requestSingleInstanceLock()`
3. **离屏窗口恢复**`window.ts:146` `loadBounds()``screen.getDisplayMatching()` 选显示器,但**不把 x/y clamp 进 workArea**——拔掉外接屏后窗口开到不可见区域Windows 笔记本 dock/undock 高频场景的经典 bug。现有 `looksMaximizedBounds` 只处理"存的时候正好最大化"的情况。
4. **托盘交互反 Windows 惯例**`tray.ts:340-342` win32 下左键弹上下文菜单。Windows 惯例:左键 = 显示主窗口,右键 = 菜单(`setContextMenu` 后右键自动弹,不需要代码)。
5. **待处理提醒在 Windows 无落点**renderer 经 `kimi:tray-attention` 推送 `{unread, approvals, questions, items}``ipc.ts:69-74``tray.ts setTrayAttention`macOS 落点是菜单栏计数 + Dock badgeWindows 上 `Tray.setTitle` 无效、`app.dock` 不存在,只有 tooltip/菜单变化——用户不点托盘永远看不到。任务栏角标(`win.setOverlayIcon`)与闪动(`win.flashFrame`)都没接。
6. **Jump List 缺失**:任务栏右键没有最近工作区/常用任务。主进程不知道工作区列表(数据在 renderer`useWorkspaceState.ts:955` `api.getFsHome()``recentRoots` + 当前 workspaces 列表),需要新 IPC 推送。`native-todos.md` 的「最近工作区接入 OS」条目就是这个。
7. **OpenIn 目录 Windows 为空**`open-in.ts:93` 非 darwin 直接 `return []`renderer 把整个入口隐藏(`nativeOpenIn.ts` 空目录即隐藏)。该模块是纯函数 + 依赖注入fs/platform/home加平台分支即可。Windows 侧目标应用VS Code、Cursor、Explorer恒有、Windows Terminal`wt.exe`)。
相关既有机制(复用,不重造):
- macOS hide-on-close 全链路:`window.ts` `isQuitting` / `installQuitWatch` / `markQuitting`updater 安装前显式标记)、全屏先退再藏(`pendingFullscreenHide`)、`showMainWindow()` un-minimize + show + focus、`activate` 重建。全部平台无关,直接生效。
- 托盘菜单重建:`setTrayAttention``renderTray()`,字符串表双语(`TRAY_STRINGS`)。
- 唤起快捷键已调 `showMainWindow()``shortcuts.ts`hide-on-close 后在 Windows 上自动可用。
- OpenIn renderer 侧已按「空目录即隐藏」设计win32 目录非空后入口自动出现;图标映射在 `src/renderer/lib/nativeOpenIn.ts``openInAppIcon(id)`
## 2. 目标 / 非目标
**目标**
1. Windows 关窗 = 隐藏驻留托盘(真退出走托盘「退出」/ 更新器安装),行为与 macOS 对齐。
2. 单实例:二次启动聚焦已有窗口,不再起第二实例。
3. 窗口位置恢复永不落到屏幕外。
4. 托盘左键显示窗口、右键菜单,符合 Windows 惯例。
5. 有待处理项时任务栏图标有角标;新增待处理且窗口未聚焦时任务栏闪动。
6. 任务栏 Jump List「新建会话」task + 最近工作区(点击直达该工作区)。
7. OpenIn 在 Windows 可用VS Code / Cursor / Explorer / Windows Terminal。
**非目标(不要做)**
- 不做自定义标题栏 / `titleBarOverlay`(用户明确放到最后,单独立项)。
- 不改 macOS 任何既有行为(所有平台门控新增 win32 分支,不动 darwin 分支逻辑)。
- 不动 `apps/web``packages/*``kimi-code/` submodule。
- 不做 Windows 签名、不做原生通知、不做桌宠 Windows 版、不做 NSIS 安装器定制(后续单独立项)。
- 不做多窗口。
## 3. 任务分解
任务 1-4 互相独立、都是小改动,可按序一个 PR 或分 PR任务 5/7 中等;任务 6 最大(跨 argv/IPC/renderer 三段),放最后。每个任务完成都要:补测试、`pnpm --filter kimi-code-app run typecheck && pnpm --filter kimi-code-app run test` 过、`pnpm test`(根 vitest过、按 `changeset` skill 生成 patch changeset只选 `kimi-code-app`)、更新 `apps/desktop/docs/native-todos.md` 对应条目。
### 任务 1Windows 关窗驻留托盘hide-on-close 扩展到 win32
- `window.ts` `shouldHideOnClose(platform, quitting)``platform === 'darwin'` 改为 `(platform === 'darwin' || platform === 'win32')`quitting 语义不变。
- 全屏隐藏分支(`win.isFullScreen()` → 先 `setFullScreen(false)` 再 hide在 Windows 同样成立F11 全屏),无需改。
- `app.ts` `window-all-closed` → 非 darwin `app.quit()` **保留**hide-on-close 下正常路径不再触发窗口销毁,它是「窗口真被销毁」的兜底,语义刚好正确。
- 退出路径已闭环:托盘「退出」→ `app.quit()``before-quit``isQuitting = true` → close 放行销毁updater 安装走 `markQuitting()`。菜单 File →「关闭窗口」close role在 Windows 也变成隐藏,与 macOS Cmd+W 语义对齐,可接受。
- 注意 `tray.ts` 头部注释与 `window.ts` 各注释里「macOS hide-on-close」的表述要随代码同步更新行为变了注释不能留旧描述
- 测试:更新 `tests/main/window.test.ts``shouldHideOnClose` 用例win32 true、win32+quitting false、linux false
- 真机验证(`pnpm dev:desktop`):点 X 窗口隐藏、托盘还在;托盘「显示主窗口」/ 唤起快捷键秒回(不重载 renderer托盘「退出」真退出更新安装流程不受阻。
### 任务 2单实例锁
- `app.ts` `main()` 最前(`registerRendererScheme()` 之后、其余注册之前):`const gotLock = app.requestSingleInstanceLock()``!gotLock``app.quit()` + return不注册 IPC、不起 server、不建窗
- `app.on('second-instance', () => showMainWindow())` 挂在拿到锁的分支里。任务 6 会扩展这里解析 argv。
- 三平台统一生效macOS 正常路径不触发 second-instance无害dev 与打包版 userData 目录不同name vs productName 派生锁互不影响保留「dev 与正式版同机并存」的既有调试能力tray 标题 `dev` 前缀的注释验证了这是既有意图)。
- 无合理单测姿势app.ts 是 Electron 编排层),真机验证:双击第二次图标 → 旧窗口被聚焦,无新托盘/新窗口。
### 任务 3离屏窗口 clamp
- `window.ts` 新增纯函数 `clampBoundsToWorkArea(bounds, workArea)` 并导出x/y 溢出 workArea 时 clamp 到边缘(保留至少 ~100px 可见即可,简单 clamp 不做居中size 不动。
- `loadBounds()` 在现有 `looksMaximizedBounds` 检查后过一遍 clamp。
- 测试:`tests/main/window.test.ts` 加用例(完全离屏 → clamp 回边缘、部分离屏 → clamp、正常 → 原样、无 x/y → 默认)。
### 任务 4托盘交互对齐 Windows 惯例
- `tray.ts` win32 分支:`tray.on('click', ...)` 由弹菜单改为 `actions.showMainWindow()`;右键菜单是 `setContextMenu` 的系统默认行为,无需代码。可补 `double-click``showMainWindow()`(部分用户习惯双击)。
- 同步更新 `tray.ts:8-9` 头部注释(旧行为描述)。
- 测试tray 模块的纯函数不受影响交互行为真机验证左键出窗口、右键出菜单、attention 菜单点击跳会话不回退)。
### 任务 5任务栏 attentionoverlay icon + flashFrame
- 新模块 `src/main/taskbar.ts`(依赖方向 `tray.ts → taskbar.ts → window.ts`无环window.ts 不 import 这两个):
- `setTaskbarAttention(total: number)`win32 且 `total > 0``win.setOverlayIcon(badge, tooltip)``total === 0``setOverlayIcon(null, '')`。其他平台 no-op。
- 闪动total **从 0 升到 >0**(或比上次增大)且窗口未聚焦(`!win.isFocused()`,含隐藏)→ `win.flashFrame(true)``win.on('focus')` / total 归零 → `flashFrame(false)`。Windows 上 flashFrame(true) 会一直闪到聚焦,符合惯例。
- 角标资产:新增 `build/overlay-badge.png`16/20/24/32 多尺寸或单 32px红色圆点 + 白色计数数字渲染成本高,第一版用**纯红点**tooltip 文案带分类汇总——复用 `trayAttentionSummary`)。资产走 `extraResources``build/` + filter 既有模式filter 列表加 `overlay-*`(注意 `electron-builder.config.cjs` 注释的坑:`from` 不吃 glob`filter` 数组里加)。
- 缺资产时 `nativeImage.isEmpty()` 降级为只闪动不角标(照 tray.ts:326 的 loud-degrade 模式)。
- `tray.ts` `setTrayAttention` 里调 `setTaskbarAttention(total)`macOS 上 no-opmac 已有菜单栏计数 + Dock badge
- 测试:新建 `tests/main/taskbar.test.ts`——mock window 对象断言 overlay 设置/清除、闪动触发条件0→N 且未聚焦触发、聚焦时不触发、归零清除)、非 win32 no-op、缺资产降级。
### 任务 6Jump List新建会话 + 最近工作区)
分三段,最大的一项:
- **推送链路**:新 IPC channel `kimi:jump-list``ipc-channels.ts` + `ipc.ts` handler + `preload.ts` 白名单方法 `setJumpList(items)` + `tests/main/preload.test.ts`。renderer 新 composable `src/renderer/composables/useJumpList.ts`desktop-only无桥 no-op`useTrayAttention.ts` 模式watch 当前 workspaces 列表(名称 + 路径,按最近活动排序,上限 9 条)推给主进程;主进程校验(结构 + 条数上限)后 win32 调 `app.setJumpList`
- `tasks` 段:「新建会话」`{ program: process.execPath, args: '--new-chat', iconPath: process.execPath }`
- custom 段「最近」(双语,走 tray.ts 字符串表同款模式):每个工作区一个 `{ type: 'task', program: process.execPath, args: '--workspace=<path>', title: <name> }`。**不用 `type: 'file'`**——目录没有文件关联点了不可靠task + argv 自己解析才可控。
- **argv 解析与路由**:主进程新增纯函数 `parseLaunchArgs(argv): { newChat: boolean; workspace?: string }``--new-chat` / `--workspace=<path>`)。两个入口:首实例启动(`process.argv`,在 `connect()` 完成、renderer ready 后经 `sendToRenderer` 下发——用 window.ts 现有的「renderer 未就绪先排队」同款模式,别新造队列)和 `second-instance`(任务 2 的回调扩展,解析 `argv` 再下发)。下发走新的 renderer event channel `kimi:launch-action`payload `{action: 'new-chat'} | {action: 'open-workspace', path}`)。
- **renderer 消费**`App.vue` 订阅desktop 分叉块):`new-chat` → 复用 `handleCreateSession()``open-workspace` → 复用 add-workspace 全链路(`createAddWorkspaceEntry``addWorkspace` 路径——工作区已存在则选中不存在则添加并选中。preload 白名单加 `onLaunchAction`
- macOS 不注册 Jump List无此 APIargv 解析保留无害。
- 测试:`tests/main/` 新增——`parseLaunchArgs`(各形态/畸形、payload 校验、Jump List 模板构建(双语);`tests/renderer/useJumpList.test.ts`(无桥 no-op、推送去重、排序截断preload 白名单同步。
- 真机验证:打包后验证 Jump List 条目出现与点击行为dev 下 `process.execPath` 是 electron 二进制Jump List 仅打包版可见计划内说明即可dev 下可用命令行 `--workspace=...` 验证 argv 路由。
### 任务 7OpenIn Windows 目录
- `open-in.ts` 加 win32 分支(现有 DI 模式不变deps 加 `env`/`which` 探针):
- **VS Code** / **Cursor**:探测固定安装路径(`%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe``%LOCALAPPDATA%\Programs\cursor\Cursor.exe`,再退 `%ProgramFiles%` 变体);启动 `spawn(exe, [dir], { detached, stdio: ignore })`
- **Windows Terminal**`wt.exe` 在 PATH 上Store 安装);探测走 PATH 扫描;启动 `wt -d <dir>`
- **Explorer**:系统恒有;启动 `explorer.exe <dir>`——注意 explorer 成功也常返回 exit code 1用 detached + `unref`,不把 exit code 当失败(单独处理,别污染 vscode 的错误判定)。
- id 复用现有 `OpenInAppId``vscode` / `cursor`,新增 `explorer` / `windows-terminal`(类型与 `OPEN_IN_APP_IDS` 同步)。
- 菜单顺序保持「编辑器 → 文件管理器 → 终端」的既有约定。
- renderer 图标:`nativeOpenIn.ts` `openInAppIcon` 加新 id 映射;`src/renderer/assets/app-icons/` 需要 Explorer / WT 的图标资产(无法像 macOS 从 icns 提取,用商标官方 PNG 或先回退到既有的 tabler 图标——执行时按资产可得性定,缺图标时菜单项必须有无图标兜底渲染,先确认 `OpenInMenu.vue` 对无 icon 的行为)。
- `listAvailableOpenInApps` / `openInApp` 的平台门控从 `!== 'darwin' return []` 改为 darwin / win32 双分支linux 仍空。
- 测试:`tests/main/open-in.test.ts` 加 win32 用例(平台门控、四应用探测命中/未装、argv 构造、explorer exit-code 特例、未装回传mac 用例不动。
- 同步 `native-todos.md` 的 OpenIn 条目(「已知限制:第一版仅 macOS」更新
## 4. 验证与收尾(每个 PR 都要)
```bash
pnpm --filter kimi-code-app run typecheck
pnpm --filter kimi-code-app run test
pnpm test # 根 vitest
pnpm lint
```
- 真机:本机就是 Windows`pnpm dev:desktop` 逐项过行为(任务 1/2/3/4/5 都可 dev 验证;任务 6 的 Jump List 弹出需打包,可用 `electron-builder --dir` 出免安装目录验证,不必全量打包)。
- `apps/desktop/docs/native-todos.md`每完成一项更新对应条目已完成项打勾并补实现要点OpenIn 条目改「已知限制」表述)。
- 根 `AGENTS.md``apps/desktop/README.md` 如涉及结构/行为约定变化hide-on-close 跨平台、单实例),同步一句。
- 每个任务(或批次)按 `changeset` skill 写 patch changeset只选 `kimi-code-app`
## 5. 建议执行顺序
1. 任务 2单实例→ 任务 1hide-on-close先保证二次启动不炸再改变关窗语义。
2. 任务 3clamp、任务 4托盘交互独立小项可与 1 同一批。
3. 任务 5任务栏 attention
4. 任务 7OpenIn Windows
5. 任务 6Jump List最大且依赖任务 2 的 second-instance 钩子。

View file

@ -1,8 +1,29 @@
import type { UserConfig } from 'vite';
import type { Plugin, UserConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Icons from 'unplugin-icons/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
const RAW_ICON_PREFIX = '\0kimi-raw-icon:';
function rawIconPlugin(icons: Plugin): Plugin {
return {
name: 'kimi-raw-icons',
enforce: 'pre',
resolveId(id) {
if (/^(?:\/?~icons\/|virtual[:/]icons\/).+[?&]raw(?:[=&]|$)/.test(id)) {
return `${RAW_ICON_PREFIX}${encodeURIComponent(id)}`;
}
return null;
},
async load(id) {
if (!id.startsWith(RAW_ICON_PREFIX) || typeof icons.load !== 'function') {
return null;
}
return icons.load.call(this, decodeURIComponent(id.slice(RAW_ICON_PREFIX.length)));
},
};
}
export interface KimiRendererViteOptions {
readonly root: string;
readonly iconsDir: string;
@ -12,16 +33,24 @@ export interface KimiRendererViteOptions {
export function kimiRendererViteConfig(opts: KimiRendererViteOptions): UserConfig {
const { root, iconsDir, defines, target = 'es2022' } = opts;
const iconPlugins = [
Icons({
compiler: 'vue3',
customCollections: {
kimi: FileSystemIconLoader(iconsDir),
},
}),
].flat();
const icons = iconPlugins.find(
(plugin): plugin is Plugin => plugin.name === 'unplugin-icons' && typeof plugin.load === 'function',
);
if (!icons) throw new Error('unplugin-icons did not provide a load hook');
return {
root,
plugins: [
vue(),
Icons({
compiler: 'vue3',
customCollections: {
kimi: FileSystemIconLoader(iconsDir),
},
}),
rawIconPlugin(icons),
...iconPlugins,
],
define: {
// Bundle build time (ISO), shown as "build time" in settings → advanced.

View file

@ -10,4 +10,16 @@ describe('kimiRendererViteConfig', () => {
const plugins = (cfg.plugins ?? []).flat().filter(Boolean).map((p: any) => p?.name).join('|');
expect(plugins).toMatch(/unplugin-icons/);
});
it('loads raw icon imports through an internal virtual id', async () => {
const cfg = kimiRendererViteConfig({ root: '/x', iconsDir: '/x/icons/kimi' });
const plugins = (cfg.plugins ?? []).flat().filter(Boolean) as any[];
const rawIcons = plugins.find((plugin) => plugin.name === 'kimi-raw-icons');
const resolved = rawIcons.resolveId('~icons/ri/add-line?raw');
expect(resolved).toBe('\0kimi-raw-icon:~icons%2Fri%2Fadd-line%3Fraw');
await expect(rawIcons.load.call({}, resolved)).resolves.toMatchObject({
code: expect.stringContaining('export default "<svg'),
});
});
});