mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
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
This commit is contained in:
@@ -2,6 +2,21 @@ import SwiftUI
|
|||||||
import AppKit
|
import AppKit
|
||||||
import CCSBarCore
|
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,
|
/// 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
|
/// per-account rows + actions, an offline state when CCS isn't running, and
|
||||||
/// footer controls.
|
/// footer controls.
|
||||||
@@ -16,13 +31,36 @@ struct BarMenuView: View {
|
|||||||
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
|
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
|
||||||
/// the exact fragility of BUG 1).
|
/// the exact fragility of BUG 1).
|
||||||
@State private var quitArmed = false
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
if viewModel.offline {
|
if viewModel.offline || viewModel.isStarting {
|
||||||
offlineState.padding(14)
|
offlineState.padding(14)
|
||||||
} else {
|
} else {
|
||||||
// The scroll indicator is suppressed (.never, not just .hidden) AND the
|
// The scroll indicator is suppressed (.never, not just .hidden) AND the
|
||||||
@@ -78,12 +116,30 @@ struct BarMenuView: View {
|
|||||||
ScrollerHider().frame(width: 0, height: 0)
|
ScrollerHider().frame(width: 0, height: 0)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.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)
|
.scrollIndicators(.never)
|
||||||
// 700 gives more vertical breathing room before scroll engages — useful
|
// Deterministic height: exactly the content size up to the screen cap.
|
||||||
// for 3-4 subscription cards each carrying multiple quota windows.
|
// Content shorter than the cap renders FULLY (no empty space, no clip).
|
||||||
// Scroll still engages gracefully on genuine overflow.
|
// Content taller scrolls. contentHeight starts at 0 so we show screenCap
|
||||||
.frame(maxHeight: 700)
|
// 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()
|
Divider()
|
||||||
@@ -205,15 +261,51 @@ struct BarMenuView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var offlineState: some View {
|
/// Offline / starting state view. Two sub-states:
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
///
|
||||||
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
/// **Starting** (`isStarting == true`): server launch is in progress. Shows a
|
||||||
.font(.body)
|
/// spinner row so the user knows something is happening. No action needed.
|
||||||
Text("Start CCS, then reopen this menu.")
|
///
|
||||||
.font(.caption)
|
/// **Truly offline** (`offline == true`, `isStarting == false`): server is not
|
||||||
.foregroundStyle(.secondary)
|
/// running and no launch is in progress. Shows a primary "Start CCS" button
|
||||||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
/// that triggers the launcher + poll sequence, a secondary "Retry" that
|
||||||
.controlSize(.small)
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import SwiftUI
|
|||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
|
|
||||||
/// Observable state for the menu bar. Holds the last-known rows for instant
|
/// 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
|
/// paint, reconnects to the CCS web-server via the discovery file or by
|
||||||
/// debounced force-refresh when the menu opens.
|
/// self-starting the server, and fires a debounced force-refresh when the
|
||||||
|
/// menu opens.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class BarViewModel: ObservableObject {
|
final class BarViewModel: ObservableObject {
|
||||||
@Published var rows: [BarSummaryRow] = []
|
@Published var rows: [BarSummaryRow] = []
|
||||||
@@ -12,6 +13,8 @@ final class BarViewModel: ObservableObject {
|
|||||||
@Published var offline = false
|
@Published var offline = false
|
||||||
@Published var lastError: String?
|
@Published var lastError: String?
|
||||||
@Published var isRefreshing = false
|
@Published var isRefreshing = false
|
||||||
|
/// True while the launcher is waiting for the server to become reachable.
|
||||||
|
@Published var isStarting = false
|
||||||
@Published var iconStyle: BarIconStyle {
|
@Published var iconStyle: BarIconStyle {
|
||||||
didSet { MenuBarIcon.saveStyle(iconStyle) }
|
didSet { MenuBarIcon.saveStyle(iconStyle) }
|
||||||
}
|
}
|
||||||
@@ -38,27 +41,32 @@ final class BarViewModel: ObservableObject {
|
|||||||
private var client: CCSBarClient?
|
private var client: CCSBarClient?
|
||||||
private var debouncer = RefreshDebouncer(interval: 15)
|
private var debouncer = RefreshDebouncer(interval: 15)
|
||||||
/// Periodic background refresh so the glance self-heals from a transient
|
/// Periodic background refresh so the glance self-heals from a transient
|
||||||
/// server gap (e.g. a momentary backend restart that dropped the native rows)
|
/// server gap without the user having to reopen the menu. Each tick re-probes
|
||||||
/// without the user having to reopen the menu. Cheap + safe: it reads the
|
/// (not just re-reads bar.json) so a server that restarted on a new port is
|
||||||
/// local server's caches and never hammers providers (native quota is
|
/// found automatically.
|
||||||
/// TTL-gated server-side).
|
|
||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
|
/// Guard against overlapping start-and-connect sequences.
|
||||||
|
private var connectTask: Task<Void, Never>?
|
||||||
private let prefs: BarPreferences
|
private let prefs: BarPreferences
|
||||||
private let notifier: NotificationDelivering
|
private let notifier: NotificationDelivering
|
||||||
|
private let probe: BarServerProbe
|
||||||
|
private let launcher: BarServerLauncher
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
init(
|
init(
|
||||||
home: String = NSHomeDirectory(),
|
home: String = NSHomeDirectory(),
|
||||||
prefs: BarPreferences = BarPreferences(),
|
prefs: BarPreferences = BarPreferences(),
|
||||||
notifier: NotificationDelivering? = nil
|
notifier: NotificationDelivering? = nil,
|
||||||
|
probe: BarServerProbe = BarServerProbe(),
|
||||||
|
launcher: BarServerLauncher? = nil
|
||||||
) {
|
) {
|
||||||
self.home = home
|
self.home = home
|
||||||
self.prefs = prefs
|
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()
|
prefs.registerDefaults()
|
||||||
// Default to the real UN-backed notifier; tests inject a recording one.
|
|
||||||
self.notifier = notifier ?? BarNotifier()
|
self.notifier = notifier ?? BarNotifier()
|
||||||
|
self.probe = probe
|
||||||
|
self.launcher = launcher ?? BarServerLauncher(home: home)
|
||||||
self.iconStyle = MenuBarIcon.loadStyle()
|
self.iconStyle = MenuBarIcon.loadStyle()
|
||||||
self.appearance = BarAppearanceStore.load()
|
self.appearance = BarAppearanceStore.load()
|
||||||
self.glanceMode = prefs.load().glanceMode
|
self.glanceMode = prefs.load().glanceMode
|
||||||
@@ -67,24 +75,27 @@ final class BarViewModel: ObservableObject {
|
|||||||
startBackgroundPolling()
|
startBackgroundPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periodically re-poll for the app's lifetime so a transient empty/missing-row
|
// MARK: - Background polling
|
||||||
/// 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
|
/// Periodically re-probe for the app's lifetime so a transient empty/missing-row
|
||||||
/// server-side caches). This is what prevents the menu from getting stuck after
|
/// state recovers on its own within one interval. Each tick re-probes the
|
||||||
/// the server momentarily restarts.
|
/// network (not just re-reads bar.json) so a server that moved ports is found.
|
||||||
private func startBackgroundPolling() {
|
private func startBackgroundPolling() {
|
||||||
pollTask?.cancel()
|
pollTask?.cancel()
|
||||||
pollTask = Task { [weak self] in
|
pollTask = Task { [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(60))
|
try? await Task.sleep(for: .seconds(60))
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
// Re-probe first; if live, reconnect; then load cached data.
|
||||||
|
await self.reconnectIfNeeded()
|
||||||
await self.load(force: false)
|
await self.load(force: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read prefs after the preferences sheet writes through, so the next poll
|
// MARK: - Prefs
|
||||||
/// (and the live title) reflects the change immediately.
|
|
||||||
|
/// Re-read prefs after the preferences sheet writes through.
|
||||||
func reloadPrefs() {
|
func reloadPrefs() {
|
||||||
glanceMode = prefs.load().glanceMode
|
glanceMode = prefs.load().glanceMode
|
||||||
}
|
}
|
||||||
@@ -94,44 +105,136 @@ final class BarViewModel: ObservableObject {
|
|||||||
iconStyle = (iconStyle == .color) ? .mono : .color
|
iconStyle = (iconStyle == .color) ? .mono : .color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Status title
|
||||||
|
|
||||||
/// Compact status-bar title, resolved through the user's chosen glance mode.
|
/// Compact status-bar title, resolved through the user's chosen glance mode.
|
||||||
var statusTitle: String {
|
var statusTitle: String {
|
||||||
offline
|
if isStarting { return "CCS starting…" }
|
||||||
|
return offline
|
||||||
? "CCS offline"
|
? "CCS offline"
|
||||||
: BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode)
|
: BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the discovery file and (re)build the client. Marks offline when
|
// MARK: - Connection / discovery
|
||||||
/// CCS hasn't been launched.
|
|
||||||
|
/// 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() {
|
func reconnect() {
|
||||||
switch BarDiscovery.load(home: home) {
|
switch BarDiscovery.load(home: home) {
|
||||||
case .success(let discovery):
|
case .success(let discovery):
|
||||||
if let url = discovery.resolvedURL {
|
if let url = discovery.resolvedURL {
|
||||||
client = CCSBarClient(baseURL: url)
|
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 {
|
} else {
|
||||||
client = nil
|
client = nil
|
||||||
offline = true
|
|
||||||
}
|
}
|
||||||
case .failure:
|
case .failure:
|
||||||
client = nil
|
client = nil
|
||||||
offline = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Menu opened: cached rows are already on screen; fire a debounced
|
/// Full async reconnect: probe the network for a live server, build a client
|
||||||
/// force-refresh so the glance reflects live provider data.
|
/// when found, launch the server if not found. Updates `isStarting`/`offline`.
|
||||||
func onOpen() {
|
///
|
||||||
let force = debouncer.shouldRefresh(now: Date())
|
/// This is the entry point called by `onOpen()` and the background poll.
|
||||||
Task { await load(force: force) }
|
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 {
|
/// Core sequence: probe → if live build client + load; else launch + poll → load.
|
||||||
if client == nil { reconnect() }
|
private func connectSequence(force: Bool) async {
|
||||||
guard let client else {
|
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
|
offline = true
|
||||||
return
|
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 }
|
if force { isRefreshing = true }
|
||||||
defer { isRefreshing = false }
|
defer { isRefreshing = false }
|
||||||
do {
|
do {
|
||||||
@@ -154,11 +257,9 @@ final class BarViewModel: ObservableObject {
|
|||||||
evaluateAlerts()
|
evaluateAlerts()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the pure rule engine once per poll against the freshly-loaded state,
|
// MARK: - Alert engine
|
||||||
/// deliver any new notifications, and overwrite the persisted fired-key set
|
|
||||||
/// verbatim. The engine's dedupe means repeated polls with unchanged state
|
/// Run the pure rule engine once per poll against the freshly-loaded state.
|
||||||
/// deliver nothing, so this never spams. Delivery is best-effort and never
|
|
||||||
/// blocks the UI (the notifier hops to its own task).
|
|
||||||
private func evaluateAlerts() {
|
private func evaluateAlerts() {
|
||||||
let current = prefs.load()
|
let current = prefs.load()
|
||||||
let eval = BarAlertEngine.evaluate(
|
let eval = BarAlertEngine.evaluate(
|
||||||
@@ -170,13 +271,11 @@ final class BarViewModel: ObservableObject {
|
|||||||
for notification in eval.toDeliver {
|
for notification in eval.toDeliver {
|
||||||
notifier.deliver(notification)
|
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
|
prefs.firedKeys = eval.firedKeys
|
||||||
activeAlerts = eval.toDeliver
|
activeAlerts = eval.toDeliver
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Account actions
|
// MARK: - Account actions
|
||||||
|
|
||||||
func pause(_ row: BarSummaryRow) {
|
func pause(_ row: BarSummaryRow) {
|
||||||
perform { try await $0.pause(provider: row.provider, accountId: row.accountId) }
|
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) }
|
perform { try await $0.solo(provider: row.provider, accountId: row.accountId) }
|
||||||
}
|
}
|
||||||
func setDefault(_ row: BarSummaryRow) {
|
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) }
|
perform { try await $0.setDefault(name: row.id) }
|
||||||
}
|
}
|
||||||
func tierLock(_ row: BarSummaryRow, tier: String?) {
|
func tierLock(_ row: BarSummaryRow, tier: String?) {
|
||||||
@@ -213,3 +309,14 @@ final class BarViewModel: ObservableObject {
|
|||||||
String(describing: error)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
// Outside a bundle, string() must not crash and returns nil (no assertion on value — bundle absent).
|
||||||
let _ = BarVersionDisplay.string()
|
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
|
// cleanup
|
||||||
try? FileManager.default.removeItem(atPath: tmp)
|
try? FileManager.default.removeItem(atPath: tmp)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user