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
This commit is contained in:
Tam Nhu Tran committed 2026-06-22 21:00:02 -04:00
1 parent d263fbc2ec
commit 0a2c549268
11 files changed
+383 -18

No files matched your search

+4
View File
@@ -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" <<NOTES
CCS Bar v${VERSION}
+54 -5
View File
@@ -76,7 +76,13 @@ struct BarMenuView: View {
// genuine pool/model overflow.
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// (1) ALERTS first — urgent quota crossings surface above everything.
// (1) UPDATE BANNER — shown only when a newer version is available.
// Placed at the very top so it is seen without scrolling.
if viewModel.updateAvailable {
updateBanner
}
// (2) ALERTS — urgent quota crossings surface above accounts.
// Spend-cap alerts are opt-in OFF by default, so by default only
// quota/reauth/cooldown conditions appear here.
if !viewModel.activeAlerts.isEmpty {
@@ -88,10 +94,10 @@ struct BarMenuView: View {
}
}
// (2) SUBSCRIPTIONS — the dominant section, opens here.
// (3) SUBSCRIPTIONS — the dominant section, opens here.
accountsSection
// (3) SPEND — demoted to a thin informational strip below the cockpit.
// (4) SPEND — demoted to a thin informational strip below the cockpit.
// spendChartStyle and spendPeriod are threaded from the viewModel and
// toggled/selected inline from the Spend header so changes are live.
if let analytics = viewModel.analytics {
@@ -107,10 +113,10 @@ struct BarMenuView: View {
onSelectPeriod: { viewModel.spendPeriod = $0 })
}
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
// (5) POOL ACCOUNTS — compact generic rows, subordinate.
poolSection
// (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
// (6) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
if let analytics = viewModel.analytics,
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
{
@@ -161,6 +167,49 @@ struct BarMenuView: View {
}
}
/// "Update available" banner. Shown when `viewModel.updateAvailable` is true.
/// Styled to match the existing AlertRow / ErrorBanner patterns (tinted
/// background card, section label, borderless button).
@ViewBuilder private var updateBanner: some View {
VStack(alignment: .leading, spacing: 8) {
SectionLabel("Update")
HStack(spacing: 10) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(theme.accent)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Update available")
.font(.system(.caption, design: .default).weight(.semibold))
if let v = viewModel.latestVersion {
Text("CCS Bar \(v)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 4)
if viewModel.isInstallingUpdate {
HStack(spacing: 4) {
ProgressView().controlSize(.mini)
Text("Updating...")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Button("Update Now") {
viewModel.installUpdate()
}
.buttonStyle(.borderless)
.font(.caption.weight(.medium))
.foregroundStyle(theme.accent)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
}
}
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
/// (closest to empty on top) so the window the user is about to run out of
@@ -11,6 +11,13 @@ import CCSBarCore
struct BarPreferences {
let defaults: UserDefaults
// MARK: - Update-check preference key
/// UserDefaults key for the auto-update-check toggle.
static let autoCheckUpdatesKey = "ccsBar.autoCheckUpdates"
// MARK: - Init
/// Default to the standard suite. A stable suite name is intentionally NOT used
/// here because the rest of the app (MenuBarIcon) already persists to
/// `.standard`; keeping one suite avoids split state across the two.
@@ -18,6 +25,17 @@ struct BarPreferences {
self.defaults = defaults
}
// MARK: - Update preference
/// Whether the app should periodically check for CCS Bar updates. Defaults to
/// `true` — users who never open Settings still get update awareness.
var autoCheckUpdates: Bool {
get { defaults.bool(forKey: BarPreferences.autoCheckUpdatesKey) }
nonmutating set { defaults.set(newValue, forKey: BarPreferences.autoCheckUpdatesKey) }
}
// MARK: - Registration
/// Seed the registration domain so missing keys resolve to their real defaults
/// rather than the type-zero value. Idempotent — safe to call on every launch.
///
@@ -30,6 +48,9 @@ struct BarPreferences {
var d = BarAlertPrefsStore.registrationDefaults
d[BarAlertPrefsStore.Key.dailyEnabled] = false
d[BarAlertPrefsStore.Key.monthEnabled] = false
// autoCheckUpdates defaults to true — registered here so the Bool key is
// never absent (absent Bool reads as false, which would silently opt-out).
d[BarPreferences.autoCheckUpdatesKey] = true
defaults.register(defaults: d)
}
@@ -24,6 +24,7 @@ struct BarPreferencesView: View {
Form {
appearanceSection
glanceSection
updatesSection
quotaSection
spendSection
accountSection
@@ -78,6 +79,20 @@ struct BarPreferencesView: View {
}
}
/// Auto-update check toggle. Bound directly to the UserDefaults-backed pref
/// via the same write-through pattern used for quota/spend toggles.
private var updatesSection: some View {
Section("Updates") {
Toggle(
"Check for CCS Bar updates automatically",
isOn: Binding(
get: { prefs.autoCheckUpdates },
set: { prefs.autoCheckUpdates = $0 }
)
)
}
}
private var quotaSection: some View {
Section("Quota") {
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import SwiftUI
import CCSBarCore
@@ -42,6 +43,18 @@ final class BarViewModel: ObservableObject {
/// dropdown so users who deny notifications still see the conditions.
@Published var activeAlerts: [BarNotification] = []
// MARK: - Update state
/// Set to `true` when a newer published release is detected. Drives the
/// "Update available" affordance in `BarMenuView`.
@Published var updateAvailable: Bool = false
/// The remote version string, e.g. `"1.8.0"`. Non-nil only while
/// `updateAvailable` is true.
@Published var latestVersion: String? = nil
/// True while the installer process is being launched. Disables the button
/// and shows a progress label so the user knows the action fired.
@Published var isInstallingUpdate: Bool = false
private let home: String
private var client: CCSBarClient?
private var debouncer = RefreshDebouncer(interval: 15)
@@ -52,6 +65,11 @@ final class BarViewModel: ObservableObject {
private var pollTask: Task<Void, Never>?
/// Guard against overlapping start-and-connect sequences.
private var connectTask: Task<Void, Never>?
/// Background update-check task — kept so it can be cancelled on deinit.
private var updateCheckTask: Task<Void, Never>?
/// 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.
@@ -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? {
+22
View File
@@ -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)
@@ -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]]
}
}
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.8.0
+4
View File
@@ -34,6 +34,10 @@ export async function showHelp(): Promise<void> {
[
['--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)',
],
],
],
[
+32 -1
View File
@@ -490,9 +490,10 @@ export async function handleBarInstall(
args: string[],
deps: Partial<InstallDeps> = {}
): Promise<void> {
// 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<void>((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 {