Files
Tam Nhu Tran 64fbf2e062 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
2026-06-15 17:13:58 -04:00

164 lines
5.5 KiB
Swift

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
}
}
}