Skip to content

feat(terminal): 在终端打开支持自定义选择终端应用(Ghostty/iTerm2 等)#569

Open
thirking wants to merge 9 commits into
ZToolsCenter:mainfrom
thirking:feature/configurable-terminal-launcher
Open

feat(terminal): 在终端打开支持自定义选择终端应用(Ghostty/iTerm2 等)#569
thirking wants to merge 9 commits into
ZToolsCenter:mainfrom
thirking:feature/configurable-terminal-launcher

Conversation

@thirking

@thirking thirking commented Jul 2, 2026

Copy link
Copy Markdown

Summary

「在终端打开」此前在 macOS 上硬编码系统 Terminal.app,无法选择其他终端。本 PR 将其做成可配置功能,并统一了两处重复的终端启动逻辑。

  • 新建 src/main/utils/terminalLauncher.ts:统一终端启动模块。包含三平台预设注册表(macOS: Terminal/Ghostty/iTerm2;Linux: gnome-terminal/konsole/xterm;Windows: wt/powershell/cmd)、自定义命令模板({path} 占位符)、纯函数(可单测)与 openInTerminal 编排函数(读 settings-general 配置分发)。
  • 消除两处重复systemCommands.handleOpenTerminalappleScriptHelper.openInTerminal 都改为委托 terminalLauncher.openInTerminal,删除迁出的 5 个函数(openTerminalOnMac/openTerminalOnLinux/tryLaunchWindowsTerminal/escapePowerShellPath/escapeCmdPath)。
  • 设置 UI:通用设置页新增「终端打开」分组(Dropdown 选预设 + 选中「自定义」时展开命令输入框),三平台均支持。
  • 向后兼容:未配置时 terminal='default',行为与改动前完全一致(Terminal.app / Linux 回退链 / Windows 回退链)。
  • 安全改善:集中化的 runAppleScript 单引号转义修复了旧 openTerminalOnMac 的潜在 shell 注入;CLI/自定义命令用 spawn(非 shell),{path} 是 argv 级替换。

Closes #568

设计与计划

  • 设计文档:docs/superpowers/specs/2026-07-02-configurable-terminal-launcher-design.md
  • 实现计划:docs/superpowers/plans/2026-07-02-configurable-terminal-launcher.md

Test Plan

  • pnpm typecheck:node 干净(主进程)
  • pnpm test tests/main/terminalLauncher.test.ts —— 23 测试全过(纯函数 + 编排分发 + 路径转义)
  • 全量 pnpm test 零新增失败(2 个预存在失败 pluginRemovalCleanup/databasePluginIsolation 在 main 上同样失败,已确认无关)
  • pnpm build:setting(沙箱环境缺 unocss 未能验证,需在正常环境确认设置插件构建)
  • pnpm build 整体构建通过
  • 手动:macOS Finder 唤出 → 设置选「Ghostty」→ 在目标目录打开 Ghostty 窗口
  • 手动:选「系统默认」→ 行为与改动前一致(Terminal.app 打开并 cd)
  • 手动:选「自定义」填 open -na Ghostty.app --args --working-directory={path} → 同样打开 Ghostty
  • 手动:改终端设置后,通用设置页其他字段(theme / 唤醒黑名单等)未被覆盖(合并写安全)

与计划的已知偏离

  1. 内联命令解析器(不导出复用 linuxLauncher.tsparseCommandString):避免把 linuxLauncherdialog/WindowManager/fs 重依赖传染进 terminalLauncher,保持其为干净叶子模块。
  2. 暂不含 Warp 预设:Warp 在 macOS 的「打开并 cd」启动方式未经验证,先不发布可能无效的预设,待确认后补。

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a configurable terminal launcher feature, unifying terminal launching logic across macOS, Linux, and Windows into a new terminalLauncher.ts utility, and adding corresponding configuration UI to the general settings page. The code review identified critical security vulnerabilities, specifically potential AppleScript injection in the macOS Terminal and iTerm2 presets, and shell injection in runAppleScript (recommending execFileAsync over execAsync). Additionally, the reviewer pointed out a bug in asynchronous error handling where returning promises without await bypasses the try-catch block, a type safety issue in parseCommandString, and provided corresponding test updates.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/utils/terminalLauncher.ts Outdated
Comment on lines +121 to +126
build: (p) => `
tell application "Terminal"
activate
do script "cd " & quoted form of "${p}"
end tell
`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

此处直接将路径 p 插值到 AppleScript 的双引号字符串中:quoted form of "${p}"
如果路径 p 中包含双引号 " 或反斜杠 \,不仅会导致 AppleScript 语法解析错误,还可能引发 AppleScript 注入攻击(例如恶意构造的路径可以执行任意 AppleScript 代码)。

虽然使用了 quoted form of,但它是在 AppleScript 运行时对 Shell 进行转义,无法保护 AppleScript 自身的字符串字面量免受注入。

建议在插值前,对路径中的反斜杠 \ 和双引号 " 进行安全转义。

      build: (p) => {
        const safePath = p.replaceAll('\\', '\\\\').replaceAll('\"', '\\\"')
        return `
    tell application "Terminal"
      activate
      do script "cd " & quoted form of "${safePath}"
    end tell
  `
      }

Comment thread src/main/utils/terminalLauncher.ts Outdated
Comment on lines +143 to +150
build: (p) => `
tell application "iTerm"
activate
tell (create window with default profile)
write session "cd " & quoted form of "${p}"
end tell
end tell
`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

与 Terminal 预设类似,此处直接将路径 p 插值到 AppleScript 的双引号字符串中,存在 AppleScript 注入攻击 的风险。

建议在插值前,对路径中的反斜杠 \ 和双引号 " 进行安全转义。

      build: (p) => {
        const safePath = p.replaceAll('\\', '\\\\').replaceAll('\"', '\\\"')
        return `
    tell application "iTerm"
      activate
      tell (create window with default profile)
        write session "cd " & quoted form of "${safePath}"
      end tell
    end tell
  `
      }

Comment on lines +72 to +75
async function runAppleScript(script: string): Promise<void> {
const escaped = script.replace(/'/g, "'\\''")
await execAsync(`osascript -e '${escaped}'`)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

建议改用 execFileAsync 直接执行 osascript,避免通过 Shell 解析命令。这样可以完全免去对单引号的转义处理,并且彻底杜绝 Shell 注入的安全隐患。

async function runAppleScript(script: string): Promise<void> {
  await execFileAsync('osascript', ['-e', script])
}

Comment thread src/main/utils/terminalLauncher.ts Outdated
Comment on lines +268 to +277
if (terminal === 'custom') {
const parsed = parseCustomCommand(customCommand ?? '')
if (parsed) {
return runCli(parsed.command, applyPathToArgs(parsed.args, folderPath))
}
// 自定义命令为空 → 回退默认
return executePreset(resolvePreset('default', process.platform), folderPath)
}

return executePreset(resolvePreset(terminal, process.platform), folderPath)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

async 函数中,如果在 try-catch 块内直接 return 一个 Promise(例如 runCliexecutePreset 的返回值)而没有使用 await,那么当该 Promise 发生 reject 时,当前函数内的 catch 块将无法捕获到该异常。异常会直接抛给调用方,导致此处的 try-catch 容错逻辑失效。

建议在 return 时加上 await,以确保 openInTerminal 内部的 catch 块能够正确捕获并处理启动失败的异常,安全地返回 false

    // 自定义命令优先
    if (terminal === 'custom') {
      const parsed = parseCustomCommand(customCommand ?? '')
      if (parsed) {
        return await runCli(parsed.command, applyPathToArgs(parsed.args, folderPath))
      }
      // 自定义命令为空 → 回退默认
      return await executePreset(resolvePreset('default', process.platform), folderPath)
    }

    return await executePreset(resolvePreset(terminal, process.platform), folderPath)

Comment thread src/main/utils/terminalLauncher.ts Outdated
// ==================== 命令字符串解析(内联,避免耦合 commandLauncher)====================

/** 将命令字符串拆分为 [可执行文件, 参数列表],处理引号 */
function parseCommandString(cmd: string): [string, string[]] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

parseCommandString 中,如果输入的命令为空或仅包含空格,parts 数组将为空,此时 parts[0]undefined
然而,该函数的返回类型声明为 [string, string[]],这在类型安全上存在隐患(运行时 parts[0] 可能为 undefined,但 TypeScript 认为它是 string)。

建议将返回类型修改为 [string | undefined, string[]],以确保类型安全。

Suggested change
function parseCommandString(cmd: string): [string, string[]] {
function parseCommandString(cmd: string): [string | undefined, string[]] {

Comment thread src/main/utils/terminalLauncher.ts Outdated
Comment on lines +1 to +5
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
import databaseAPI from '../api/shared/database'

const execAsync = promisify(exec)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了更安全地执行 osascript,建议引入 execFile 并使用 promisify 将其包装为异步函数。

Suggested change
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
import databaseAPI from '../api/shared/database'
const execAsync = promisify(exec)
import { exec, spawn, execFile } from 'child_process'
import { promisify } from 'util'
import databaseAPI from '../api/shared/database'
const execAsync = promisify(exec)
const execFileAsync = promisify(execFile)

Comment thread tests/main/terminalLauncher.test.ts Outdated
Comment on lines +3 to +15
const { mockDbGet, mockSpawn, mockExec } = vi.hoisted(() => ({
mockDbGet: vi.fn(),
mockSpawn: vi.fn(),
mockExec: vi.fn((...args: unknown[]) => {
const cb = args[args.length - 1]
if (typeof cb === 'function') cb(null, { stdout: '', stderr: '' })
})
}))

vi.mock('child_process', () => ({ spawn: mockSpawn, exec: mockExec }))
vi.mock('../../src/main/api/shared/database', () => ({
default: { dbGet: mockDbGet, dbPut: vi.fn() }
}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

由于主进程代码中建议将 runAppleScript 改为使用更安全的 execFile,我们需要在测试中 mock 并断言 execFile 的调用,以确保单测能够顺利通过。

const { mockDbGet, mockSpawn, mockExec, mockExecFile } = vi.hoisted(() => ({
  mockDbGet: vi.fn(),
  mockSpawn: vi.fn(),
  mockExec: vi.fn((...args: unknown[]) => {
    const cb = args[args.length - 1]
    if (typeof cb === 'function') cb(null, { stdout: '', stderr: '' })
  }),
  mockExecFile: vi.fn((...args: unknown[]) => {
    const cb = args[args.length - 1]
    if (typeof cb === 'function') cb(null, { stdout: '', stderr: '' })
  })
}))

vi.mock('child_process', () => ({ spawn: mockSpawn, exec: mockExec, execFile: mockExecFile }))
vi.mock('../../src/main/api/shared/database', () => ({
  default: { dbGet: mockDbGet, dbPut: vi.fn() }
}))

Comment on lines +185 to +193
it('自定义命令为空 → 回退默认(mac 走 applescript,不 spawn)', async () => {
mockDbGet.mockReturnValue({ terminal: 'custom', terminalCustomCommand: '' })
const ok = await openInTerminal('/x')
expect(ok).toBe(true)
expect(mockSpawn).not.toHaveBeenCalled()
expect(mockExec).toHaveBeenCalled()
// execAsync(`osascript -e '${escaped}'`) → exec(cmd, callback),cmd 内含 folderPath
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('/x'), expect.anything())
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

配合 runAppleScript 改用 execFile 的改动,此处断言也应从 mockExec 改为 mockExecFile

  it('自定义命令为空 → 回退默认(mac 走 applescript,不 spawn)', async () => {
    mockDbGet.mockReturnValue({ terminal: 'custom', terminalCustomCommand: '' })
    const ok = await openInTerminal('/x')
    expect(ok).toBe(true)
    expect(mockSpawn).not.toHaveBeenCalled()
    expect(mockExecFile).toHaveBeenCalled()
    // execFileAsync('osascript', ['-e', script]) → execFile(cmd, args, callback)
    expect(mockExecFile).toHaveBeenCalledWith(
      'osascript',
      expect.arrayContaining([expect.stringContaining('/x')]),
      expect.anything()
    )
  })

- runAppleScript 改用 execFileAsync 直调 osascript,不经 shell,杜绝 shell 注入
- Terminal/iTerm2 预设路径经 escapeAppleScriptString 转义反斜杠与双引号,防 AppleScript 注入
- openInTerminal 内 return 改为 await,使 reject 能被 try/catch 捕获返回 false
- parseCommandString 返回类型改为 [string | undefined, string[]] 修正类型安全
- 测试 mock 改 execFile 并断言 osascript 调用,新增 escapeAppleScriptString 测试
@thirking

thirking commented Jul 2, 2026

Copy link
Copy Markdown
Author

感谢 @gemini-code-assist 的评审,所有意见已在 commit 9c3453c 修复并通过验证(pnpm typecheck:node 干净,terminalLauncher 25 测试全过)。

逐条对应:

意见 定级 修复
Terminal 预设 ${p} AppleScript 注入 Critical 新增 escapeAppleScriptString,路径转义 \" 后再插值进 AppleScript 双引号串
iTerm2 预设同样问题 Critical 同上
runAppleScript 经 shell(execAsync) High 改用 execFileAsync('osascript', ['-e', script]) 直调 osascript,不经 shell,去掉单引号转义
return Promise 未 await 绕过 try/catch High openInTerminal 三处 return 加 await,reject 现可被 catch 捕获返回 false
parseCommandString 返回类型不安全 Medium 改为 [string | undefined, string[]]
导入 execFile Medium import { spawn, execFile } + execFileAsync = promisify(execFile)
测试 mock execFile Medium mockExecFile 加入 vi.hoistedvi.mock('child_process', { spawn, execFile })
测试断言改 mockExecFile Medium 第 3 个用例断言 osascript + 含 folderPath 的 args

额外:为 escapeAppleScriptString 补了 2 个单测(与既有 escapePowerShellPath / escapeCmdPath 的测试模式一致——安全关键转义函数应有覆盖)。

@gemini-code-assist

Copy link
Copy Markdown
Contributor

感谢你的详细反馈和高质量的修复!

你不仅解决了之前指出的 AppleScript 注入和 Promise 异步处理问题,还通过引入 execFileAsync 彻底规避了 shell 注入风险,这非常棒。新增的 escapeAppleScriptString 辅助函数和针对性的单元测试也大大增强了代码的健壮性。

从代码结构来看,将终端启动逻辑统一到 terminalLauncher.ts 并通过配置分发,极大地提升了可维护性。对于 parseCommandString 的内联处理也很好地避免了不必要的模块耦合。

目前的实现已经非常完善,完全符合高质量代码的标准。期待你的后续合并!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 「在终端打开」支持自定义选择终端应用(Ghostty/iTerm2 等)

1 participant