diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 0281713738b..89bbefc5b02 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift index 57164ebb892..6340dee2ca5 100644 --- a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift +++ b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift @@ -17,9 +17,14 @@ enum AgentWorkspace { AgentWorkspace.userFilename, AgentWorkspace.bootstrapFilename, ] - enum BootstrapSafety: Equatable { - case safe - case unsafe (reason: String) + struct BootstrapSafety: Equatable { + let unsafeReason: String? + + static let safe = Self(unsafeReason: nil) + + static func blocked(_ reason: String) -> Self { + Self(unsafeReason: reason) + } } static func displayPath(for url: URL) -> String { @@ -71,9 +76,7 @@ enum AgentWorkspace { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return .safe } - if !isDir.boolValue { - return .unsafe (reason: "Workspace path points to a file.") - } + if !isDir.boolValue { return .blocked("Workspace path points to a file.") } let agentsURL = self.agentsURL(workspaceURL: workspaceURL) if fm.fileExists(atPath: agentsURL.path) { return .safe @@ -82,9 +85,9 @@ enum AgentWorkspace { let entries = try self.workspaceEntries(workspaceURL: workspaceURL) return entries.isEmpty ? .safe - : .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") + : .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.") } catch { - return .unsafe (reason: "Couldn't inspect the workspace folder.") + return .blocked("Couldn't inspect the workspace folder.") } } diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index e9ca6c35359..ef4917e7768 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -356,6 +356,70 @@ final class AppState { return trimmed } + private static func updateGatewayString( + _ dictionary: inout [String: Any], + key: String, + value: String?) -> Bool + { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + guard dictionary[key] != nil else { return false } + dictionary.removeValue(forKey: key) + return true + } + if (dictionary[key] as? String) != trimmed { + dictionary[key] = trimmed + return true + } + return false + } + + private static func updatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + { + var remote = current + var changed = false + + switch transport { + case .direct: + changed = Self.updateGatewayString( + &remote, + key: "transport", + value: RemoteTransport.direct.rawValue) || changed + + let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { + changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed + } + + case .ssh: + changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed + + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed + } + + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed + changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed + } + + return (remote, changed) + } + private func startConfigWatcher() { let configUrl = OpenClawConfigFile.url() self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in @@ -470,69 +534,16 @@ final class AppState { } if connectionMode == .remote { - var remote = gateway["remote"] as? [String: Any] ?? [:] - var remoteChanged = false - - if remoteTransport == .direct { - let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedUrl.isEmpty { - if remote["url"] != nil { - remote.removeValue(forKey: "url") - remoteChanged = true - } - } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { - if (remote["url"] as? String) != normalizedUrl { - remote["url"] = normalizedUrl - remoteChanged = true - } - } - if (remote["transport"] as? String) != RemoteTransport.direct.rawValue { - remote["transport"] = RemoteTransport.direct.rawValue - remoteChanged = true - } - } else { - if remote["transport"] != nil { - remote.removeValue(forKey: "transport") - remoteChanged = true - } - if let host = remoteHost { - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl - remoteChanged = true - } - } - - let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) - if !sanitizedTarget.isEmpty { - if (remote["sshTarget"] as? String) != sanitizedTarget { - remote["sshTarget"] = sanitizedTarget - remoteChanged = true - } - } else if remote["sshTarget"] != nil { - remote.removeValue(forKey: "sshTarget") - remoteChanged = true - } - - let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - if (remote["sshIdentity"] as? String) != trimmedIdentity { - remote["sshIdentity"] = trimmedIdentity - remoteChanged = true - } - } else if remote["sshIdentity"] != nil { - remote.removeValue(forKey: "sshIdentity") - remoteChanged = true - } - } - - if remoteChanged { - gateway["remote"] = remote + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity) + if updated.changed { + gateway["remote"] = updated.remote changed = true } } diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift index 2dd720741bb..ad40d2c3803 100644 --- a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift +++ b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -8,7 +8,7 @@ enum ExecAllowlistMatcher { for entry in entries { switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { - case .valid(let pattern): + case let .valid(pattern): let target = resolvedPath ?? rawExecutable if self.matches(pattern: pattern, target: target) { return entry } case .invalid: diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 08567cd0b09..73aa3899d82 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -439,9 +439,9 @@ enum ExecApprovalsStore { static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { let normalizedPattern: String switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let validPattern): + case let .valid(validPattern): normalizedPattern = validPattern - case .invalid(let reason): + case let .invalid(reason): return reason } @@ -571,7 +571,7 @@ enum ExecApprovalsStore { private static func normalizedPattern(_ pattern: String?) -> String? { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalized): + case let .valid(normalized): return normalized.lowercased() case .invalid(.empty): return nil @@ -587,7 +587,7 @@ enum ExecApprovalsStore { let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): return ExecAllowlistEntry( id: entry.id, pattern: pattern, @@ -596,7 +596,7 @@ enum ExecApprovalsStore { lastResolvedPath: normalizedResolved) case .invalid: switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { - case .valid(let migratedPattern): + case let .valid(migratedPattern): return ExecAllowlistEntry( id: entry.id, pattern: migratedPattern, @@ -629,7 +629,7 @@ enum ExecApprovalsStore { let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): normalized.append( ExecAllowlistEntry( id: migrated.id, @@ -637,7 +637,7 @@ enum ExecApprovalsStore { lastUsedAt: migrated.lastUsedAt, lastUsedCommand: migrated.lastUsedCommand, lastResolvedPath: normalizedResolvedPath)) - case .invalid(let reason): + case let .invalid(reason): if dropInvalid { rejected.append( ExecAllowlistRejectedEntry( diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 130e9473117..16f8db91305 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -366,9 +366,9 @@ private enum ExecHostExecutor { rawCommand: request.rawCommand) let displayCommand: String switch validatedCommand { - case .ok(let resolved): + case let .ok(resolved): displayCommand = resolved.displayCommand - case .invalid(let message): + case let .invalid(message): return self.errorResponse( code: "INVALID_REQUEST", message: message, diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift index ca6a934adb5..06851a7d065 100644 --- a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -63,11 +63,11 @@ enum ExecShellWrapperParser { private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { switch spec.kind { case .posix: - return self.extractPosixInlineCommand(command) + self.extractPosixInlineCommand(command) case .cmd: - return self.extractCmdInlineCommand(command) + self.extractCmdInlineCommand(command) case .powershell: - return self.extractPowerShellInlineCommand(command) + self.extractPowerShellInlineCommand(command) } } @@ -81,7 +81,9 @@ enum ExecShellWrapperParser { } private static func extractCmdInlineCommand(_ command: [String]) -> String? { - guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else { + guard let idx = command + .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) + else { return nil } let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift index f3bdac5e71e..707a46322d8 100644 --- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -77,11 +77,10 @@ enum ExecSystemRunCommandValidator { let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv - let inferred: String - if let shellCommand, !mustBindDisplayToFullArgv { - inferred = shellCommand + let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv { + shellCommand } else { - inferred = ExecCommandFormatter.displayString(for: command) + ExecCommandFormatter.displayString(for: command) } if let raw = normalizedRaw, raw != inferred { @@ -189,7 +188,7 @@ enum ExecSystemRunCommandValidator { } var appletIndex = 1 - if appletIndex < argv.count && argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { appletIndex += 1 } guard appletIndex < argv.count else { @@ -255,14 +254,13 @@ enum ExecSystemRunCommandValidator { return false } - let inlineCommandIndex: Int? - if wrapper == "powershell" || wrapper == "pwsh" { - inlineCommandIndex = self.resolveInlineCommandTokenIndex( + let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" { + self.resolveInlineCommandTokenIndex( wrapperArgv, flags: self.powershellInlineCommandFlags, allowCombinedC: false) } else { - inlineCommandIndex = self.resolveInlineCommandTokenIndex( + self.resolveInlineCommandTokenIndex( wrapperArgv, flags: self.posixInlineCommandFlags, allowCombinedC: true) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 60cfdfb1d73..4dae858771c 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -304,8 +304,7 @@ struct GeneralSettings: View { .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text( - "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." - ) + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) @@ -549,8 +548,7 @@ extension GeneralSettings { } guard Self.isValidWsUrl(trimmedUrl) else { self.remoteStatus = .failed( - "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" - ) + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") return } } else { diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 00e2a9be0a6..d7ab72ce86f 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -431,7 +431,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { } } -extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} +extension SparkleUpdaterController: SPUUpdaterDelegate {} private func isDeveloperIDSigned(bundleURL: URL) -> Bool { var staticCode: SecStaticCode? diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5b05ab164c2..ed40bd2ed58 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -87,19 +87,9 @@ extension OnboardingView { self.onboardingCard(spacing: 12, padding: 14) { VStack(alignment: .leading, spacing: 10) { - let localSubtitle: String = { - guard let probe = self.localGatewayProbe else { - return "Gateway starts automatically on this Mac." - } - let base = probe.expected - ? "Existing gateway detected" - : "Port \(probe.port) already in use" - let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" - return "\(base)\(command). Will attach." - }() self.connectionChoiceButton( title: "This Mac", - subtitle: localSubtitle, + subtitle: self.localGatewaySubtitle, selected: self.state.connectionMode == .local) { self.selectLocalGateway() @@ -107,50 +97,7 @@ extension OnboardingView { Divider().padding(.vertical, 4) - HStack(spacing: 8) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.caption) - .foregroundStyle(.secondary) - Text(self.gatewayDiscovery.statusText) - .font(.caption) - .foregroundStyle(.secondary) - if self.gatewayDiscovery.gateways.isEmpty { - ProgressView().controlSize(.small) - Button("Refresh") { - self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) - } - .buttonStyle(.link) - .help("Retry Tailscale discovery (DNS-SD).") - } - Spacer(minLength: 0) - } - - if self.gatewayDiscovery.gateways.isEmpty { - Text("Searching for nearby gateways…") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } else { - VStack(alignment: .leading, spacing: 6) { - Text("Nearby gateways") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in - self.connectionChoiceButton( - title: gateway.displayName, - subtitle: self.gatewaySubtitle(for: gateway), - selected: self.isSelectedGateway(gateway)) - { - self.selectRemoteGateway(gateway) - } - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - } + self.gatewayDiscoverySection() self.connectionChoiceButton( title: "Configure later", @@ -160,104 +107,168 @@ extension OnboardingView { self.selectUnconfiguredGateway() } - Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - self.showAdvancedConnection.toggle() - } - if self.showAdvancedConnection, self.state.connectionMode != .remote { - self.state.connectionMode = .remote - } - } - .buttonStyle(.link) + self.advancedConnectionSection() + } + } + } + } - if self.showAdvancedConnection { - let labelWidth: CGFloat = 110 - let fieldWidth: CGFloat = 320 + private var localGatewaySubtitle: String { + guard let probe = self.localGatewayProbe else { + return "Gateway starts automatically on this Mac." + } + let base = probe.expected + ? "Existing gateway detected" + : "Port \(probe.port) already in use" + let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" + return "\(base)\(command). Will attach." + } - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text("Transport") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - Picker("Transport", selection: self.$state.remoteTransport) { - Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) - Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) - } - .pickerStyle(.segmented) - .frame(width: fieldWidth) - } - if self.state.remoteTransport == .direct { - GridRow { - Text("Gateway URL") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - if self.state.remoteTransport == .ssh { - GridRow { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if let message = CommandResolver - .sshTargetValidationMessage(self.state.remoteTarget) - { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text(message) - .font(.caption) - .foregroundStyle(.red) - .frame(width: fieldWidth, alignment: .leading) - } - } - GridRow { - Text("Identity file") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("Project root") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField( - "/Applications/OpenClaw.app/.../openclaw", - text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - } + @ViewBuilder + private func gatewayDiscoverySection() -> some View { + HStack(spacing: 8) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.gatewayDiscovery.statusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.gatewayDiscovery.gateways.isEmpty { + ProgressView().controlSize(.small) + Button("Refresh") { + self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) + } + .buttonStyle(.link) + .help("Retry Tailscale discovery (DNS-SD).") + } + Spacer(minLength: 0) + } - Text(self.state.remoteTransport == .direct - ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." - : "Tip: keep Tailscale enabled so your gateway stays reachable.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .transition(.opacity.combined(with: .move(edge: .top))) + if self.gatewayDiscovery.gateways.isEmpty { + Text("Searching for nearby gateways…") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Nearby gateways") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in + self.connectionChoiceButton( + title: gateway.displayName, + subtitle: self.gatewaySubtitle(for: gateway), + selected: self.isSelectedGateway(gateway)) + { + self.selectRemoteGateway(gateway) } } } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + } + } + + @ViewBuilder + private func advancedConnectionSection() -> some View { + Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + self.showAdvancedConnection.toggle() + } + if self.showAdvancedConnection, self.state.connectionMode != .remote { + self.state.connectionMode = .remote + } + } + .buttonStyle(.link) + + if self.showAdvancedConnection { + let labelWidth: CGFloat = 110 + let fieldWidth: CGFloat = 320 + + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) + } + .pickerStyle(.segmented) + .frame(width: fieldWidth) + } + if self.state.remoteTransport == .direct { + GridRow { + Text("Gateway URL") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + } + if self.state.remoteTransport == .ssh { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + if let message = CommandResolver + .sshTargetValidationMessage(self.state.remoteTarget) + { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(width: fieldWidth, alignment: .leading) + } + } + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField( + "/Applications/OpenClaw.app/.../openclaw", + text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + } + } + + Text(self.state.remoteTransport == .direct + ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." + : "Tip: keep Tailscale enabled so your gateway stays reachable.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .transition(.opacity.combined(with: .move(edge: .top))) } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift index 1895b2af94f..7538f846b89 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift @@ -13,8 +13,10 @@ extension OnboardingView { guard self.state.connectionMode == .local else { return } let configured = await self.loadAgentWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - switch AgentWorkspace.bootstrapSafety(for: url) { - case .safe: + let safety = AgentWorkspace.bootstrapSafety(for: url) + if let reason = safety.unsafeReason { + self.workspaceStatus = "Workspace not touched: \(reason)" + } else { do { _ = try AgentWorkspace.bootstrap(workspaceURL: url) if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -23,8 +25,6 @@ extension OnboardingView { } catch { self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" } - case let .unsafe (reason): - self.workspaceStatus = "Workspace not touched: \(reason)" } self.refreshBootstrapStatus() } @@ -54,7 +54,7 @@ extension OnboardingView { do { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - if case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) { + if let reason = AgentWorkspace.bootstrapSafety(for: url).unsafeReason { self.workspaceStatus = "Workspace not created: \(reason)" return } diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift index a6d81f50bca..7c047e01d03 100644 --- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift +++ b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift @@ -383,12 +383,12 @@ final class ExecApprovalsSettingsModel { func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { guard !self.isDefaultsScope else { return nil } switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) self.allowlistValidationMessage = rejected.first?.reason.message return rejected.first?.reason - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } @@ -400,9 +400,9 @@ final class ExecApprovalsSettingsModel { guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } var next = entry switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): next.pattern = normalizedPattern - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 70184ce9cc7..a8d8008c653 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -810,25 +810,59 @@ extension TalkModeRuntime { return trimmed.isEmpty ? nil : trimmed } + private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? { + if let typed = value.value as? [String: AnyCodable] { + return typed + } + if let foundation = value.value as? [String: Any] { + return foundation.mapValues(AnyCodable.init) + } + if let nsDict = value.value as? NSDictionary { + var converted: [String: AnyCodable] = [:] + for case let (key as String, raw) in nsDict { + converted[key] = AnyCodable(raw) + } + return converted + } + return nil + } + + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { + guard let raw else { return [:] } + var providerMap: [String: AnyCodable] = [:] + if let typed = raw.value as? [String: AnyCodable] { + providerMap = typed + } else if let foundation = raw.value as? [String: Any] { + providerMap = foundation.mapValues(AnyCodable.init) + } else if let nsDict = raw.value as? NSDictionary { + for case let (key as String, value) in nsDict { + providerMap[key] = AnyCodable(value) + } + } else { + return [:] + } + + return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let providerConfig = Self.normalizedTalkProviderConfig(entry.value) + else { return } + acc[providerID] = providerConfig + } + } + static func selectTalkProviderConfig( _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? { guard let talk else { return nil } let rawProvider = talk["provider"]?.stringValue - let rawProviders = talk["providers"]?.dictionaryValue + let rawProviders = talk["providers"] let hasNormalizedPayload = rawProvider != nil || rawProviders != nil if hasNormalizedPayload { - let normalizedProviders = - rawProviders?.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in - guard - let providerID = Self.normalizedTalkProviderID(entry.key), - let providerConfig = entry.value.dictionaryValue - else { return } - acc[providerID] = providerConfig - } ?? [:] + let normalizedProviders = Self.normalizedTalkProviders(rawProviders) let providerID = Self.normalizedTalkProviderID(rawProvider) ?? - normalizedProviders.keys.sorted().first ?? + normalizedProviders.keys.min() ?? Self.defaultTalkProvider return TalkProviderConfigSelection( provider: providerID, @@ -877,14 +911,14 @@ extension TalkModeRuntime { let apiKey = activeConfig?["apiKey"]?.stringValue let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) } else { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) } let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { (envApiKey?.isEmpty == false ? envApiKey : nil) ?? - (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) } else { nil } diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift index 6d5e4a37efd..8794a3f22fc 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift @@ -59,12 +59,7 @@ struct AgentWorkspaceTests { try "hello".write(to: marker, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .unsafe: - break - case .safe: - #expect(Bool(false), "Expected unsafe bootstrap safety result.") - } + #expect(result.unsafeReason != nil) } @Test @@ -77,12 +72,7 @@ struct AgentWorkspaceTests { try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .safe: - break - case .unsafe: - #expect(Bool(false), "Expected safe bootstrap safety result.") - } + #expect(result.unsafeReason == nil) } @Test