From 4e74ba996daac7d643448c4f3415a9f3966854f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 02:20:48 +0100 Subject: [PATCH] feat(macos): add unconfigured gateway mode --- .../Clawdis/AnthropicAuthControls.swift | 8 +- apps/macos/Sources/Clawdis/AppState.swift | 10 ++- .../Clawdis/ConnectionModeCoordinator.swift | 8 ++ .../Sources/Clawdis/ControlChannel.swift | 6 ++ apps/macos/Sources/Clawdis/DebugActions.swift | 3 + .../Clawdis/GatewayEndpointStore.swift | 20 ++++- .../Sources/Clawdis/GeneralSettings.swift | 18 +++- .../Sources/Clawdis/MenuContentView.swift | 15 +++- apps/macos/Sources/Clawdis/Onboarding.swift | 85 +++++++++++++------ apps/macos/Sources/Clawdis/PortGuardian.swift | 32 ++++--- apps/macos/Sources/Clawdis/Utilities.swift | 10 ++- .../GatewayEndpointStoreTests.swift | 14 +++ .../MenuContentSmokeTests.swift | 8 +- 13 files changed, 188 insertions(+), 49 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift index 6b5ae05bf..e6ee42049 100644 --- a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift +++ b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift @@ -19,8 +19,8 @@ struct AnthropicAuthControls: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - if self.connectionMode == .remote { - Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.") + if self.connectionMode != .local { + Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -64,7 +64,7 @@ struct AnthropicAuthControls: View { } } .buttonStyle(.borderedProminent) - .disabled(self.connectionMode == .remote || self.busy) + .disabled(self.connectionMode != .local || self.busy) if self.pkce != nil { Button("Cancel") { @@ -101,7 +101,7 @@ struct AnthropicAuthControls: View { Task { await self.finishOAuth() } } .buttonStyle(.bordered) - .disabled(self.busy || self.connectionMode == .remote || self.code + .disabled(self.busy || self.connectionMode != .local || self.code .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty) } diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 7d1e111a4..9e80b3d96 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -17,6 +17,7 @@ final class AppState { } enum ConnectionMode: String { + case unconfigured case local case remote } @@ -182,9 +183,10 @@ final class AppState { init(preview: Bool = false) { self.isPreview = preview + let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.launchAtLogin = false - self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") + self.onboardingSeen = onboardingSeen self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled") let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false @@ -225,7 +227,11 @@ final class AppState { } let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) - self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local + if let storedMode { + self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local + } else { + self.connectionMode = onboardingSeen ? .local : .unconfigured + } self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" diff --git a/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift b/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift index 3fe35f5ff..65a74e2cb 100644 --- a/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift @@ -11,6 +11,14 @@ final class ConnectionModeCoordinator { /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. func apply(mode: AppState.ConnectionMode, paused: Bool) async { switch mode { + case .unconfigured: + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + GatewayProcessManager.shared.stop() + await GatewayConnection.shared.shutdown() + await ControlChannel.shared.disconnect() + Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } + case .local: await RemoteTunnelManager.shared.stopAll() WebChatManager.shared.resetTunnels() diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 92decf315..1e210fa5d 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -92,6 +92,12 @@ final class ControlChannel { } } + func disconnect() async { + await GatewayConnection.shared.shutdown() + self.state = .disconnected + self.lastPingMs = nil + } + func health(timeout: TimeInterval? = nil) async throws -> Data { do { let start = Date() diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 12da3d524..a87ddb6dd 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -100,6 +100,9 @@ enum DebugActions { // ControlChannel will surface a degraded state; also refresh health to update the menu text. Task { await HealthStore.shared.refresh(onDemand: true) } } + case .unconfigured: + await GatewayConnection.shared.shutdown() + await ControlChannel.shared.disconnect() } } } diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 20cc38d85..192eb3838 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -37,8 +37,24 @@ actor GatewayEndpointStore { init(deps: Deps = .live) { self.deps = deps + let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) + let initialMode: AppState.ConnectionMode + if let modeRaw { + initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local + } else { + let seen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") + initialMode = seen ? .local : .unconfigured + } + let port = deps.localPort() - self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token()) + switch initialMode { + case .local: + self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token()) + case .remote: + self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") + case .unconfigured: + self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") + } } func subscribe(bufferingNewest: Int = 1) -> AsyncStream { @@ -72,6 +88,8 @@ actor GatewayEndpointStore { return } self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token)) + case .unconfigured: + self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) } } diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index ee03ed068..0a389f1da 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -111,12 +111,20 @@ struct GeneralSettings: View { .frame(maxWidth: .infinity, alignment: .leading) Picker("", selection: self.$state.connectionMode) { + Text("Not configured").tag(AppState.ConnectionMode.unconfigured) Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Remote over SSH").tag(AppState.ConnectionMode.remote) } .pickerStyle(.segmented) .frame(width: 380, alignment: .leading) + if self.state.connectionMode == .unconfigured { + Text("Pick Local or Remote to start the Gateway.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if self.state.connectionMode == .local { self.gatewayInstallerCard self.healthRow @@ -560,9 +568,13 @@ extension GeneralSettings { } // Restore original mode if we temporarily switched - if originalMode != .remote { - let restoreMode: ControlChannel.Mode = .local - try? await ControlChannel.shared.configure(mode: restoreMode) + switch originalMode { + case .remote: + break + case .local: + try? await ControlChannel.shared.configure(mode: .local) + case .unconfigured: + await ControlChannel.shared.disconnect() } } diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 448cf5c71..e9c8f36c8 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -22,12 +22,12 @@ struct MenuContent: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Toggle(isOn: self.activeBinding) { - let label = self.state.connectionMode == .remote ? "Remote Clawdis Active" : "Clawdis Active" VStack(alignment: .leading, spacing: 2) { - Text(label) + Text(self.connectionLabel) self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color) } } + .disabled(self.state.connectionMode == .unconfigured) Divider() Toggle(isOn: self.heartbeatsBinding) { VStack(alignment: .leading, spacing: 2) { @@ -105,6 +105,17 @@ struct MenuContent: View { } } + private var connectionLabel: String { + switch self.state.connectionMode { + case .unconfigured: + return "Clawdis Not Configured" + case .remote: + return "Remote Clawdis Active" + case .local: + return "Clawdis Active" + } + } + @ViewBuilder private var debugMenu: some View { if self.state.debugPaneEnabled { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 68821abf8..b74870c54 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -89,12 +89,16 @@ struct OnboardingView: View { private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect() private let permissionsPageIndex = 5 private var pageOrder: [Int] { - if self.state.connectionMode == .remote { + switch self.state.connectionMode { + case .remote: // Remote setup doesn't need local gateway/CLI/workspace setup pages, // and WhatsApp/Telegram setup is optional. return [0, 1, 5, 9] + case .unconfigured: + return [0, 1, 9] + case .local: + return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] } - return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] } private var pageCount: Int { self.pageOrder.count } @@ -266,7 +270,7 @@ struct OnboardingView: View { .font(.largeTitle.weight(.semibold)) Text( "Clawdis uses a single Gateway that stays running. Pick this Mac, " + - "or connect to a discovered Gateway nearby.") + "connect to a discovered Gateway nearby, or configure later.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -322,37 +326,54 @@ struct OnboardingView: View { .fill(Color(NSColor.controlBackgroundColor))) } + self.connectionChoiceButton( + title: "Configure later", + subtitle: "Don’t start the Gateway yet.", + selected: self.state.connectionMode == .unconfigured) + { + self.selectUnconfiguredGateway() + } + Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { self.showAdvancedConnection.toggle() } + if self.showAdvancedConnection, self.state.connectionMode != .remote { + self.state.connectionMode = .remote + } } .buttonStyle(.link) if self.showAdvancedConnection { - let labelWidth: CGFloat = 90 + let labelWidth: CGFloat = 110 let fieldWidth: CGFloat = 320 - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 12) { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - - LabeledContent("Identity file") { - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - - LabeledContent("Project root") { - TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } Text("Tip: keep Tailscale enabled so your gateway stays reachable.") @@ -370,6 +391,14 @@ struct OnboardingView: View { private func selectLocalGateway() { self.state.connectionMode = .local self.preferredGatewayID = nil + self.showAdvancedConnection = false + BridgeDiscoveryPreferences.setPreferredStableID(nil) + } + + private func selectUnconfiguredGateway() { + self.state.connectionMode = .unconfigured + self.preferredGatewayID = nil + self.showAdvancedConnection = false BridgeDiscoveryPreferences.setPreferredStableID(nil) } @@ -1064,6 +1093,14 @@ struct OnboardingView: View { Text("All set") .font(.largeTitle.weight(.semibold)) self.onboardingCard { + if self.state.connectionMode == .unconfigured { + self.featureRow( + title: "Configure later", + subtitle: "Pick Local or Remote in Settings → General whenever you’re ready.", + systemImage: "gearshape") + Divider() + .padding(.vertical, 6) + } if self.state.connectionMode == .remote { self.featureRow( title: "Remote gateway checklist", diff --git a/apps/macos/Sources/Clawdis/PortGuardian.swift b/apps/macos/Sources/Clawdis/PortGuardian.swift index 92206c80a..07f84658c 100644 --- a/apps/macos/Sources/Clawdis/PortGuardian.swift +++ b/apps/macos/Sources/Clawdis/PortGuardian.swift @@ -38,6 +38,10 @@ actor PortGuardian { func sweep(mode: AppState.ConnectionMode) async { self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") + guard mode != .unconfigured else { + self.logger.info("port sweep skipped (mode=unconfigured)") + return + } let ports = [18789] for port in ports { let listeners = await self.listeners(on: port) @@ -141,6 +145,9 @@ actor PortGuardian { } func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { + if mode == .unconfigured { + return [] + } let ports = [18789] var reports: [PortReport] = [] @@ -150,17 +157,20 @@ actor PortGuardian { let okPredicate: (Listener) -> Bool let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"] - switch mode { - case .remote: - expectedDesc = "SSH tunnel to remote gateway" - okPredicate = { $0.command.lowercased().contains("ssh") } - case .local: - expectedDesc = "Gateway websocket (node/tsx)" - okPredicate = { listener in - let c = listener.command.lowercased() - return expectedCommands.contains { c.contains($0) } - } + switch mode { + case .remote: + expectedDesc = "SSH tunnel to remote gateway" + okPredicate = { $0.command.lowercased().contains("ssh") } + case .local: + expectedDesc = "Gateway websocket (node/tsx)" + okPredicate = { listener in + let c = listener.command.lowercased() + return expectedCommands.contains { c.contains($0) } } + case .unconfigured: + expectedDesc = "Gateway not configured" + okPredicate = { _ in false } + } if listeners.isEmpty { let text = "Nothing is listening on \(port) (\(expectedDesc))." @@ -292,6 +302,8 @@ actor PortGuardian { return false case .local: return expectedCommands.contains { cmd.contains($0) } + case .unconfigured: + return false } } diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index c4cc10c6c..01140811f 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -575,8 +575,14 @@ enum CommandResolver { } static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { - let modeRaw = defaults.string(forKey: connectionModeKey) ?? "local" - let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local + let modeRaw = defaults.string(forKey: connectionModeKey) + let mode: AppState.ConnectionMode + if let modeRaw { + mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local + } else { + let seen = defaults.bool(forKey: "clawdis.onboardingSeen") + mode = seen ? .local : .unconfigured + } let target = defaults.string(forKey: remoteTargetKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift index f6140c970..64f117ec2 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift @@ -77,4 +77,18 @@ import Testing #expect(url.absoluteString == "ws://127.0.0.1:5555") #expect(token == "tok") } + + @Test func unconfiguredModeRejectsConfig() async { + let mode = ModeBox(.unconfigured) + let store = GatewayEndpointStore(deps: .init( + mode: { mode.get() }, + token: { nil }, + localPort: { 18789 }, + remotePortIfRunning: { nil }, + ensureRemoteTunnel: { 18789 })) + + await #expect(throws: Error.self) { + _ = try await store.requireConfig() + } + } } diff --git a/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift index 3a52943e6..ceeac0569 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift @@ -18,5 +18,11 @@ struct MenuContentSmokeTests { let view = MenuContent(state: state, updater: nil) _ = view.body } -} + @Test func menuContentBuildsBodyUnconfiguredMode() { + let state = AppState(preview: true) + state.connectionMode = .unconfigured + let view = MenuContent(state: state, updater: nil) + _ = view.body + } +}