codeburn/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
iamtoruk 869474b3b4
Some checks are pending
CI / semgrep (push) Waiting to run
Fix update button stuck spinning after successful install
terminationHandler only reset isUpdating on non-zero exit, assuming
the app would be killed and relaunched on success. If pkill fails
silently the old process survives with isUpdating stuck true. Now
always resets on termination and clears the update badge on success.
2026-05-05 11:38:54 -07:00

106 lines
3.9 KiB
Swift

import Foundation
import Observation
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest"
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
private let lastCheckKey = "UpdateChecker.lastCheckDate"
private let cachedVersionKey = "UpdateChecker.latestVersion"
@MainActor
@Observable
final class UpdateChecker {
var latestVersion: String?
var isUpdating = false
var updateError: String?
var updateAvailable: Bool {
guard let latest = latestVersion else { return false }
let current = currentVersion
let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest
let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current
guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false }
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
}
var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
func checkIfNeeded() async {
let lastCheck = UserDefaults.standard.double(forKey: lastCheckKey)
let now = Date().timeIntervalSince1970
if now - lastCheck < checkIntervalSeconds {
latestVersion = UserDefaults.standard.string(forKey: cachedVersionKey)
return
}
await check()
}
func check() async {
guard let url = URL(string: releasesAPI) else { return }
var request = URLRequest(url: url)
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
do {
let (data, _) = try await URLSession.shared.data(for: request)
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
guard let asset = release.assets.first(where: {
$0.name.hasPrefix("CodeBurnMenubar-") && $0.name.hasSuffix(".zip")
}) else { return }
let version = asset.name
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
.replacingOccurrences(of: ".zip", with: "")
latestVersion = version
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
UserDefaults.standard.set(version, forKey: cachedVersionKey)
} catch {
NSLog("CodeBurn: update check failed: \(error)")
}
}
func performUpdate() {
isUpdating = true
updateError = nil
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
let errPipe = Pipe()
process.standardOutput = FileHandle.nullDevice
process.standardError = errPipe
process.terminationHandler = { [weak self] proc in
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let stderr = String(data: errData, encoding: .utf8) ?? ""
Task { @MainActor in
guard let self else { return }
self.isUpdating = false
if proc.terminationStatus != 0 {
self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
} else {
self.latestVersion = nil
}
}
}
do {
try process.run()
} catch {
isUpdating = false
updateError = error.localizedDescription
NSLog("CodeBurn: update spawn failed: \(error)")
}
}
}
private struct GitHubRelease: Decodable {
let tag_name: String
let assets: [GitHubAsset]
}
private struct GitHubAsset: Decodable {
let name: String
let browser_download_url: String
}