mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +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.
|
||||
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}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
['--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[],
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user