mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 10:16:34 +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
164 lines
5.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|