diff --git a/.github/workflows/bar-release.yml b/.github/workflows/bar-release.yml index a89b68cb..db1220d5 100644 --- a/.github/workflows/bar-release.yml +++ b/.github/workflows/bar-release.yml @@ -60,6 +60,10 @@ jobs: # gh lives in Homebrew on the self-hosted runner; ensure it is on PATH. export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" gh release upload ccs-bar-latest macos-bar/dist/CCS-Bar.app.zip --clobber + printf '%s\n' "${VERSION}" > version.txt + gh release upload ccs-bar-latest version.txt --clobber + echo "[OK] Published version.txt (${VERSION}) to ccs-bar-latest" + rm -f version.txt notes="$(mktemp)" cat > "$notes" <? /// Guard against overlapping start-and-connect sequences. private var connectTask: Task? + /// Background update-check task — kept so it can be cancelled on deinit. + private var updateCheckTask: Task? + /// Tracks when the last successful remote version check ran so we never + /// hammer the release URL more than once every 6 hours. + private var lastUpdateCheckDate: Date? = nil private let prefs: BarPreferences private let notifier: NotificationDelivering private let probe: BarServerProbe @@ -79,6 +97,7 @@ final class BarViewModel: ObservableObject { self.spendPeriod = SpendPeriodStore.load() reconnect() startBackgroundPolling() + startUpdateChecking() } // MARK: - Background polling @@ -99,6 +118,105 @@ final class BarViewModel: ObservableObject { } } + // MARK: - Update checking + + /// Minimum interval between remote version checks (6 hours in seconds). + private static let updateCheckInterval: TimeInterval = 6 * 60 * 60 + + /// Starts a long-lived background task that checks for updates once shortly + /// after launch and then at most once every 6 hours, respecting the + /// `autoCheckUpdates` preference. Never blocks the UI. + private func startUpdateChecking() { + updateCheckTask?.cancel() + // Mirror startBackgroundPolling: weak capture, re-unwrap `self` inside the + // loop each iteration so the closure never strongly retains the view model. + updateCheckTask = Task { [weak self] in + // Brief initial delay so launch completes before the first network call. + try? await Task.sleep(for: .seconds(5)) + // First check fires immediately; subsequent ones wake every 30 minutes to + // test the 6-hour gate, so a machine resuming from sleep re-checks without + // a relaunch. + while !Task.isCancelled { + guard let self else { return } + await self.runUpdateCheckIfNeeded() + try? await Task.sleep(for: .seconds(30 * 60)) + } + } + } + + /// Runs the remote version check when the preference is enabled and the + /// 6-hour throttle permits. Safe to call from any context. + private func runUpdateCheckIfNeeded() async { + guard prefs.autoCheckUpdates else { return } + if let last = lastUpdateCheckDate, + Date().timeIntervalSince(last) < BarViewModel.updateCheckInterval + { + return + } + lastUpdateCheckDate = Date() + await performUpdateCheck() + } + + /// Fetches the latest published version and updates `updateAvailable` / + /// `latestVersion`. The network call is nonisolated; the published-state + /// mutation happens back on the main actor (this class is @MainActor, so + /// the assignment after `await` re-enters the main actor automatically). + private func performUpdateCheck() async { + // Hop off main actor for the network call so we don't block the UI. + let latest = await Task.detached(priority: .utility) { + await BarUpdateChecker.fetchLatestPublishedVersion() + }.value + + // Back on the main actor (implicit — this method is on @MainActor class). + let current = + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" + if let v = latest, BarUpdateChecker.isNewer(v, than: current) { + latestVersion = v + updateAvailable = true + } else { + latestVersion = nil + updateAvailable = false + } + } + + /// Launches `ccs bar install --launch --await-quit` as a detached process + /// that survives this app terminating, then schedules a short-delay quit so + /// the installer can swap the (locked) bundle while no app holds it. + func installUpdate() { + guard !isInstallingUpdate else { return } + isInstallingUpdate = true + + guard let bin = CCSBinaryResolver.resolve() else { + print("[CCS Bar] installUpdate: ccs binary not found; cannot install update") + isInstallingUpdate = false + return + } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: bin) + proc.arguments = ["bar", "install", "--launch", "--await-quit"] + // Detach stdio so the process is not tied to this app's file descriptors. + let null = FileHandle(forWritingAtPath: "/dev/null") + proc.standardOutput = null + proc.standardError = null + proc.standardInput = null + + do { + try proc.run() + // Do NOT call waitUntilExit — the installer must outlive us. + } catch { + print("[CCS Bar] installUpdate: failed to launch installer: \(error)") + isInstallingUpdate = false + return + } + + // Give the installer a moment to register, then quit so the bundle is + // unlocked and the installer can swap + relaunch CCS Bar. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { + NSApplication.shared.terminate(nil) + } + } + // MARK: - Prefs /// Re-read prefs after the preferences sheet writes through. diff --git a/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift b/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift index 137ee48f..625ec8fe 100644 --- a/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift +++ b/macos-bar/Sources/CCSBarApp/DashboardLauncher.swift @@ -2,16 +2,16 @@ import AppKit import CCSBarCore import Foundation -/// Opens the CCS dashboard, starting the local server when it isn't running. -/// -/// The dashboard is just a page served by the CCS web-server, so it can't load -/// if no server is up. When the discovered URL isn't reachable we launch -/// `ccs config`, which boots the server AND opens the dashboard in the browser -/// itself — so we must not also open it (that would double-open a tab). -enum DashboardLauncher { - /// A GUI app does not inherit the shell PATH, so probe the common install - /// locations for the `ccs` binary explicitly. First executable match wins. - private static var ccsCandidates: [String] { +// MARK: - Shared ccs binary resolver + +/// Resolves the `ccs` binary path for GUI apps that don't inherit the shell +/// PATH. Shared by `DashboardLauncher` and `BarViewModel` so the candidate +/// list stays in one place (DRY). +enum CCSBinaryResolver { + /// Ordered list of candidate paths for the `ccs` binary. First executable + /// match wins. GUI apps don't inherit the shell PATH, so we must enumerate + /// common install locations explicitly. + static var candidates: [String] { let home = NSHomeDirectory() return [ "\(home)/.bun/bin/ccs", @@ -23,8 +23,24 @@ enum DashboardLauncher { ] } + /// Returns the path of the first executable `ccs` binary found, or `nil` + /// when none of the candidates exist. + static func resolve() -> String? { + candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } +} + +// MARK: - Dashboard launcher + +/// Opens the CCS dashboard, starting the local server when it isn't running. +/// +/// The dashboard is just a page served by the CCS web-server, so it can't load +/// if no server is up. When the discovered URL isn't reachable we launch +/// `ccs config`, which boots the server AND opens the dashboard in the browser +/// itself — so we must not also open it (that would double-open a tab). +enum DashboardLauncher { private static func ccsBinary() -> String? { - ccsCandidates.first { FileManager.default.isExecutableFile(atPath: $0) } + CCSBinaryResolver.resolve() } private static func dashboardURL() -> URL? { diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index f11dbfd7..0c9d5833 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1664,6 +1664,28 @@ do { check(port3000Count == 1, "probe: bar.json port 3000 deduped — probed once, not twice") } +// (BarUpdateChecker) Semver comparison drives the in-app "Update available" +// affordance, so ordering must be numeric (1.10.0 > 1.9.0), tolerate +// pre-release suffixes, and reject malformed input. +do { + check(BarUpdateChecker.isNewer("1.8.0", than: "1.7.0"), "isNewer: newer minor (1.8.0 > 1.7.0)") + check(BarUpdateChecker.isNewer("1.7.1", than: "1.7.0"), "isNewer: newer patch (1.7.1 > 1.7.0)") + check(BarUpdateChecker.isNewer("2.0.0", than: "1.9.9"), "isNewer: newer major (2.0.0 > 1.9.9)") + check( + BarUpdateChecker.isNewer("1.10.0", than: "1.9.0"), + "isNewer: numeric not lexicographic (1.10.0 > 1.9.0)") + check(!BarUpdateChecker.isNewer("1.7.0", than: "1.7.0"), "isNewer: equal is not newer") + check(!BarUpdateChecker.isNewer("1.6.0", than: "1.7.0"), "isNewer: older is not newer") + check( + BarUpdateChecker.isNewer("1.8.0-beta.1", than: "1.7.0"), + "isNewer: pre-release suffix stripped, core still newer") + check( + !BarUpdateChecker.isNewer("1.7.0-beta.1", than: "1.7.0"), + "isNewer: equal core after stripping suffix is not newer") + check(!BarUpdateChecker.isNewer("garbage", than: "1.7.0"), "isNewer: malformed latest rejected") + check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift b/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift new file mode 100644 index 00000000..d5339680 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarUpdateChecker.swift @@ -0,0 +1,85 @@ +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]] + } +} diff --git a/macos-bar/VERSION b/macos-bar/VERSION index bd8bf882..27f9cd32 100644 --- a/macos-bar/VERSION +++ b/macos-bar/VERSION @@ -1 +1 @@ -1.7.0 +1.8.0 diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 95b63095..fbc22031 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -34,6 +34,10 @@ export async function showHelp(): Promise { [ ['--launch', 'Launch CCS Bar immediately after install without prompting'], ['--no-launch', 'Skip the launch prompt after install'], + [ + '--await-quit', + 'Wait for a running CCS Bar to quit, then swap and relaunch (used by the in-app updater)', + ], ], ], [ diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index 96893ba4..d0fd2903 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -490,9 +490,10 @@ export async function handleBarInstall( args: string[], deps: Partial = {} ): Promise { - // Parse --launch / --no-launch flags before delegating to deps. + // Parse --launch / --no-launch / --await-quit flags before delegating to deps. const forceLaunch = hasAnyFlag(args, ['--launch']); const noLaunch = hasAnyFlag(args, ['--no-launch']); + const awaitQuit = hasAnyFlag(args, ['--await-quit']); const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset; const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract; @@ -584,6 +585,36 @@ export async function handleBarInstall( return; } + // 3b-pre. --await-quit: if the app is running, poll until it exits before swapping. + // Without this flag the existing behavior is preserved exactly (no change). + if (awaitQuit) { + const POLL_INTERVAL_MS = 300; + const TIMEOUT_MS = 15_000; + const deadline = Date.now() + TIMEOUT_MS; + + if (await isBarRunning()) { + console.log('[i] Waiting for CCS Bar to quit before swapping...'); + + let exited = false; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (!(await isBarRunning())) { + exited = true; + break; + } + } + + if (!exited) { + console.error( + '[!] CCS Bar is still running; quit it from the menu bar and re-run `ccs bar install`.' + ); + process.exitCode = 1; + cleanupStaging(); + return; + } + } + } + // 3b. New bundle verified in staging — now safe to remove the old install. if (fs.existsSync(appPath)) { try {