codeburn/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
2026-05-14 18:32:15 -07:00

184 lines
6.9 KiB
Swift

import Foundation
import Observation
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20"
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
private let lastCheckKey = "UpdateChecker.lastCheckDate"
private let cachedVersionKey = "UpdateChecker.latestVersion"
private let updateTimeoutSeconds: UInt64 = 120
private let maxUpdateStderrBytes = 64 * 1024
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 {
var latestVersion: String?
var isUpdating = false
var updateError: String?
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 currentVersion: String {
AppVersion.normalizedBundleShortVersion
}
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 {
updateError = nil
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, 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)
guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else {
throw UpdateCheckError.missingMenubarAsset
}
let version = resolved.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 {
updateError = "Update check failed: \(error.localizedDescription)"
NSLog("CodeBurn: update check failed: \(error)")
}
}
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
}
func performUpdate() {
isUpdating = true
updateError = nil
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: updateTimeoutSeconds * 1_000_000_000)
if process.isRunning {
NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds)
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.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)")
}
}
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): "GitHub returned HTTP \(status)."
case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found."
}
}
}
struct GitHubRelease: Decodable {
let tag_name: String
let assets: [GitHubAsset]
}
struct GitHubAsset: Decodable {
let name: String
let browser_download_url: String
}