codeburn/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift
ozymandiashh 1959550218 feat(menubar): add preferred terminal setting with Terminal.app fallback
Full Report and Optimize always opened Terminal.app. Add a closed
PreferredTerminal enum (Terminal.app, iTerm2), a General settings picker,
and graceful fallback: chosen terminal -> Terminal.app -> headless spawn.

Defaults to Terminal.app so existing users see no change.

The terminal is selected from a closed enum, never a user string, so the
`tell application "..."` target stays a compile-time literal. Commands are
still whitespace-joined argv validated token-by-token by CodeburnCLI.isSafe
before any interpolation, preserving the shell-injection invariant.

Only terminals with a real "run in a live window" scripting verb are listed:
Terminal.app has `do script`, iTerm2 has `write text` on a session. Ghostty,
WezTerm, Warp, Alacritty and kitty expose no equivalent, so they keep the
existing headless fallback rather than shipping a window that closes on exit.

The iTerm2 script targets `application "iTerm"`, not `"iTerm2"`. AppleScript
resolves the name of a not-yet-running app through LaunchServices by bundle
file name, and the bundle is iTerm.app. Measured on iTerm2 3.6.11: with the
app quit, `tell application "iTerm2"` fails to compile (-2741) while
`tell application "iTerm"` compiles, cold-launches iTerm2 and runs the
command. The `"iTerm2"` spelling only works while the app already happens to
be running.

Fallback is a chain that checks results rather than a single fire-and-forget
pick, because "installed" does not imply "scriptable": osascript can still
fail on a missing Automation approval or a broken bundle. Each candidate is
run, waited on and its exit status checked, off the main thread so the
popover stays responsive; only once every candidate has failed do we spawn
headless. Every step logs via NSLog, so a user who sees no window has a trail
in Console.app instead of an app that looks dead. The decision logic is
extracted into terminalChain/runFirstWorking so tests exercise
"primary failed -> fell back" without launching anything.

Document the setting in the README next to the other menubar defaults keys.

Closes #877
2026-08-04 00:19:00 +03:00

167 lines
8.3 KiB
Swift

import AppKit
import Foundation
/// Runs commands in the user's preferred terminal (#877). Every string that reaches AppleScript
/// `do script` / `write text` must be whitespace-joined argv where each token passes
/// `CodeburnCLI.isSafe` (regex allowlist that excludes shell metacharacters), OR a hardcoded
/// literal defined here. `runScript` re-validates defensively so a future caller can't bypass
/// the invariant.
///
/// The terminal itself is chosen from the closed `PreferredTerminal` enum, never from a
/// user-supplied string, so the `tell application "..."` target stays a compile-time literal.
///
/// Resolution is a chain, not a single pick, because "installed" does not imply "scriptable":
/// osascript can still fail on a missing Automation (TCC) approval, an app that is present but
/// broken, or terminology it cannot load. So each candidate is actually run and its exit status
/// checked, and only once every candidate has failed do we fall back to a detached headless
/// spawn -- which at least still runs the subcommand on machines with no scriptable terminal
/// (Ghostty/Warp/kitty users). Each step logs, so a user who sees nothing has a trail in
/// Console.app instead of an app that looks dead.
enum TerminalLauncher {
/// Upper bound on how long we wait for one `osascript` invocation.
///
/// The failure modes we care about are fast: a terminology/compile error returns in ~0.15s
/// and a denied Automation prompt is comparably quick. The only slow case is a *successful*
/// cold app launch, which is seconds. So a timeout is not the mechanism that detects
/// failure -- the exit status is -- and hitting it is treated as "it is still working",
/// not as a failure. Falling back on timeout would open a second window in a second
/// terminal, which is worse than waiting. The bound exists purely so a wedged osascript
/// cannot pin a background worker forever.
private static let scriptTimeout: TimeInterval = 30
static func open(subcommand: [String]) {
guard let command = safeCommand(argv: CodeburnCLI.baseArgv() + subcommand) else {
NSLog("CodeBurn: refusing to open terminal with unsafe argv")
return
}
let chain = terminalChain()
// Knowing whether osascript worked means waiting for it, and a cold app launch keeps it
// busy for a second or two. Callers are SwiftUI button actions on the main thread, so
// the whole chain runs on a background queue: the popover stays responsive and the
// fallback decision is made on a real exit status rather than on a guess.
DispatchQueue.global(qos: .userInitiated).async {
if runFirstWorking(chain: chain, command: command, attempt: runScript) != nil { return }
if !chain.isEmpty {
NSLog("CodeBurn: no terminal accepted the command; running it headless instead")
}
let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
do {
try headless.run()
} catch {
NSLog("CodeBurn: headless fallback also failed: \(error.localizedDescription)")
}
}
}
/// Launches `claude login` in the preferred terminal so the user can complete the OAuth
/// flow without leaving CodeBurn. The command is a hardcoded literal -- no user input is
/// interpolated, so there's no injection surface.
///
/// Returns whether a scriptable terminal exists at all. It cannot report the eventual exit
/// status without blocking the main thread, so a later failure is logged rather than
/// returned; there is no headless fallback here because a login flow is interactive by
/// definition and would be useless without a window.
@discardableResult
static func openClaudeLogin() -> Bool {
let chain = terminalChain()
guard !chain.isEmpty else {
NSLog("CodeBurn: no scriptable terminal present; user must run `claude login` manually")
return false
}
DispatchQueue.global(qos: .userInitiated).async {
if runFirstWorking(chain: chain, command: "claude login", attempt: runScript) == nil {
NSLog("CodeBurn: no terminal accepted `claude login`; user must run it manually")
}
}
return true
}
/// Joins `argv` into the command string, or returns nil if any token fails the allowlist.
/// Extracted so the invariant is directly testable without launching anything.
static func safeCommand(argv: [String]) -> String? {
guard argv.allSatisfy(CodeburnCLI.isSafe) else { return nil }
return argv.joined(separator: " ")
}
/// Terminals to try, most preferred first: the configured one when installed, then
/// Terminal.app as the always-present backstop. Empty means nothing scriptable is present
/// and the caller should go headless. `isInstalled` is injectable for tests.
static func terminalChain(
preference: PreferredTerminal = PreferredTerminal.saved(),
isInstalled: (PreferredTerminal) -> Bool = { $0.isInstalled }
) -> [PreferredTerminal] {
var chain: [PreferredTerminal] = []
if isInstalled(preference) { chain.append(preference) }
if preference != .terminal, isInstalled(.terminal) { chain.append(.terminal) }
return chain
}
/// The terminal that will be attempted first, or nil when nothing scriptable is installed.
static func resolvedTerminal(
preference: PreferredTerminal = PreferredTerminal.saved(),
isInstalled: (PreferredTerminal) -> Bool = { $0.isInstalled }
) -> PreferredTerminal? {
terminalChain(preference: preference, isInstalled: isInstalled).first
}
/// Runs `command` in the first terminal of `chain` that actually succeeds and returns it,
/// or nil when every candidate failed. `attempt` is injectable so tests can exercise
/// "primary failed -> fell back" without launching anything.
@discardableResult
static func runFirstWorking(
chain: [PreferredTerminal],
command: String,
attempt: (PreferredTerminal, String) -> Bool
) -> PreferredTerminal? {
for terminal in chain {
if attempt(terminal, command) { return terminal }
NSLog("CodeBurn: \(terminal.label) did not run the command; trying the next fallback")
}
return nil
}
/// Drives one terminal via osascript and reports whether it worked.
private static func runScript(_ terminal: PreferredTerminal, command: String) -> Bool {
// Defence in depth: every caller validates already, but re-check so a future caller
// cannot reach osascript with an unvalidated string.
let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard tokens.allSatisfy(CodeburnCLI.isSafe) else {
NSLog("CodeBurn: refusing to run unvalidated command in \(terminal.label)")
return false
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", terminal.script(command: command)]
let errorPipe = Pipe()
process.standardError = errorPipe
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
NSLog("CodeBurn: could not spawn osascript for \(terminal.label): \(error.localizedDescription)")
return false
}
guard finished.wait(timeout: .now() + scriptTimeout) == .success else {
NSLog("CodeBurn: osascript for \(terminal.label) still running after \(Int(scriptTimeout))s; assuming its window opened")
return true
}
guard process.terminationStatus == 0 else {
// osascript writes one short line here, so reading after exit cannot deadlock on a
// full pipe buffer.
let detail = String(
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
NSLog("CodeBurn: osascript for \(terminal.label) exited \(process.terminationStatus): \(detail)")
return false
}
return true
}
}