fix: route macOS remote config via gateway

This commit is contained in:
Peter Steinberger
2026-01-01 18:58:41 +01:00
parent 351db0632d
commit 850cbfe369
11 changed files with 170 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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