Files
ccs/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift
T
Tam Nhu Tran 0a2c549268 feat(bar): one-click in-app updater with update-available notification
Adds a lightweight self-update path to the CCS Bar menu-bar app so users are
told when a newer build is available and can update with one click.

- App: BarUpdateChecker fetches the published version.txt and compares it to
  the running bundle version (numeric semver). A throttled background check
  (post-launch, then every 6h) surfaces an "Update available" row in the menu;
  "Update Now" spawns `ccs bar install --launch --await-quit` then quits so the
  installer can swap the locked bundle and relaunch. A Settings toggle gates
  the auto-check (default on). Offline checks degrade silently.
- CLI: `ccs bar install --await-quit` waits for a running CCS Bar to quit
  (300ms poll, 15s cap) before swapping, enabling the one-click relaunch.
- Release: bar-release publishes a machine-readable version.txt asset beside
  the app zip so the check is cheap (no full download just to compare).
- Shares the ccs-binary resolver between DashboardLauncher and the updater.
- Bumps bar VERSION to 1.8.0; adds isNewer assertions to ccs-bar-check.

Closes #1588
2026-06-22 21:00:02 -04:00

86 lines
3.2 KiB
Swift

import Foundation
/// Lightweight update checker for CCS Bar.
///
/// Self-contained: the fetch is the only side effect. `isNewer` is pure and
/// unit-test-friendly with no bundle or network dependency.
public enum BarUpdateChecker {
/// Published-version artifact URL. Stable redirect target maintained by the
/// bar-release workflow; always points to the latest released version.txt.
private static let versionURL = URL(
string:
"https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/version.txt"
)!
/// Validates that a string looks like a semver (permissive: pre-release
/// suffixes after `-` are allowed but optional).
private static let semverRegex = try! NSRegularExpression(
pattern: #"^\d+\.\d+\.\d+([-.0-9A-Za-z]*)?$"#
)
// MARK: - Public API
/// Fetches the latest published version string from the stable release URL.
///
/// - Returns: A trimmed semver string (e.g. `"1.8.0"`) when the remote
/// returns HTTP 200 with a recognisable semver body; `nil` on any error,
/// timeout, non-200 status, or invalid body.
/// - Never throws: all errors are swallowed and surfaced as `nil` so callers
/// never need a do/catch.
public static func fetchLatestPublishedVersion() async -> String? {
var request = URLRequest(url: versionURL)
request.timeoutInterval = 8
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
return nil
}
guard let body = String(data: data, encoding: .utf8) else { return nil }
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard isValidSemver(trimmed) else { return nil }
return trimmed
} catch {
// Network errors, timeouts, cancellation — all silently nil.
return nil
}
}
/// Compares two semver strings and returns `true` when `latest` is strictly
/// newer than `current`.
///
/// - Strips any pre-release suffix (everything from the first `-` onward)
/// before comparing. Compares major, minor, patch as `Int`s.
/// - Returns `false` on any malformed input.
public static func isNewer(_ latest: String, than current: String) -> Bool {
guard
let latestParts = parseMajorMinorPatch(latest),
let currentParts = parseMajorMinorPatch(current)
else { return false }
for (l, c) in zip(latestParts, currentParts) {
if l > c { return true }
if l < c { return false }
}
return false // equal
}
// MARK: - Private helpers
private static func isValidSemver(_ s: String) -> Bool {
let range = NSRange(s.startIndex..., in: s)
return semverRegex.firstMatch(in: s, range: range) != nil
}
/// Split a semver into [major, minor, patch], stripping any pre-release suffix.
private static func parseMajorMinorPatch(_ version: String) -> [Int]? {
// Strip pre-release suffix (anything from the first `-` onward).
let core = version.split(separator: "-", maxSplits: 1).first.map(String.init) ?? version
let parts = core.split(separator: ".").compactMap { Int($0) }
guard parts.count >= 3 else { return nil }
return [parts[0], parts[1], parts[2]]
}
}