外部打开快速启动器功能实现文档
- 外部应用检测与打开能力
- 如支持 VS Code / Zed / Cursor / Terminal / Warp / Finder 或 File Explorer
- 读取系统应用图标
- 默认打开应用配置
1. 功能概述
外部打开快速启动器是在标题栏搜索框右侧增加的一个紧凑入口,用于快速将当前文件、当前文件夹或工作区根目录交给外部应用打开。当前支持的应用类型包括:
- 编辑器:VS Code、Zed、Cursor
- 终端:Warp、Terminal、Windows cmd
- 文件管理器:macOS Finder、Windows File Explorer
该功能遵循两个目标:
- 优先打开当前文件树选中的文件或文件夹。
- 如果没有选中项,则打开当前工作区根目录。
入口本身包含一个默认应用按钮和一个下拉菜单。点击默认应用按钮会直接使用当前默认应用打开目标;点击下拉菜单项会切换默认应用、持久化该选择,并立即使用该应用打开目标。
2. 相关文件
主进程
src/main/external-open.ts- 外部应用检测、真实应用图标读取、外部打开命令执行都在这里。
src/main/file.ts- 对外提供
listAvailableExternalOpenApps和openPathWithExternalApp包装方法。
- 对外提供
src/main/ipc/file.ipc.ts- 注册外部打开相关 IPC handler。
Preload
src/preload/api/file.api.ts- 通过
ipcRenderer.invoke暴露安全的 renderer API。
- 通过
共享类型与常量
src/shared/types/index.ts- 定义
ExternalOpenAppId、ExternalOpenAppKind、ExternalOpenApp。
- 定义
src/shared/constants/ipc-channels.ts- 定义
file:list-external-open-apps和file:open-with-external-app。
- 定义
Renderer
src/renderer/src/components/layout/title-bar.tsx- 标题栏快速入口和下拉菜单。
src/renderer/src/features/settings/components/settings-modal.tsx- 设置页中的默认打开应用选择和标题栏入口开关。
src/renderer/src/features/external-open/external-open-options.ts- 目标路径解析和默认应用兜底选择。
src/renderer/src/features/external-open/external-open-icons.tsx- 应用图标渲染,优先使用系统真实图标,失败后使用内置 SVG 兜底。
src/renderer/src/store/editor.store.tsappearance.defaultExternalOpenApp和appearance.showTitleBarQuickLauncher的持久化状态。
src/renderer/src/features/editor/lib/editor-state-migration.ts- 持久化状态迁移与默认应用白名单校验。
3. 数据结构
核心共享类型在 src/shared/types/index.ts:
export type ExternalOpenAppId =
| "vscode"
| "zed"
| "cursor"
| "warp"
| "terminal"
| "file-manager";
export type ExternalOpenAppKind = "editor" | "terminal" | "file-manager";
export interface ExternalOpenApp {
id: ExternalOpenAppId;
label: string;
kind: ExternalOpenAppKind;
available: boolean;
iconDataUrl?: string;
}
其中:
id是 renderer 与 main 通信时使用的稳定标识。label是 UI 展示名称,文件管理器会按平台显示为 Finder 或 File Explorer。kind用于默认应用兜底选择,比如优先选择可用编辑器。available表示该应用当前系统是否可用。iconDataUrl是主进程读取到的真实系统应用图标,使用 PNG data URL 传给 renderer。
4. IPC 调用链路
外部打开功能通过 Electron IPC 串联 renderer、preload 和 main。
4.1 获取可用外部应用
调用链如下:
TitleBar / SettingsModal
-> window.electronAPI.listExternalOpenApps()
-> preload fileApi.listExternalOpenApps()
-> ipcRenderer.invoke("file:list-external-open-apps")
-> ipcMain.handle(...)
-> listAvailableExternalOpenApps()
-> listExternalOpenApps()
对应文件:
- Renderer 调用:
title-bar.tsx、settings-modal.tsx - Preload 暴露:
src/preload/api/file.api.ts - IPC 注册:
src/main/ipc/file.ipc.ts - 主进程实现:
src/main/file.ts、src/main/external-open.ts
4.2 使用外部应用打开路径
调用链如下:
TitleBar
-> window.electronAPI.openWithExternalApp(targetPath, appId)
-> preload fileApi.openWithExternalApp(...)
-> ipcRenderer.invoke("file:open-with-external-app", targetPath, appId)
-> ipcMain.handle(...)
-> openPathWithExternalApp(targetPath, appId)
-> openWithExternalApp(targetPath, appId)
主进程收到 targetPath 和 appId 后,会根据应用类型进入不同打开策略:
- 编辑器:
openEditor - Warp:
openWarp - Terminal/cmd:
openTerminal - Finder/File Explorer:
openFileManager
5. 外部应用检测原理
应用检测集中在 src/main/external-open.ts 的 listExternalOpenApps。
- 扫描用户的安装目录
5.0 使用到的核心技术点
外部应用检测运行在 Electron main process 中,主要使用以下 Node.js、Electron 和系统能力:
| 技术点 | 使用位置 | 作用 |
|---|---|---|
process.platform | resolveDeps | 判断当前是 macOS、Windows 还是其他平台。 |
fs.promises.access | defaultPathExists | 判断某个 .app、.exe 或系统路径是否存在。 |
fs.promises.readdir | findMacAppInApplicationSubfolders、getMacBundleIconDataUrl | 扫描 /Applications 子目录和 .app/Contents/Resources 下的图标资源。 |
node:path | 全文件 | 拼接跨平台路径,避免手写路径分隔符。 |
node:os | getMacAppPaths、临时目录 | 获取用户 home 目录和系统临时目录。 |
child_process.spawn | defaultResolveCommandPath、runCommandForOutput、convertIcnsToPngDataUrl、打开外部应用 | 执行 which、where.exe、PlistBuddy、sips、open、cmd.exe 以及外部应用。 |
electron.app.getFileIcon | defaultGetFileIconDataUrl | 在 Windows 或非 .app 场景读取系统文件图标。 |
electron.shell.openPath | openFileManager | 用系统文件管理器打开目录。 |
electron.shell.showItemInFolder | openFileManager | 在系统文件管理器中定位文件。 |
/usr/libexec/PlistBuddy | getMacBundleIconFile | 读取 macOS 应用包 Info.plist 中声明的主图标文件。 |
sips | convertIcnsToPngDataUrl | 将 macOS .icns 图标转换成 PNG。 |
这些能力全部放在 main process 中,而不是 renderer 中,原因是:
- renderer 不应该直接访问本地文件系统。
- renderer 不应该直接执行系统命令或启动外部进程。
- preload 只暴露受控的 IPC 方法,减少安全边界。
5.1 平台过滤
当前仅支持 macOS 和 Windows:
if (!["darwin", "win32"].includes(resolvedDeps.platform)) {
return [];
}
Linux 暂不返回外部应用列表。
5.2 检测输入模型
每个可检测应用都需要维护一份“检测元数据”,主要包括:
id:内部稳定 id,例如vscode、warp。label:展示名称,例如VS Code、Warp。appName:macOS.app名称,例如Visual Studio Code、Warp。command:命令行名称,例如code、zed、cursor、warp。windowsPaths:Windows 常见安装相对路径。
编辑器定义在 EDITORS 中:
const EDITORS = [
{ id: "vscode", label: "VS Code", appName: "Visual Studio Code", command: "code", ... },
{ id: "zed", label: "Zed", appName: "Zed", command: "zed", ... },
{ id: "cursor", label: "Cursor", appName: "Cursor", command: "cursor", ... },
];
Warp 定义为终端类应用:
const WARP = {
id: "warp",
label: "Warp",
appName: "Warp",
command: "warp",
windowsPaths: ["Programs/Warp/Warp.exe", "Warp/Warp.exe"],
};
检测函数不直接把 UI 写死在 renderer,而是让 main process 返回 ExternalOpenApp[]。这样 renderer 只关心“有哪些应用可用”,不关心“系统如何找到这些应用”。
5.3 macOS 应用检测
macOS 应用通常是 .app bundle,本质上是目录。检测顺序如下:
/Applications/<AppName>.app~/Applications/<AppName>.app/Applications/<一级子目录>/<AppName>.app~/Applications/<一级子目录>/<AppName>.app- 命令行路径:
which <command>
第 3、4 步用于支持类似 /Applications/tools-dev/Warp.app 这样的自定义应用分组目录。
实现细节:
getMacAppPaths(appName)返回标准位置:
[
path.join("/Applications", `${appName}.app`),
path.join(os.homedir(), "Applications", `${appName}.app`),
]
resolveMacAppPath(appName, deps)先检查标准位置,找不到时再扫描 Applications 一级子目录。findMacAppInApplicationSubfolders(appName, applicationRoots)会读取/Applications和~/Applications下的一级目录,并拼接<子目录>/<AppName>.app。
为什么只扫一级子目录:
- 覆盖常见分组目录,例如
/Applications/tools-dev/Warp.app。 - 避免深度递归扫描整个
/Applications,降低启动时 I/O 成本。 - 避免扫描用户目录中过多无关路径。
命令检测使用:
which code
which zed
which cursor
which warp
实现上通过 child_process.spawn("which", [command]) 执行,并从 stdout 读取第一行路径。如果返回码不是 0,则认为命令不存在。
5.4 Windows 应用检测
Windows 应用通常是 .exe 文件。检测顺序如下:
%LOCALAPPDATA%下的常见安装路径%ProgramFiles%下的常见安装路径%ProgramFiles(x86)%下的常见安装路径- 命令行路径:
where.exe <command>
实现细节:
getWindowsAppPaths(windowsPaths)先取系统环境变量:
process.env.LOCALAPPDATAprocess.env.ProgramFilesprocess.env["ProgramFiles(x86)"]
- 再把应用定义里的相对路径拼接成完整路径。
- 对每个候选路径调用
fs.promises.access判断文件是否存在。 - 如果固定路径找不到,再调用:
where.exe codewhere.exe zedwhere.exe cursorwhere.exe warp
与 macOS 的 which 类似,where.exe 的 stdout 第一行会被当作命令真实路径。
5.5 命令路径检测原理
命令检测由 defaultResolveCommandPath(command, platform) 实现。它不是只返回 boolean,而是尽量返回真实路径:
const checker = platform === "win32" ? "where.exe" : "which";
这样做有两个收益:
- 判断应用是否可用。
- 如果命令能解析成真实路径,可以继续用这个路径读取系统图标。
该函数监听子进程:
error:命令执行失败,返回null。close:检查退出码。- stdout:按行切分,取第一条非空路径。
如果只需要 boolean,commandExists 会基于 defaultResolveCommandPath 包装:
Boolean(await defaultResolveCommandPath(command, platform))
5.6 路径检测与命令检测的组合策略
核心函数是 resolveDetectedAppPath(appName, command, windowsPaths, deps):
const appPath = deps.platform === "darwin" ? await resolveMacAppPath(appName, deps) : await resolveFirstExistingPath(getWindowsAppPaths(windowsPaths), deps.pathExists);return appPath ?? deps.resolveCommandPath(command);
也就是:
- 优先找真实应用路径。
- 找不到真实应用路径时,再找命令路径。
- 返回的路径后续会用于读取真实图标。
编辑器列表里的每个应用都走这个策略。Warp 也走这个策略。
5.7 Warp 检测
Warp 的关键点是它虽然是终端,但不是系统内置终端,因此不能像 Terminal/cmd 一样无条件加入列表。必须检测到后才显示。
检测成功条件:
- 找到
Warp.app或Warp.exe。 - 或者找到
warp命令。
macOS 会覆盖这些路径:
/Applications/Warp.app~/Applications/Warp.app/Applications/<一级子目录>/Warp.app~/Applications/<一级子目录>/Warp.app
这可以覆盖 Homebrew Cask、手动拖拽安装、以及用户自定义应用分组目录。
如果检测到 Warp:
- 返回
id: "warp"。 - 返回
kind: "terminal"。 - 放在系统 Terminal/cmd 前面。
- 图标读取使用真实
Warp.app或Warp.exe路径。
5.8 文件管理器与系统终端
系统 Terminal/cmd 和 Finder/File Explorer 是平台内置能力,所以默认加入列表,不需要像编辑器和 Warp 那样检测是否安装:
- macOS:
- Terminal 图标路径:
/System/Applications/Utilities/Terminal.app - Finder 图标路径:
/System/Library/CoreServices/Finder.app
- Terminal 图标路径:
- Windows:
- Terminal/cmd 图标路径:
%ComSpec%或System32/cmd.exe - File Explorer 图标路径:
%WINDIR%/explorer.exe
- Terminal/cmd 图标路径:
不过它们仍会经过 withIconDataUrl,尝试读取系统真实图标。
5.9 依赖注入与可测试性
external-open.ts 的很多底层能力都可以通过 ExternalOpenDeps 注入:
interface ExternalOpenDeps { platform?: Platform; spawn?: SpawnCommand; pathExists?: PathExists; commandExists?: CommandExists; resolveCommandPath?: ResolveCommandPath; stat?: StatPath; getFileIconDataUrl?: GetFileIconDataUrl; macApplicationRoots?: string[]; shell?: ExternalShell;}
这样做是为了让主进程能力可测试:
- 测试中可以伪造
platform,覆盖 macOS 和 Windows。 - 测试中可以伪造
pathExists,模拟应用是否安装。 - 测试中可以伪造
getFileIconDataUrl,避免真实读取系统图标。 - 测试中可以伪造
macApplicationRoots,验证/Applications/<子目录>/Warp.app这种安装方式。 - 测试中可以伪造
spawn,验证打开命令是否正确,而不真的启动外部应用。
5.10 为什么检测不放在 renderer
外部应用检测不放在 renderer 的原因:
- renderer 无法安全访问
/Applications、Program Files等系统路径。 - renderer 不应该执行
which、where.exe、PlistBuddy、sips等系统命令。 - Electron 安全模型推荐把本地系统能力放在 main process。
- preload 只暴露
listExternalOpenApps和openWithExternalApp两个窄接口。
因此检测链路是:
renderer 请求应用列表 -> preload IPC invoke -> main process 检测系统应用 -> main process 返回 ExternalOpenApp[] -> renderer 只负责展示和交互
6. 系统应用图标获取原理
图标获取也在主进程完成,原因是 renderer 不具备安全访问本地应用包和系统文件的能力。
6.1 图标输出格式
ExternalOpenApp.iconDataUrl 使用 PNG data URL:
data:image/png;base64,...
Renderer 直接使用 <img src={iconDataUrl} /> 渲染。
6.2 macOS .app 图标
macOS 应用图标优先从 .app 包中读取:
- 进入
<App>.app/Contents/Info.plist - 用
/usr/libexec/PlistBuddy读取CFBundleIconFile - 解析到主图标文件,例如:
- VS Code:
Code.icns - Zed:
Zed.icns - Terminal:
Terminal.icns - Finder:
Finder.icns
- VS Code:
- 使用系统
sips将.icns转成 32x32 PNG - 读取 PNG 文件并转为 base64 data URL
- 删除临时目录
这样做的原因是:直接对 .app 调用 Electron app.getFileIcon 在部分环境中可能返回通用占位图,导致 UI 中看起来像空白图标。读取应用包内主 .icns 再转换成 PNG 更稳定。
6.3 Windows 图标
Windows 会优先根据 .exe 路径调用 Electron 的:
app.getFileIcon(targetPath, { size: "normal" })
然后 resize 到 32x32,并转换为 data URL。
6.4 图标兜底
Renderer 的 ExternalOpenAppIcon 会优先显示 iconDataUrl。如果:
iconDataUrl为空- 图片加载失败触发
onError
则回退到内置 SVG 图标,避免下拉菜单出现空白图标。
7. 打开外部应用的实现原理
7.1 统一目标路径解析
Renderer 通过 resolveExternalOpenTargetPath 决定要打开的路径:
return selectedPath || rootPath || null;
也就是:
- 优先当前文件树选中项。
- 如果没有选中项,使用当前工作区根目录。
- 两者都没有则不执行打开。
7.2 编辑器打开
编辑器可以打开文件或目录,所以直接传入目标路径。
macOS:
open -a <AppName> <targetPath>
Windows:
<Editor.exe 或 command> <targetPath>
Windows 上会优先使用检测到的 .exe 路径;如果没有路径但命令存在,则使用命令名。
7.3 终端打开
终端只能稳定打开目录,所以 getDirectoryTargetForExternalApp 会先判断目标类型:
- 如果目标是目录,直接使用该目录。
- 如果目标是文件,转换为父目录。
- 如果
stat失败,也退回父目录。
Warp:
- macOS 使用解析到的
Warp.app路径或Warp名称:
open -a <Warp.app 或 Warp> <directoryPath>
- Windows 使用
Warp.exe或warp命令,并设置cwd:
Warp.exe <directoryPath>cwd = <directoryPath>
Terminal/cmd:
- macOS:
open -a Terminal <directoryPath>
- Windows:
cmd.exe /kcwd = <directoryPath>
Windows 终端不通过 cmd /c start <path> 打开,是为了避免路径中包含 & 等字符时被 shell 重新解析。
7.4 文件管理器打开
文件管理器通过 Electron shell 实现:
- 如果目标是目录,调用:
shell.openPath(targetPath)
- 如果目标是文件,调用:
shell.showItemInFolder(targetPath)
这样可以避免把 Markdown 文件当成默认应用直接打开,而是定位到文件所在位置。
7.5 子进程启动与错误判断
外部程序启动统一通过 spawnDetached:
detached: truestdio: "ignore"- 启动后调用
unref
同时监听:
spawn:认为启动成功error:认为启动失败
这样可以避免命令不存在时仍然乐观返回成功。
8. Renderer UI 实现
8.1 标题栏入口
标题栏组件在挂载时调用:
window.electronAPI.listExternalOpenApps()
获取应用列表并保存到本地 React state:
const [externalOpenApps, setExternalOpenApps] = useState<ExternalOpenApp[]>([]);
默认应用计算逻辑:
resolveEffectiveExternalOpenApp(apps, appearance.defaultExternalOpenApp)
兜底顺序:
- 用户设置的默认应用
- 第一个可用编辑器
- 文件管理器
- 应用列表第一项
点击默认应用按钮:
openWithExternalApp(externalOpenTargetPath, effectiveExternalOpenApp.id)
点击下拉菜单项:
- 调用
setAppearance({ defaultExternalOpenApp: app.id }) - 因为 editor store 使用 Zustand persist,所以选择会持久化。
- 立即调用
openWithExternalApp打开目标。
8.2 no-drag 处理
标题栏是自定义 frameless window 的 drag 区域。按钮必须设置为 no-drag,否则点击会被窗口拖拽吞掉。
实现方式是在 effect 中遍历标题栏内按钮:
titleBarRef.current.querySelectorAll("button").forEach((btn) => { btn.style.webkitAppRegion = "no-drag";});
该 effect 依赖 externalOpenApps.length 和 appearance.showTitleBarQuickLauncher,确保动态渲染的快速入口按钮也会被设置为 no-drag。
8.3 设置页
设置页提供两个配置:
- 默认打开目标:选择默认应用。
- 标题栏快速打开器:控制标题栏入口是否显示。
默认打开目标使用和标题栏一致的应用列表、图标组件和 DropdownMenu。用户在设置里选择应用后,会调用:
setAppearance({ defaultExternalOpenApp: app.id })
标题栏入口开关调用:
setAppearance({ showTitleBarQuickLauncher: checked })
9. 持久化与迁移
状态位于 EditorAppearance:
defaultExternalOpenApp: ExternalOpenAppId;showTitleBarQuickLauncher: boolean;
默认值:
defaultExternalOpenApp: "vscode";showTitleBarQuickLauncher: true;
useEditorStore 使用 Zustand persist,所以 setAppearance 会自动持久化到本地。
迁移逻辑在 normalizePersistedAppearance 中,会校验历史持久化数据中的 defaultExternalOpenApp 是否仍是合法值:
["vscode", "zed", "cursor", "warp", "terminal", "file-manager"]
如果不是合法值,则恢复默认应用。
10. 监听机制说明
外部打开快速启动器本身不持续监听文件系统或外部应用安装变化。
当前机制是:
- 标题栏挂载时调用一次
listExternalOpenApps。 - 设置弹窗打开时调用一次
listExternalOpenApps。 - 用户更改默认应用时通过 Zustand persist 持久化。
也就是说,如果用户在应用运行期间安装或移动了外部应用,需要重新打开设置或重启/刷新应用,才能重新获取应用列表。
需要注意:代码中已有文件内容监听和工作区监听机制,但它们属于编辑器文件更新能力,不属于外部应用检测能力:
FILE.WATCHFILE.UNWATCHFILE.WATCH_WORKSPACEFILE.UNWATCH_WORKSPACE
这些监听用于文件内容和目录结构变化,不会用于监听应用安装变化。
11. 安全边界
外部打开属于本地系统能力,因此只在 main process 中执行:
- renderer 不直接访问 Node.js 文件系统。
- renderer 不直接 spawn 进程。
- preload 只暴露窄接口:
listExternalOpenAppsopenWithExternalApp
这种结构符合 Electron 安全模型:渲染层只表达用户意图,实际文件系统和进程能力由主进程执行。
12. 核心方法补充说明
本节用带注释的核心方法摘录说明主要实现。代码以当前实现为基础做了适度压缩,重点展示逻辑结构。
12.1 listExternalOpenApps:外部应用列表总入口
该方法负责返回 renderer 需要展示的完整应用列表。
export async function listExternalOpenApps( deps?: ExternalOpenDeps,): Promise<ExternalOpenApp[]> { // 统一解析依赖:真实运行时使用 Node/Electron API,测试时可注入 mock。 const resolvedDeps = resolveDeps(deps); // 当前只支持 macOS 和 Windows;其他平台暂不展示入口。 if (!["darwin", "win32"].includes(resolvedDeps.platform)) { return []; } const apps: ExternalOpenApp[] = []; // 1. 检测编辑器:VS Code / Zed / Cursor。 for (const editor of EDITORS) { // 优先解析真实应用路径,其次解析命令路径。 const iconPath = await resolveDetectedAppPath( editor.appName, editor.command, editor.windowsPaths, resolvedDeps, ); // 可用性判断:找到真实路径即可认为可用;否则继续走平台特定检测。 const available = Boolean(iconPath) || (resolvedDeps.platform === "darwin" ? await isMacEditorAvailable(editor, resolvedDeps) : await isWindowsEditorAvailable(editor, resolvedDeps)); if (available) { // withIconDataUrl 会尝试读取真实系统应用图标。 apps.push( await withIconDataUrl( { id: editor.id, label: editor.label, kind: "editor", available: true, }, iconPath, resolvedDeps, ), ); } } // 2. 检测 Warp。Warp 是第三方终端,只有检测到时才显示。 const warpIconPath = await resolveDetectedAppPath( WARP.appName, WARP.command, WARP.windowsPaths, resolvedDeps, ); if (warpIconPath || (await resolvedDeps.commandExists(WARP.command))) { apps.push( await withIconDataUrl( { id: WARP.id, label: WARP.label, kind: "terminal", available: true, }, warpIconPath, resolvedDeps, ), ); } // 3. 系统终端默认可用:macOS Terminal / Windows cmd。 apps.push( await withIconDataUrl( { id: "terminal", label: "Terminal", kind: "terminal", available: true, }, resolvedDeps.platform === "darwin" ? getMacTerminalIconPath() : getWindowsTerminalIconPath(), resolvedDeps, ), ); // 4. 系统文件管理器默认可用:Finder / File Explorer。 apps.push( await withIconDataUrl( { id: "file-manager", label: resolvedDeps.platform === "darwin" ? "Finder" : "File Explorer", kind: "file-manager", available: true, }, resolvedDeps.platform === "darwin" ? getMacFileManagerIconPath() : getWindowsFileManagerIconPath(), resolvedDeps, ), ); return apps;}
关键点:
listExternalOpenApps是检测入口,不直接关心 UI。- 检测结果统一返回
ExternalOpenApp[]。 - 第三方应用需要检测后显示,系统内置能力默认显示。
- 图标读取和应用检测在同一个阶段完成,减少 renderer 额外请求。
12.2 resolveDeps:依赖解析与测试注入
该方法把真实运行依赖和测试依赖统一收口。
function resolveDeps(deps?: ExternalOpenDeps) { const platform = deps?.platform ?? process.platform; const hasInjectedDeps = deps !== undefined; return { platform, // 默认使用 node:child_process.spawn。 spawn: deps?.spawn ?? nodeSpawn, // 默认使用 fs.promises.access 判断路径是否存在。 pathExists: deps?.pathExists ?? defaultPathExists, // 命令路径解析:macOS 使用 which,Windows 使用 where.exe。 resolveCommandPath: deps?.resolveCommandPath ?? (hasInjectedDeps ? async () => null : (command: string) => defaultResolveCommandPath(command, platform)), // commandExists 是 resolveCommandPath 的 boolean 包装。 commandExists: deps?.commandExists ?? (hasInjectedDeps ? async () => false : async (command: string) => Boolean(await defaultResolveCommandPath(command, platform))), // stat 用于判断目标是文件还是目录。 stat: deps?.stat ?? fs.promises.stat, // 图标读取:真实运行时读取系统图标;测试时默认返回 null。 getFileIconDataUrl: deps?.getFileIconDataUrl ?? (hasInjectedDeps ? async () => null : defaultGetFileIconDataUrl), // macOS 应用扫描根目录,测试时可注入临时目录。 macApplicationRoots: deps?.macApplicationRoots ?? getDefaultMacApplicationRoots(), // 如果是真实运行,允许扫描 Applications 子目录; // 如果是测试注入依赖,默认不扫真实文件系统,除非显式传 macApplicationRoots。 allowFilesystemSearch: !hasInjectedDeps || deps?.macApplicationRoots !== undefined, shell: deps?.shell, };}
设计目的:
- 避免测试真的启动外部应用。
- 避免测试依赖本机是否安装 VS Code、Warp 等应用。
- 让路径存在性、命令解析、图标读取、平台判断都可控。
12.3 resolveDetectedAppPath:应用路径解析核心
该方法负责把“应用元数据”转换为可用路径。
async function resolveDetectedAppPath( appName: string, command: string, windowsPaths: string[], deps: ReturnType<typeof resolveDeps>,): Promise<string | null> { // macOS 解析 .app,Windows 解析 .exe。 const appPath = deps.platform === "darwin" ? await resolveMacAppPath(appName, deps) : await resolveFirstExistingPath( getWindowsAppPaths(windowsPaths), deps.pathExists, ); // 如果真实应用路径不存在,再尝试命令路径。 return appPath ?? deps.resolveCommandPath(command);}
为什么要返回路径而不只是 boolean:
- 路径可以用于判断应用可用。
- 路径可以用于读取真实系统图标。
- 路径可以用于打开应用,尤其是 macOS 自定义子目录安装的 Warp。
12.4 resolveMacAppPath:macOS .app 查找
macOS 应用是 .app bundle,因此检测的是目录是否存在。
async function resolveMacAppPath( appName: string, deps: ReturnType<typeof resolveDeps>,): Promise<string | null> { // 先检查标准路径:/Applications 和 ~/Applications。 const directAppPath = await resolveFirstExistingPath( getMacAppPaths(appName), deps.pathExists, ); if (directAppPath) return directAppPath; // 测试场景默认不扫描真实文件系统。 if (!deps.allowFilesystemSearch) return null; // 再检查 Applications 下一级分组目录。 return findMacAppInApplicationSubfolders(appName, deps.macApplicationRoots);}
对应的子目录扫描:
async function findMacAppInApplicationSubfolders( appName: string, applicationRoots: string[],): Promise<string | null> { for (const applicationRoot of applicationRoots) { // 读取 /Applications 或 ~/Applications 下的一级目录。 const entries = await fs.promises .readdir(applicationRoot, { withFileTypes: true }) .catch(() => []); for (const entry of entries) { if (!entry.isDirectory()) continue; // 拼出 /Applications/<folder>/<AppName>.app。 const candidatePath = path.join( applicationRoot, entry.name, `${appName}.app`, ); if (await defaultPathExists(candidatePath)) return candidatePath; } } return null;}
这个方法解决的问题:
- 用户可能把应用放在
/Applications/tools-dev/Warp.app。 - LaunchServices 或命令行不一定能找到这种自定义位置。
- 扫描一级目录可以兼顾覆盖率和性能。
12.5 defaultResolveCommandPath:命令路径解析
命令检测不是简单执行 shell 字符串,而是使用 spawn 直接调用系统工具。
function defaultResolveCommandPath( command: string, platform: Platform,): Promise<string | null> { // Windows 用 where.exe,macOS 用 which。 const checker = platform === "win32" ? "where.exe" : "which"; return new Promise((resolve) => { const child = nodeSpawn(checker, [command], { stdio: ["ignore", "pipe", "ignore"], }); let output = ""; // 收集 stdout,成功时里面通常是命令真实路径。 child.stdout?.on("data", (chunk) => { output += String(chunk); }); // 命令执行失败,认为不存在。 child.once("error", () => resolve(null)); child.once("close", (code) => { if (code !== 0) { resolve(null); return; } // where/which 可能返回多行,取第一条非空路径。 const commandPath = output .split(/\r?\n/) .map((line) => line.trim()) .find(Boolean); resolve(commandPath ?? null); }); });}
技术注意点:
- 不通过
shell: true执行,减少 shell 注入风险。 where.exe可能返回多行路径,只取第一条。- 该方法既服务检测,也服务图标读取和 Windows 打开命令。
12.6 defaultGetFileIconDataUrl:真实系统图标读取
该方法负责把系统应用图标转换为 renderer 可显示的 data URL。
async function defaultGetFileIconDataUrl( targetPath: string,): Promise<string | null> { try { // macOS .app 优先从应用包读取主图标,避免 getFileIcon 返回占位图。 const bundleIconDataUrl = await getMacBundleIconDataUrl(targetPath); if (bundleIconDataUrl) return bundleIconDataUrl; // 其他情况使用 Electron 系统图标 API。 const { app } = await import("electron"); const icon = await app.getFileIcon(targetPath, { size: "normal" }); if (icon.isEmpty()) return null; // 统一缩放成 32x32,再转成 data URL。 const iconDataUrl = icon.resize({ width: 32, height: 32 }).toDataURL(); return isUsableIconDataUrl(iconDataUrl) ? iconDataUrl : null; } catch (error) { console.error("Failed to load external app icon:", error); return null; }}
macOS .app 图标读取核心:
async function getMacBundleIconDataUrl( appPath: string,): Promise<string | null> { if (!appPath.endsWith(".app")) return null; // 读取 .app/Contents/Resources 下的 .icns 文件。 const resourcesPath = path.join(appPath, "Contents", "Resources"); const iconFiles = (await fs.promises.readdir(resourcesPath)).filter((file) => file.toLowerCase().endsWith(".icns"), ); // 根据 Info.plist 的 CFBundleIconFile 找主图标。 const preferredIconFile = await getMacBundlePreferredIconFile( appPath, iconFiles, ); if (!preferredIconFile) return null; // 用 sips 转成 PNG data URL。 return convertIcnsToPngDataUrl(path.join(resourcesPath, preferredIconFile));}
读取 Info.plist:
async function getMacBundleIconFile(appPath: string): Promise<string | null> { const infoPlistPath = path.join(appPath, "Contents", "Info.plist"); // PlistBuddy 是 macOS 自带工具,用来读取 plist 字段。 const iconFile = await runCommandForOutput("/usr/libexec/PlistBuddy", [ "-c", "Print :CFBundleIconFile", infoPlistPath, ]); return iconFile || null;}
转换 .icns:
async function convertIcnsToPngDataUrl( iconPath: string,): Promise<string | null> { // 创建临时目录,避免污染项目目录。 const tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), "keep-notes-icon-"), ); const outputPath = path.join(tempDir, "icon.png"); try { // sips 是 macOS 自带图片处理工具,这里输出 32x32 PNG。 await new Promise<void>((resolve, reject) => { const child = nodeSpawn( "sips", ["-z", "32", "32", "-s", "format", "png", iconPath, "--out", outputPath], { stdio: "ignore" }, ); child.once("error", reject); child.once("close", (code) => { if (code === 0) resolve(); else reject(new Error(`sips exited with code ${code}`)); }); }); // 读取 PNG 并转成 renderer 可直接展示的 data URL。 const imageBuffer = await fs.promises.readFile(outputPath); if (imageBuffer.byteLength === 0) return null; return `data:image/png;base64,${imageBuffer.toString("base64")}`; } finally { // 清理临时目录。 await fs.promises.rm(tempDir, { recursive: true, force: true }); }}
12.7 withIconDataUrl:给应用附加图标
async function withIconDataUrl( app: ExternalOpenApp, iconPath: string | null, deps: ReturnType<typeof resolveDeps>,): Promise<ExternalOpenApp> { // 没有路径就无法读取真实系统图标,直接返回原始应用信息。 if (!iconPath) return app; // 尝试把系统图标转换为 data URL。 const iconDataUrl = await deps.getFileIconDataUrl(iconPath); // 读取成功才追加 iconDataUrl;失败时 renderer 会使用 SVG 兜底。 return iconDataUrl ? { ...app, iconDataUrl } : app;}
该方法把“应用检测”和“图标读取”解耦:
- 检测逻辑只负责找路径。
- 图标逻辑只负责把路径转成可展示资源。
- renderer 不知道图标来自
.icns、.exe还是 fallback。
12.8 openWithExternalApp:外部打开总入口
export async function openWithExternalApp( targetPath: string, appId: ExternalOpenAppId, deps?: ExternalOpenDeps,): Promise<boolean> { const resolvedDeps = resolveDeps(deps); // 如果是编辑器,走编辑器打开逻辑。 const editor = EDITORS.find((app) => app.id === appId); if (editor) { return openEditor(targetPath, editor, resolvedDeps); } // 系统终端。 if (appId === "terminal") { return openTerminal(targetPath, resolvedDeps); } // Warp 终端。 if (appId === "warp") { return openWarp(targetPath, resolvedDeps); } // Finder / File Explorer。 if (appId === "file-manager") { return openFileManager(targetPath, resolvedDeps); } // 未知 id 直接拒绝,避免执行意外命令。 return false;}
该方法是 IPC file:open-with-external-app 最终进入的业务入口。
12.9 getDirectoryTargetForExternalApp:终端目标路径转换
终端类应用适合打开目录,不适合打开具体文件。因此需要把文件目标转换为父目录。
export async function getDirectoryTargetForExternalApp( targetPath: string, stat: StatPath = fs.promises.stat, platform: Platform = process.platform,): Promise<string> { try { const stats = await stat(targetPath); // 目录可以直接作为终端 cwd 或打开目标。 if (stats.isDirectory()) return targetPath; } catch { // 如果 stat 失败,也尽量回退到父目录。 return getParentDirectory(targetPath, platform); } // 文件目标统一转成父目录。 return getParentDirectory(targetPath, platform);}
这也是为什么选中 Markdown 文件时,用 Terminal 或 Warp 打开的是文件所在目录。
12.10 openWarp:Warp 打开逻辑
async function openWarp( targetPath: string, deps: ReturnType<typeof resolveDeps>,): Promise<boolean> { // Warp 是终端类应用,先把目标转换成目录。 const directoryPath = await getDirectoryTargetForExternalApp( targetPath, deps.stat, deps.platform, ); if (deps.platform === "darwin") { // 优先使用真实 Warp.app 路径,支持 /Applications/tools-dev/Warp.app。 const warpAppPath = await resolveMacAppPath(WARP.appName, deps); return openMacApp(deps.spawn, warpAppPath ?? WARP.appName, directoryPath); } if (deps.platform === "win32") { // Windows 优先找 Warp.exe,其次找 warp 命令。 const command = await getWindowsTerminalAppCommand(WARP, deps); if (!command) return false; // 设置 cwd,保证终端工作目录正确。 return spawnDetached(deps.spawn, command, [directoryPath], { cwd: directoryPath, }); } return false;}
注意:macOS 使用 open -a <app> <dir>,其中 <app> 可以是应用名,也可以是 .app 路径。使用真实路径是为了支持非标准安装目录。
12.11 spawnDetached:安全启动外部程序
function spawnDetached( spawn: SpawnCommand, command: string, args: string[], options: SpawnOptions = {},): Promise<boolean> { try { const child = spawn(command, args, { ...options, detached: true, stdio: "ignore", }); // 不让子进程阻塞 Keep Notes 主进程退出。 child?.unref?.(); if (typeof child?.once !== "function") return Promise.resolve(true); return new Promise((resolve) => { let settled = false; const settle = (result: boolean) => { if (settled) return; settled = true; resolve(result); }; // 启动失败,例如命令不存在。 child.once?.("error", () => settle(false)); // 子进程成功 spawn,不等待外部应用退出。 child.once?.("spawn", () => settle(true)); }); } catch { return Promise.resolve(false); }}
关键点:
- 不使用 shell 拼接命令,降低路径字符导致的解析风险。
- 使用
detached + unref,外部应用独立运行。 - 只判断是否成功启动,不等待外部应用关闭。
12.12 resolveExternalOpenTargetPath:renderer 目标选择
export function resolveExternalOpenTargetPath( selectedPath: string | null | undefined, rootPath: string | null | undefined,): string | null { // 优先当前文件树选中项;没有选中项时用工作区根目录。 return selectedPath || rootPath || null;}
该方法让标题栏入口符合产品预期:
- 用户选中了某个文件或文件夹,就打开该项。
- 用户没有选中任何项,就打开当前工作区根目录。
12.13 resolveEffectiveExternalOpenApp:默认应用兜底
export function resolveEffectiveExternalOpenApp( apps: ExternalOpenApp[], preferredAppId: ExternalOpenAppId,): ExternalOpenApp | null { const availableApps = apps.filter((app) => app.available); return ( // 优先使用用户设置的默认应用。 availableApps.find((app) => app.id === preferredAppId) ?? // 默认应用不可用时,优先选择编辑器。 availableApps.find((app) => app.kind === "editor") ?? // 没有编辑器时,使用文件管理器。 availableApps.find((app) => app.id === "file-manager") ?? // 最后兜底到任意可用应用。 availableApps[0] ?? null );}
这个方法保证:
- 用户配置优先。
- 用户配置失效时 UI 不会空白。
- 新设备上即使没装编辑器,也能至少用 Finder/File Explorer 打开。
12.14 标题栏选择应用并持久化
标题栏下拉项点击后,会同时做两件事:
const handleSelectExternalOpenApp = useCallback( (appId: ExternalOpenAppId) => { // 1. 更新默认应用。editor store 使用 persist,会自动保存到本地。 setAppearance({ defaultExternalOpenApp: appId }); // 2. 立即使用这个应用打开当前目标。 handleOpenWithExternalApp(appId); }, [handleOpenWithExternalApp, setAppearance],);
因此用户在标题栏里选择 Warp 后:
- 本次立即用 Warp 打开目标。
- 默认应用变成 Warp。
- 下次启动仍会记住 Warp。
13. 测试覆盖
主要测试在 src/main/external-open.test.ts:
- macOS 文件管理器名称为 Finder。
- Windows 文件管理器名称为 File Explorer。
- Warp 在标准
/Applications/Warp.app下可检测。 - Warp 在
/Applications/<子目录>/Warp.app下可检测。 - 终端打开文件时会转为父目录。
- 不支持的应用 id 不会触发 spawn。
- Windows 终端使用
cwd,避免cmd startshell 重解析路径。 - Windows 编辑器优先直接执行
.exe。
常用验证命令:
./node_modules/.bin/eslint src/main/external-open.ts src/main/external-open.test.ts./node_modules/.bin/vitest run src/main/external-open.test.ts
14. 后续可扩展方向
后续如果要增加新的外部应用,推荐按以下步骤扩展:
- 在
ExternalOpenAppId中新增 id。 - 在
external-open.ts中新增应用定义。 - 补充 macOS
.app名称、Windows 常见安装路径和命令名。 - 如果是终端类应用,复用
getDirectoryTargetForExternalApp。 - 如果是编辑器类应用,复用
openEditor模式。 - 在
editor-state-migration.ts的合法默认应用白名单中加入新 id。 - 增加检测和打开测试。