From 3438c3940eb0af9a680954526ba63274bf768c30 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 15:25:45 -0400 Subject: [PATCH 1/3] feat(bar): show running app version in the panel header A right-aligned v{CFBundleShortVersionString} label fills the unused header space next to the CCS name, styled like the subtitle and hidden when no bundle version is available (e.g. swift run). The display logic lives in CCSBarCore as a pure helper with ccs-bar-check coverage, making the on-screen build identifiable after reinstalls. --- macos-bar/Sources/CCSBarApp/BarMenuView.swift | 5 ++++ macos-bar/Sources/CCSBarCheck/main.swift | 9 ++++++ .../CCSBarCore/BarVersionDisplay.swift | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 macos-bar/Sources/CCSBarCore/BarVersionDisplay.swift diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index b5d29124..3293883e 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -192,6 +192,11 @@ struct BarMenuView: View { Text("usage & accounts").font(.caption2).foregroundStyle(.secondary) } Spacer() + if let v = BarVersionDisplay.string() { + Text(v) + .font(.caption2) + .foregroundStyle(.secondary) + } if viewModel.isRefreshing { ProgressView().controlSize(.small) } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 0932ca85..a49e9b92 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1436,6 +1436,15 @@ do { check(selectedIdx == 1, "screenPicker: exact hit selects right screen (index 1) for #1502 anchor") } +// MARK: BarVersionDisplay — pure display-string helper + +check(BarVersionDisplay.displayString(for: nil) == nil, "version nil raw -> nil (no dangling 'v')") +check(BarVersionDisplay.displayString(for: "") == nil, "version empty raw -> nil (no dangling 'v')") +check(BarVersionDisplay.displayString(for: "1.5.0") == "v1.5.0", "version '1.5.0' -> 'v1.5.0'") +check(BarVersionDisplay.displayString(for: "2.0.0") == "v2.0.0", "version '2.0.0' -> 'v2.0.0'") +// Outside a bundle, string() must not crash and returns nil (no assertion on value — bundle absent). +let _ = BarVersionDisplay.string() + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarVersionDisplay.swift b/macos-bar/Sources/CCSBarCore/BarVersionDisplay.swift new file mode 100644 index 00000000..54bdee79 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarVersionDisplay.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Pure helper for the version string shown in the bar panel header. +/// +/// Kept tiny so it can be tested in CCSBarCheck without a bundle present: +/// the display-string logic is pure-Foundation and has no AppKit dependency. +public enum BarVersionDisplay { + + /// Converts a raw version string to a "v{version}" display string. + /// + /// - Returns: `"v\(raw)"` when `raw` is non-nil and non-empty; `nil` otherwise. + /// + /// This is the logic under test. The actual `Bundle.main` lookup is in `string()`. + public static func displayString(for raw: String?) -> String? { + guard let v = raw, !v.isEmpty else { return nil } + return "v\(v)" + } + + /// Returns the display string for the running app's bundle version, or `nil` + /// when the key is absent (e.g. `swift run` outside a bundle). + /// + /// Never produces a dangling "v" prefix: if `CFBundleShortVersionString` is + /// missing or empty, returns `nil` so the caller can omit the label entirely. + public static func string() -> String? { + let raw = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return displayString(for: raw) + } +} From 3aab14ca85f704cfda95f3e4d3769fb6f5cca4c7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 15:25:55 -0400 Subject: [PATCH 2/3] fix(bar): correct the already-running reinstall hint 'open -a' only activates a running app, so suggesting 'ccs bar' as an alternative to quitting could not load the new binary. The hint now says to quit from the menu bar first, then run 'ccs bar' to relaunch the updated app. --- src/commands/bar/install-subcommand.ts | 2 +- tests/unit/commands/bar-command.test.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index af5b2670..c51d740a 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -681,7 +681,7 @@ export async function handleBarInstall( if (barIsRunning && !forceLaunch) { console.log( - '[!] CCS Bar is currently running the previous version. Quit and reopen it (or run `ccs bar`) to use the update.' + '[!] CCS Bar is currently running an older build. Quit it from the menu bar, then run `ccs bar` to relaunch the updated app.' ); } else if (forceLaunch) { await launchBar([]); diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 4ad796f7..9c1f7c8d 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -2622,8 +2622,9 @@ describe('bar install: already-running detection (Finding 3)', () => { const allOutput = consoleOutput.join('\n'); // Must print the restart hint - expect(allOutput).toMatch(/currently running the previous version/i); - expect(allOutput).toMatch(/Quit and reopen/i); + expect(allOutput).toMatch(/currently running an older build/i); + expect(allOutput).toMatch(/Quit it from the menu bar/i); + expect(allOutput).toMatch(/run `ccs bar` to relaunch/i); }); it('when not running after install: prompt path unchanged', async () => { @@ -2646,7 +2647,7 @@ describe('bar install: already-running detection (Finding 3)', () => { expect(promptCalled).toBe(true); const allOutput = consoleOutput.join('\n'); // The restart hint must NOT appear when bar is not running - expect(allOutput).not.toMatch(/currently running the previous version/i); + expect(allOutput).not.toMatch(/currently running an older build/i); }); it('pgrep error treated as not running: prompt path unchanged', async () => { @@ -2670,7 +2671,7 @@ describe('bar install: already-running detection (Finding 3)', () => { // pgrep error treated as not running → normal prompt flow expect(promptCalled).toBe(true); const allOutput = consoleOutput.join('\n'); - expect(allOutput).not.toMatch(/currently running the previous version/i); + expect(allOutput).not.toMatch(/currently running an older build/i); }); it('--launch with bar already running: launchBar still invoked', async () => { @@ -2693,7 +2694,7 @@ describe('bar install: already-running detection (Finding 3)', () => { expect(launchCalled).toBe(true); // Restart hint must NOT appear when --launch is passed const allOutput = consoleOutput.join('\n'); - expect(allOutput).not.toMatch(/currently running the previous version/i); + expect(allOutput).not.toMatch(/currently running an older build/i); }); }); From a5c41864b3710b31b9cd740305928f76643d9a35 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 15:33:06 -0400 Subject: [PATCH 3/3] fix(bar): keep header version label rightmost during refresh The refresh spinner rendered after the version label, pushing the version away from the header edge while a refresh was in progress. The spinner now sits between the Spacer and the version label. --- macos-bar/Sources/CCSBarApp/BarMenuView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 3293883e..14a09437 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -192,14 +192,14 @@ struct BarMenuView: View { Text("usage & accounts").font(.caption2).foregroundStyle(.secondary) } Spacer() + if viewModel.isRefreshing { + ProgressView().controlSize(.small) + } if let v = BarVersionDisplay.string() { Text(v) .font(.caption2) .foregroundStyle(.secondary) } - if viewModel.isRefreshing { - ProgressView().controlSize(.small) - } } .padding(.horizontal, 14) .padding(.vertical, 10)