feat(macos): add unconfigured gateway mode
parent
80a87e5f9e
commit
4e74ba996d
|
|
@ -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 isn’t 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) ?? ""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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: "Don’t 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 you’re 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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) ?? ""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue