From 19595502183c7b159c58c5eb6ff896ecab55060f Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:11:22 +0300 Subject: [PATCH] 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 --- README.md | 8 + .../Security/PreferredTerminal.swift | 124 ++++++++ .../Security/TerminalLauncher.swift | 182 ++++++++--- .../CodeBurnMenubar/Views/SettingsView.swift | 21 ++ .../TerminalLauncherTests.swift | 293 ++++++++++++++++++ 5 files changed, 588 insertions(+), 40 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift diff --git a/README.md b/README.md index d5a318c..3d23c59 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,14 @@ defaults write org.agentseal.codeburn-menubar CodeBurnMenubarRefreshSeconds -int Seconds between refreshes: `60`, `300`, or `900`; `0` is Manual and `-1` is Auto. Takes effect on the next refresh tick, no relaunch needed. +**Preferred terminal** decides where Full Report and Optimize open. Set it in Settings → General → Terminal, or from Terminal: + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnPreferredTerminal -string iterm2 +``` + +Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. Anything else falls back to `terminal`. Only terminals that can script a command into a live window are offered; if the chosen app is missing or fails to accept the command, CodeBurn tries Terminal.app and then runs the command in the background, logging each step to Console.app. Takes effect on the next launch of a command, no relaunch needed. + ### Linux (GNOME) Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/): diff --git a/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift b/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift new file mode 100644 index 0000000..7712bd5 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Closed set of terminal emulators CodeBurn knows how to drive (#877). +/// +/// SECURITY: this type exists specifically so that a *user preference* can never become a +/// free-form string inside an AppleScript. The preference is persisted as a raw value that is +/// parsed back through `init(rawValue:)`; anything unrecognised collapses to `.default`. Every +/// application name that reaches `osascript` is a hardcoded literal in `script(command:)` -- +/// never a stored or interpolated string. The only interpolated value is the command, which +/// callers must have already validated token-by-token with `CodeburnCLI.isSafe`. +/// +/// Only terminals with a real "run this in an interactive session and leave the window open" +/// 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 +/// AppleScript verb -- launching them with `-e ` tears the window down the moment the +/// command exits, which would make "Full Report" flash and vanish, so they intentionally stay +/// out of this enum rather than shipping broken. +enum PreferredTerminal: String, CaseIterable, Identifiable, Sendable { + case terminal + case iTerm2 = "iterm2" + + var id: String { rawValue } + + var label: String { + switch self { + case .terminal: return "Terminal (macOS default)" + case .iTerm2: return "iTerm2" + } + } + + /// Bundle locations probed with a plain `fileExists` check, mirroring the pre-#877 + /// behaviour. + /// + /// This is a simplicity/determinism choice, NOT a security boundary. A fixed list needs no + /// LaunchServices database state, gives the same answer on every machine, and keeps the + /// Settings "(not installed)" hint honest without a framework round-trip. + /// + /// It is worth being precise about what it does *not* buy, because an earlier version of + /// this comment overstated it. `NSWorkspace.urlForApplication(withBundleIdentifier:)` does + /// register and resolve bundles outside the standard folders -- an app dropped in + /// ~/Downloads is picked up within seconds -- but when a copy also exists in /Applications, + /// LaunchServices ranks /Applications first, and it still does so when the ~/Downloads copy + /// advertises a *higher* CFBundleShortVersionString. So on a normally installed machine the + /// two approaches resolve to the same bundle; the fixed list only differs by declining to + /// find an install the user put somewhere unusual, in which case we fall back to + /// Terminal.app rather than driving a bundle from a transient location such as a mounted + /// DMG. Neither approach checks a code signature or team ID, so neither authenticates the + /// app it drives. + var appPaths: [String] { + switch self { + case .terminal: + return [ + "/System/Applications/Utilities/Terminal.app", + "/Applications/Utilities/Terminal.app", + ] + case .iTerm2: + let home = FileManager.default.homeDirectoryForCurrentUser.path + return [ + "/Applications/iTerm.app", + "\(home)/Applications/iTerm.app", + ] + } + } + + var isInstalled: Bool { + appPaths.contains(where: FileManager.default.fileExists(atPath:)) + } + + /// AppleScript that brings the terminal forward, opens a window and runs `command`. + /// + /// `command` is the ONLY interpolated value; callers guarantee it is whitespace-joined argv + /// where every token passed `CodeburnCLI.isSafe` (no quotes, no `$`, no backticks, no `;`), + /// or a hardcoded literal. The `tell application` target is a compile-time literal per case. + func script(command: String) -> String { + switch self { + case .terminal: + return """ + tell application "Terminal" + activate + do script "\(command)" + end tell + """ + case .iTerm2: + // iTerm2 has no `do script`. A window must be created from a profile first, then + // text is written into its session. + // + // The target MUST be "iTerm", not "iTerm2", even though the app calls itself iTerm2 + // and its CFBundleName is "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 even compile (-2741, "expected , but found + // class name" -- `text` binds to the built-in class because iTerm2's terminology + // never loads) while `tell application "iTerm"` compiles, cold-launches the app and + // runs the command. `"iTerm2"` only works while iTerm2 already happens to be + // running, which made the bug easy to miss when testing interactively. + return """ + tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + write text "\(command)" + end tell + end tell + """ + } + } + + // MARK: - Persistence + + static let defaultsKey = "CodeBurnPreferredTerminal" + + /// Terminal.app, i.e. exactly the pre-#877 behaviour, so users who never open Settings + /// see no change. + static let `default`: PreferredTerminal = .terminal + + static func saved(defaults: UserDefaults = .standard) -> PreferredTerminal { + guard let raw = defaults.string(forKey: defaultsKey) else { return .default } + return PreferredTerminal(rawValue: raw) ?? .default + } + + func persist(defaults: UserDefaults = .standard) { + defaults.set(rawValue, forKey: Self.defaultsKey) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift index 9d0b3e7..ba16201 100644 --- a/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift +++ b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift @@ -1,65 +1,167 @@ import AppKit import Foundation -/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script` -/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist -/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private -/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't -/// bypass the invariant. -/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp -/// users) so the subcommand still runs. +/// 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 { - private static let terminalPaths = [ - "/System/Applications/Utilities/Terminal.app", - "/Applications/Utilities/Terminal.app", - ] + /// 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]) { - let argv = CodeburnCLI.baseArgv() + subcommand - guard argv.allSatisfy(CodeburnCLI.isSafe) else { + guard let command = safeCommand(argv: CodeburnCLI.baseArgv() + subcommand) else { NSLog("CodeBurn: refusing to open terminal with unsafe argv") return } - let command = argv.joined(separator: " ") - if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) { - runInTerminal(command: command, preValidated: true) - 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)") + } } - - let headless = CodeburnCLI.makeProcess(subcommand: subcommand) - try? headless.run() } - /// Launches `claude login` in Terminal.app so the user can complete the OAuth flow - /// without leaving CodeBurn. The command is a hardcoded literal -- no user input is + /// 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 { - guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else { - NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually") + let chain = terminalChain() + guard !chain.isEmpty else { + NSLog("CodeBurn: no scriptable terminal present; user must run `claude login` manually") return false } - runInTerminal(command: "claude login", preValidated: true) + 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 } - private static func runInTerminal(command: String, preValidated: Bool) { - if !preValidated { - 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") - return - } + /// 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") } - let script = """ - tell application "Terminal" - activate - do script "\(command)" - end tell - """ + 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", script] - try? process.run() + 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 } } diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 289ad58..e8c1f21 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -58,6 +58,11 @@ private struct GeneralSettingsTab: View { @AppStorage(UsageRefreshCadence.defaultsKey) private var usageRefreshSeconds: Int = UsageRefreshCadence.default.rawValue + // Stored as the raw string so an unrecognised value (older build, manual + // `defaults write`) parses back to .terminal instead of failing to decode. + @AppStorage(PreferredTerminal.defaultsKey) + private var preferredTerminalRaw: String = PreferredTerminal.default.rawValue + private let costPresets: Set = [25, 50, 100, 200, 500] private let tokenPresets: Set = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000] @@ -149,6 +154,22 @@ private struct GeneralSettingsTab: View { .foregroundStyle(.secondary) } + Section("Terminal") { + Picker("Open commands in", selection: Binding( + get: { PreferredTerminal(rawValue: preferredTerminalRaw) ?? .default }, + set: { preferredTerminalRaw = $0.rawValue } + )) { + ForEach(PreferredTerminal.allCases) { terminal in + Text(terminal.isInstalled ? terminal.label : "\(terminal.label) (not installed)") + .tag(terminal) + } + } + .pickerStyle(.menu) + Text("Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Section("Alerts") { // The budget tracks whatever the menubar metric shows: dollars for // the Cost metric, tokens for the Tokens / Total Tokens metrics. diff --git a/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift b/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift new file mode 100644 index 0000000..0a6dcbd --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift @@ -0,0 +1,293 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Preferred terminal selection and script generation") +struct TerminalLauncherTests { + // MARK: - Enum -> app path mapping + + @Test("Terminal.app keeps both stock install locations") + func terminalKeepsStockPaths() { + #expect(PreferredTerminal.terminal.appPaths == [ + "/System/Applications/Utilities/Terminal.app", + "/Applications/Utilities/Terminal.app", + ]) + } + + @Test("iTerm2 probes the system and per-user Applications folders") + func iTermProbesBothApplicationsFolders() { + let paths = PreferredTerminal.iTerm2.appPaths + let home = FileManager.default.homeDirectoryForCurrentUser.path + #expect(paths == ["/Applications/iTerm.app", "\(home)/Applications/iTerm.app"]) + } + + @Test("every case maps to absolute .app bundle paths") + func everyCaseMapsToAbsoluteBundlePaths() { + for terminal in PreferredTerminal.allCases { + #expect(!terminal.appPaths.isEmpty) + for path in terminal.appPaths { + #expect(path.hasPrefix("/")) + #expect(path.hasSuffix(".app")) + } + } + } + + // MARK: - Script generation per terminal + + @Test("Terminal.app uses the `do script` dialect") + func terminalUsesDoScript() { + let script = PreferredTerminal.terminal.script(command: "codeburn report") + #expect(script.contains("tell application \"Terminal\"")) + #expect(script.contains("do script \"codeburn report\"")) + #expect(script.contains("activate")) + // iTerm2 verbs must not leak into the Terminal.app dialect. + #expect(!script.contains("write text")) + #expect(!script.contains("create window with default profile")) + } + + @Test("iTerm2 uses the `create window` + `write text` dialect") + func iTermUsesWriteText() { + let script = PreferredTerminal.iTerm2.script(command: "codeburn report") + #expect(script.contains("tell application \"iTerm\"")) + #expect(script.contains("create window with default profile")) + #expect(script.contains("write text \"codeburn report\"")) + // `do script` is a Terminal.app-only verb; sending it to iTerm2 would fail silently. + #expect(!script.contains("do script")) + } + + @Test("iTerm2 is addressed as `iTerm`, the bundle name, so it compiles while the app is quit") + func iTermIsAddressedByBundleName() { + // Regression guard. `tell application "iTerm2"` only compiles while iTerm2 already + // happens to be running; with the app quit AppleScript resolves the name through + // LaunchServices by bundle file name (iTerm.app) and otherwise fails with -2741, + // which made "Full Report" do nothing at all. + let script = PreferredTerminal.iTerm2.script(command: "codeburn report") + #expect(!script.contains("tell application \"iTerm2\"")) + } + + @Test("each case targets exactly one hardcoded application name") + func eachCaseTargetsOneHardcodedApplication() { + let names: [PreferredTerminal: String] = [.terminal: "Terminal", .iTerm2: "iTerm"] + for terminal in PreferredTerminal.allCases { + let script = terminal.script(command: "codeburn report") + let tells = script.components(separatedBy: "tell application ").count - 1 + #expect(tells == 1) + #expect(script.contains("tell application \"\(names[terminal]!)\"")) + } + } + + @Test("the command is the only value interpolated into the script") + func commandIsTheOnlyInterpolatedValue() { + // Swapping the command must change nothing but the command occurrence, proving the + // app name and verbs are compile-time literals rather than stored strings. + for terminal in PreferredTerminal.allCases { + let a = terminal.script(command: "codeburn report") + let b = terminal.script(command: "codeburn optimize") + #expect(a != b) + #expect(a.replacingOccurrences(of: "codeburn report", with: "codeburn optimize") == b) + } + } + + // MARK: - Fallback selection when an app is absent + + @Test("the configured terminal is used when it is installed") + func configuredTerminalWins() { + let resolved = TerminalLauncher.resolvedTerminal(preference: .iTerm2, isInstalled: { _ in true }) + #expect(resolved == .iTerm2) + } + + @Test("a missing configured terminal falls back to Terminal.app") + func missingConfiguredTerminalFallsBackToTerminal() { + let resolved = TerminalLauncher.resolvedTerminal( + preference: .iTerm2, + isInstalled: { $0 == .terminal } + ) + #expect(resolved == .terminal) + } + + @Test("nil is returned when nothing scriptable exists so the caller goes headless") + func nothingInstalledResolvesToNil() { + #expect(TerminalLauncher.resolvedTerminal(preference: .iTerm2, isInstalled: { _ in false }) == nil) + #expect(TerminalLauncher.resolvedTerminal(preference: .terminal, isInstalled: { _ in false }) == nil) + } + + @Test("Terminal.app preference never resolves to another terminal") + func terminalPreferenceNeverResolvesElsewhere() { + // iTerm2 installed but Terminal.app chosen and absent -> headless, not a surprise app. + let resolved = TerminalLauncher.resolvedTerminal( + preference: .terminal, + isInstalled: { $0 == .iTerm2 } + ) + #expect(resolved == nil) + } + + // MARK: - Chain construction + + @Test("the chain is the configured terminal then Terminal.app as backstop") + func chainPutsPreferenceFirstThenTerminal() { + let chain = TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { _ in true }) + #expect(chain == [.iTerm2, .terminal]) + } + + @Test("Terminal.app is never listed twice when it is also the preference") + func chainDoesNotDuplicateTerminal() { + let chain = TerminalLauncher.terminalChain(preference: .terminal, isInstalled: { _ in true }) + #expect(chain == [.terminal]) + } + + @Test("an uninstalled preference drops out of the chain entirely") + func chainSkipsUninstalledPreference() { + let chain = TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { $0 == .terminal }) + #expect(chain == [.terminal]) + } + + @Test("no installed terminal yields an empty chain so the caller goes headless") + func chainIsEmptyWhenNothingInstalled() { + #expect(TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { _ in false }).isEmpty) + } + + // MARK: - Runtime fallback when a terminal is installed but osascript fails + + @Test("a terminal that fails at runtime falls through to the next candidate") + func runtimeFailureFallsBackToNextTerminal() { + // The real trigger: iTerm2 is installed, so it is picked, but osascript exits non-zero + // (terminology it cannot load, a denied Automation prompt, a broken bundle). Before + // this the launcher fired and forgot, so the user got no window and no error at all. + var attempted: [PreferredTerminal] = [] + let used = TerminalLauncher.runFirstWorking( + chain: [.iTerm2, .terminal], + command: "codeburn report", + attempt: { terminal, _ in + attempted.append(terminal) + return terminal == .terminal + } + ) + #expect(used == .terminal) + #expect(attempted == [.iTerm2, .terminal]) + } + + @Test("a working first terminal short-circuits the rest of the chain") + func successfulFirstTerminalStopsTheChain() { + var attempted: [PreferredTerminal] = [] + let used = TerminalLauncher.runFirstWorking( + chain: [.iTerm2, .terminal], + command: "codeburn report", + attempt: { terminal, _ in + attempted.append(terminal) + return true + } + ) + #expect(used == .iTerm2) + #expect(attempted == [.iTerm2]) + } + + @Test("every candidate failing returns nil so the caller can go headless") + func exhaustedChainReturnsNil() { + var attempted: [PreferredTerminal] = [] + let used = TerminalLauncher.runFirstWorking( + chain: [.iTerm2, .terminal], + command: "codeburn report", + attempt: { terminal, _ in + attempted.append(terminal) + return false + } + ) + #expect(used == nil) + #expect(attempted == [.iTerm2, .terminal]) + } + + @Test("an empty chain attempts nothing and reports failure immediately") + func emptyChainAttemptsNothing() { + var attempts = 0 + let used = TerminalLauncher.runFirstWorking( + chain: [], + command: "codeburn report", + attempt: { _, _ in + attempts += 1 + return true + } + ) + #expect(used == nil) + #expect(attempts == 0) + } + + @Test("the command reaches each attempted terminal unchanged") + func commandIsForwardedToEveryAttempt() { + var seen: [String] = [] + _ = TerminalLauncher.runFirstWorking( + chain: [.iTerm2, .terminal], + command: "codeburn optimize", + attempt: { _, command in + seen.append(command) + return false + } + ) + #expect(seen == ["codeburn optimize", "codeburn optimize"]) + } + + // MARK: - argv safety validation + + @Test("safe argv joins into a command") + func safeArgvJoins() { + #expect(TerminalLauncher.safeCommand(argv: ["codeburn", "report"]) == "codeburn report") + #expect( + TerminalLauncher.safeCommand(argv: ["/opt/homebrew/bin/codeburn", "optimize"]) + == "/opt/homebrew/bin/codeburn optimize" + ) + } + + @Test("shell metacharacters are still rejected before reaching AppleScript") + func unsafeArgvIsRejected() { + let hostile = [ + "codeburn; rm -rf ~", + "codeburn && curl evil.sh", + "codeburn | tee /tmp/x", + "$(whoami)", + "`whoami`", + "codeburn \"quoted\"", + "codeburn'q", + "codeburn\nreport", + "codeburn > /tmp/x", + ] + for token in hostile { + #expect(!CodeburnCLI.isSafe(token), "expected \(token) to be rejected") + #expect(TerminalLauncher.safeCommand(argv: ["codeburn", token]) == nil) + } + } + + @Test("a single unsafe token poisons the whole argv") + func oneUnsafeTokenRejectsEverything() { + #expect(TerminalLauncher.safeCommand(argv: ["codeburn", "report", "; id"]) == nil) + #expect(TerminalLauncher.safeCommand(argv: [""]) == nil) + } + + // MARK: - Persistence + + @Test("preference defaults to Terminal.app when unset, preserving pre-#877 behaviour") + func defaultsToTerminalWhenUnset() { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(PreferredTerminal.saved(defaults: defaults) == .terminal) + #expect(PreferredTerminal.default == .terminal) + } + + @Test("preference round-trips and unknown values collapse to the default") + func preferenceRoundTripsAndRejectsGarbage() { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + PreferredTerminal.iTerm2.persist(defaults: defaults) + #expect(defaults.string(forKey: PreferredTerminal.defaultsKey) == "iterm2") + #expect(PreferredTerminal.saved(defaults: defaults) == .iTerm2) + + PreferredTerminal.terminal.persist(defaults: defaults) + #expect(PreferredTerminal.saved(defaults: defaults) == .terminal) + + // A hand-written defaults value must never become a `tell application` target. + defaults.set("Terminal\" \nto do shell script \"id", forKey: PreferredTerminal.defaultsKey) + #expect(PreferredTerminal.saved(defaults: defaults) == .terminal) + } +}