mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
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:
1 parent
d263fbc2ec
commit
0a2c549268
11 files changed
+383
-18
No files matched your search
@@ -60,6 +60,10 @@ jobs:
|
|||||||
# gh lives in Homebrew on the self-hosted runner; ensure it is on PATH.
|
# gh lives in Homebrew on the self-hosted runner; ensure it is on PATH.
|
||||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||||
gh release upload ccs-bar-latest macos-bar/dist/CCS-Bar.app.zip --clobber
|
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)"
|
notes="$(mktemp)"
|
||||||
cat > "$notes" <<NOTES
|
cat > "$notes" <<NOTES
|
||||||
CCS Bar v${VERSION}
|
CCS Bar v${VERSION}
|
||||||
|
|||||||
@@ -76,7 +76,13 @@ struct BarMenuView: View {
|
|||||||
// genuine pool/model overflow.
|
// genuine pool/model overflow.
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
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
|
// Spend-cap alerts are opt-in OFF by default, so by default only
|
||||||
// quota/reauth/cooldown conditions appear here.
|
// quota/reauth/cooldown conditions appear here.
|
||||||
if !viewModel.activeAlerts.isEmpty {
|
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
|
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
|
// spendChartStyle and spendPeriod are threaded from the viewModel and
|
||||||
// toggled/selected inline from the Spend header so changes are live.
|
// toggled/selected inline from the Spend header so changes are live.
|
||||||
if let analytics = viewModel.analytics {
|
if let analytics = viewModel.analytics {
|
||||||
@@ -107,10 +113,10 @@ struct BarMenuView: View {
|
|||||||
onSelectPeriod: { viewModel.spendPeriod = $0 })
|
onSelectPeriod: { viewModel.spendPeriod = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
// (5) POOL ACCOUNTS — compact generic rows, subordinate.
|
||||||
poolSection
|
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,
|
if let analytics = viewModel.analytics,
|
||||||
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
|
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
|
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
|
||||||
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
|
/// `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
|
/// (closest to empty on top) so the window the user is about to run out of
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import CCSBarCore
|
|||||||
struct BarPreferences {
|
struct BarPreferences {
|
||||||
let defaults: UserDefaults
|
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
|
/// Default to the standard suite. A stable suite name is intentionally NOT used
|
||||||
/// here because the rest of the app (MenuBarIcon) already persists to
|
/// here because the rest of the app (MenuBarIcon) already persists to
|
||||||
/// `.standard`; keeping one suite avoids split state across the two.
|
/// `.standard`; keeping one suite avoids split state across the two.
|
||||||
@@ -18,6 +25,17 @@ struct BarPreferences {
|
|||||||
self.defaults = defaults
|
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
|
/// 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.
|
/// rather than the type-zero value. Idempotent — safe to call on every launch.
|
||||||
///
|
///
|
||||||
@@ -30,6 +48,9 @@ struct BarPreferences {
|
|||||||
var d = BarAlertPrefsStore.registrationDefaults
|
var d = BarAlertPrefsStore.registrationDefaults
|
||||||
d[BarAlertPrefsStore.Key.dailyEnabled] = false
|
d[BarAlertPrefsStore.Key.dailyEnabled] = false
|
||||||
d[BarAlertPrefsStore.Key.monthEnabled] = 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)
|
defaults.register(defaults: d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ struct BarPreferencesView: View {
|
|||||||
Form {
|
Form {
|
||||||
appearanceSection
|
appearanceSection
|
||||||
glanceSection
|
glanceSection
|
||||||
|
updatesSection
|
||||||
quotaSection
|
quotaSection
|
||||||
spendSection
|
spendSection
|
||||||
accountSection
|
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 {
|
private var quotaSection: some View {
|
||||||
Section("Quota") {
|
Section("Quota") {
|
||||||
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
|
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
@@ -42,6 +43,18 @@ final class BarViewModel: ObservableObject {
|
|||||||
/// dropdown so users who deny notifications still see the conditions.
|
/// dropdown so users who deny notifications still see the conditions.
|
||||||
@Published var activeAlerts: [BarNotification] = []
|
@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 let home: String
|
||||||
private var client: CCSBarClient?
|
private var client: CCSBarClient?
|
||||||
private var debouncer = RefreshDebouncer(interval: 15)
|
private var debouncer = RefreshDebouncer(interval: 15)
|
||||||
@@ -52,6 +65,11 @@ final class BarViewModel: ObservableObject {
|
|||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
/// Guard against overlapping start-and-connect sequences.
|
/// Guard against overlapping start-and-connect sequences.
|
||||||
private var connectTask: Task<Void, Never>?
|
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 prefs: BarPreferences
|
||||||
private let notifier: NotificationDelivering
|
private let notifier: NotificationDelivering
|
||||||
private let probe: BarServerProbe
|
private let probe: BarServerProbe
|
||||||
@@ -79,6 +97,7 @@ final class BarViewModel: ObservableObject {
|
|||||||
self.spendPeriod = SpendPeriodStore.load()
|
self.spendPeriod = SpendPeriodStore.load()
|
||||||
reconnect()
|
reconnect()
|
||||||
startBackgroundPolling()
|
startBackgroundPolling()
|
||||||
|
startUpdateChecking()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background polling
|
// 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
|
// MARK: - Prefs
|
||||||
|
|
||||||
/// Re-read prefs after the preferences sheet writes through.
|
/// Re-read prefs after the preferences sheet writes through.
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import AppKit
|
|||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Opens the CCS dashboard, starting the local server when it isn't running.
|
// MARK: - Shared ccs binary resolver
|
||||||
///
|
|
||||||
/// The dashboard is just a page served by the CCS web-server, so it can't load
|
/// Resolves the `ccs` binary path for GUI apps that don't inherit the shell
|
||||||
/// if no server is up. When the discovered URL isn't reachable we launch
|
/// PATH. Shared by `DashboardLauncher` and `BarViewModel` so the candidate
|
||||||
/// `ccs config`, which boots the server AND opens the dashboard in the browser
|
/// list stays in one place (DRY).
|
||||||
/// itself — so we must not also open it (that would double-open a tab).
|
enum CCSBinaryResolver {
|
||||||
enum DashboardLauncher {
|
/// Ordered list of candidate paths for the `ccs` binary. First executable
|
||||||
/// A GUI app does not inherit the shell PATH, so probe the common install
|
/// match wins. GUI apps don't inherit the shell PATH, so we must enumerate
|
||||||
/// locations for the `ccs` binary explicitly. First executable match wins.
|
/// common install locations explicitly.
|
||||||
private static var ccsCandidates: [String] {
|
static var candidates: [String] {
|
||||||
let home = NSHomeDirectory()
|
let home = NSHomeDirectory()
|
||||||
return [
|
return [
|
||||||
"\(home)/.bun/bin/ccs",
|
"\(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? {
|
private static func ccsBinary() -> String? {
|
||||||
ccsCandidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
CCSBinaryResolver.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func dashboardURL() -> URL? {
|
private static func dashboardURL() -> URL? {
|
||||||
|
|||||||
@@ -1664,6 +1664,28 @@ do {
|
|||||||
check(port3000Count == 1, "probe: bar.json port 3000 deduped — probed once, not twice")
|
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
|
// cleanup
|
||||||
try? FileManager.default.removeItem(atPath: tmp)
|
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
@@ -1 +1 @@
|
|||||||
1.7.0
|
1.8.0
|
||||||
@@ -34,6 +34,10 @@ export async function showHelp(): Promise<void> {
|
|||||||
[
|
[
|
||||||
['--launch', 'Launch CCS Bar immediately after install without prompting'],
|
['--launch', 'Launch CCS Bar immediately after install without prompting'],
|
||||||
['--no-launch', 'Skip the launch prompt after install'],
|
['--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)',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -490,9 +490,10 @@ export async function handleBarInstall(
|
|||||||
args: string[],
|
args: string[],
|
||||||
deps: Partial<InstallDeps> = {}
|
deps: Partial<InstallDeps> = {}
|
||||||
): Promise<void> {
|
): 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 forceLaunch = hasAnyFlag(args, ['--launch']);
|
||||||
const noLaunch = hasAnyFlag(args, ['--no-launch']);
|
const noLaunch = hasAnyFlag(args, ['--no-launch']);
|
||||||
|
const awaitQuit = hasAnyFlag(args, ['--await-quit']);
|
||||||
|
|
||||||
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
||||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||||
@@ -584,6 +585,36 @@ export async function handleBarInstall(
|
|||||||
return;
|
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.
|
// 3b. New bundle verified in staging — now safe to remove the old install.
|
||||||
if (fs.existsSync(appPath)) {
|
if (fs.existsSync(appPath)) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user