fix: route macOS remote config via gateway
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
48
apps/macos/Sources/Clawdis/ConfigStore.swift
Normal file
48
apps/macos/Sources/Clawdis/ConfigStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user