Files
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

88 lines
3.0 KiB
Swift

import AppKit
import CCSBarCore
import Foundation
// 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",
"/opt/homebrew/bin/ccs",
"/usr/local/bin/ccs",
"\(home)/.local/bin/ccs",
"\(home)/.npm-global/bin/ccs",
"/usr/bin/ccs",
]
}
/// 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? {
CCSBinaryResolver.resolve()
}
private static func dashboardURL() -> URL? {
if case .success(let discovery) = BarDiscovery.load() { return discovery.resolvedURL }
return nil
}
/// Quick reachability probe so a stale `bar.json` URL doesn't send the user to
/// a dead page. Short timeout — the dashboard is local.
private static func isReachable(_ url: URL) async -> Bool {
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 1.5
return (try? await URLSession.shared.data(for: request)) != nil
}
@MainActor
static func openOrStart() async {
if let url = dashboardURL(), await isReachable(url) {
NSWorkspace.shared.open(url)
return
}
// Server isn't up. Start it via `ccs config`; it opens the dashboard on its
// own. Avoid a shell here: candidate paths can include user-controlled
// components, and Process can pass the executable and arguments directly.
if let bin = ccsBinary() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: bin)
proc.arguments = ["config"]
let null = FileHandle(forWritingAtPath: "/dev/null")
proc.standardOutput = null
proc.standardError = null
try? proc.run()
return
}
// Can't find the ccs binary — best effort: open whatever URL we know so the
// user at least lands on the right place (or sees the connection is refused).
if let url = dashboardURL() {
NSWorkspace.shared.open(url)
}
}
}