mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-25 08:09:35 +00:00
cli_error gains duration, exit, a locally classified reason and the scoped provider. New provider_read_fail (desktop) and update_result (desktop, menubar, tray) events, enums only.
613 lines
28 KiB
Swift
613 lines
28 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20"
|
|
// Written by release-menubar.yml after a mac-v* release is verified; the `codeburn menubar`
|
|
// installer reads the same file, so the pill and the install agree on "latest".
|
|
private let menubarFeedURL = "https://github.com/getagentseal/codeburn/releases/download/update-feeds/menubar-latest.json"
|
|
private let menubarAssetURLPrefix = "https://github.com/getagentseal/codeburn/releases/download/mac-v"
|
|
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
|
|
private let lastCheckKey = "UpdateChecker.lastCheckDate"
|
|
private let cachedVersionKey = "UpdateChecker.latestVersion"
|
|
private let cachedCliVersionKey = "UpdateChecker.latestCliVersion"
|
|
private let lastNotifiedVersionsKey = "UpdateChecker.lastNotifiedVersions"
|
|
// Written just before the installer runs, settled on the next launch: the installer
|
|
// stops and relaunches this app, so nothing tracked in this process would survive.
|
|
private let pendingUpdateKey = "UpdateChecker.pendingUpdate"
|
|
// A global npm install on a slow link or a cold cache can run for minutes.
|
|
private let cliUpdateTimeoutSeconds: UInt64 = 600
|
|
private let menubarUpdateTimeoutSeconds: UInt64 = 300
|
|
private let maxUpdateStderrBytes = 64 * 1024
|
|
// The installer that scans `mac-v*` releases for the menubar zip (instead of
|
|
// `/releases/latest`, which can resolve to a CLI release that carries no menubar
|
|
// asset) landed in CLI 0.9.9 (commit 909efcf). Older CLIs cannot perform a correct
|
|
// `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first.
|
|
private let minCliVersionForUpdate = "0.9.9"
|
|
|
|
enum UpdateFailureStage: Equatable {
|
|
case check
|
|
case cliUpdate
|
|
case menubarUpdate
|
|
|
|
var badgeLabel: String {
|
|
switch self {
|
|
case .check: L("Update Check Failed")
|
|
case .cliUpdate: L("CLI Update Failed")
|
|
case .menubarUpdate: L("Menubar Update Failed")
|
|
}
|
|
}
|
|
|
|
var summary: String {
|
|
switch self {
|
|
case .check: L("CodeBurn could not check GitHub for updates.")
|
|
case .cliUpdate: L("CodeBurn could not update the CLI.")
|
|
case .menubarUpdate: L("CodeBurn could not update the menubar app.")
|
|
}
|
|
}
|
|
|
|
var retryHelp: String {
|
|
switch self {
|
|
case .check: L("Click to retry the update check.")
|
|
case .cliUpdate, .menubarUpdate: L("Click to retry the update.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class LockedDataBuffer: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var data = Data()
|
|
|
|
func append(_ chunk: Data, limit: Int) {
|
|
lock.withLock {
|
|
guard data.count < limit else { return }
|
|
data.append(Data(chunk.prefix(limit - data.count)))
|
|
}
|
|
}
|
|
|
|
func snapshot() -> Data {
|
|
lock.withLock { data }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class UpdateChecker {
|
|
@ObservationIgnored private let defaults: UserDefaults
|
|
@ObservationIgnored private let makeNotifier: () -> any UpdateNotifier
|
|
@ObservationIgnored private var notifier: (any UpdateNotifier)?
|
|
@ObservationIgnored private let track: @MainActor (String, JSONValue) -> Void
|
|
|
|
init(
|
|
defaults: UserDefaults = .standard,
|
|
makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() },
|
|
track: @escaping @MainActor (String, JSONValue) -> Void = { Telemetry.shared.track($0, $1) }
|
|
) {
|
|
self.defaults = defaults
|
|
self.makeNotifier = makeNotifier
|
|
self.track = track
|
|
}
|
|
|
|
private func trackUpdateResult(from: String, to: String, outcome: String) {
|
|
track("update_result", .object(["from": .string(from), "to": .string(to), "outcome": .string(outcome)]))
|
|
}
|
|
|
|
/// On launch: a newer version running means the last install landed, and `to` is what
|
|
/// runs; the old one still running means it did not. An older one is a manual
|
|
/// downgrade: the marker goes and nothing is sent.
|
|
func settlePendingUpdate(running: String? = nil) {
|
|
guard let pending = defaults.dictionary(forKey: pendingUpdateKey) as? [String: String],
|
|
let from = pending["from"], let to = pending["to"] else { return }
|
|
defaults.removeObject(forKey: pendingUpdateKey)
|
|
let now = AppVersion.normalize(running ?? currentVersion)
|
|
switch now.compare(from, options: .numeric) {
|
|
case .orderedDescending: trackUpdateResult(from: from, to: now, outcome: "ok")
|
|
case .orderedSame: trackUpdateResult(from: from, to: to, outcome: "install_fail")
|
|
case .orderedAscending: break
|
|
}
|
|
}
|
|
|
|
/// A failed `codeburn menubar --force`, sorted by the installer's own messages
|
|
/// (src/menubar-installer.ts) so the stderr itself never leaves the machine.
|
|
nonisolated static func updateFailureOutcome(stderr: String) -> String {
|
|
let text = stderr.lowercased()
|
|
let verify = ["checksum", "not signed", "notarized", "bundle id", "refusing"]
|
|
if verify.contains(where: text.contains) { return "verify_fail" }
|
|
if text.range(of: #"failed after \d+ attempts|http \d{3}|econn|etimedout|enotfound|network"#, options: .regularExpression) != nil {
|
|
return "download_fail"
|
|
}
|
|
return "install_fail"
|
|
}
|
|
|
|
private func failPendingUpdate(outcome: String) {
|
|
guard let pending = defaults.dictionary(forKey: pendingUpdateKey) as? [String: String],
|
|
let from = pending["from"], let to = pending["to"] else { return }
|
|
defaults.removeObject(forKey: pendingUpdateKey)
|
|
trackUpdateResult(from: from, to: to, outcome: outcome)
|
|
}
|
|
|
|
var latestVersion: String?
|
|
var latestCliVersion: String?
|
|
var installedCliVersion: String?
|
|
var isUpdating = false
|
|
var updateError: String?
|
|
var updateFailureStage: UpdateFailureStage?
|
|
|
|
var updateBadgeLabel: String {
|
|
if isUpdating { return L("Updating...") }
|
|
return updateFailureStage?.badgeLabel ?? L("Update")
|
|
}
|
|
|
|
var updateHelpText: String {
|
|
guard let error = updateError, let stage = updateFailureStage else {
|
|
return L("Update the CLI and menubar to the latest release")
|
|
}
|
|
return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)"
|
|
}
|
|
|
|
var updateAvailable: Bool {
|
|
guard let latest = latestVersion else { return false }
|
|
let current = currentVersion
|
|
let normalizedLatest = AppVersion.normalize(latest)
|
|
let normalizedCurrent = AppVersion.normalize(current)
|
|
guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false }
|
|
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
|
|
}
|
|
|
|
var cliUpdateAvailable: Bool {
|
|
guard let latest = latestCliVersion, let installed = installedCliVersion else { return false }
|
|
let normalizedLatest = AppVersion.normalize(latest)
|
|
let normalizedInstalled = AppVersion.normalize(installed)
|
|
guard !normalizedInstalled.isEmpty else { return false }
|
|
return normalizedLatest.compare(normalizedInstalled, options: .numeric) == .orderedDescending
|
|
}
|
|
|
|
/// True when the installed CLI predates the `menubar --force` fix and would fail to
|
|
/// install the new app. Distinct from `cliUpdateAvailable`: a CLI can be behind the
|
|
/// latest release yet still new enough (>= 0.9.9) to update the menubar correctly.
|
|
var cliTooOldForUpdate: Bool {
|
|
Self.isCliTooOld(installed: installedCliVersion)
|
|
}
|
|
|
|
var cliUpdateCommand: String {
|
|
let argv = CodeburnCLI.baseArgv()
|
|
return Self.cliUpdateCommand(cliPath: argv.first ?? "")
|
|
}
|
|
|
|
/// Manual counterpart of `cliUpdateInvocation`, so the hint shown when the
|
|
/// one-click path cannot find a manager names the same manager that path
|
|
/// would have used.
|
|
nonisolated static func cliUpdateCommand(
|
|
cliPath: String,
|
|
resolvingSymlinks: (String) -> String = { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }
|
|
) -> String {
|
|
if isHomebrewInstall(cliPath: cliPath, resolvingSymlinks: resolvingSymlinks) { return "brew upgrade codeburn" }
|
|
return "npm update -g codeburn"
|
|
}
|
|
|
|
var currentVersion: String {
|
|
AppVersion.normalizedBundleShortVersion
|
|
}
|
|
|
|
func checkIfNeeded() async {
|
|
installedCliVersion = Self.queryInstalledCliVersion()
|
|
let lastCheck = defaults.double(forKey: lastCheckKey)
|
|
let now = Date().timeIntervalSince1970
|
|
if now - lastCheck < checkIntervalSeconds {
|
|
latestVersion = defaults.string(forKey: cachedVersionKey)
|
|
latestCliVersion = defaults.string(forKey: cachedCliVersionKey)
|
|
return
|
|
}
|
|
await check()
|
|
// Only the background poll notifies; a manual check already shows its result.
|
|
await notifyIfUpdateAvailable(
|
|
appVersion: updateAvailable ? latestVersion : nil,
|
|
cliVersion: cliUpdateAvailable ? latestCliVersion : nil
|
|
)
|
|
}
|
|
|
|
func check() async {
|
|
updateError = nil
|
|
updateFailureStage = nil
|
|
installedCliVersion = Self.queryInstalledCliVersion()
|
|
guard let url = URL(string: releasesAPI) else { return }
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 30
|
|
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
|
|
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
|
|
do {
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
throw UpdateCheckError.http(status)
|
|
}
|
|
let releases = try JSONDecoder().decode([GitHubRelease].self, from: data)
|
|
let version: String
|
|
if let feedVersion = await Self.fetchFeedVersion() {
|
|
version = feedVersion
|
|
} else {
|
|
guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else {
|
|
throw UpdateCheckError.missingMenubarAsset
|
|
}
|
|
version = resolved.asset.name
|
|
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
|
|
.replacingOccurrences(of: ".zip", with: "")
|
|
}
|
|
|
|
let cliVersion = Self.resolveLatestCliVersion(in: releases)
|
|
|
|
latestVersion = version
|
|
latestCliVersion = cliVersion
|
|
defaults.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
|
|
defaults.set(version, forKey: cachedVersionKey)
|
|
if let cliVersion { defaults.set(cliVersion, forKey: cachedCliVersionKey) }
|
|
} catch {
|
|
updateFailureStage = .check
|
|
updateError = error.localizedDescription
|
|
NSLog("CodeBurn: update check failed: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Marks the notifications a tap should install an update from. Other
|
|
/// posters share the notification delegate.
|
|
nonisolated static let notificationIdentifierPrefix = "UpdateChecker."
|
|
|
|
/// Posts at most one notification per new version pair; the 2-day repoll
|
|
/// finds the same pair stamped and stays quiet.
|
|
func notifyIfUpdateAvailable(appVersion: String?, cliVersion: String?) async {
|
|
guard UpdateNotificationPreference.isEnabled(defaults: defaults) else { return }
|
|
guard let copy = Self.updateNotificationCopy(appVersion: appVersion, cliVersion: cliVersion) else { return }
|
|
let stamp = "\(appVersion ?? "-")|\(cliVersion ?? "-")"
|
|
guard defaults.string(forKey: lastNotifiedVersionsKey) != stamp else { return }
|
|
let notifier = notifier ?? makeNotifier()
|
|
self.notifier = notifier
|
|
guard await notifier.requestAuthorizationIfNeeded() else { return }
|
|
notifier.post(title: copy.title, body: copy.body, identifier: "\(Self.notificationIdentifierPrefix)\(stamp)")
|
|
defaults.set(stamp, forKey: lastNotifiedVersionsKey)
|
|
}
|
|
|
|
nonisolated static func updateNotificationCopy(appVersion: String?, cliVersion: String?) -> (title: String, body: String)? {
|
|
switch (appVersion, cliVersion) {
|
|
case let (app?, cli?):
|
|
return (L("CodeBurn %@ available", AppVersion.display(app)), L("App and CLI %@ updates are ready. Click to install.", AppVersion.display(cli)))
|
|
case let (app?, nil):
|
|
return (L("CodeBurn %@ available", AppVersion.display(app)), L("Click to install the update."))
|
|
case let (nil, cli?):
|
|
return (L("CodeBurn CLI %@ available", AppVersion.display(cli)), L("Click to install the update."))
|
|
case (nil, nil):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
nonisolated static func fetchFeedVersion() async -> String? {
|
|
guard let url = URL(string: menubarFeedURL) else { return nil }
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 30
|
|
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
|
|
guard let (data, response) = try? await URLSession.shared.data(for: request),
|
|
(response as? HTTPURLResponse)?.statusCode == 200
|
|
else { return nil }
|
|
return menubarVersion(fromFeed: data)
|
|
}
|
|
|
|
/// Nil unless the feed names a version and points at that version's mac-v* release zip.
|
|
nonisolated static func menubarVersion(fromFeed data: Data) -> String? {
|
|
guard let feed = try? JSONDecoder().decode(MenubarFeed.self, from: data) else { return nil }
|
|
let version = AppVersion.normalize(feed.version)
|
|
guard !version.isEmpty,
|
|
feed.url == "\(menubarAssetURLPrefix)\(version)/CodeBurnMenubar-v\(version).zip"
|
|
else { return nil }
|
|
return "v\(version)"
|
|
}
|
|
|
|
nonisolated static func resolveLatestCliVersion(in releases: [GitHubRelease]) -> String? {
|
|
for release in releases where release.tag_name.hasPrefix("v") && !release.tag_name.hasPrefix("mac-v") {
|
|
return AppVersion.normalize(release.tag_name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
nonisolated static func queryInstalledCliVersion() -> String? {
|
|
let process = CodeburnCLI.makeProcess(subcommand: ["--version"])
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = FileHandle.nullDevice
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
guard process.terminationStatus == 0 else { return nil }
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return output.isEmpty ? nil : output
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
nonisolated static func resolveLatestMenubarRelease(in releases: [GitHubRelease]) -> (release: GitHubRelease, asset: GitHubAsset)? {
|
|
for release in releases where release.tag_name.hasPrefix("mac-v") {
|
|
guard let asset = release.assets.first(where: {
|
|
$0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip")
|
|
}) else { continue }
|
|
guard release.assets.contains(where: { $0.name == "\(asset.name).sha256" }) else { continue }
|
|
return (release, asset)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
nonisolated static func isCliTooOld(installed: String?) -> Bool {
|
|
guard let installed else { return false }
|
|
let normalizedInstalled = AppVersion.normalize(installed)
|
|
guard !normalizedInstalled.isEmpty else { return false }
|
|
return AppVersion.normalize(minCliVersionForUpdate).compare(normalizedInstalled, options: .numeric) == .orderedDescending
|
|
}
|
|
|
|
/// True when the CLI launcher resolves into a Homebrew cellar.
|
|
///
|
|
/// The launcher's own directory is not enough to tell brew from npm: Homebrew's
|
|
/// Node sets the npm global prefix to the Homebrew prefix, so an
|
|
/// `npm install -g codeburn` lands at `<prefix>/bin/codeburn` and points at
|
|
/// `<prefix>/lib/node_modules/codeburn`. A directory test sees `/homebrew/`
|
|
/// there and picks brew, which has no codeburn formula to upgrade.
|
|
nonisolated static func isHomebrewInstall(
|
|
cliPath: String,
|
|
resolvingSymlinks: (String) -> String = { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }
|
|
) -> Bool {
|
|
let resolved = resolvingSymlinks(cliPath)
|
|
if resolved.contains("/Cellar/codeburn/") { return true }
|
|
if resolved.contains("/node_modules/codeburn/") { return false }
|
|
return cliPath.contains("/homebrew/") || cliPath.contains("/Cellar/")
|
|
}
|
|
|
|
/// The package-manager invocation that updates the CLI in place, derived
|
|
/// from where the running CLI binary actually lives. Returns nil when no
|
|
/// known manager is recognizable; callers fall back to showing the manual
|
|
/// command rather than guessing at a mutation.
|
|
nonisolated static func cliUpdateInvocation(
|
|
cliPath: String,
|
|
fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) },
|
|
resolvingSymlinks: (String) -> String = { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }
|
|
) -> [String]? {
|
|
let dir = (cliPath as NSString).deletingLastPathComponent
|
|
if isHomebrewInstall(cliPath: cliPath, resolvingSymlinks: resolvingSymlinks) {
|
|
for brew in ["\(dir)/brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"] where fileExists(brew) {
|
|
return [brew, "upgrade", "codeburn"]
|
|
}
|
|
return nil
|
|
}
|
|
// npm-managed installs (plain npm -g, nvm, volta, asdf shims) keep npm
|
|
// in the same bin directory as the codeburn launcher.
|
|
for npm in ["\(dir)/npm", "/opt/homebrew/bin/npm", "/usr/local/bin/npm"] where fileExists(npm) {
|
|
return [npm, "install", "-g", "codeburn@latest", "--force"]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The launcher the CodeBurn desktop app writes for the CLI it carries
|
|
/// (app/electron/mac-menubar.ts). Only a desktop app update moves it.
|
|
nonisolated static func isDesktopBundledCli(_ cliPath: String) -> Bool {
|
|
(cliPath as NSString).lastPathComponent == "codeburn-desktop-cli.sh"
|
|
}
|
|
|
|
/// `codeburn menubar --force` installs the menubar matching the CLI's own
|
|
/// version, so without a CLI update it only moves the app forward when the
|
|
/// CLI is already ahead of it.
|
|
nonisolated static func menubarInstallAdvances(installedCli: String?, app: String) -> Bool {
|
|
guard let installedCli else { return false }
|
|
let cli = AppVersion.normalize(installedCli)
|
|
let current = AppVersion.normalize(app)
|
|
guard !cli.isEmpty, !current.isEmpty, current != "dev" else { return false }
|
|
return cli.compare(current, options: .numeric) == .orderedDescending
|
|
}
|
|
|
|
nonisolated static func cliUpdateFailureMessage(stderr: String, status: Int32, manualCommand: String) -> String {
|
|
if stderr.contains("EACCES") || stderr.contains("EPERM") {
|
|
return L("npm does not have permission to update the global codeburn install. Run “%@” in Terminal, then try again.", manualCommand)
|
|
}
|
|
return stderr.isEmpty ? L("CLI update failed (exit %lld)", status) : stderr
|
|
}
|
|
|
|
/// One click, both updates: the CLI first (so the new `menubar --force`
|
|
/// installer runs from the version it ships with), then the app itself.
|
|
/// Each stage surfaces its own error and stops the sequence.
|
|
func performFullUpdate() {
|
|
installedCliVersion = Self.queryInstalledCliVersion()
|
|
guard !isUpdating else { return }
|
|
|
|
if cliUpdateAvailable || cliTooOldForUpdate {
|
|
updateError = nil
|
|
updateFailureStage = nil
|
|
let cliPath = CodeburnCLI.baseArgv().first ?? ""
|
|
let bundled = Self.isDesktopBundledCli(cliPath)
|
|
guard !bundled, let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else {
|
|
if updateAvailable && !cliTooOldForUpdate
|
|
&& Self.menubarInstallAdvances(installedCli: installedCliVersion, app: currentVersion) {
|
|
performUpdate()
|
|
return
|
|
}
|
|
updateFailureStage = .cliUpdate
|
|
updateError = bundled
|
|
? L("This codeburn CLI comes with the CodeBurn desktop app. Update the desktop app, then try again.")
|
|
: L(
|
|
"Could not find the package manager for %1$@. Run “%2$@” manually, then try again.",
|
|
cliPath.isEmpty ? L("the CLI") : cliPath,
|
|
cliUpdateCommand
|
|
)
|
|
return
|
|
}
|
|
isUpdating = true
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: bin)
|
|
process.arguments = Array(argv.dropFirst())
|
|
runCaptured(process) { [weak self] status, stderr in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
if status != 0 {
|
|
self.isUpdating = false
|
|
self.updateFailureStage = .cliUpdate
|
|
self.updateError = Self.cliUpdateFailureMessage(stderr: stderr, status: status, manualCommand: self.cliUpdateCommand)
|
|
NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)")
|
|
return
|
|
}
|
|
self.installedCliVersion = Self.queryInstalledCliVersion()
|
|
self.latestCliVersion = self.installedCliVersion ?? self.latestCliVersion
|
|
self.isUpdating = false
|
|
if self.updateAvailable {
|
|
self.performUpdate()
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if updateAvailable { performUpdate() }
|
|
}
|
|
|
|
/// Shared spawn-with-timeout-and-stderr-capture used by both update stages.
|
|
nonisolated private func runCaptured(_ process: Process, onExit: @escaping @Sendable (Int32, String) -> Void) {
|
|
let errPipe = Pipe()
|
|
let errBuffer = LockedDataBuffer()
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = errPipe
|
|
errPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
let chunk = handle.availableData
|
|
guard !chunk.isEmpty else { return }
|
|
errBuffer.append(chunk, limit: maxUpdateStderrBytes)
|
|
}
|
|
let timeoutTask = Task.detached(priority: .utility) {
|
|
try? await Task.sleep(nanoseconds: cliUpdateTimeoutSeconds * 1_000_000_000)
|
|
if process.isRunning {
|
|
NSLog("CodeBurn: update subprocess timed out after %llus - terminating", cliUpdateTimeoutSeconds)
|
|
process.terminate()
|
|
}
|
|
}
|
|
process.terminationHandler = { proc in
|
|
timeoutTask.cancel()
|
|
errPipe.fileHandleForReading.readabilityHandler = nil
|
|
let stderr = Self.sanitizeForDisplay(String(data: errBuffer.snapshot(), encoding: .utf8) ?? "")
|
|
onExit(proc.terminationStatus, stderr)
|
|
}
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
timeoutTask.cancel()
|
|
errPipe.fileHandleForReading.readabilityHandler = nil
|
|
onExit(-1, error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func performUpdate() {
|
|
installedCliVersion = Self.queryInstalledCliVersion()
|
|
if cliTooOldForUpdate {
|
|
updateFailureStage = .menubarUpdate
|
|
updateError = L(
|
|
"Your codeburn CLI (%1$@) is too old to update the menubar. Run “%2$@” first, then try again.",
|
|
AppVersion.display(installedCliVersion ?? ""),
|
|
cliUpdateCommand
|
|
)
|
|
return
|
|
}
|
|
isUpdating = true
|
|
updateError = nil
|
|
updateFailureStage = nil
|
|
// `menubar --force` installs the CLI's own version, so that is the target; when it
|
|
// is not ahead of this app the run cannot move it, and there is nothing to settle.
|
|
if Self.menubarInstallAdvances(installedCli: installedCliVersion, app: currentVersion),
|
|
let installedCliVersion {
|
|
defaults.set(
|
|
["from": AppVersion.normalize(currentVersion), "to": AppVersion.normalize(installedCliVersion)],
|
|
forKey: pendingUpdateKey
|
|
)
|
|
}
|
|
|
|
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
|
|
let errPipe = Pipe()
|
|
let errBuffer = LockedDataBuffer()
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = errPipe
|
|
errPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
let chunk = handle.availableData
|
|
guard !chunk.isEmpty else { return }
|
|
errBuffer.append(chunk, limit: maxUpdateStderrBytes)
|
|
}
|
|
|
|
let timeoutTask = Task.detached(priority: .utility) {
|
|
try? await Task.sleep(nanoseconds: menubarUpdateTimeoutSeconds * 1_000_000_000)
|
|
if process.isRunning {
|
|
NSLog("CodeBurn: update subprocess timed out after %llus - terminating", menubarUpdateTimeoutSeconds)
|
|
process.terminate()
|
|
}
|
|
}
|
|
|
|
process.terminationHandler = { [weak self] proc in
|
|
timeoutTask.cancel()
|
|
errPipe.fileHandleForReading.readabilityHandler = nil
|
|
let stderrData = errBuffer.snapshot()
|
|
let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "")
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.isUpdating = false
|
|
if proc.terminationStatus != 0 {
|
|
self.failPendingUpdate(outcome: Self.updateFailureOutcome(stderr: stderr))
|
|
self.updateFailureStage = .menubarUpdate
|
|
self.updateError = stderr.isEmpty ? L("Update failed (exit %lld)", proc.terminationStatus) : stderr
|
|
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
|
|
} else {
|
|
self.latestVersion = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
failPendingUpdate(outcome: "install_fail")
|
|
isUpdating = false
|
|
updateFailureStage = .menubarUpdate
|
|
updateError = error.localizedDescription
|
|
NSLog("CodeBurn: update spawn failed: \(error)")
|
|
}
|
|
}
|
|
|
|
nonisolated private static func sanitizeForDisplay(_ value: String) -> String {
|
|
var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "")
|
|
let patterns: [(String, String)] = [
|
|
(#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"),
|
|
(#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"),
|
|
(#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"),
|
|
(#"(?i)Bearer\s+\S+"#, "Bearer ***"),
|
|
]
|
|
for (pattern, replacement) in patterns {
|
|
cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression)
|
|
}
|
|
if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." }
|
|
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
enum UpdateCheckError: LocalizedError {
|
|
case http(Int)
|
|
case missingMenubarAsset
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .http(status): L("GitHub returned HTTP %lld.", status)
|
|
case .missingMenubarAsset: L("No mac-v release with a menubar zip and checksum was found.")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MenubarFeed: Decodable {
|
|
let version: String
|
|
let url: String
|
|
}
|
|
|
|
struct GitHubRelease: Decodable {
|
|
let tag_name: String
|
|
let assets: [GitHubAsset]
|
|
}
|
|
|
|
struct GitHubAsset: Decodable {
|
|
let name: String
|
|
let browser_download_url: String
|
|
}
|