codeburn/mac/Sources/CodeBurnMenubar/Security/PreferredTerminal.swift
ozymandiashh d62857ea4e feat(menubar): route every user-facing string through a strings catalog
Groundwork for #1219. The menubar app hardcoded ~530 English strings across
Views/, the presentation structs, the status-item menu and the update alerts,
so there was nothing for a translator to translate.

Every user-facing literal now goes through `L(_:)` / `L(_:_:)`, which look the
string up in `Localizable.strings` in the SwiftPM target resource bundle. The
key *is* the English copy, so English stays the development language and a
missing translation degrades to correct English instead of a dotted
identifier. `en.lproj` is therefore an identity table; it exists so the bundle
advertises `en` and so a translation can be diffed against it.

Why the lookups are explicit rather than relying on SwiftUI's implicit
`LocalizedStringKey`: SwiftPM emits target resources into a sibling bundle
(`CodeBurnMenubar_CodeBurnMenubar.bundle`) that the packaging scripts copy into
`Contents/Resources`. `Bundle.main` has no `.lproj` at all, so `Text("literal")`
would always miss. Naming `Bundle.module` is the one form that resolves the
same way in `swift run`, in `swift test` and in the packaged `.app`.

Enum raw values that double as identity (`Period`, `MenubarScope`,
`InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gain
a `displayLabel`, so persistence and cache keys are untouched by translation.

Three display-only date formatters that were pinned to `en_US_POSIX` with fixed
patterns now use `setLocalizedDateFormatFromTemplate`, and the calendar
popover's weekday row comes from the locale's own symbols. The `yyyy-MM-dd`
formatter stays POSIX: it parses and builds data keys, not display text.

Not extracted, deliberately: provider, model and plan names; units; currency
codes; shell commands and paths; quota window labels that policy code matches
on by English substring (`QuotaSummary.headlineWindow`); and anything the
`codeburn` CLI produces.

Also copies the SwiftPM resource bundle into the app in build-local.sh, using
the named-bundle + `[[ -d ]]` pattern package-app.sh already uses. Without it
the assembled app finds no strings table (and already trapped on first icon
load, the resource-bundle half of #1262).
2026-09-12 15:21:49 -07:00

124 lines
5.8 KiB
Swift

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 <cmd>` 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 L("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)
}
}