跳到主要内容

外部打开快速启动器功能实现文档

  • 外部应用检测与打开能力
    • 如支持 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.ts
    • appearance.defaultExternalOpenApp 和 appearance.showTitleBarQuickLauncher 的持久化状态。
  • src/renderer/src/features/editor/lib/editor-state-migration.ts
    • 持久化状态迁移与默认应用白名单校验。

3. 数据结构​

核心共享类型在 src/shared/types/index.ts:

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 获取可用外部应用​

调用链如下:

text
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 使用外部应用打开路径​

调用链如下:

text
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.platformresolveDeps判断当前是 macOS、Windows 还是其他平台。
fs.promises.accessdefaultPathExists判断某个 .app、.exe 或系统路径是否存在。
fs.promises.readdirfindMacAppInApplicationSubfolders、getMacBundleIconDataUrl扫描 /Applications 子目录和 .app/Contents/Resources 下的图标资源。
node:path全文件拼接跨平台路径,避免手写路径分隔符。
node:osgetMacAppPaths、临时目录获取用户 home 目录和系统临时目录。
child_process.spawndefaultResolveCommandPath、runCommandForOutput、convertIcnsToPngDataUrl、打开外部应用执行 which、where.exe、PlistBuddy、sips、open、cmd.exe 以及外部应用。
electron.app.getFileIcondefaultGetFileIconDataUrl在 Windows 或非 .app 场景读取系统文件图标。
electron.shell.openPathopenFileManager用系统文件管理器打开目录。
electron.shell.showItemInFolderopenFileManager在系统文件管理器中定位文件。
/usr/libexec/PlistBuddygetMacBundleIconFile读取 macOS 应用包 Info.plist 中声明的主图标文件。
sipsconvertIcnsToPngDataUrl将 macOS .icns 图标转换成 PNG。

这些能力全部放在 main process 中,而不是 renderer 中,原因是:

  • renderer 不应该直接访问本地文件系统。
  • renderer 不应该直接执行系统命令或启动外部进程。
  • preload 只暴露受控的 IPC 方法,减少安全边界。

5.1 平台过滤​

当前仅支持 macOS 和 Windows:

ts
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 中:

ts
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 定义为终端类应用:

ts
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,本质上是目录。检测顺序如下:

  1. /Applications/<AppName>.app
  2. ~/Applications/<AppName>.app
  3. /Applications/<一级子目录>/<AppName>.app
  4. ~/Applications/<一级子目录>/<AppName>.app
  5. 命令行路径:which <command>

第 3、4 步用于支持类似 /Applications/tools-dev/Warp.app 这样的自定义应用分组目录。

实现细节:

  • getMacAppPaths(appName) 返回标准位置:
ts
[
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 成本。
  • 避免扫描用户目录中过多无关路径。

命令检测使用:

text
which code
which zed
which cursor
which warp

实现上通过 child_process.spawn("which", [command]) 执行,并从 stdout 读取第一行路径。如果返回码不是 0,则认为命令不存在。

5.4 Windows 应用检测​

Windows 应用通常是 .exe 文件。检测顺序如下:

  1. %LOCALAPPDATA% 下的常见安装路径
  2. %ProgramFiles% 下的常见安装路径
  3. %ProgramFiles(x86)% 下的常见安装路径
  4. 命令行路径:where.exe <command>

实现细节:

  • getWindowsAppPaths(windowsPaths) 先取系统环境变量:
ts
process.env.LOCALAPPDATAprocess.env.ProgramFilesprocess.env["ProgramFiles(x86)"]
  • 再把应用定义里的相对路径拼接成完整路径。
  • 对每个候选路径调用 fs.promises.access 判断文件是否存在。
  • 如果固定路径找不到,再调用:
text
where.exe codewhere.exe zedwhere.exe cursorwhere.exe warp

与 macOS 的 which 类似,where.exe 的 stdout 第一行会被当作命令真实路径。

5.5 命令路径检测原理​

命令检测由 defaultResolveCommandPath(command, platform) 实现。它不是只返回 boolean,而是尽量返回真实路径:

ts
const checker = platform === "win32" ? "where.exe" : "which";

这样做有两个收益:

  • 判断应用是否可用。
  • 如果命令能解析成真实路径,可以继续用这个路径读取系统图标。

该函数监听子进程:

  • error:命令执行失败,返回 null。
  • close:检查退出码。
  • stdout:按行切分,取第一条非空路径。

如果只需要 boolean,commandExists 会基于 defaultResolveCommandPath 包装:

ts
Boolean(await defaultResolveCommandPath(command, platform))

5.6 路径检测与命令检测的组合策略​

核心函数是 resolveDetectedAppPath(appName, command, windowsPaths, deps):

ts
const appPath = deps.platform === "darwin" ? await resolveMacAppPath(appName, deps) : await resolveFirstExistingPath(getWindowsAppPaths(windowsPaths), deps.pathExists);return appPath ?? deps.resolveCommandPath(command);

也就是:

  1. 优先找真实应用路径。
  2. 找不到真实应用路径时,再找命令路径。
  3. 返回的路径后续会用于读取真实图标。

编辑器列表里的每个应用都走这个策略。Warp 也走这个策略。

5.7 Warp 检测​

Warp 的关键点是它虽然是终端,但不是系统内置终端,因此不能像 Terminal/cmd 一样无条件加入列表。必须检测到后才显示。

检测成功条件:

  • 找到 Warp.app 或 Warp.exe。
  • 或者找到 warp 命令。

macOS 会覆盖这些路径:

text
/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
  • Windows:
    • Terminal/cmd 图标路径:%ComSpec% 或 System32/cmd.exe
    • File Explorer 图标路径:%WINDIR%/explorer.exe

不过它们仍会经过 withIconDataUrl,尝试读取系统真实图标。

5.9 依赖注入与可测试性​

external-open.ts 的很多底层能力都可以通过 ExternalOpenDeps 注入:

ts
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 两个窄接口。

因此检测链路是:

text
renderer 请求应用列表 -> preload IPC invoke -> main process 检测系统应用 -> main process 返回 ExternalOpenApp[] -> renderer 只负责展示和交互

6. 系统应用图标获取原理​

图标获取也在主进程完成,原因是 renderer 不具备安全访问本地应用包和系统文件的能力。

6.1 图标输出格式​

ExternalOpenApp.iconDataUrl 使用 PNG data URL:

text
data:image/png;base64,...

Renderer 直接使用 <img src={iconDataUrl} /> 渲染。

6.2 macOS .app 图标​

macOS 应用图标优先从 .app 包中读取:

  1. 进入 <App>.app/Contents/Info.plist
  2. 用 /usr/libexec/PlistBuddy 读取 CFBundleIconFile
  3. 解析到主图标文件,例如:
    • VS Code: Code.icns
    • Zed: Zed.icns
    • Terminal: Terminal.icns
    • Finder: Finder.icns
  4. 使用系统 sips 将 .icns 转成 32x32 PNG
  5. 读取 PNG 文件并转为 base64 data URL
  6. 删除临时目录

这样做的原因是:直接对 .app 调用 Electron app.getFileIcon 在部分环境中可能返回通用占位图,导致 UI 中看起来像空白图标。读取应用包内主 .icns 再转换成 PNG 更稳定。

6.3 Windows 图标​

Windows 会优先根据 .exe 路径调用 Electron 的:

ts
app.getFileIcon(targetPath, { size: "normal" })

然后 resize 到 32x32,并转换为 data URL。

6.4 图标兜底​

Renderer 的 ExternalOpenAppIcon 会优先显示 iconDataUrl。如果:

  • iconDataUrl 为空
  • 图片加载失败触发 onError

则回退到内置 SVG 图标,避免下拉菜单出现空白图标。

7. 打开外部应用的实现原理​

7.1 统一目标路径解析​

Renderer 通过 resolveExternalOpenTargetPath 决定要打开的路径:

ts
return selectedPath || rootPath || null;

也就是:

  1. 优先当前文件树选中项。
  2. 如果没有选中项,使用当前工作区根目录。
  3. 两者都没有则不执行打开。

7.2 编辑器打开​

编辑器可以打开文件或目录,所以直接传入目标路径。

macOS:

text
open -a <AppName> <targetPath>

Windows:

text
<Editor.exe 或 command> <targetPath>

Windows 上会优先使用检测到的 .exe 路径;如果没有路径但命令存在,则使用命令名。

7.3 终端打开​

终端只能稳定打开目录,所以 getDirectoryTargetForExternalApp 会先判断目标类型:

  • 如果目标是目录,直接使用该目录。
  • 如果目标是文件,转换为父目录。
  • 如果 stat 失败,也退回父目录。

Warp:

  • macOS 使用解析到的 Warp.app 路径或 Warp 名称:
text
open -a <Warp.app 或 Warp> <directoryPath>
  • Windows 使用 Warp.exe 或 warp 命令,并设置 cwd:
text
Warp.exe <directoryPath>cwd = <directoryPath>

Terminal/cmd:

  • macOS:
text
open -a Terminal <directoryPath>
  • Windows:
text
cmd.exe /kcwd = <directoryPath>

Windows 终端不通过 cmd /c start <path> 打开,是为了避免路径中包含 & 等字符时被 shell 重新解析。

7.4 文件管理器打开​

文件管理器通过 Electron shell 实现:

  • 如果目标是目录,调用:
ts
shell.openPath(targetPath)
  • 如果目标是文件,调用:
ts
shell.showItemInFolder(targetPath)

这样可以避免把 Markdown 文件当成默认应用直接打开,而是定位到文件所在位置。

7.5 子进程启动与错误判断​

外部程序启动统一通过 spawnDetached:

  • detached: true
  • stdio: "ignore"
  • 启动后调用 unref

同时监听:

  • spawn:认为启动成功
  • error:认为启动失败

这样可以避免命令不存在时仍然乐观返回成功。

8. Renderer UI 实现​

8.1 标题栏入口​

标题栏组件在挂载时调用:

ts
window.electronAPI.listExternalOpenApps()

获取应用列表并保存到本地 React state:

ts
const [externalOpenApps, setExternalOpenApps] = useState<ExternalOpenApp[]>([]);

默认应用计算逻辑:

ts
resolveEffectiveExternalOpenApp(apps, appearance.defaultExternalOpenApp)

兜底顺序:

  1. 用户设置的默认应用
  2. 第一个可用编辑器
  3. 文件管理器
  4. 应用列表第一项

点击默认应用按钮:

ts
openWithExternalApp(externalOpenTargetPath, effectiveExternalOpenApp.id)

点击下拉菜单项:

  1. 调用 setAppearance({ defaultExternalOpenApp: app.id })
  2. 因为 editor store 使用 Zustand persist,所以选择会持久化。
  3. 立即调用 openWithExternalApp 打开目标。

8.2 no-drag 处理​

标题栏是自定义 frameless window 的 drag 区域。按钮必须设置为 no-drag,否则点击会被窗口拖拽吞掉。

实现方式是在 effect 中遍历标题栏内按钮:

ts
titleBarRef.current.querySelectorAll("button").forEach((btn) => { btn.style.webkitAppRegion = "no-drag";});

该 effect 依赖 externalOpenApps.length 和 appearance.showTitleBarQuickLauncher,确保动态渲染的快速入口按钮也会被设置为 no-drag。

8.3 设置页​

设置页提供两个配置:

  • 默认打开目标:选择默认应用。
  • 标题栏快速打开器:控制标题栏入口是否显示。

默认打开目标使用和标题栏一致的应用列表、图标组件和 DropdownMenu。用户在设置里选择应用后,会调用:

ts
setAppearance({ defaultExternalOpenApp: app.id })

标题栏入口开关调用:

ts
setAppearance({ showTitleBarQuickLauncher: checked })

9. 持久化与迁移​

状态位于 EditorAppearance:

ts
defaultExternalOpenApp: ExternalOpenAppId;showTitleBarQuickLauncher: boolean;

默认值:

ts
defaultExternalOpenApp: "vscode";showTitleBarQuickLauncher: true;

useEditorStore 使用 Zustand persist,所以 setAppearance 会自动持久化到本地。

迁移逻辑在 normalizePersistedAppearance 中,会校验历史持久化数据中的 defaultExternalOpenApp 是否仍是合法值:

ts
["vscode", "zed", "cursor", "warp", "terminal", "file-manager"]

如果不是合法值,则恢复默认应用。

10. 监听机制说明​

外部打开快速启动器本身不持续监听文件系统或外部应用安装变化。

当前机制是:

  • 标题栏挂载时调用一次 listExternalOpenApps。
  • 设置弹窗打开时调用一次 listExternalOpenApps。
  • 用户更改默认应用时通过 Zustand persist 持久化。

也就是说,如果用户在应用运行期间安装或移动了外部应用,需要重新打开设置或重启/刷新应用,才能重新获取应用列表。

需要注意:代码中已有文件内容监听和工作区监听机制,但它们属于编辑器文件更新能力,不属于外部应用检测能力:

  • FILE.WATCH
  • FILE.UNWATCH
  • FILE.WATCH_WORKSPACE
  • FILE.UNWATCH_WORKSPACE

这些监听用于文件内容和目录结构变化,不会用于监听应用安装变化。

11. 安全边界​

外部打开属于本地系统能力,因此只在 main process 中执行:

  • renderer 不直接访问 Node.js 文件系统。
  • renderer 不直接 spawn 进程。
  • preload 只暴露窄接口:
    • listExternalOpenApps
    • openWithExternalApp

这种结构符合 Electron 安全模型:渲染层只表达用户意图,实际文件系统和进程能力由主进程执行。

12. 核心方法补充说明​

本节用带注释的核心方法摘录说明主要实现。代码以当前实现为基础做了适度压缩,重点展示逻辑结构。

12.1 listExternalOpenApps:外部应用列表总入口​

该方法负责返回 renderer 需要展示的完整应用列表。

ts
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:依赖解析与测试注入​

该方法把真实运行依赖和测试依赖统一收口。

ts
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:应用路径解析核心​

该方法负责把“应用元数据”转换为可用路径。

ts
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,因此检测的是目录是否存在。

ts
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);}

对应的子目录扫描:

ts
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 直接调用系统工具。

ts
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。

ts
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 图标读取核心:

ts
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:

ts
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:

ts
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:给应用附加图标​

ts
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:外部打开总入口​

ts
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:终端目标路径转换​

终端类应用适合打开目录,不适合打开具体文件。因此需要把文件目标转换为父目录。

ts
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 打开逻辑​

ts
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:安全启动外部程序​

ts
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 目标选择​

ts
export function resolveExternalOpenTargetPath( selectedPath: string | null | undefined, rootPath: string | null | undefined,): string | null { // 优先当前文件树选中项;没有选中项时用工作区根目录。 return selectedPath || rootPath || null;}

该方法让标题栏入口符合产品预期:

  • 用户选中了某个文件或文件夹,就打开该项。
  • 用户没有选中任何项,就打开当前工作区根目录。

12.13 resolveEffectiveExternalOpenApp:默认应用兜底​

ts
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 标题栏选择应用并持久化​

标题栏下拉项点击后,会同时做两件事:

ts
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 start shell 重解析路径。
  • Windows 编辑器优先直接执行 .exe。

常用验证命令:

bash
./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. 后续可扩展方向​

后续如果要增加新的外部应用,推荐按以下步骤扩展:

  1. 在 ExternalOpenAppId 中新增 id。
  2. 在 external-open.ts 中新增应用定义。
  3. 补充 macOS .app 名称、Windows 常见安装路径和命令名。
  4. 如果是终端类应用,复用 getDirectoryTargetForExternalApp。
  5. 如果是编辑器类应用,复用 openEditor 模式。
  6. 在 editor-state-migration.ts 的合法默认应用白名单中加入新 id。
  7. 增加检测和打开测试。