From 850cbfe3698ed60135cf475cd332bd26ddd854e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 18:58:41 +0100 Subject: [PATCH] fix: route macOS remote config via gateway --- CHANGELOG.md | 1 + .../Sources/Clawdis/ConfigSettings.swift | 18 +++---- apps/macos/Sources/Clawdis/ConfigStore.swift | 48 ++++++++++++++++++ apps/macos/Sources/Clawdis/DebugActions.swift | 7 +++ .../Sources/Clawdis/GatewayEnvironment.swift | 3 ++ .../Clawdis/GatewayLaunchAgentManager.swift | 3 ++ .../Sources/Clawdis/MenuContentView.swift | 25 ++++++++-- .../Clawdis/OnboardingView+Layout.swift | 4 +- .../Clawdis/OnboardingView+Pages.swift | 11 ++-- .../Clawdis/OnboardingView+Workspace.swift | 39 +++++++++++++-- .../Clawdis/TailscaleIntegrationSection.swift | 50 ++++++++++++------- 11 files changed, 170 insertions(+), 39 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/ConfigStore.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d2957bfd9..439ac8fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b +- macOS remote: route settings through gateway config and avoid local config reads in remote mode. - Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl - Restart: use systemd on Linux (and report actual restart method) instead of always launchctl. - Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS. diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 74753d0bc..3f91e42b0 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -49,7 +49,7 @@ struct ConfigSettings: View { guard !self.hasLoaded else { return } guard !self.isPreview else { return } self.hasLoaded = true - self.loadConfig() + await self.loadConfig() await self.loadModels() await self.refreshGatewayTalkApiKey() self.allowAutosave = true @@ -369,8 +369,8 @@ struct ConfigSettings: View { .padding(.top, 2) } - private func loadConfig() { - let parsed = self.loadConfigDict() + private func loadConfig() async { + let parsed = await ConfigStore.load() let agent = parsed["agent"] as? [String: Any] let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int let heartbeatBody = agent?["heartbeatBody"] as? String @@ -429,7 +429,7 @@ struct ConfigSettings: View { self.configSaving = true defer { self.configSaving = false } - var root = self.loadConfigDict() + var root = await ConfigStore.load() var agent = root["agent"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:] @@ -473,11 +473,11 @@ struct ConfigSettings: View { talk["interruptOnSpeech"] = self.talkInterruptOnSpeech root["talk"] = talk - ClawdisConfigFile.saveDict(root) - } - - private func loadConfigDict() -> [String: Any] { - ClawdisConfigFile.loadDict() + do { + try await ConfigStore.save(root) + } catch { + self.modelError = error.localizedDescription + } } private var browserColor: Color { diff --git a/apps/macos/Sources/Clawdis/ConfigStore.swift b/apps/macos/Sources/Clawdis/ConfigStore.swift new file mode 100644 index 000000000..2354c8c8b --- /dev/null +++ b/apps/macos/Sources/Clawdis/ConfigStore.swift @@ -0,0 +1,48 @@ +import Foundation + +enum ConfigStore { + private static func isRemoteMode() async -> Bool { + await MainActor.run { AppStateStore.shared.connectionMode == .remote } + } + + static func load() async -> [String: Any] { + if await self.isRemoteMode() { + return await self.loadFromGateway() + } + return ClawdisConfigFile.loadDict() + } + + static func save(_ root: [String: Any]) async throws { + if await self.isRemoteMode() { + try await self.saveToGateway(root) + } else { + ClawdisConfigFile.saveDict(root) + } + } + + private static func loadFromGateway() async -> [String: Any] { + do { + let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .configGet, + params: nil, + timeoutMs: 8000) + return snap.config?.mapValues { $0.foundationValue } ?? [:] + } catch { + return [:] + } + } + + private static func saveToGateway(_ root: [String: Any]) async throws { + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + guard let raw = String(data: data, encoding: .utf8) else { + throw NSError(domain: "ConfigStore", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode config." + ]) + } + let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] + _ = try await GatewayConnection.shared.requestRaw( + method: .configSet, + params: params, + timeoutMs: 10000) + } +} diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 21c640841..0d7372074 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -45,6 +45,13 @@ enum DebugActions { @MainActor static func openSessionStore() { + if AppStateStore.shared.connectionMode == .remote { + let alert = NSAlert() + alert.messageText = "Remote mode" + alert.informativeText = "Session store lives on the gateway host in remote mode." + alert.runModal() + return + } let path = self.resolveSessionStorePath() let url = URL(fileURLWithPath: path) if FileManager.default.fileExists(atPath: path) { diff --git a/apps/macos/Sources/Clawdis/GatewayEnvironment.swift b/apps/macos/Sources/Clawdis/GatewayEnvironment.swift index 1f5d4d4f3..f15bce592 100644 --- a/apps/macos/Sources/Clawdis/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdis/GatewayEnvironment.swift @@ -219,6 +219,9 @@ enum GatewayEnvironment { } private static func preferredGatewayBind() -> String? { + if CommandResolver.connectionModeIsRemote() { + return nil + } if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] { let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if self.supportedBindModes.contains(trimmed) { diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index 70de79ecd..a5ee815f0 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -117,6 +117,9 @@ enum GatewayLaunchAgentManager { } private static func preferredGatewayBind() -> String? { + if CommandResolver.connectionModeIsRemote() { + return nil + } if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] { let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if self.supportedBindModes.contains(trimmed) { diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index d18e43f83..5ac5a37af 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -59,7 +59,7 @@ struct MenuContent: View { get: { self.browserControlEnabled }, set: { enabled in self.browserControlEnabled = enabled - ClawdisConfigFile.setBrowserControlEnabled(enabled) + Task { await self.saveBrowserControlEnabled(enabled) } })) { Label("Browser Control", systemImage: "globe") } @@ -140,8 +140,8 @@ struct MenuContent: View { .onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled) } - .onAppear { - self.browserControlEnabled = ClawdisConfigFile.browserControlEnabled() + .task(id: self.state.connectionMode) { + await self.loadBrowserControlEnabled() } } @@ -156,6 +156,25 @@ struct MenuContent: View { } } + private func loadBrowserControlEnabled() async { + let root = await ConfigStore.load() + let browser = root["browser"] as? [String: Any] + let enabled = browser?["enabled"] as? Bool ?? true + await MainActor.run { self.browserControlEnabled = enabled } + } + + private func saveBrowserControlEnabled(_ enabled: Bool) async { + var root = await ConfigStore.load() + var browser = root["browser"] as? [String: Any] ?? [:] + browser["enabled"] = enabled + root["browser"] = browser + do { + try await ConfigStore.save(root) + } catch { + await self.loadBrowserControlEnabled() + } + } + @ViewBuilder private var debugMenu: some View { if self.state.debugPaneEnabled { diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift b/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift index 31ac00dd2..213c53349 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift @@ -54,8 +54,8 @@ extension OnboardingView { .task { await self.refreshPerms() self.refreshCLIStatus() - self.loadWorkspaceDefaults() - self.ensureDefaultWorkspace() + await self.loadWorkspaceDefaults() + await self.ensureDefaultWorkspace() self.refreshAnthropicOAuthStatus() self.refreshBootstrapStatus() self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID() diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift index 2bcf09b24..b72a8e10c 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift @@ -564,9 +564,14 @@ extension OnboardingView { .disabled(self.workspaceApplying) Button("Save in config") { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url)) - self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (agent.workspace)" + Task { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) + if saved { + self.workspaceStatus = + "Saved to ~/.clawdis/clawdis.json (agent.workspace)" + } + } } .buttonStyle(.bordered) .disabled(self.workspaceApplying) diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift index 983dbe420..d733659a9 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift @@ -1,24 +1,24 @@ import Foundation extension OnboardingView { - func loadWorkspaceDefaults() { + func loadWorkspaceDefaults() async { guard self.workspacePath.isEmpty else { return } - let configured = ClawdisConfigFile.agentWorkspace() + let configured = await self.loadAgentWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) self.workspacePath = AgentWorkspace.displayPath(for: url) self.refreshBootstrapStatus() } - func ensureDefaultWorkspace() { + func ensureDefaultWorkspace() async { guard self.state.connectionMode == .local else { return } - let configured = ClawdisConfigFile.agentWorkspace() + let configured = await self.loadAgentWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) switch AgentWorkspace.bootstrapSafety(for: url) { case .safe: do { _ = try AgentWorkspace.bootstrap(workspaceURL: url) if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url)) + await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) } } catch { self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" @@ -66,4 +66,33 @@ extension OnboardingView { self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" } } + + private func loadAgentWorkspace() async -> String? { + let root = await ConfigStore.load() + let agent = root["agent"] as? [String: Any] + return agent?["workspace"] as? String + } + + func saveAgentWorkspace(_ workspace: String?) async -> Bool { + var root = await ConfigStore.load() + var agent = root["agent"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + agent.removeValue(forKey: "workspace") + } else { + agent["workspace"] = trimmed + } + if agent.isEmpty { + root.removeValue(forKey: "agent") + } else { + root["agent"] = agent + } + do { + try await ConfigStore.save(root) + return true + } catch { + self.workspaceStatus = "Failed to save config: \(error.localizedDescription)" + return false + } + } } diff --git a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift index 0d33a7a64..f8cabf9c7 100644 --- a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -104,7 +104,7 @@ struct TailscaleIntegrationSection: View { .disabled(self.connectionMode != .local) .task { guard !self.hasLoaded else { return } - self.loadConfig() + await self.loadConfig() self.hasLoaded = true await self.effectiveService.checkTailscaleStatus() self.startStatusTimer() @@ -113,10 +113,10 @@ struct TailscaleIntegrationSection: View { self.stopStatusTimer() } .onChange(of: self.tailscaleMode) { _, _ in - self.applySettings() + Task { await self.applySettings() } } .onChange(of: self.requireCredentialsForServe) { _, _ in - self.applySettings() + Task { await self.applySettings() } } } @@ -233,17 +233,18 @@ struct TailscaleIntegrationSection: View { SecureField("Password", text: self.$password) .textFieldStyle(.roundedBorder) .frame(maxWidth: 240) - .onSubmit { self.applySettings() } + .onSubmit { Task { await self.applySettings() } } Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.") .font(.caption) .foregroundStyle(.secondary) - Button("Update password") { self.applySettings() } + Button("Update password") { Task { await self.applySettings() } } .buttonStyle(.bordered) .controlSize(.small) } - private func loadConfig() { - let gateway = ClawdisConfigFile.loadGatewayDict() + private func loadConfig() async { + let root = await ConfigStore.load() + let gateway = root["gateway"] as? [String: Any] ?? [:] let tailscale = gateway["tailscale"] as? [String: Any] ?? [:] let modeRaw = (tailscale["mode"] as? String) ?? "serve" self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off @@ -266,7 +267,7 @@ struct TailscaleIntegrationSection: View { } } - private func applySettings() { + private func applySettings() async { guard self.hasLoaded else { return } self.validationMessage = nil self.statusMessage = nil @@ -279,18 +280,20 @@ struct TailscaleIntegrationSection: View { return } - ClawdisConfigFile.updateGatewayDict { gateway in - var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] - tailscale["mode"] = self.tailscaleMode.rawValue - gateway["tailscale"] = tailscale + var root = await ConfigStore.load() + var gateway = root["gateway"] as? [String: Any] ?? [:] + var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] + tailscale["mode"] = self.tailscaleMode.rawValue + gateway["tailscale"] = tailscale - if self.tailscaleMode != .off { - gateway["bind"] = "loopback" - } + if self.tailscaleMode != .off { + gateway["bind"] = "loopback" + } - guard self.tailscaleMode != .off else { return } + if self.tailscaleMode == .off { + gateway.removeValue(forKey: "auth") + } else { var auth = gateway["auth"] as? [String: Any] ?? [:] - if self.tailscaleMode == .serve, !self.requireCredentialsForServe { auth["allowTailscale"] = true auth.removeValue(forKey: "mode") @@ -308,6 +311,19 @@ struct TailscaleIntegrationSection: View { } } + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + + do { + try await ConfigStore.save(root) + } catch { + self.statusMessage = error.localizedDescription + return + } + if self.connectionMode == .local, !self.isPaused { self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…" } else {