mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 22:44:31 +00:00
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
167 lines
8.3 KiB
Swift
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
|
|
}
|
|
}
|