codeburn/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
Resham Joshi b876a311e2
feat: telemetry for CLI error causes, provider read failures and update results (#1702)
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.
2026-10-09 12:47:49 +02:00

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
}