mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
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
115 lines
3.8 KiB
Swift
115 lines
3.8 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|