mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-29 03:53:09 +00:00
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:
parent
41970aa227
commit
c13cef5811
43 changed files with 2067 additions and 106 deletions
5
.changeset/single-instance.md
Normal file
5
.changeset/single-instance.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复 Windows 重复启动应用时打开多个实例的问题。
|
||||
5
.changeset/windows-jump-list.md
Normal file
5
.changeset/windows-jump-list.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
Windows 任务栏右键菜单支持新建会话和打开最近工作区。
|
||||
5
.changeset/windows-open-in.md
Normal file
5
.changeset/windows-open-in.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
「用其他应用打开」支持 Windows。
|
||||
5
.changeset/windows-system-notifications.md
Normal file
5
.changeset/windows-system-notifications.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复 Windows 下的通知功能。
|
||||
5
.changeset/windows-tray-click.md
Normal file
5
.changeset/windows-tray-click.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
优化 Windows 托盘功能。
|
||||
|
|
@ -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-op);dev 下手动把 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 server(renderer 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-op);dev 下手动把 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 server(renderer 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` 等源码。
|
||||
|
|
|
|||
|
|
@ -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` 语言同步也在这里。
|
||||
badge(macOS 菜单栏计数 + 托盘菜单按会话跳转;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 版本)
|
||||
|
|
|
|||
|
|
@ -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 同步受益)。
|
||||
- 已知限制:第一版仅 macOS;Windows/Linux 返回空目录并隐藏入口。
|
||||
- 已知限制:仅 macOS / Windows(2026-07 已补 Windows 侧:VS Code / VS Code Insiders / Cursor / Zed 走 LOCALAPPDATA/ProgramFiles 固定路径探测;File Explorer 恒有;Terminal 优先 `wt.exe`、不可用时回退 Windows PowerShell;Git 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 List,2026-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 action(desktop 分叉块):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 再镜像 localStorage(7 天 TTL)。
|
||||
|
|
@ -129,6 +131,17 @@
|
|||
- TODO(代码内 `TODO(help-menu)` 注释同步):What's New(changelog)、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 白色点阵数字,1–99 显示数字、超过 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 channel,webUtils 就在 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 刻意不 stopPropagation(document 计数 floor 0 兜底);drop 提取到 ≥1 个路径才拦截并 `emit('addWorkspacePaths', paths)`,否则原样冒泡回落附件流程——普通文件拖放行为两端零变化;内部工作区排序拖是 `text/plain` payload,天然不命中启发式。高亮 UI 为 `.col` 内绝对定位 overlay(pointer-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 端「拖文件到侧边栏 = 附件」行为不变。拖到非侧边栏区域保持现状(用户明确决定:文件夹落聊天区仍走附件流程,不特判)。
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
30
apps/desktop/scripts/rebuild-native.mjs
Normal file
30
apps/desktop/scripts/rebuild-native.mjs
Normal 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);
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
159
apps/desktop/src/main/jump-list.ts
Normal file
159
apps/desktop/src/main/jump-list.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
191
apps/desktop/src/main/taskbar.ts
Normal file
191
apps/desktop/src/main/taskbar.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 web→desktop 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();
|
||||
|
|
|
|||
BIN
apps/desktop/src/renderer/assets/app-icons/explorer.png
Normal file
BIN
apps/desktop/src/renderer/assets/app-icons/explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/desktop/src/renderer/assets/app-icons/git-bash.png
Normal file
BIN
apps/desktop/src/renderer/assets/app-icons/git-bash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/desktop/src/renderer/assets/app-icons/windows-terminal.png
Normal file
BIN
apps/desktop/src/renderer/assets/app-icons/windows-terminal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 763 B |
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
110
apps/desktop/src/renderer/composables/useJumpList.ts
Normal file
110
apps/desktop/src/renderer/composables/useJumpList.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
78
apps/desktop/tests/main/app.test.ts
Normal file
78
apps/desktop/tests/main/app.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
164
apps/desktop/tests/main/jump-list.test.ts
Normal file
164
apps/desktop/tests/main/jump-list.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
188
apps/desktop/tests/main/taskbar.test.ts
Normal file
188
apps/desktop/tests/main/taskbar.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
129
apps/desktop/tests/renderer/useJumpList.test.ts
Normal file
129
apps/desktop/tests/renderer/useJumpList.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
135
docs/plans/2026-07-23-windows-native-pass.md
Normal file
135
docs/plans/2026-07-23-windows-native-pass.md
Normal 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-server,renderer 经 `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 badge;Windows 上 `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` 对应条目。
|
||||
|
||||
### 任务 1:Windows 关窗驻留托盘(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:任务栏 attention(overlay 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-op,mac 已有菜单栏计数 + Dock badge)。
|
||||
- 测试:新建 `tests/main/taskbar.test.ts`——mock window 对象断言 overlay 设置/清除、闪动触发条件(0→N 且未聚焦触发、聚焦时不触发、归零清除)、非 win32 no-op、缺资产降级。
|
||||
|
||||
### 任务 6:Jump 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(无此 API),argv 解析保留无害。
|
||||
- 测试:`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 路由。
|
||||
|
||||
### 任务 7:OpenIn 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(单实例)→ 任务 1(hide-on-close):先保证二次启动不炸,再改变关窗语义。
|
||||
2. 任务 3(clamp)、任务 4(托盘交互):独立小项,可与 1 同一批。
|
||||
3. 任务 5(任务栏 attention)。
|
||||
4. 任务 7(OpenIn Windows)。
|
||||
5. 任务 6(Jump List):最大,且依赖任务 2 的 second-instance 钩子。
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue