feat(macos): add unconfigured gateway mode

main
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 { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if self.connectionMode == .remote { if self.connectionMode != .local {
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.") Text("Gateway isnt running locally; OAuth must be created on the gateway host where Pi runs.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@ -64,7 +64,7 @@ struct AnthropicAuthControls: View {
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.connectionMode == .remote || self.busy) .disabled(self.connectionMode != .local || self.busy)
if self.pkce != nil { if self.pkce != nil {
Button("Cancel") { Button("Cancel") {
@ -101,7 +101,7 @@ struct AnthropicAuthControls: View {
Task { await self.finishOAuth() } Task { await self.finishOAuth() }
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode == .remote || self.code .disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty) .isEmpty)
} }

View File

@ -17,6 +17,7 @@ final class AppState {
} }
enum ConnectionMode: String { enum ConnectionMode: String {
case unconfigured
case local case local
case remote case remote
} }
@ -182,9 +183,10 @@ final class AppState {
init(preview: Bool = false) { init(preview: Bool = false) {
self.isPreview = preview self.isPreview = preview
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false self.launchAtLogin = false
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") self.onboardingSeen = onboardingSeen
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled") self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
@ -225,7 +227,11 @@ final class AppState {
} }
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) 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.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" 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. /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async { func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode { 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: case .local:
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() 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 { func health(timeout: TimeInterval? = nil) async throws -> Data {
do { do {
let start = Date() 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. // ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) } 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) { init(deps: Deps = .live) {
self.deps = deps 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() 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> { func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
@ -72,6 +88,8 @@ actor GatewayEndpointStore {
return return
} }
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token)) 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) .frame(maxWidth: .infinity, alignment: .leading)
Picker("", selection: self.$state.connectionMode) { Picker("", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote) Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(width: 380, alignment: .leading) .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 { if self.state.connectionMode == .local {
self.gatewayInstallerCard self.gatewayInstallerCard
self.healthRow self.healthRow
@ -560,9 +568,13 @@ extension GeneralSettings {
} }
// Restore original mode if we temporarily switched // Restore original mode if we temporarily switched
if originalMode != .remote { switch originalMode {
let restoreMode: ControlChannel.Mode = .local case .remote:
try? await ControlChannel.shared.configure(mode: restoreMode) 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 { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) { Toggle(isOn: self.activeBinding) {
let label = self.state.connectionMode == .remote ? "Remote Clawdis Active" : "Clawdis Active"
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(label) Text(self.connectionLabel)
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color) self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
} }
} }
.disabled(self.state.connectionMode == .unconfigured)
Divider() Divider()
Toggle(isOn: self.heartbeatsBinding) { Toggle(isOn: self.heartbeatsBinding) {
VStack(alignment: .leading, spacing: 2) { 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 @ViewBuilder
private var debugMenu: some View { private var debugMenu: some View {
if self.state.debugPaneEnabled { 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 static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
private let permissionsPageIndex = 5 private let permissionsPageIndex = 5
private var pageOrder: [Int] { 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, // Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional. // and WhatsApp/Telegram setup is optional.
return [0, 1, 5, 9] 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 } private var pageCount: Int { self.pageOrder.count }
@ -266,7 +270,7 @@ struct OnboardingView: View {
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
"Clawdis uses a single Gateway that stays running. Pick this Mac, " + "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) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -322,37 +326,54 @@ struct OnboardingView: View {
.fill(Color(NSColor.controlBackgroundColor))) .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…") { Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.showAdvancedConnection.toggle() self.showAdvancedConnection.toggle()
} }
if self.showAdvancedConnection, self.state.connectionMode != .remote {
self.state.connectionMode = .remote
}
} }
.buttonStyle(.link) .buttonStyle(.link)
if self.showAdvancedConnection { if self.showAdvancedConnection {
let labelWidth: CGFloat = 90 let labelWidth: CGFloat = 110
let fieldWidth: CGFloat = 320 let fieldWidth: CGFloat = 320
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 12) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
Text("SSH target") GridRow {
.font(.callout.weight(.semibold)) Text("SSH target")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField("user@host[:port]", text: self.$state.remoteTarget) .frame(width: labelWidth, alignment: .leading)
.textFieldStyle(.roundedBorder) TextField("user@host[:port]", text: self.$state.remoteTarget)
.frame(width: fieldWidth) .textFieldStyle(.roundedBorder)
} .frame(width: fieldWidth)
}
LabeledContent("Identity file") { GridRow {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) Text("Identity file")
.textFieldStyle(.roundedBorder) .font(.callout.weight(.semibold))
.frame(width: fieldWidth) .frame(width: labelWidth, alignment: .leading)
} TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
LabeledContent("Project root") { .frame(width: fieldWidth)
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) }
.textFieldStyle(.roundedBorder) GridRow {
.frame(width: fieldWidth) 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.") Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
@ -370,6 +391,14 @@ struct OnboardingView: View {
private func selectLocalGateway() { private func selectLocalGateway() {
self.state.connectionMode = .local self.state.connectionMode = .local
self.preferredGatewayID = nil 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) BridgeDiscoveryPreferences.setPreferredStableID(nil)
} }
@ -1064,6 +1093,14 @@ struct OnboardingView: View {
Text("All set") Text("All set")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
self.onboardingCard { 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 { if self.state.connectionMode == .remote {
self.featureRow( self.featureRow(
title: "Remote gateway checklist", title: "Remote gateway checklist",

View File

@ -38,6 +38,10 @@ actor PortGuardian {
func sweep(mode: AppState.ConnectionMode) async { func sweep(mode: AppState.ConnectionMode) async {
self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") 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] let ports = [18789]
for port in ports { for port in ports {
let listeners = await self.listeners(on: port) let listeners = await self.listeners(on: port)
@ -141,6 +145,9 @@ actor PortGuardian {
} }
func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] {
if mode == .unconfigured {
return []
}
let ports = [18789] let ports = [18789]
var reports: [PortReport] = [] var reports: [PortReport] = []
@ -150,17 +157,20 @@ actor PortGuardian {
let okPredicate: (Listener) -> Bool let okPredicate: (Listener) -> Bool
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"] let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
switch mode { switch mode {
case .remote: case .remote:
expectedDesc = "SSH tunnel to remote gateway" expectedDesc = "SSH tunnel to remote gateway"
okPredicate = { $0.command.lowercased().contains("ssh") } okPredicate = { $0.command.lowercased().contains("ssh") }
case .local: case .local:
expectedDesc = "Gateway websocket (node/tsx)" expectedDesc = "Gateway websocket (node/tsx)"
okPredicate = { listener in okPredicate = { listener in
let c = listener.command.lowercased() let c = listener.command.lowercased()
return expectedCommands.contains { c.contains($0) } return expectedCommands.contains { c.contains($0) }
}
} }
case .unconfigured:
expectedDesc = "Gateway not configured"
okPredicate = { _ in false }
}
if listeners.isEmpty { if listeners.isEmpty {
let text = "Nothing is listening on \(port) (\(expectedDesc))." let text = "Nothing is listening on \(port) (\(expectedDesc))."
@ -292,6 +302,8 @@ actor PortGuardian {
return false return false
case .local: case .local:
return expectedCommands.contains { cmd.contains($0) } 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 { static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let modeRaw = defaults.string(forKey: connectionModeKey) ?? "local" let modeRaw = defaults.string(forKey: connectionModeKey)
let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local 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 target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""

View File

@ -77,4 +77,18 @@ import Testing
#expect(url.absoluteString == "ws://127.0.0.1:5555") #expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok") #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) let view = MenuContent(state: state, updater: nil)
_ = view.body _ = view.body
} }
}
@Test func menuContentBuildsBodyUnconfiguredMode() {
let state = AppState(preview: true)
state.connectionMode = .unconfigured
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
}