chore: rename relay to gateway

This commit is contained in:
Peter Steinberger
2025-12-09 18:00:01 +00:00
parent bc3a14cde2
commit a3bf2bdd8c
50 changed files with 2022 additions and 2570 deletions

View File

@@ -13,7 +13,7 @@ actor AgentRPC {
private var configured = false
private var gatewayURL: URL {
let port = RelayEnvironment.gatewayPort()
let port = GatewayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")!
}

View File

@@ -111,7 +111,7 @@ struct ConfigSettings: View {
}
Text(
"""
Mac app connects to the relays loopback web chat on this port.
Mac app connects to the gateways loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)

View File

@@ -57,7 +57,7 @@ final class ControlChannel: ObservableObject {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let gateway = GatewayChannel()
private var gatewayURL: URL {
let port = RelayEnvironment.gatewayPort()
let port = GatewayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")!
}
@@ -130,16 +130,16 @@ final class ControlChannel: ObservableObject {
}
if let urlError = error as? URLError {
let port = RelayEnvironment.gatewayPort()
let port = GatewayEnvironment.gatewayPort()
switch urlError.code {
case .cancelled:
return "Gateway connection was closed; start the relay (localhost:\(port)) and retry."
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost:
return "Cannot reach gateway at localhost:\(port); ensure the relay is running."
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
case .networkConnectionLost:
return "Gateway connection dropped; relay likely restarted—retry."
return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut:
return "Gateway request timed out; check relay on localhost:\(port)."
return "Gateway request timed out; check gateway on localhost:\(port)."
case .notConnectedToInternet:
return "No network connectivity; cannot reach gateway."
default:

View File

@@ -7,7 +7,7 @@ struct CritterStatusLabel: View {
var earBoostActive: Bool
var blinkTick: Int
var sendCelebrationTick: Int
var relayStatus: RelayProcessManager.Status
var gatewayStatus: GatewayProcessManager.Status
var animationsEnabled: Bool
var iconState: IconState
@@ -98,9 +98,9 @@ struct CritterStatusLabel: View {
}
}
if self.relayNeedsAttention {
if self.gatewayNeedsAttention {
Circle()
.fill(self.relayBadgeColor)
.fill(self.gatewayBadgeColor)
.frame(width: 8, height: 8)
.offset(x: 4, y: 4)
}
@@ -192,8 +192,8 @@ struct CritterStatusLabel: View {
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
}
private var relayNeedsAttention: Bool {
switch self.relayStatus {
private var gatewayNeedsAttention: Bool {
switch self.gatewayStatus {
case .failed, .stopped:
!self.isPaused
case .starting, .restarting, .running:
@@ -201,8 +201,8 @@ struct CritterStatusLabel: View {
}
}
private var relayBadgeColor: Color {
switch self.relayStatus {
private var gatewayBadgeColor: Color {
switch self.gatewayStatus {
case .failed: .red
case .stopped: .orange
default: .clear

View File

@@ -85,9 +85,9 @@ enum DebugActions {
static func restartGateway() {
Task { @MainActor in
RelayProcessManager.shared.stop()
GatewayProcessManager.shared.stop()
try? await Task.sleep(nanoseconds: 300_000_000)
RelayProcessManager.shared.setActive(true)
GatewayProcessManager.shared.setActive(true)
}
}

View File

@@ -10,9 +10,9 @@ struct DebugSettings: View {
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@ObservedObject private var relayManager = RelayProcessManager.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@State private var relayRootInput: String = RelayProcessManager.shared.projectRootPath()
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?
@State private var debugSendInFlight = false
@@ -53,19 +53,19 @@ struct DebugSettings: View {
.textSelection(.enabled)
}
LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) }
LabeledContent("Relay status") {
LabeledContent("Gateway status") {
VStack(alignment: .leading, spacing: 2) {
Text(self.relayManager.status.label)
Text("Restarts: \(self.relayManager.restartCount)")
Text(self.gatewayManager.status.label)
Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Relay stdout/stderr")
Text("Gateway stdout/stderr")
.font(.caption.weight(.semibold))
ScrollView {
Text(self.relayManager.log.isEmpty ? "" : self.relayManager.log)
Text(self.gatewayManager.log.isEmpty ? "" : self.gatewayManager.log)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
@@ -77,7 +77,7 @@ struct DebugSettings: View {
Text("Clawdis project root")
.font(.caption.weight(.semibold))
HStack(spacing: 8) {
TextField("Path to clawdis repo", text: self.$relayRootInput)
TextField("Path to clawdis repo", text: self.$gatewayRootInput)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.onSubmit { self.saveRelayRoot() }
@@ -86,12 +86,12 @@ struct DebugSettings: View {
Button("Reset") {
let def = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdis").path
self.relayRootInput = def
self.gatewayRootInput = def
self.saveRelayRoot()
}
.buttonStyle(.bordered)
}
Text("Used for pnpm/node fallback and PATH population when launching the relay.")
Text("Used for pnpm/node fallback and PATH population when launching the gateway.")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -281,7 +281,7 @@ struct DebugSettings: View {
}
private func saveRelayRoot() {
RelayProcessManager.shared.setProjectRoot(path: self.relayRootInput)
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
}
private func loadSessionStorePath() {

View File

@@ -8,9 +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 gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstallMessage: String?
@State private var gatewayInstalling = false
@State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -68,7 +68,7 @@ struct GeneralSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.refreshCLIStatus()
self.refreshRelayStatus()
self.refreshGatewayStatus()
}
}
@@ -92,7 +92,7 @@ struct GeneralSettings: View {
.frame(width: 380, alignment: .leading)
if self.state.connectionMode == .local {
self.relayInstallerCard
self.gatewayInstallerCard
self.healthRow
}
@@ -248,31 +248,31 @@ struct GeneralSettings: View {
}
}
private var relayInstallerCard: some View {
private var gatewayInstallerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Circle()
.fill(self.relayStatusColor)
.fill(self.gatewayStatusColor)
.frame(width: 10, height: 10)
Text(self.relayStatus.message)
Text(self.gatewayStatus.message)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let relayVersion = self.relayStatus.relayVersion,
let required = self.relayStatus.requiredRelay,
relayVersion != required
if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.gatewayStatus.requiredGateway,
gatewayVersion != required
{
Text("Installed: \(relayVersion) · Required: \(required)")
Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion {
Text("Relay \(relayVersion) detected")
} else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Gateway \(gatewayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.relayStatus.nodeVersion {
if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
@@ -280,24 +280,24 @@ struct GeneralSettings: View {
HStack(spacing: 10) {
Button {
Task { await self.installRelay() }
Task { await self.installGateway() }
} label: {
if self.relayInstalling {
if self.gatewayInstalling {
ProgressView().controlSize(.small)
} else {
Text("Install/Update relay")
Text("Install/Update gateway")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.relayInstalling)
.disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshRelayStatus() }
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
.disabled(self.relayInstalling)
.disabled(self.gatewayInstalling)
}
Text(self
.relayInstallMessage ??
.gatewayInstallMessage ??
"Installs the global \"clawdis\" package and expects the gateway on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -326,27 +326,27 @@ struct GeneralSettings: View {
self.cliInstalled = installLocation != nil
}
private func refreshRelayStatus() {
self.relayStatus = RelayEnvironment.check()
private func refreshGatewayStatus() {
self.gatewayStatus = GatewayEnvironment.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 }
private func installGateway() async {
guard !self.gatewayInstalling else { return }
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
self.refreshRelayStatus()
self.refreshGatewayStatus()
}
private var relayStatusColor: Color {
switch self.relayStatus.kind {
private var gatewayStatusColor: Color {
switch self.gatewayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingRelay, .incompatible, .error: .orange
case .missingNode, .missingGateway, .incompatible, .error: .orange
}
}

View File

@@ -157,7 +157,7 @@ final class HealthStore: ObservableObject {
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 "Timed out waiting for the control server; the gateway may be crashed or still starting."
}
return error
}

View File

@@ -129,7 +129,7 @@ final class InstancesStore: ObservableObject {
self.logger.error("instances fetch returned empty payload")
self.instances = [self.localFallbackInstance(reason: "no presence payload")]
self.lastError = nil
self.statusMessage = "No presence payload from relay; showing local fallback + health probe."
self.statusMessage = "No presence payload from gateway; showing local fallback + health probe."
await self.probeHealthIfNeeded(reason: "no payload")
return
}
@@ -255,7 +255,7 @@ final class InstancesStore: ObservableObject {
guard let snap = decodeHealthSnapshot(from: data) else { return }
let entry = InstanceInfo(
id: "health-\(snap.ts)",
host: "relay (health)",
host: "gateway (health)",
ip: nil,
version: nil,
lastInputSeconds: nil,
@@ -317,14 +317,14 @@ extension InstancesStore {
text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3",
ts: Date().timeIntervalSince1970 * 1000),
InstanceInfo(
id: "relay",
host: "relay",
id: "gateway",
host: "gateway",
ip: "100.64.0.2",
version: "1.2.3",
lastInputSeconds: 45,
mode: "remote",
reason: "preview",
text: "Relay node · tunnel ok",
text: "Gateway node · tunnel ok",
ts: Date().timeIntervalSince1970 * 1000 - 45000),
]) -> InstancesStore {
let store = InstancesStore(isPreview: true)

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState
@StateObject private var relayManager = RelayProcessManager.shared
@StateObject private var gatewayManager = GatewayProcessManager.shared
@StateObject private var activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@@ -27,7 +27,7 @@ struct ClawdisApp: App {
earBoostActive: self.state.earBoostActive,
blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick,
relayStatus: self.relayManager.status,
gatewayStatus: self.gatewayManager.status,
animationsEnabled: self.state.iconAnimationsEnabled,
iconState: self.effectiveIconState)
}
@@ -38,7 +38,7 @@ struct ClawdisApp: App {
}
.onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused)
self.relayManager.setActive(!paused)
self.gatewayManager.setActive(!paused)
}
Settings {
@@ -86,7 +86,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
self.state = AppStateStore.shared
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
if let state {
RelayProcessManager.shared.setActive(!state.isPaused)
GatewayProcessManager.shared.setActive(!state.isPaused)
}
Task {
await ControlChannel.shared.configure()
@@ -104,7 +104,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
}
func applicationWillTerminate(_ notification: Notification) {
RelayProcessManager.shared.stop()
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
WebChatManager.shared.close()
Task { await AgentRPC.shared.shutdown() }

View File

@@ -7,7 +7,7 @@ import SwiftUI
struct MenuContent: View {
@ObservedObject var state: AppState
let updater: UpdaterProviding?
@ObservedObject private var relayManager = RelayProcessManager.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared

View File

@@ -46,9 +46,9 @@ 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?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
@ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
@@ -70,7 +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.gatewayPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
@@ -100,7 +100,7 @@ struct OnboardingView: View {
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.refreshRelayStatus()
self.refreshGatewayStatus()
}
}
@@ -177,9 +177,9 @@ struct OnboardingView: View {
}
}
private func relayPage() -> some View {
private func gatewayPage() -> some View {
self.onboardingPage {
Text("Install the relay")
Text("Install the gateway")
.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.")
@@ -193,27 +193,27 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Circle()
.fill(self.relayStatusColor)
.fill(self.gatewayStatusColor)
.frame(width: 10, height: 10)
Text(self.relayStatus.message)
Text(self.gatewayStatus.message)
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}
if let relayVersion = self.relayStatus.relayVersion,
let required = self.relayStatus.requiredRelay,
relayVersion != required
if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.gatewayStatus.requiredGateway,
gatewayVersion != required
{
Text("Installed: \(relayVersion) · Required: \(required)")
Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion {
Text("Relay \(relayVersion) detected")
} else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Gateway \(gatewayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.relayStatus.nodeVersion {
if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
@@ -221,24 +221,24 @@ struct OnboardingView: View {
HStack(spacing: 12) {
Button {
Task { await self.installRelay() }
Task { await self.installGateway() }
} label: {
if self.relayInstalling {
if self.gatewayInstalling {
ProgressView()
} else {
Text("Install / Update relay")
Text("Install / Update gateway")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.relayInstalling)
.disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshRelayStatus() }
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
.disabled(self.relayInstalling)
.disabled(self.gatewayInstalling)
}
if let relayInstallMessage {
Text(relayInstallMessage)
if let gatewayInstallMessage {
Text(gatewayInstallMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -350,7 +350,7 @@ struct OnboardingView: View {
.font(.largeTitle.weight(.semibold))
Text(
"""
Run `clawdis login` where the relay runs (local if local mode, remote if remote).
Run `clawdis login` where the gateway runs (local if local mode, remote if remote).
Scan the QR to pair your account.
""")
.font(.body)
@@ -368,7 +368,7 @@ struct OnboardingView: View {
title: "Run `clawdis login --verbose`",
subtitle: """
Scan the QR code with WhatsApp on your phone.
We only use your personal session; no cloud relay involved.
We only use your personal session; no cloud gateway involved.
""",
systemImage: "qrcode.viewfinder")
self.featureRow(
@@ -568,27 +568,27 @@ struct OnboardingView: View {
self.cliInstalled = installLocation != nil
}
private func refreshRelayStatus() {
self.relayStatus = RelayEnvironment.check()
private func refreshGatewayStatus() {
self.gatewayStatus = GatewayEnvironment.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 }
private func installGateway() async {
guard !self.gatewayInstalling else { return }
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
self.refreshRelayStatus()
self.refreshGatewayStatus()
}
private var relayStatusColor: Color {
switch self.relayStatus.kind {
private var gatewayStatusColor: Color {
switch self.gatewayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingRelay, .incompatible, .error: .orange
case .missingNode, .missingGateway, .incompatible, .error: .orange
}
}

View File

@@ -1,196 +0,0 @@
import ClawdisIPC
import Foundation
// 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()
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.relayEntrypoint(in: projectRoot)
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):
let relayBin = CommandResolver.clawdisExecutable()
if relayBin == nil, projectEntrypoint == nil {
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 = relayBin.flatMap { self.readRelayVersion(binary: $0) }
?? self.readLocalRelayVersion(projectRoot: projectRoot)
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.")
}
let relayLabel = relayBin != nil ? "global" : "local"
let relayVersionText = installedRelay?.description ?? "unknown"
return RelayEnvironmentStatus(
kind: .ok,
nodeVersion: runtime.version.description,
relayVersion: relayVersionText,
requiredRelay: expected?.description,
message: "Node \(runtime.version.description); relay \(relayVersionText) (\(relayLabel))")
}
}
static func resolveGatewayCommand() -> RelayCommandResolution {
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.relayEntrypoint(in: projectRoot)
let status = self.check()
let relayBin = CommandResolver.clawdisExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else {
return RelayCommandResolution(status: status, command: nil)
}
let port = self.gatewayPort()
if let relayBin {
let cmd = [relayBin, "gateway", "--port", "\(port)"]
return RelayCommandResolution(status: status, command: cmd)
}
if let entry = projectEntrypoint,
case let .success(resolvedRuntime) = runtime
{
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
return RelayCommandResolution(status: status, command: cmd)
}
return RelayCommandResolution(status: status, command: nil)
}
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 ShellExecutor.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
}
}
private static func readLocalRelayVersion(projectRoot: URL) -> Semver? {
let pkg = projectRoot.appendingPathComponent("package.json")
guard let data = try? Data(contentsOf: pkg) else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = json["version"] as? String
else { return nil }
return Semver.parse(version)
}
}

View File

@@ -1,230 +0,0 @@
import Foundation
import OSLog
import Subprocess
#if canImport(Darwin)
import Darwin
#endif
#if canImport(System)
import System
#else
import SystemPackage
#endif
@MainActor
final class RelayProcessManager: ObservableObject {
static let shared = RelayProcessManager()
enum Status: Equatable {
case stopped
case starting
case running(pid: Int32)
case restarting
case failed(String)
var label: String {
switch self {
case .stopped: "Stopped"
case .starting: "Starting…"
case let .running(pid): "Running (pid \(pid))"
case .restarting: "Restarting…"
case let .failed(reason): "Failed: \(reason)"
}
}
}
@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
private var stopping = false
private var recentCrashes: [Date] = []
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "relay")
private let logLimit = 20000 // characters to keep in-memory
private let maxCrashes = 3
private let crashWindow: TimeInterval = 120 // seconds
func setActive(_ active: Bool) {
self.desiredActive = active
self.refreshEnvironmentStatus()
if active {
self.startIfNeeded()
} else {
self.stop()
}
}
func startIfNeeded() {
guard self.execution == nil, self.desiredActive else { return }
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; giving up")
return
}
self.status = self.status == .restarting ? .restarting : .starting
Task.detached { [weak self] in
guard let self else { return }
await self.spawnRelay()
}
}
func stop() {
self.desiredActive = false
self.stopping = true
guard let execution else {
self.status = .stopped
return
}
self.status = .stopped
Task {
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))])
}
self.execution = nil
}
func refreshEnvironmentStatus() {
self.environmentStatus = RelayEnvironment.check()
}
// MARK: - Internals
private func spawnRelay() async {
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")
do {
let result = try await run(
.name(command.first ?? "clawdis"),
arguments: Arguments(Array(command.dropFirst())),
environment: self.makeEnvironment(),
workingDirectory: FilePath(cwd))
{ execution, stdin, stdout, stderr in
self.didStart(execution)
// Consume stdout/stderr eagerly so the relay can't block on full pipes.
async let out: Void = self.stream(output: stdout, label: "stdout")
async let err: Void = self.stream(output: stderr, label: "stderr")
try await stdin.finish()
await out
await err
}
await self.handleTermination(status: result.terminationStatus)
} catch {
await self.handleError(error)
}
}
private func didStart(_ execution: Execution) {
self.execution = execution
self.stopping = false
self.status = .running(pid: execution.processIdentifier.value)
self.logger.info("relay started pid \(execution.processIdentifier.value)")
}
private func handleTermination(status: TerminationStatus) async {
let code: Int32 = switch status {
case let .exited(exitCode): exitCode
case let .unhandledException(sig): -Int32(sig)
}
self.execution = nil
if self.stopping || !self.desiredActive {
self.status = .stopped
self.stopping = false
return
}
self.recentCrashes.append(Date())
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
self.restartCount += 1
self.appendLog("[relay] exited (\(code)).\n")
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; stopped auto-restart.")
self.logger.error("relay crash loop detected; giving up")
return
}
self.status = .restarting
self.logger.warning("relay crashed (code \(code)); restarting")
// Slight backoff to avoid hammering the system in case of immediate crash-on-start.
try? await Task.sleep(nanoseconds: 750_000_000)
self.startIfNeeded()
}
private func handleError(_ error: any Error) async {
self.execution = nil
var message = error.localizedDescription
if let sp = error as? SubprocessError {
message = "SubprocessError \(sp.code.value): \(sp)"
}
self.appendLog("[relay] failed: \(message)\n")
self.logger.error("relay failed: \(message, privacy: .public)")
if self.desiredActive, !self.shouldGiveUpAfterCrashes() {
self.status = .restarting
self.recentCrashes.append(Date())
self.startIfNeeded()
} else {
self.status = .failed(error.localizedDescription)
}
}
private func shouldGiveUpAfterCrashes() -> Bool {
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
return self.recentCrashes.count >= self.maxCrashes
}
private func stream(output: AsyncBufferSequence, label: String) async {
do {
for try await line in output.lines() {
await MainActor.run {
self.appendLog(line + "\n")
}
}
} catch {
await MainActor.run {
self.appendLog("[relay \(label)] stream error: \(error.localizedDescription)\n")
}
}
}
private func appendLog(_ chunk: String) {
self.log.append(chunk)
if self.log.count > self.logLimit {
self.log = String(self.log.suffix(self.logLimit))
}
}
private func makeEnvironment() -> Environment {
let merged = CommandResolver.preferredPaths().joined(separator: ":")
return .inherit.updating([
"PATH": merged,
"PNPM_HOME": FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/pnpm").path,
"CLAWDIS_PROJECT_ROOT": CommandResolver.projectRoot().path,
])
}
private func defaultProjectRoot() -> URL {
CommandResolver.projectRoot()
}
func setProjectRoot(path: String) {
CommandResolver.setProjectRoot(path)
}
func projectRootPath() -> String {
CommandResolver.projectRootPath()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -204,10 +204,10 @@ enum CLIInstaller {
}
enum CommandResolver {
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
private static let helperName = "clawdis"
static func relayEntrypoint(in root: URL) -> String? {
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdis.js").path
@@ -326,7 +326,7 @@ enum CommandResolver {
return [clawdisPath, subcommand] + extraArgs
}
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
if let entry = self.gatewayEntrypoint(in: self.projectRoot()) {
return self.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,

View File

@@ -26,8 +26,8 @@ import Testing
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
try self.makeExec(at: clawdisPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
let cmd = CommandResolver.clawdisCommand(subcommand: "gateway")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "gateway"]))
}
@Test func fallsBackToNodeAndScript() async throws {