From 64fbf2e0621aca4398693de5b5ef43b9448bfecc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 15 Jun 2026 17:13:58 -0400 Subject: [PATCH] feat(bar): self-start the server from the app and stop dropdown clipping The app now port-probes for a live CCS server and, if none is found, starts the recorded detached server itself (launch.json, with a login-shell fallback) before connecting, so double-clicking the app works without the CLI. The offline panel gains a Start CCS action and a starting state. The dropdown is sized to its measured content up to a screen-aware cap instead of a fixed maxHeight, so it no longer collapses and clips rows. Refs #1526, #1527 --- macos-bar/Sources/CCSBarApp/BarMenuView.swift | 120 +++++++++-- .../Sources/CCSBarApp/BarServerLauncher.swift | 163 +++++++++++++++ .../Sources/CCSBarApp/BarViewModel.swift | 193 ++++++++++++++---- macos-bar/Sources/CCSBarCheck/main.swift | 182 +++++++++++++++++ .../Sources/CCSBarCore/BarServerProbe.swift | 114 +++++++++++ 5 files changed, 715 insertions(+), 57 deletions(-) create mode 100644 macos-bar/Sources/CCSBarApp/BarServerLauncher.swift create mode 100644 macos-bar/Sources/CCSBarCore/BarServerProbe.swift diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 14a09437..59099ede 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -2,6 +2,21 @@ import SwiftUI import AppKit import CCSBarCore +// MARK: - Content height preference key + +/// Preference key used to bubble the measured content VStack height up to the +/// ScrollView parent without a feedback loop. The reduction takes the MAX so +/// that intermediate layout passes that report a smaller size don't cause the +/// frame to shrink, which would trigger another layout pass (the classic +/// GeometryReader loop). A `@State` var is updated only when the value changes, +/// keeping SwiftUI's diffing stable. +private struct ContentHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + /// Dropdown content for the menu bar: a CCS-branded header, usage analytics, /// per-account rows + actions, an offline state when CCS isn't running, and /// footer controls. @@ -16,13 +31,36 @@ struct BarMenuView: View { /// no modal, no .confirmationDialog (those steal focus and dismiss the popover, /// the exact fragility of BUG 1). @State private var quitArmed = false + /// Measured height of the scroll content VStack, updated via preference key. + /// Starts at 0; the preference fires on the first layout pass and grows + /// monotonically (ContentHeightKey.reduce takes the max), so the frame never + /// thrashes downward. + @State private var contentHeight: CGFloat = 0 + + // MARK: - Screen cap + + /// Maximum height the scroll area may occupy: screen visible height minus space + /// for the macOS menu bar, the CCS Bar header, footer, and a small safety margin. + /// Clamped to a floor of 240 so the popover is always usable even on tiny displays. + private var screenCap: CGFloat { + let visibleHeight = NSScreen.main?.visibleFrame.height ?? 800 + return max(240, visibleHeight - 120) + } + + /// The resolved scroll-area height: the smaller of measured content and the cap. + /// Content shorter than the cap renders FULLY with no scroll bar. + /// Content taller than the cap scrolls. + private var scrollAreaHeight: CGFloat { + guard contentHeight > 0 else { return screenCap } + return min(contentHeight, screenCap) + } var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() - if viewModel.offline { + if viewModel.offline || viewModel.isStarting { offlineState.padding(14) } else { // The scroll indicator is suppressed (.never, not just .hidden) AND the @@ -78,12 +116,30 @@ struct BarMenuView: View { ScrollerHider().frame(width: 0, height: 0) } .padding(14) + // Measure the content height via a background GeometryReader that + // reports into ContentHeightKey. Using .background keeps the reader + // out of the layout pass that sizes the VStack itself, avoiding the + // classic GeometryReader-inside-ScrollView feedback loop. + .background( + GeometryReader { geo in + Color.clear + .preference(key: ContentHeightKey.self, value: geo.size.height) + } + ) } .scrollIndicators(.never) - // 700 gives more vertical breathing room before scroll engages — useful - // for 3-4 subscription cards each carrying multiple quota windows. - // Scroll still engages gracefully on genuine overflow. - .frame(maxHeight: 700) + // Deterministic height: exactly the content size up to the screen cap. + // Content shorter than the cap renders FULLY (no empty space, no clip). + // Content taller scrolls. contentHeight starts at 0 so we show screenCap + // until the first preference fires. + .frame(height: scrollAreaHeight) + .onPreferenceChange(ContentHeightKey.self) { measured in + // Only grow, never shrink — prevents a feedback loop where a smaller + // frame triggers a re-layout that reports an even smaller height. + if measured > contentHeight { + contentHeight = measured + } + } } Divider() @@ -205,15 +261,51 @@ struct BarMenuView: View { .padding(.vertical, 10) } - private var offlineState: some View { - VStack(alignment: .leading, spacing: 6) { - Label("CCS is not running", systemImage: "bolt.slash.fill") - .font(.body) - Text("Start CCS, then reopen this menu.") - .font(.caption) - .foregroundStyle(.secondary) - Button("Retry") { viewModel.reconnect(); viewModel.onOpen() } - .controlSize(.small) + /// Offline / starting state view. Two sub-states: + /// + /// **Starting** (`isStarting == true`): server launch is in progress. Shows a + /// spinner row so the user knows something is happening. No action needed. + /// + /// **Truly offline** (`offline == true`, `isStarting == false`): server is not + /// running and no launch is in progress. Shows a primary "Start CCS" button + /// that triggers the launcher + poll sequence, a secondary "Retry" that + /// re-probes without launching, and a guidance caption. + /// + /// All controls remain inside the popover — NO sheets, modals, or + /// .confirmationDialog (those steal focus and auto-dismiss the MenuBarExtra, + /// the known constraint noted throughout BarMenuView). + @ViewBuilder private var offlineState: some View { + if viewModel.isStarting { + // Starting state: spinner + label. Primary action is implicit (wait). + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Starting CCS…") + .font(.body) + .foregroundStyle(.secondary) + } + } else { + // Truly offline state: actionable controls. + VStack(alignment: .leading, spacing: 8) { + Label("CCS is not running", systemImage: "bolt.slash.fill") + .font(.body) + Text("Start CCS, then the menu will connect automatically.") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + // Primary: launches the server (reads launch.json or falls back to ccs on PATH). + Button("Start CCS") { + viewModel.startCCS() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + // Secondary: re-probe only (no launch attempt). + Button("Retry") { + viewModel.onOpen() + } + .controlSize(.small) + } + } } } diff --git a/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift new file mode 100644 index 00000000..45691c59 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift @@ -0,0 +1,163 @@ +import Foundation +import CCSBarCore + +/// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached, +/// redirecting stdout/stderr to `~/.ccs/bar/serve.log`. +/// +/// If `launch.json` is absent the launcher falls back to resolving `ccs` on +/// PATH via `/bin/zsh -lic "command -v ccs"` and spawning `ccs bar serve`. +/// +/// This type is intentionally NOT on the main actor: `Process` is blocking and +/// must never run on the main thread. Explicit `Sendable` is safe because the +/// only stored property is an immutable `String`. +struct BarServerLauncher: Sendable { + let home: String + + init(home: String = NSHomeDirectory()) { + self.home = home + } + + // MARK: - Public + + /// Attempt to start the CCS bar server. + /// Returns `true` when the process was successfully spawned (detached), + /// `false` when the launch is not possible (no descriptor, no ccs on PATH). + /// This does NOT wait for the server to become reachable — callers must + /// poll BarServerProbe after calling start(). + @discardableResult + func start() -> Bool { + if let descriptor = loadDescriptor() { + return spawnFromDescriptor(descriptor) + } + return spawnFallback() + } + + // MARK: - Descriptor loading + + private func loadDescriptor() -> BarLaunchDescriptor? { + let path = BarLaunchDescriptor.defaultPath(home: home) + guard FileManager.default.fileExists(atPath: path), + let data = FileManager.default.contents(atPath: path) + else { return nil } + return try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data) + } + + // MARK: - Spawn from descriptor + + private func spawnFromDescriptor(_ descriptor: BarLaunchDescriptor) -> Bool { + guard !descriptor.runtime.isEmpty, !descriptor.args.isEmpty else { return false } + + let logURL = serveLogURL() + ensureLogDirectory() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: descriptor.runtime) + // Drop the first arg (the entry point itself) when the runtime IS the + // entry point; args[0] is the entry script, args[1..] are the remaining. + // Per the plan: args = [absoluteEntryScript, "bar", "serve"]. + proc.arguments = Array(descriptor.args.dropFirst()) + + // Prepend the entry script as the first positional arg to the runtime. + // e.g. node /path/to/ccs.js bar serve + if let first = descriptor.args.first { + proc.arguments = [first] + (proc.arguments ?? []) + } + + proc.currentDirectoryURL = URL(fileURLWithPath: descriptor.home) + + var env = ProcessInfo.processInfo.environment + if let ccsHome = descriptor.ccsHome, !ccsHome.isEmpty { + env["CCS_HOME"] = ccsHome + } + proc.environment = env + + attachLog(proc, logURL: logURL) + return launchDetached(proc) + } + + // MARK: - Fallback: resolve `ccs` then spawn + + private func spawnFallback() -> Bool { + guard let ccsPath = resolveCCSPath() else { return false } + + let logURL = serveLogURL() + ensureLogDirectory() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: ccsPath) + proc.arguments = ["bar", "serve"] + proc.currentDirectoryURL = URL(fileURLWithPath: home) + proc.environment = ProcessInfo.processInfo.environment + attachLog(proc, logURL: logURL) + return launchDetached(proc) + } + + /// Resolve the `ccs` binary path by running `/bin/zsh -lic "command -v ccs"`. + /// Synchronous; called only when launch.json is absent. + private func resolveCCSPath() -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/zsh") + proc.arguments = ["-lic", "command -v ccs"] + + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() // discard stderr + + guard (try? proc.run()) != nil else { return nil } + proc.waitUntilExit() + + guard proc.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let raw = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? nil : raw + } + + // MARK: - Helpers + + /// URL for the serve log file: `~/.ccs/bar/serve.log`. + private func serveLogURL() -> URL { + URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar") + .appendingPathComponent("serve.log") + } + + /// Ensure `~/.ccs/bar/` directory exists before writing the log. + private func ensureLogDirectory() { + let dir = URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar") + .path + try? FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true, attributes: nil) + } + + /// Wire up stdout + stderr to the log file (appending). Creates the file if absent. + private func attachLog(_ proc: Process, logURL: URL) { + // Create or open the file for appending. + if !FileManager.default.fileExists(atPath: logURL.path) { + FileManager.default.createFile(atPath: logURL.path, contents: nil) + } + if let handle = try? FileHandle(forWritingTo: logURL) { + handle.seekToEndOfFile() + proc.standardOutput = handle + proc.standardError = handle + } else { + // Fallback: discard output rather than block the launch. + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + } + } + + /// Launch `proc` detached (does not wait for it). Returns success/failure. + private func launchDetached(_ proc: Process) -> Bool { + do { + try proc.run() + // Do NOT call waitUntilExit() — the process must outlive this app launch. + return true + } catch { + return false + } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index b8106ef4..cbfd324a 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -3,8 +3,9 @@ import SwiftUI import CCSBarCore /// Observable state for the menu bar. Holds the last-known rows for instant -/// paint, reconnects to the CCS web-server via the discovery file, and fires a -/// debounced force-refresh when the menu opens. +/// paint, reconnects to the CCS web-server via the discovery file or by +/// self-starting the server, and fires a debounced force-refresh when the +/// menu opens. @MainActor final class BarViewModel: ObservableObject { @Published var rows: [BarSummaryRow] = [] @@ -12,6 +13,8 @@ final class BarViewModel: ObservableObject { @Published var offline = false @Published var lastError: String? @Published var isRefreshing = false + /// True while the launcher is waiting for the server to become reachable. + @Published var isStarting = false @Published var iconStyle: BarIconStyle { didSet { MenuBarIcon.saveStyle(iconStyle) } } @@ -38,27 +41,32 @@ final class BarViewModel: ObservableObject { private var client: CCSBarClient? private var debouncer = RefreshDebouncer(interval: 15) /// Periodic background refresh so the glance self-heals from a transient - /// server gap (e.g. a momentary backend restart that dropped the native rows) - /// without the user having to reopen the menu. Cheap + safe: it reads the - /// local server's caches and never hammers providers (native quota is - /// TTL-gated server-side). + /// server gap without the user having to reopen the menu. Each tick re-probes + /// (not just re-reads bar.json) so a server that restarted on a new port is + /// found automatically. private var pollTask: Task? + /// Guard against overlapping start-and-connect sequences. + private var connectTask: Task? private let prefs: BarPreferences private let notifier: NotificationDelivering + private let probe: BarServerProbe + private let launcher: BarServerLauncher + + // MARK: - Init init( home: String = NSHomeDirectory(), prefs: BarPreferences = BarPreferences(), - notifier: NotificationDelivering? = nil + notifier: NotificationDelivering? = nil, + probe: BarServerProbe = BarServerProbe(), + launcher: BarServerLauncher? = nil ) { self.home = home self.prefs = prefs - // Seed registration defaults before the first pref read. Idempotent, and the - // App-level call is a redundant safety net for the @StateObject default-init - // ordering (stored-property defaults run before the App.init body). prefs.registerDefaults() - // Default to the real UN-backed notifier; tests inject a recording one. self.notifier = notifier ?? BarNotifier() + self.probe = probe + self.launcher = launcher ?? BarServerLauncher(home: home) self.iconStyle = MenuBarIcon.loadStyle() self.appearance = BarAppearanceStore.load() self.glanceMode = prefs.load().glanceMode @@ -67,24 +75,27 @@ final class BarViewModel: ObservableObject { startBackgroundPolling() } - /// Periodically re-poll for the app's lifetime so a transient empty/missing-row - /// state recovers on its own within one interval — each tick reconnects if the - /// client/discovery was lost, then loads (non-force, so it respects the - /// server-side caches). This is what prevents the menu from getting stuck after - /// the server momentarily restarts. + // MARK: - Background polling + + /// Periodically re-probe for the app's lifetime so a transient empty/missing-row + /// state recovers on its own within one interval. Each tick re-probes the + /// network (not just re-reads bar.json) so a server that moved ports is found. private func startBackgroundPolling() { pollTask?.cancel() pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) guard let self else { return } + // Re-probe first; if live, reconnect; then load cached data. + await self.reconnectIfNeeded() await self.load(force: false) } } } - /// Re-read prefs after the preferences sheet writes through, so the next poll - /// (and the live title) reflects the change immediately. + // MARK: - Prefs + + /// Re-read prefs after the preferences sheet writes through. func reloadPrefs() { glanceMode = prefs.load().glanceMode } @@ -94,44 +105,136 @@ final class BarViewModel: ObservableObject { iconStyle = (iconStyle == .color) ? .mono : .color } + // MARK: - Status title + /// Compact status-bar title, resolved through the user's chosen glance mode. var statusTitle: String { - offline + if isStarting { return "CCS starting…" } + return offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode) } - /// Resolve the discovery file and (re)build the client. Marks offline when - /// CCS hasn't been launched. + // MARK: - Connection / discovery + + /// Fast synchronous reconnect: reads bar.json and builds a client if the URL + /// is valid. Does NOT probe the network. Used as a cheap first-pass before + /// the async probe path. func reconnect() { switch BarDiscovery.load(home: home) { case .success(let discovery): if let url = discovery.resolvedURL { client = CCSBarClient(baseURL: url) - offline = false + // Don't clear `offline` yet — the network probe in connectAndLoad() + // will confirm liveness. Leave existing offline state until we know. } else { client = nil - offline = true } case .failure: client = nil - offline = true } } - /// Menu opened: cached rows are already on screen; fire a debounced - /// force-refresh so the glance reflects live provider data. - func onOpen() { - let force = debouncer.shouldRefresh(now: Date()) - Task { await load(force: force) } + /// Full async reconnect: probe the network for a live server, build a client + /// when found, launch the server if not found. Updates `isStarting`/`offline`. + /// + /// This is the entry point called by `onOpen()` and the background poll. + func reconnectAndLoad(force: Bool = false) { + // Cancel any in-flight connect so we don't stack up. + connectTask?.cancel() + connectTask = Task { [weak self] in + guard let self else { return } + await self.connectSequence(force: force) + } } - func load(force: Bool) async { - if client == nil { reconnect() } - guard let client else { + /// Core sequence: probe → if live build client + load; else launch + poll → load. + private func connectSequence(force: Bool) async { + let discovery = try? BarDiscovery.load(home: home).get() + + // 1. Probe for an already-running server. + if let liveURL = await probe.findLiveServer(discovery: discovery) { + client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport()) + offline = false + isStarting = false + await load(force: force) + return + } + + // 2. No live server found — try to start one. + isStarting = true + offline = false // show spinner, not the error state + + let launched = await Task.detached(priority: .utility) { [launcher = self.launcher] in + launcher.start() + }.value + + if !launched { + // Could not even attempt a launch (no descriptor, no ccs on PATH). + isStarting = false offline = true return } + + // 3. Poll until reachable (timeout ~12s, 500ms interval). + let deadline = Date().addingTimeInterval(12) + while Date() < deadline { + guard !Task.isCancelled else { break } + try? await Task.sleep(for: .milliseconds(500)) + if let liveURL = await probe.findLiveServer(discovery: BarDiscovery.load(home: home).ok) { + client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport()) + isStarting = false + offline = false + await load(force: true) + return + } + } + + // 4. Timed out. + isStarting = false + offline = true + } + + /// Re-probe silently during the background poll. If a live server is found and + /// we currently have no client (or are offline), build the client and clear + /// offline. If no server found and we had one, mark offline. + private func reconnectIfNeeded() async { + let discovery = try? BarDiscovery.load(home: home).get() + if let liveURL = await probe.findLiveServer(discovery: discovery) { + client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport()) + if offline { offline = false } + } else if client != nil { + // We had a client but server is gone. + client = nil + if rows.isEmpty { offline = true } + } + } + + // MARK: - Menu open + + /// Menu opened: cached rows are already on screen; fire a debounced reconnect + /// + force-refresh so the glance reflects live provider data. + func onOpen() { + let force = debouncer.shouldRefresh(now: Date()) + reconnectAndLoad(force: force) + } + + // MARK: - Start CCS (called from offline UI button) + + /// Trigger a full connect sequence (probe → launch → poll). Used by the + /// "Start CCS" button in the offline state. + func startCCS() { + reconnectAndLoad(force: true) + } + + // MARK: - Data loading + + func load(force: Bool) async { + // If we still have no client after the connect sequence, bail. + guard let client else { + if !isStarting { offline = true } + return + } if force { isRefreshing = true } defer { isRefreshing = false } do { @@ -154,11 +257,9 @@ final class BarViewModel: ObservableObject { evaluateAlerts() } - /// Run the pure rule engine once per poll against the freshly-loaded state, - /// deliver any new notifications, and overwrite the persisted fired-key set - /// verbatim. The engine's dedupe means repeated polls with unchanged state - /// deliver nothing, so this never spams. Delivery is best-effort and never - /// blocks the UI (the notifier hops to its own task). + // MARK: - Alert engine + + /// Run the pure rule engine once per poll against the freshly-loaded state. private func evaluateAlerts() { let current = prefs.load() let eval = BarAlertEngine.evaluate( @@ -170,13 +271,11 @@ final class BarViewModel: ObservableObject { for notification in eval.toDeliver { notifier.deliver(notification) } - // Persist the COMPLETE next-state set verbatim (no merge) — this is what keeps - // the stored set bounded and lets cleared conditions re-arm. prefs.firedKeys = eval.firedKeys activeAlerts = eval.toDeliver } - // MARK: Account actions + // MARK: - Account actions func pause(_ row: BarSummaryRow) { perform { try await $0.pause(provider: row.provider, accountId: row.accountId) } @@ -188,9 +287,6 @@ final class BarViewModel: ObservableObject { perform { try await $0.solo(provider: row.provider, accountId: row.accountId) } } func setDefault(_ row: BarSummaryRow) { - // The server's /api/accounts/default parses CLIProxy accounts as the - // composite "provider:accountId" key; row.id already has that shape. - // Sending the bare accountId fails parseCliproxyKey and 500s / no-ops. perform { try await $0.setDefault(name: row.id) } } func tierLock(_ row: BarSummaryRow, tier: String?) { @@ -213,3 +309,14 @@ final class BarViewModel: ObservableObject { String(describing: error) } } + +// MARK: - Result convenience + +private extension Result { + /// Extract the success value without throwing, for use in async contexts where + /// we want an optional rather than a thrown error. + var ok: Success? { + guard case .success(let v) = self else { return nil } + return v + } +} diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index a49e9b92..f4379124 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1445,6 +1445,188 @@ check(BarVersionDisplay.displayString(for: "2.0.0") == "v2.0.0", "version '2.0.0 // Outside a bundle, string() must not crash and returns nil (no assertion on value — bundle absent). let _ = BarVersionDisplay.string() +// MARK: - BarLaunchDescriptor JSON decoding + +// (L1) Full descriptor with ccsHome round-trips correctly. +do { + let json = """ + { + "schema": 1, + "runtime": "/usr/local/bin/node", + "args": ["/home/kai/.ccs/bin/ccs.js", "bar", "serve"], + "home": "/home/kai", + "ccsHome": "/home/kai/.ccs-custom" + } + """ + let d = try JSONDecoder().decode(BarLaunchDescriptor.self, from: Data(json.utf8)) + check(d.schema == 1, "descriptor: schema decodes to 1") + check(d.runtime == "/usr/local/bin/node", "descriptor: runtime decodes") + check(d.args == ["/home/kai/.ccs/bin/ccs.js", "bar", "serve"], "descriptor: args decode") + check(d.home == "/home/kai", "descriptor: home decodes") + check(d.ccsHome == "/home/kai/.ccs-custom", "descriptor: ccsHome decodes when present") +} + +// (L2) Descriptor without ccsHome (optional field absent) decodes to nil. +do { + let json = """ + { + "schema": 1, + "runtime": "/opt/homebrew/bin/bun", + "args": ["/usr/local/lib/ccs/dist/index.js", "bar", "serve"], + "home": "/Users/kai" + } + """ + let d = try JSONDecoder().decode(BarLaunchDescriptor.self, from: Data(json.utf8)) + check(d.ccsHome == nil, "descriptor: absent ccsHome decodes to nil") + check(d.runtime == "/opt/homebrew/bin/bun", "descriptor: bun runtime decodes") + check(d.args.count == 3, "descriptor: args count is 3") + check(d.args.last == "serve", "descriptor: last arg is 'serve'") +} + +// (L3) defaultPath uses ~/.ccs/bar/launch.json. +do { + let home = "/Users/testuser" + let path = BarLaunchDescriptor.defaultPath(home: home) + check(path.hasSuffix("/.ccs/bar/launch.json"), "descriptor: defaultPath ends with /.ccs/bar/launch.json") + check(path.hasPrefix(home), "descriptor: defaultPath starts with home dir") +} + +// (L4) Malformed JSON is non-decodable (no crash, just nil from try?). +do { + let bad = Data("{\"schema\": \"not-an-int\"}".utf8) + let d = try? JSONDecoder().decode(BarLaunchDescriptor.self, from: bad) + check(d == nil, "descriptor: malformed JSON decodes to nil (no crash)") +} + +// MARK: - BarServerProbe ordering and selection + +// Mock transport that returns 200 for exactly one (host, port) combination and +// times out (throws) for everything else. Used to verify probe ordering. +final class SelectiveTransport: HTTPTransport, @unchecked Sendable { + /// The URL prefix that should succeed (e.g. "http://127.0.0.1:3001"). + let successPrefix: String + /// Track every URL probed, in order. + var probed: [String] = [] + + init(successPrefix: String) { + self.successPrefix = successPrefix + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let urlStr = request.url?.absoluteString ?? "" + probed.append(urlStr) + if urlStr.hasPrefix(successPrefix) { + let http = HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(), http) + } + // Simulate connection refused — throw so the probe moves on. + throw URLError(.cannotConnectToHost) + } +} + +// (P1) bar.json port is probed FIRST, then fallbacks in order. +// With no live server the probe visits: bar.json port (127.0.0.1 then ::1), +// then 3000, 3001, ... until it finds one or exhausts all. +do { + // bar.json says port 9999 (unusual, not in fallbacks). We make 127.0.0.1:9999 succeed. + let transport = SelectiveTransport(successPrefix: "http://127.0.0.1:9999") + let probe = BarServerProbe(transport: transport) + let discovery = BarDiscovery(baseUrl: "http://127.0.0.1:9999", port: 9999, authMode: "loopback") + let result = await probe.findLiveServer(discovery: discovery) + + // The first URL probed must be the bar.json port on 127.0.0.1. + check( + transport.probed.first?.hasPrefix("http://127.0.0.1:9999") == true, + "probe: bar.json port is tried first (127.0.0.1)") + check(result?.absoluteString.hasPrefix("http://127.0.0.1:9999") == true, + "probe: returns live URL when bar.json port responds 200") +} + +// (P2) Falls through to fallback list when bar.json port is dead. +// Make port 3001 on 127.0.0.1 the live one. bar.json points at dead port 9999. +do { + let transport = SelectiveTransport(successPrefix: "http://127.0.0.1:3001") + let probe = BarServerProbe(transport: transport) + let discovery = BarDiscovery(baseUrl: "http://127.0.0.1:9999", port: 9999, authMode: "loopback") + let result = await probe.findLiveServer(discovery: discovery) + + check(result?.absoluteString.hasPrefix("http://127.0.0.1:3001") == true, + "probe: falls through to fallback 3001 when bar.json port 9999 is dead") + + // bar.json port 9999 must have been probed before any fallback. + let idx9999 = transport.probed.firstIndex(where: { $0.contains(":9999/") }) + let idx3001 = transport.probed.firstIndex(where: { $0.contains(":3001/") }) + if let i = idx9999, let j = idx3001 { + check(i < j, "probe: bar.json port (9999) tried before fallback port (3001)") + } else { + check(false, "probe: expected probes for ports 9999 and 3001") + } +} + +// (P3) IPv4 tried before IPv6 for each port. +// Make only the IPv6 address of 3000 succeed so we can verify that 127.0.0.1 +// was tried before [::1] for the same port. +do { + let transport = SelectiveTransport(successPrefix: "http://[::1]:3000") + let probe = BarServerProbe(transport: transport) + // No bar.json — nil discovery. + let result = await probe.findLiveServer(discovery: nil) + + check(result?.absoluteString.hasPrefix("http://[::1]:3000") == true, + "probe: IPv6 [::1]:3000 selected when 127.0.0.1:3000 is dead") + + let idx4 = transport.probed.firstIndex(where: { $0.contains("127.0.0.1:3000") }) + let idx6 = transport.probed.firstIndex(where: { $0.contains("[::1]:3000") }) + if let i = idx4, let j = idx6 { + check(i < j, "probe: 127.0.0.1 tried before [::1] for same port") + } else { + check(false, "probe: expected probes for both 127.0.0.1:3000 and [::1]:3000") + } +} + +// (P4) Returns nil when no server responds. +do { + // A transport that always throws — simulates total offline. + struct DeadTransport: HTTPTransport { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + throw URLError(.cannotConnectToHost) + } + } + let probe = BarServerProbe(transport: DeadTransport()) + let result = await probe.findLiveServer(discovery: nil) + check(result == nil, "probe: returns nil when no server responds") +} + +// (P5) Non-200 response is treated as dead (not live). +do { + struct Status404Transport: HTTPTransport { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let http = HTTPURLResponse( + url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)! + return (Data(), http) + } + } + let probe = BarServerProbe(transport: Status404Transport()) + let result = await probe.findLiveServer(discovery: nil) + check(result == nil, "probe: 404 response treated as dead (not live)") +} + +// (P6) Fallback list deduplication: when bar.json port equals a fallback port +// (e.g. 3000), that port is not probed twice. +do { + let transport = SelectiveTransport(successPrefix: "http://127.0.0.1:3000") + let probe = BarServerProbe(transport: transport) + // bar.json port == 3000, which is also in the fallback list. + let discovery = BarDiscovery(baseUrl: "http://127.0.0.1:3000", port: 3000, authMode: "loopback") + _ = await probe.findLiveServer(discovery: discovery) + + // Count how many times :3000/ appears across all probed URLs. + let port3000Count = transport.probed.filter { $0.contains(":3000/") }.count + // Expect exactly 1 probe for 3000 on 127.0.0.1 (finds it immediately; stops before ::1). + check(port3000Count == 1, "probe: bar.json port 3000 deduped — probed once, not twice") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarServerProbe.swift b/macos-bar/Sources/CCSBarCore/BarServerProbe.swift new file mode 100644 index 00000000..cd9327cb --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarServerProbe.swift @@ -0,0 +1,114 @@ +import Foundation + +// MARK: - BarLaunchDescriptor (written by ccs bar install / ccs bar, decoded by the Swift app) + +/// Schema-versioned descriptor that tells the Swift app how to start the server +/// without a shell PATH. Stored at `~/.ccs/bar/launch.json`. +public struct BarLaunchDescriptor: Codable, Sendable { + /// Schema version — always 1 for the current format. + public let schema: Int + /// Absolute path to the node/bun/runtime binary (`process.execPath`). + public let runtime: String + /// Arguments to pass to `runtime`: [absoluteEntryScript, "bar", "serve"]. + public let args: [String] + /// Working directory for the spawned server (`os.homedir()`). + public let home: String + /// Value of `CCS_HOME` env var, if it was set when the descriptor was written. + public let ccsHome: String? + + public init(schema: Int = 1, runtime: String, args: [String], home: String, ccsHome: String?) { + self.schema = schema + self.runtime = runtime + self.args = args + self.home = home + self.ccsHome = ccsHome + } + + /// Default path for the launch descriptor under `~/.ccs/bar/launch.json`. + public static func defaultPath(home: String = NSHomeDirectory()) -> String { + URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar") + .appendingPathComponent("launch.json") + .path + } +} + +// MARK: - BarServerProbe + +/// Async port-probe that mirrors the TS `defaultFindRunningServer` order: +/// 1. bar.json port (if available) +/// 2. 3000, 3001, 3002, 8000, 8080 +/// Each port is tried on 127.0.0.1 then ::1. +/// Liveness check: GET /api/bar/summary -> 200. +/// +/// The transport is injectable so the check harness can test ordering without +/// a live server. +public struct BarServerProbe: Sendable { + /// Fallback probe ports in order (after bar.json port). + static let fallbackPorts = [3000, 3001, 3002, 8000, 8080] + + private let transport: HTTPTransport + + public init(transport: HTTPTransport = URLSessionTransport()) { + self.transport = transport + } + + /// Probe for a live CCS server. Returns the base URL of the first responding + /// server, or `nil` if none respond within the attempt. + /// + /// - Parameter discovery: The current bar.json contents, if available. + /// Its port is probed first before the fallback list. + public func findLiveServer(discovery: BarDiscovery?) async -> URL? { + let candidatePorts = buildCandidatePorts(discovery: discovery) + + for port in candidatePorts { + if let url = await probePort(port) { + return url + } + } + return nil + } + + // MARK: - Private + + /// Build the ordered port list: bar.json port first (deduplicated), then fallbacks. + private func buildCandidatePorts(discovery: BarDiscovery?) -> [Int] { + var ports: [Int] = [] + if let d = discovery { + ports.append(d.port) + } + for p in Self.fallbackPorts where !ports.contains(p) { + ports.append(p) + } + return ports + } + + /// Try 127.0.0.1 then ::1 for the given port. + /// Returns the first base URL that responds 200 to /api/bar/summary, or nil. + private func probePort(_ port: Int) async -> URL? { + let hosts = ["127.0.0.1", "::1"] + for host in hosts { + // IPv6 addresses must be bracketed in URLs. + let hostStr = host.contains(":") ? "[\(host)]" : host + guard let base = URL(string: "http://\(hostStr):\(port)") else { continue } + if await isLive(baseURL: base) { + return base + } + } + return nil + } + + /// Returns true if GET {baseURL}/api/bar/summary responds with HTTP 200. + private func isLive(baseURL: URL) async -> Bool { + let url = baseURL.appendingPathComponent("api/bar/summary") + var req = URLRequest(url: url) + req.timeoutInterval = 2.0 + do { + let (_, http) = try await transport.send(req) + return http.statusCode == 200 + } catch { + return false + } + } +}