Mac: launch gateway and add relay installer

This commit is contained in:
Peter Steinberger
2025-12-09 16:15:53 +00:00
parent 96be7c8990
commit e40f9c9730
12 changed files with 616 additions and 73 deletions

View File

@@ -13,9 +13,8 @@ actor AgentRPC {
private var configured = false
private var gatewayURL: URL {
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
let effectivePort = port > 0 ? port : 18789
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
let port = RelayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")!
}
private var gatewayToken: String? {

View File

@@ -57,9 +57,8 @@ final class ControlChannel: ObservableObject {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let gateway = GatewayChannel()
private var gatewayURL: URL {
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
let effectivePort = port > 0 ? port : 18789
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
let port = RelayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")!
}
private var gatewayToken: String? {

View File

@@ -8,6 +8,9 @@ struct GeneralSettings: View {
@State private var cliStatus: String?
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var relayStatus: RelayEnvironmentStatus = .checking
@State private var relayInstallMessage: String?
@State private var relayInstalling = false
@State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false
@@ -61,7 +64,10 @@ struct GeneralSettings: View {
.padding(.horizontal, 22)
.padding(.bottom, 16)
}
.onAppear { self.refreshCLIStatus() }
.onAppear {
self.refreshCLIStatus()
self.refreshRelayStatus()
}
}
private var activeBinding: Binding<Bool> {
@@ -84,6 +90,7 @@ struct GeneralSettings: View {
.frame(width: 380, alignment: .leading)
if self.state.connectionMode == .local {
self.relayInstallerCard
self.healthRow
}
@@ -239,6 +246,64 @@ struct GeneralSettings: View {
}
}
private var relayInstallerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Circle()
.fill(self.relayStatusColor)
.frame(width: 10, height: 10)
Text(self.relayStatus.message)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let relayVersion = self.relayStatus.relayVersion,
let required = self.relayStatus.requiredRelay,
relayVersion != required
{
Text("Installed: \(relayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion {
Text("Relay \(relayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.relayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
Button {
Task { await self.installRelay() }
} label: {
if self.relayInstalling {
ProgressView().controlSize(.small)
} else {
Text("Install/Update relay")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.relayInstalling)
Button("Recheck") { self.refreshRelayStatus() }
.buttonStyle(.bordered)
.disabled(self.relayInstalling)
}
Text(self.relayInstallMessage ?? "Installs the global \"clawdis\" package and expects the gateway on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
}
private func installCLI() async {
guard !self.isInstallingCLI else { return }
self.isInstallingCLI = true
@@ -257,6 +322,30 @@ struct GeneralSettings: View {
self.cliInstalled = installLocation != nil
}
private func refreshRelayStatus() {
self.relayStatus = RelayEnvironment.check()
}
private func installRelay() async {
guard !self.relayInstalling else { return }
self.relayInstalling = true
defer { self.relayInstalling = false }
self.relayInstallMessage = nil
let expected = RelayEnvironment.expectedRelayVersion()
await RelayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.relayInstallMessage = message }
}
self.refreshRelayStatus()
}
private var relayStatusColor: Color {
switch self.relayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingRelay, .incompatible, .error: .orange
}
}
private var healthCard: some View {
let snapshot = self.healthStore.snapshot
return VStack(alignment: .leading, spacing: 6) {

View File

@@ -151,15 +151,15 @@ final class HealthStore: ObservableObject {
/// Short, human-friendly detail for the last failure, used in the UI.
var detailLine: String? {
if let error = self.lastError, !error.isEmpty {
let lower = error.lowercased()
if lower.contains("connection refused") {
return "The relay control port (127.0.0.1:18789) isnt listening — restart Clawdis to bring it back."
}
if lower.contains("timeout") {
return "Timed out waiting for the control server; the relay may be crashed or still starting."
}
return error
if let error = self.lastError, !error.isEmpty {
let lower = error.lowercased()
if lower.contains("connection refused") {
return "The gateway control port (127.0.0.1:18789) isnt listening — restart Clawdis to bring it back."
}
if lower.contains("timeout") {
return "Timed out waiting for the control server; the relay may be crashed or still starting."
}
return error
}
return nil
}

View File

@@ -46,13 +46,16 @@ struct OnboardingView: View {
@State private var monitoringPermissions = false
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var relayStatus: RelayEnvironmentStatus = .checking
@State private var relayInstalling = false
@State private var relayInstallMessage: String?
@ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
private let permissionsPageIndex = 2
private var pageCount: Int { 6 }
private let permissionsPageIndex = 3
private var pageCount: Int { 7 }
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
@@ -67,6 +70,7 @@ struct OnboardingView: View {
HStack(spacing: 0) {
self.welcomePage().frame(width: self.pageWidth)
self.connectionPage().frame(width: self.pageWidth)
self.relayPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
@@ -96,6 +100,7 @@ struct OnboardingView: View {
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.refreshRelayStatus()
}
}
@@ -172,6 +177,81 @@ struct OnboardingView: View {
}
}
private func relayPage() -> some View {
self.onboardingPage {
Text("Install the relay")
.font(.largeTitle.weight(.semibold))
Text("Clawdis now runs the WebSocket gateway from the global \"clawdis\" package. Install/update it here and well check Node for you.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10, padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Circle()
.fill(self.relayStatusColor)
.frame(width: 10, height: 10)
Text(self.relayStatus.message)
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}
if let relayVersion = self.relayStatus.relayVersion,
let required = self.relayStatus.requiredRelay,
relayVersion != required
{
Text("Installed: \(relayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion {
Text("Relay \(relayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.relayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.installRelay() }
} label: {
if self.relayInstalling {
ProgressView()
} else {
Text("Install / Update relay")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.relayInstalling)
Button("Recheck") { self.refreshRelayStatus() }
.buttonStyle(.bordered)
.disabled(self.relayInstalling)
}
if let relayInstallMessage {
Text(relayInstallMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text("Uses \"pnpm add -g clawdis@<version>\" on your PATH. We keep the gateway on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
private func permissionsPage() -> some View {
self.onboardingPage {
Text("Grant permissions")
@@ -481,6 +561,30 @@ struct OnboardingView: View {
self.cliInstalled = installLocation != nil
}
private func refreshRelayStatus() {
self.relayStatus = RelayEnvironment.check()
}
private func installRelay() async {
guard !self.relayInstalling else { return }
self.relayInstalling = true
defer { self.relayInstalling = false }
self.relayInstallMessage = nil
let expected = RelayEnvironment.expectedRelayVersion()
await RelayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.relayInstallMessage = message }
}
self.refreshRelayStatus()
}
private var relayStatusColor: Color {
switch self.relayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingRelay, .incompatible, .error: .orange
}
}
private func copyToPasteboard(_ text: String) {
let pb = NSPasteboard.general
pb.clearContents()

View File

@@ -0,0 +1,162 @@
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
}
}
}

View File

@@ -35,6 +35,7 @@ final class RelayProcessManager: ObservableObject {
@Published private(set) var status: Status = .stopped
@Published private(set) var log: String = ""
@Published private(set) var restartCount: Int = 0
@Published private(set) var environmentStatus: RelayEnvironmentStatus = .checking
private var execution: Execution?
private var desiredActive = false
@@ -48,6 +49,7 @@ final class RelayProcessManager: ObservableObject {
func setActive(_ active: Bool) {
self.desiredActive = active
self.refreshEnvironmentStatus()
if active {
self.startIfNeeded()
} else {
@@ -82,10 +84,22 @@ final class RelayProcessManager: ObservableObject {
self.execution = nil
}
func refreshEnvironmentStatus() {
self.environmentStatus = RelayEnvironment.check()
}
// MARK: - Internals
private func spawnRelay() async {
let command = self.resolveCommand()
let resolution = RelayEnvironment.resolveGatewayCommand()
await MainActor.run { self.environmentStatus = resolution.status }
guard let command = resolution.command else {
await MainActor.run {
self.status = .failed(resolution.status.message)
}
return
}
let cwd = self.defaultProjectRoot().path
self.appendLog("[relay] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
@@ -192,29 +206,6 @@ final class RelayProcessManager: ObservableObject {
}
}
private func resolveCommand() -> [String] {
// Force the app-managed relay to use system Node (no bundled runtime, no bun).
let runtimeResult = CommandResolver.runtimeResolution()
guard case let .success(runtime) = runtimeResult else {
if case let .failure(err) = runtimeResult {
return CommandResolver.runtimeErrorCommand(err)
}
return ["/bin/sh", "-c", "echo 'runtime resolution failed' >&2; exit 1"]
}
let relayRoot = CommandResolver.projectRoot()
if let entry = CommandResolver.relayEntrypoint(in: relayRoot) {
return CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "relay",
extraArgs: [])
}
return CommandResolver.errorCommand(
with: "clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.")
}
private func makeEnvironment() -> Environment {
let merged = CommandResolver.preferredPaths().joined(separator: ":")
return .inherit.updating([

View File

@@ -201,12 +201,6 @@ enum CommandResolver {
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
private static let helperName = "clawdis"
private static func bundledRelayRoot() -> URL? {
guard let resource = Bundle.main.resourceURL else { return nil }
let relay = resource.appendingPathComponent("Relay")
return FileManager.default.fileExists(atPath: relay.path) ? relay : nil
}
static func relayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
@@ -244,11 +238,9 @@ enum CommandResolver {
}
static func projectRoot() -> URL {
if let bundled = self.bundledRelayRoot() {
return bundled
}
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored)
let url = self.expandPath(stored),
FileManager.default.fileExists(atPath: url.path)
{
return url
}
@@ -272,16 +264,13 @@ enum CommandResolver {
let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? []
var extras = [
self.projectRoot().appendingPathComponent("node_modules/.bin").path,
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path,
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
]
if let relay = self.bundledRelayRoot() {
extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0)
}
extras.insert(self.projectRoot().appendingPathComponent("node_modules/.bin").path, at: 0)
var seen = Set<String>()
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
return (extras + current).filter { seen.insert($0).inserted }
@@ -327,14 +316,8 @@ enum CommandResolver {
switch runtimeResult {
case let .success(runtime):
if let relay = self.bundledRelayRoot(),
let entry = self.relayEntrypoint(in: relay)
{
return self.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: subcommand,
extraArgs: extraArgs)
if let clawdisPath = self.clawdisExecutable() {
return [clawdisPath, subcommand] + extraArgs
}
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
@@ -344,10 +327,6 @@ enum CommandResolver {
subcommand: subcommand,
extraArgs: extraArgs)
}
if let clawdisPath = self.clawdisExecutable() {
return [clawdisPath, subcommand] + extraArgs
}
if let pnpm = self.findExecutable(named: "pnpm") {
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs