codeburn/mac/Sources/CodeBurnMenubar/Localization.swift
Resham Joshi b8cc0edcbf
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Windows Menubar CI / check (windows-latest) (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Waiting to run
0.9.25 round 2: fixes found testing the built apps (#1517)
* fix: open menu bar Settings centered on the active screen

The window was positioned at a 520x380 placeholder while SwiftUI's own
minimum is 880x620, so the grown window kept its top-left corner and ran
off the bottom-right edge. Size the window from the view's minimum, place
it with a tested pure function, and honour a saved frame only while it
still fits on one of today's screens.

* fix: say where the telemetry toggle is managed when the desktop app owns it

A desktop telemetry.v1.json with enabled:false locks the menu bar toggle
off, so clicking it does nothing. Name the desktop app's Privacy & data
pane in the caption, and pin the four desktop/veto combinations plus the
standalone case in tests.

* fix(desktop): export the provider id, and never swallow a rejected setter

The export pane built its provider list from `current.providers`, which is
keyed on the lowercased display name. Five of the seventeen providers on a
real corpus ("cursor agent", "grok build", "kimi code", "hermes agent",
"grok bot") are not valid ids, so vProvider rejected them as bad-args.
`exportNow` had a try/finally and no catch, so the rejection produced no
toast and no file: the button did nothing. Use detectedProviders, which
already resolves ids from providerDetails, and toast on rejection.

The telemetry consent toggle swallowed its rejection the same way.

* fix(desktop): warm only the selected period, and only while visible

The post-paint sweep warmed all six standard periods x seven reports plus
the provider overviews: up to 48 serve-routed queries, ~18 CPU-seconds a
minute for five minutes and 2.79 GB peak RSS on a real corpus, and it ran
with the window hidden. It now warms the selected period's reports (its
own overview is already under that memo key from the visible poll) and
the other providers' headlines for that same period, holding before every
request while a user fetch is in flight or the window is hidden. Other
periods take the ordinary on-demand path.

Also corrects two comments that claimed background priority lets a click
jump ahead: spawnCli takes the serve fast path before priority is read,
and the resident child answers FIFO.

* fix(desktop): answer the click, report the write, tell the truth about the Claude login

Nine bridge setters in Settings attached only .then(), so a rejected
envelope produced no toast and the control read as if it had ignored the
click. One shared helper toasts the rejection, per pane fallback wording.

Telemetry save() swallowed write errors while setEnabled returned the
in-memory status, so the UI could show ON while the file the macOS menu
bar app reads still said OFF. The setters now carry persisted:boolean and
the renderer toasts when it is false; the write goes through the existing
atomic writeFileAtomic (temp file, rename) so a crash cannot tear it.

The Claude card called a logged-in Claude Code 2.x user "disconnected"
forever, because the credential lives only in the keychain and the first
poll never reads it. A new darwin-only keychainUnchecked state says we
have not looked yet and offers the forced read, keeping the login
instructions for the genuinely-logged-out case. ConnectAffordance's
toggle is renamed to what it does: it discloses instructions.

* fix: retry tax is the cost of retried edit turns, not a multiple of spend

* stop the change guard watching a Warp path it can never stat

* apply a menu bar language change in place instead of relaunching

* keep honoring WARP_DB_PATH in the change guard

* restart a menu bar too old to watch AppleLanguages

* one rebuild per language change, and no global bundle in tests

* fix(desktop): a forced quota refresh must not join the poll it just aborted

Check now / Refresh / ⌘R invalidated the quota service and then handed the
caller the background fetch it had just aborted, so every provider failed
its generation check and resolved disconnected: the click repainted every
card as logged out without ever reading the keychain, and Claude showed
"Not connected. Log in with the Claude CLI." A call now joins an in-flight
run only when that run is at least as privileged, so a keychain-allowed
call starts its own; a second forced click still joins the first, which
keeps the single macOS prompt. A superseded run hands its caller the run
that replaced it instead of its own abort artefacts.

retainOnFailure also holds accessDenied through a keychainUnchecked poll,
so the "Allow in keychain" guidance is not replaced by "Check now" one
cycle after the user denied the prompt.

* fix(desktop): stop the sweep churning, and never spend a query on an abandoned one

The prefetch effect depended on the detected-provider entries, whose costs
move on every overview poll, so it was torn down and restarted each cadence
tick: a warm slower than the interval was discarded and re-issued forever.
It now depends on the provider ids alone, and a finished warm is kept for
its own period/provider key even if the effect was torn down meanwhile —
only a config-generation change makes a result wrong.

Both loops re-test cancellation and the memo after the hold, which has no
bound, so an abandoned sweep issues nothing and a section the user visited
meanwhile is not parsed again. The hidden half of the hold waits on
visibilitychange instead of waking the renderer every two seconds for a
window nobody is looking at.

* fix(desktop): never offer a provider id the CLI will reject, and keep the keychain note

detectedProviders applied the round-trip filter only to the legacy
providers map; a providerDetails id with a space reached the picker and the
export as a --provider value the main process rejects.

The Plans settings row showed the "not read your login yet" line without
the note that macOS may ask once for keychain access, which the Plans card
does show.
2026-09-21 13:53:45 +02:00

192 lines
8.8 KiB
Swift

import Foundation
import Observation
/// Localization for the menubar app (#1219). No third-party library: one
/// `Localizable.strings` table per locale, shipped as SwiftPM target resources
/// and resolved against the chosen language by `L10n.use(_:)`.
///
/// # Why every lookup is explicit
///
/// SwiftPM emits target resources into a *sibling* bundle
/// (`CodeBurnMenubar_CodeBurnMenubar.bundle`, copied into the app's
/// `Contents/Resources` by the packaging scripts), never into the app
/// bundle's resource root. `Bundle.main` therefore has no `.lproj` at all, so
/// the implicit `LocalizedStringKey` path that SwiftUI uses for
/// `Text("literal")` would always miss. Routing every string through `L(_:)`,
/// which names `Bundle.module`, is the one form that resolves identically in
/// `swift run`, in `swift test`, and in the packaged `.app`.
///
/// # Keys are the English copy
///
/// The key *is* the English string (`"Refresh Now"`, `"%lld sessions"`), so
/// English stays the development language: a key with no translation renders
/// as correct English rather than a visible identifier, and `en.lproj` is an
/// identity table kept only so the bundle advertises `en` as a localization
/// and so `LocalizationCatalogTests` can diff the two tables.
///
/// # What is not translated
///
/// Provider and model names (`Claude`, `Codex`, `Gemini`, `Sonnet`), units
/// (`tok/s`, `ACU`, `%`), currency codes, shell commands, and anything the
/// `codeburn` CLI itself produces (payload labels, activity and project names,
/// error text forwarded from the subprocess) stay verbatim. Numbers, dates,
/// and currency keep going through the locale-aware formatters they already
/// used — `L(_:_:)` only substitutes already-formatted values.
enum L10n {
/// The bundle that carries `en.lproj` / `zh-Hans.lproj`.
static let bundle: Bundle = .module
/// Table name, i.e. `Localizable.strings`.
static let table = "Localizable"
/// Locales shipped today. Mirrored by `CFBundleLocalizations` in the two
/// packaging scripts and asserted by `LocalizationCatalogTests`.
static let supportedLocalizations = ["en", "fr", "ja", "ko", "zh-Hans", "zh-Hant"]
private static let lock = NSLock()
private nonisolated(unsafe) static var resolved: Bundle?
/// The single `.lproj` sub-bundle `L(_:)` reads from. Naming the sub-bundle
/// rather than `bundle` is what makes a language change land without a
/// restart: CFBundle picks its localization once, the first time a table is
/// resolved, and never revisits it — so a live `AppleLanguages` change is
/// invisible to `bundle.localizedString(...)`, which is why the app used to
/// have to relaunch. An explicit sub-bundle has no such cache.
static var active: Bundle {
lock.withLock {
if let resolved { return resolved }
let bundle = subbundle(for: LanguagePreference.current())
resolved = bundle
return bundle
}
}
/// Point every later `L(_:)` at `preference`'s table.
///
/// The `AppleLanguages` observer is the only caller in the app: the picker
/// persists the preference and the observer applies it, so one pick is one
/// rebuild. A write that does not move the resolved language — the picker
/// re-choosing what is already in force, or a global-domain change while an
/// override is set — returns without bumping, because a bump tears the
/// Settings window and the dock rail down and builds them again.
@MainActor
static func use(_ preference: LanguagePreference) {
let bundle = subbundle(for: preference)
let changed = lock.withLock { () -> Bool in
guard resolved?.bundlePath != bundle.bundlePath else { return false }
resolved = bundle
return true
}
guard changed else { return }
LanguageGeneration.shared.bump()
}
/// The OS language order, read straight from the global domain. Neither
/// `Locale.preferredLanguages` nor `Bundle.preferredLocalizations` can be
/// used for this: both honour an `AppleLanguages` override in the app's own
/// domain, so with one set they would report the override as the system
/// language and "System" could never mean "follow the OS" again.
static var systemPreferredLanguages: [String] {
UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?[LanguagePreference.defaultsKey] as? [String]
?? Locale.preferredLanguages
}
/// The language whose `.lproj` should serve lookups. A language the bundle
/// does not ship falls back to the development language, which is English
/// and therefore the key text itself.
static func language(
for preference: LanguagePreference,
available: [String],
development: String,
systemPreferred: [String]
) -> String {
guard preference == .system else {
return available.contains(preference.rawValue) ? preference.rawValue : development
}
return Bundle.preferredLocalizations(from: available, forPreferences: systemPreferred).first ?? development
}
static func subbundle(
for preference: LanguagePreference,
in base: Bundle = L10n.bundle,
systemPreferred: [String]? = nil
) -> Bundle {
let development = base.developmentLocalization ?? "en"
let chosen = language(
for: preference,
// `supportedLocalizations`, not `base.localizations`: NSBundle
// lowercases what it reports, and `zh-hans` matches neither the
// preference's raw value nor the `zh-Hans.lproj` on disk. The two
// lists are held equal by `LocalizationCatalogTests`, and a
// disagreement only costs the fallback below.
available: supportedLocalizations,
development: development,
systemPreferred: systemPreferred ?? systemPreferredLanguages
)
return lproj(chosen, in: base) ?? lproj(development, in: base) ?? base
}
/// The lookup `L(_:)` performs, with the bundle named rather than taken from
/// `active`. Tests assert a language's table through this, so checking what
/// French renders as never has to repoint the process-wide bundle that every
/// other suite is reading at the same time.
static func lookup(_ key: String, in bundle: Bundle) -> String {
bundle.localizedString(forKey: key, value: key, table: table)
}
/// SwiftPM writes the resource bundle's directories all lowercase, so
/// `zh-Hans.lproj` in the source tree is `zh-hans.lproj` in the build
/// product. `path(forResource:ofType:)` matches the name literally where
/// CFBundle's own localization matching does not, so both spellings are
/// tried. Getting this wrong is silent: the lookup falls through to English.
private static func lproj(_ language: String, in base: Bundle) -> Bundle? {
for candidate in [language, language.lowercased()] {
if let path = base.path(forResource: candidate, ofType: "lproj"),
let resolved = Bundle(path: path) {
return resolved
}
}
return nil
}
}
/// Bumped whenever `L(_:)` starts answering in a different language. SwiftUI
/// views that outlive a language change (the Settings window, the Capacity Dock
/// rail) read it so their bodies re-run; the popover is rebuilt on every show
/// and needs nothing.
@MainActor
@Observable
final class LanguageGeneration {
static let shared = LanguageGeneration()
private(set) var value = 0
fileprivate func bump() { value += 1 }
}
/// Localized copy for `key`, falling back to the key (its English text) when a
/// translation is missing.
func L(_ key: String) -> String {
L10n.lookup(key, in: L10n.active)
}
/// Localized format string for `key`, filled with `arguments`.
///
/// The specifiers in the key are part of the contract between the tables:
/// `%@` for an already-formatted value (currency, token count, provider name),
/// `%lld` for a plain `Int`. Deliberately formatted without a locale so the
/// substituted values keep exactly the grouping the existing formatters chose
/// — re-grouping a `%lld` here would disagree with the
/// `asCurrency()` / `asThousandsSeparated()` output next to it.
func L(_ key: String, _ arguments: CVarArg...) -> String {
String(format: L(key), arguments: arguments)
}
/// A quota window's label. Provider adapters hand these over in English and most
/// are passed through verbatim (a vendor's own wording, per `QuotaCrossing`);
/// the generic billing period the app itself composes is the one the dock and
/// the menu bar translate, so it does not read English amid localized copy.
func localizedWindowLabel(_ label: String) -> String {
switch label {
case "Monthly": return L("Monthly")
default: return label
}
}