diff --git a/.changeset/single-instance.md b/.changeset/single-instance.md new file mode 100644 index 000000000..874e1de76 --- /dev/null +++ b/.changeset/single-instance.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复 Windows 重复启动应用时打开多个实例的问题。 diff --git a/.changeset/windows-jump-list.md b/.changeset/windows-jump-list.md new file mode 100644 index 000000000..8eb2bec79 --- /dev/null +++ b/.changeset/windows-jump-list.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +Windows 任务栏右键菜单支持新建会话和打开最近工作区。 diff --git a/.changeset/windows-open-in.md b/.changeset/windows-open-in.md new file mode 100644 index 000000000..f6b23bfe2 --- /dev/null +++ b/.changeset/windows-open-in.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +「用其他应用打开」支持 Windows。 diff --git a/.changeset/windows-system-notifications.md b/.changeset/windows-system-notifications.md new file mode 100644 index 000000000..5a0d8f452 --- /dev/null +++ b/.changeset/windows-system-notifications.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复 Windows 下的通知功能。 diff --git a/.changeset/windows-tray-click.md b/.changeset/windows-tray-click.md new file mode 100644 index 000000000..7ccac7c72 --- /dev/null +++ b/.changeset/windows-tray-click.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +优化 Windows 托盘功能。 diff --git a/AGENTS.md b/AGENTS.md index 8bd506df8..8a8904798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`:回环 + 临时端口 + 注册进 `/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`:回环 + 临时端口 + 注册进 `/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` 等源码。 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index adfd6a9e5..69d5bf2e5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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=""` + 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 版本) diff --git a/apps/desktop/docs/native-todos.md b/apps/desktop/docs/native-todos.md index 9edb43a9f..20d085116 100644 --- a/apps/desktop/docs/native-todos.md +++ b/apps/desktop/docs/native-todos.md @@ -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)` 映射;菜单项与快捷按钮用 `` 渲染(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=""` / `--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 端「拖文件到侧边栏 = 附件」行为不变。拖到非侧边栏区域保持现状(用户明确决定:文件夹落聊天区仍走附件流程,不特判)。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4da630cd3..344731857 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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": { diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index a912da06a..40933f9cb 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -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); } diff --git a/apps/desktop/scripts/rebuild-native.mjs b/apps/desktop/scripts/rebuild-native.mjs new file mode 100644 index 000000000..1a45a1c96 --- /dev/null +++ b/apps/desktop/scripts/rebuild-native.mjs @@ -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); diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index 28797d096..a3ca6b2cc 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -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. diff --git a/apps/desktop/src/main/ipc-channels.ts b/apps/desktop/src/main/ipc-channels.ts index 64d811762..667e6e32e 100644 --- a/apps/desktop/src/main/ipc-channels.ts +++ b/apps/desktop/src/main/ipc-channels.ts @@ -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=) 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; diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 210112449..c8914c55a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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(); diff --git a/apps/desktop/src/main/jump-list.ts b/apps/desktop/src/main/jump-list.ts new file mode 100644 index 000000000..5f8c41961 --- /dev/null +++ b/apps/desktop/src/main/jump-list.ts @@ -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 = { + 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(); +} diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 49257322e..1e20bccd1 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -238,7 +238,7 @@ export function bindingToAccelerator(binding: string | null): string | undefined // hides the `available` state). async function runMenuUpdateCheck(): Promise { 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'); }, diff --git a/apps/desktop/src/main/open-in.ts b/apps/desktop/src/main/open-in.ts index 4d3627703..97219eccf 100644 --- a/apps/desktop/src/main/open-in.ts +++ b/apps/desktop/src/main/open-in.ts @@ -1,11 +1,11 @@ -// Desktop-native "open this workspace in ". 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 ` 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 ". 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; + 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 { + 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 { + 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); diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index a411a8ad5..353d25bb2 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -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; /** 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); diff --git a/apps/desktop/src/main/taskbar.ts b/apps/desktop/src/main/taskbar.ts new file mode 100644 index 000000000..e5a4f9e34 --- /dev/null +++ b/apps/desktop/src/main/taskbar.ts @@ -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 = { + '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(); +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); +} diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 1a2b2891b..c0bcfcf96 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -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. */ diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index c51f1e60f..1dc8ecf44 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -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); diff --git a/apps/desktop/src/renderer/App.vue b/apps/desktop/src/renderer/App.vue index e80dba3fc..7b371c795 100644 --- a/apps/desktop/src/renderer/App.vue +++ b/apps/desktop/src/renderer/App.vue @@ -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 { } } +// 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 { + 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(); diff --git a/apps/desktop/src/renderer/assets/app-icons/explorer.png b/apps/desktop/src/renderer/assets/app-icons/explorer.png new file mode 100644 index 000000000..72edfd579 Binary files /dev/null and b/apps/desktop/src/renderer/assets/app-icons/explorer.png differ diff --git a/apps/desktop/src/renderer/assets/app-icons/git-bash.png b/apps/desktop/src/renderer/assets/app-icons/git-bash.png new file mode 100644 index 000000000..08160a4e4 Binary files /dev/null and b/apps/desktop/src/renderer/assets/app-icons/git-bash.png differ diff --git a/apps/desktop/src/renderer/assets/app-icons/windows-terminal.png b/apps/desktop/src/renderer/assets/app-icons/windows-terminal.png new file mode 100644 index 000000000..3121a0c7e Binary files /dev/null and b/apps/desktop/src/renderer/assets/app-icons/windows-terminal.png differ diff --git a/apps/desktop/src/renderer/components/chat/ChatHeader.vue b/apps/desktop/src/renderer/components/chat/ChatHeader.vue index 480994677..2b2e99929 100644 --- a/apps/desktop/src/renderer/components/chat/ChatHeader.vue +++ b/apps/desktop/src/renderer/components/chat/ChatHeader.vue @@ -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 { if (!props.workspaceRoot) return; @@ -343,7 +342,7 @@ async function onOpenInApp(appId: string): Promise { diff --git a/apps/desktop/src/renderer/components/chat/OpenInMenu.vue b/apps/desktop/src/renderer/components/chat/OpenInMenu.vue index cc2f9b61d..863db2cf4 100644 --- a/apps/desktop/src/renderer/components/chat/OpenInMenu.vue +++ b/apps/desktop/src/renderer/components/chat/OpenInMenu.vue @@ -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); diff --git a/apps/desktop/src/renderer/composables/useJumpList.ts b/apps/desktop/src/renderer/composables/useJumpList.ts new file mode 100644 index 000000000..ae4914608 --- /dev/null +++ b/apps/desktop/src/renderer/composables/useJumpList.ts @@ -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, +): () => 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>; + /** False until the client's first load() settles (see useWorkspaceState). */ + initialized: Ref; +} + +/** 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(() => { + 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); + } +} diff --git a/apps/desktop/src/renderer/composables/useTrayAttention.ts b/apps/desktop/src/renderer/composables/useTrayAttention.ts index ebafc9541..5b6352ccd 100644 --- a/apps/desktop/src/renderer/composables/useTrayAttention.ts +++ b/apps/desktop/src/renderer/composables/useTrayAttention.ts @@ -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 diff --git a/apps/desktop/src/renderer/lib/nativeOpenIn.ts b/apps/desktop/src/renderer/lib/nativeOpenIn.ts index eb820655b..f3051f5fa 100644 --- a/apps/desktop/src/renderer/lib/nativeOpenIn.ts +++ b/apps/desktop/src/renderer/lib/nativeOpenIn.ts @@ -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 " 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 " 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 = { vscode: iconVscode, @@ -36,6 +40,9 @@ const APP_ICONS: Record = { 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). */ diff --git a/apps/desktop/tests/main/app.test.ts b/apps/desktop/tests/main/app.test.ts new file mode 100644 index 000000000..0e9886e2a --- /dev/null +++ b/apps/desktop/tests/main/app.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const listeners = new Map 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((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(); + }); +}); diff --git a/apps/desktop/tests/main/connect.test.ts b/apps/desktop/tests/main/connect.test.ts index f972690f4..7079821ef 100644 --- a/apps/desktop/tests/main/connect.test.ts +++ b/apps/desktop/tests/main/connect.test.ts @@ -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(''); expect(win1.loadURL).toHaveBeenCalledWith('error-url'); diff --git a/apps/desktop/tests/main/downloads.test.ts b/apps/desktop/tests/main/downloads.test.ts index ad7317d46..5b0f846c2 100644 --- a/apps/desktop/tests/main/downloads.test.ts +++ b/apps/desktop/tests/main/downloads.test.ts @@ -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'), }); }); diff --git a/apps/desktop/tests/main/jump-list.test.ts b/apps/desktop/tests/main/jump-list.test.ts new file mode 100644 index 000000000..d61957fc2 --- /dev/null +++ b/apps/desktop/tests/main/jump-list.test.ts @@ -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' }]); + }); +}); diff --git a/apps/desktop/tests/main/log.test.ts b/apps/desktop/tests/main/log.test.ts index 40ba4002e..44939723d 100644 --- a/apps/desktop/tests/main/log.test.ts +++ b/apps/desktop/tests/main/log.test.ts @@ -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; diff --git a/apps/desktop/tests/main/open-in.test.ts b/apps/desktop/tests/main/open-in.test.ts index 9abb1b7a1..429ae089e 100644 --- a/apps/desktop/tests/main/open-in.test.ts +++ b/apps/desktop/tests/main/open-in.test.ts @@ -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 ` (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 ', 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' }); + }); +}); diff --git a/apps/desktop/tests/main/preload.test.ts b/apps/desktop/tests/main/preload.test.ts index f4d684e97..883c9807d 100644 --- a/apps/desktop/tests/main/preload.test.ts +++ b/apps/desktop/tests/main/preload.test.ts @@ -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 () => { diff --git a/apps/desktop/tests/main/taskbar.test.ts b/apps/desktop/tests/main/taskbar.test.ts new file mode 100644 index 000000000..2169b1994 --- /dev/null +++ b/apps/desktop/tests/main/taskbar.test.ts @@ -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([]); + }); +}); diff --git a/apps/desktop/tests/main/window.test.ts b/apps/desktop/tests/main/window.test.ts index 92e28cb9b..eff46ddfc 100644 --- a/apps/desktop/tests/main/window.test.ts +++ b/apps/desktop/tests/main/window.test.ts @@ -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 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({ diff --git a/apps/desktop/tests/renderer/useJumpList.test.ts b/apps/desktop/tests/renderer/useJumpList.test.ts new file mode 100644 index 000000000..bdc02eca7 --- /dev/null +++ b/apps/desktop/tests/renderer/useJumpList.test.ts @@ -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(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(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(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' }); + }); +}); diff --git a/docs/plans/2026-07-23-windows-native-pass.md b/docs/plans/2026-07-23-windows-native-pass.md new file mode 100644 index 000000000..d151a40a4 --- /dev/null +++ b/docs/plans/2026-07-23-windows-native-pass.md @@ -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=', title: }`。**不用 `type: 'file'`**——目录没有文件关联,点了不可靠;task + argv 自己解析才可控。 +- **argv 解析与路由**:主进程新增纯函数 `parseLaunchArgs(argv): { newChat: boolean; workspace?: string }`(`--new-chat` / `--workspace=`)。两个入口:首实例启动(`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 `。 + - **Explorer**:系统恒有;启动 `explorer.exe `——注意 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 钩子。 diff --git a/packages/vite-preset/src/index.ts b/packages/vite-preset/src/index.ts index 838e1cfdd..c3c74eadc 100644 --- a/packages/vite-preset/src/index.ts +++ b/packages/vite-preset/src/index.ts @@ -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. diff --git a/packages/vite-preset/test/preset.test.ts b/packages/vite-preset/test/preset.test.ts index 72e38b3ba..da5067fb3 100644 --- a/packages/vite-preset/test/preset.test.ts +++ b/packages/vite-preset/test/preset.test.ts @@ -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 "