chore: rename relay to gateway
This commit is contained in:
@@ -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)")!
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the relay’s loopback web chat on this port.
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ final class HealthStore: ObservableObject {
|
||||
return "The gateway control port (127.0.0.1:18789) isn’t 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 we’ll 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user