feat(macos): add unconfigured gateway mode

This commit is contained in:
Peter Steinberger
2025-12-20 02:20:48 +01:00
parent 80a87e5f9e
commit 4e74ba996d
13 changed files with 188 additions and 49 deletions

View File

@@ -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 isnt 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)
}

View File

@@ -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) ?? ""

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()
}
}
}

View File

@@ -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<GatewayEndpointState> {
@@ -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"))
}
}

View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -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: "Dont 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 youre ready.",
systemImage: "gearshape")
Divider()
.padding(.vertical, 6)
}
if self.state.connectionMode == .remote {
self.featureRow(
title: "Remote gateway checklist",

View File

@@ -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
}
}

View File

@@ -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) ?? ""

View File

@@ -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()
}
}
}

View File

@@ -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
}
}