163 lines
6.2 KiB
Swift
163 lines
6.2 KiB
Swift
import Foundation
|
|
import ClawdisIPC
|
|
|
|
// Lightweight SemVer helper (major.minor.patch only) for relay compatibility checks.
|
|
struct Semver: Comparable, CustomStringConvertible, Sendable {
|
|
let major: Int
|
|
let minor: Int
|
|
let patch: Int
|
|
|
|
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
|
|
|
static func < (lhs: Semver, rhs: Semver) -> Bool {
|
|
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
|
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
|
return lhs.patch < rhs.patch
|
|
}
|
|
|
|
static func parse(_ raw: String?) -> Semver? {
|
|
guard let raw, !raw.isEmpty else { return nil }
|
|
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
|
|
let parts = cleaned.split(separator: ".")
|
|
guard parts.count >= 3,
|
|
let major = Int(parts[0]),
|
|
let minor = Int(parts[1])
|
|
else { return nil }
|
|
let patch = Int(parts[2]) ?? 0
|
|
return Semver(major: major, minor: minor, patch: patch)
|
|
}
|
|
|
|
func compatible(with required: Semver) -> Bool {
|
|
// Same major and not older than required.
|
|
self.major == required.major && self >= required
|
|
}
|
|
}
|
|
|
|
enum RelayEnvironmentKind: Equatable {
|
|
case checking
|
|
case ok
|
|
case missingNode
|
|
case missingRelay
|
|
case incompatible(found: String, required: String)
|
|
case error(String)
|
|
}
|
|
|
|
struct RelayEnvironmentStatus: Equatable {
|
|
let kind: RelayEnvironmentKind
|
|
let nodeVersion: String?
|
|
let relayVersion: String?
|
|
let requiredRelay: String?
|
|
let message: String
|
|
|
|
static var checking: Self {
|
|
.init(kind: .checking, nodeVersion: nil, relayVersion: nil, requiredRelay: nil, message: "Checking…")
|
|
}
|
|
}
|
|
|
|
struct RelayCommandResolution {
|
|
let status: RelayEnvironmentStatus
|
|
let command: [String]?
|
|
}
|
|
|
|
enum RelayEnvironment {
|
|
static func gatewayPort() -> Int {
|
|
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
|
return stored > 0 ? stored : 18789
|
|
}
|
|
|
|
static func expectedRelayVersion() -> Semver? {
|
|
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
|
return Semver.parse(bundleVersion)
|
|
}
|
|
|
|
static func check() -> RelayEnvironmentStatus {
|
|
let expected = self.expectedRelayVersion()
|
|
|
|
switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) {
|
|
case let .failure(err):
|
|
return RelayEnvironmentStatus(
|
|
kind: .missingNode,
|
|
nodeVersion: nil,
|
|
relayVersion: nil,
|
|
requiredRelay: expected?.description,
|
|
message: RuntimeLocator.describeFailure(err))
|
|
case let .success(runtime):
|
|
guard let relayBin = CommandResolver.clawdisExecutable() else {
|
|
return RelayEnvironmentStatus(
|
|
kind: .missingRelay,
|
|
nodeVersion: runtime.version.description,
|
|
relayVersion: nil,
|
|
requiredRelay: expected?.description,
|
|
message: "clawdis CLI not found in PATH; install the global package.")
|
|
}
|
|
|
|
let installedRelay = self.readRelayVersion(binary: relayBin)
|
|
if let expected, let installed = installedRelay, !installed.compatible(with: expected) {
|
|
return RelayEnvironmentStatus(
|
|
kind: .incompatible(found: installed.description, required: expected.description),
|
|
nodeVersion: runtime.version.description,
|
|
relayVersion: installed.description,
|
|
requiredRelay: expected.description,
|
|
message: "Relay version \(installed.description) is incompatible with app \(expected.description); install/update the global package.")
|
|
}
|
|
|
|
return RelayEnvironmentStatus(
|
|
kind: .ok,
|
|
nodeVersion: runtime.version.description,
|
|
relayVersion: installedRelay?.description,
|
|
requiredRelay: expected?.description,
|
|
message: "Node \(runtime.version.description); relay \(installedRelay?.description ?? "unknown")")
|
|
}
|
|
}
|
|
|
|
static func resolveGatewayCommand() -> RelayCommandResolution {
|
|
let status = self.check()
|
|
guard case .ok = status.kind, let relayBin = CommandResolver.clawdisExecutable() else {
|
|
return RelayCommandResolution(status: status, command: nil)
|
|
}
|
|
|
|
let port = self.gatewayPort()
|
|
let cmd = [relayBin, "gateway", "--port", "\(port)"]
|
|
return RelayCommandResolution(status: status, command: cmd)
|
|
}
|
|
|
|
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
|
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
|
let target = version?.description ?? "latest"
|
|
let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm"
|
|
let cmd = [pnpm, "add", "-g", "clawdis@\(target)"]
|
|
|
|
statusHandler("Installing clawdis@\(target) via pnpm…")
|
|
let response = await ShellRunner.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
|
if response.ok {
|
|
statusHandler("Installed clawdis@\(target)")
|
|
} else {
|
|
let detail = response.message ?? "install failed"
|
|
statusHandler("Install failed: \(detail)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Internals
|
|
|
|
private static func readRelayVersion(binary: String) -> Semver? {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: binary)
|
|
process.arguments = ["--version"]
|
|
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
|
|
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = pipe
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return Semver.parse(raw)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
}
|