import AppKit import Observation import OpenClawDiscovery import OpenClawIPC import OpenClawKit import SwiftUI struct GeneralSettings: View { enum Page { case general case connection } private static let remoteFieldWidth: CGFloat = 320 private static let remoteSecretFieldWidth: CGFloat = 300 @Bindable var state: AppState @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false let page: Page let isActive: Bool private let healthStore = HealthStore.shared private let gatewayManager = GatewayProcessManager.shared @State private var gatewayDiscovery = GatewayDiscoveryModel( localDisplayName: InstanceIdentity.displayName) @State private var gatewayStatus: GatewayEnvironmentStatus = .checking @State private var remoteStatus: RemoteStatus = .idle @State private var showRemoteAdvanced = false private let isPreview = ProcessInfo.processInfo.isPreview private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } init(state: AppState, page: Page = .general, isActive: Bool = true) { self.state = state self.page = page self.isActive = isActive } var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 20) { switch self.page { case .general: self.generalPage case .connection: self.connectionPage } } .settingsDetailContent() } .onAppear { self.updateActiveWork(active: self.isActive) } .onChange(of: self.isActive) { _, active in self.updateActiveWork(active: active) } .onChange(of: self.state.canvasEnabled) { _, enabled in if !enabled { CanvasManager.shared.hideAll() } } .onDisappear { self.gatewayDiscovery.stop() } } private var generalPage: some View { VStack(alignment: .leading, spacing: 20) { SettingsPageHeader( title: "General", subtitle: "Everyday OpenClaw app behavior.") self.openClawStatusPanel SettingsCardGroup("App") { SettingsCardToggleRow( title: "Launch at login", subtitle: "Automatically start OpenClaw after you sign in.", binding: self.$state.launchAtLogin) SettingsCardToggleRow( title: "Show Dock icon", subtitle: "Keep OpenClaw visible in the Dock. When off, windows still show the Dock icon while open.", binding: self.$state.showDockIcon) SettingsCardToggleRow( title: "Play menu bar icon animations", subtitle: "Enable idle blinks and wiggles on the status icon.", binding: self.$state.iconAnimationsEnabled, showsDivider: false) } SettingsCardGroup("Capabilities") { SettingsCardToggleRow( title: "Allow Canvas", subtitle: "Allow the agent to show and control the Canvas panel.", binding: self.$state.canvasEnabled) SettingsCardToggleRow( title: "Allow Camera", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", binding: self.$cameraEnabled) SettingsCardToggleRow( title: "Enable Peekaboo Bridge", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", binding: self.$state.peekabooBridgeEnabled, showsDivider: false) } SettingsCardGroup("Developer") { SettingsCardToggleRow( title: "Enable debug tools", subtitle: "Show the Debug page with development utilities.", binding: self.$state.debugPaneEnabled, showsDivider: false) } HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 3) { Text("App session") .font(.callout.weight(.medium)) Text("Quit only when you want to stop the menu bar app completely.") .font(.footnote) .foregroundStyle(.secondary) } Spacer(minLength: 18) Button("Quit") { NSApp.terminate(nil) } .buttonStyle(.bordered) .controlSize(.small) } .padding(.top, 2) } } private var openClawStatusPanel: some View { HStack(alignment: .center, spacing: 14) { ZStack { Circle() .fill(self.state.isPaused ? Color.orange.opacity(0.18) : Color.green.opacity(0.18)) Image(systemName: self.state.isPaused ? "pause.fill" : "checkmark") .font(.system(size: 16, weight: .bold)) .foregroundStyle(self.state.isPaused ? .orange : .green) } .frame(width: 42, height: 42) VStack(alignment: .leading, spacing: 4) { Text(self.state.isPaused ? "OpenClaw paused" : "OpenClaw active") .font(.headline) Text(self.generalStatusSubtitle) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 20) Toggle("OpenClaw active", isOn: self.activeBinding) .labelsHidden() .toggleStyle(.switch) } .padding(.horizontal, 16) .padding(.vertical, 14) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(.white.opacity(0.06)) } } private var generalStatusSubtitle: String { if self.state.isPaused { return "Gateway work is paused; incoming messages will wait." } switch self.state.connectionMode { case .local: return "Processing messages through the local Gateway on this Mac." case .remote: return "Connected to a remote Gateway configuration." case .unconfigured: return "Ready to run after you choose a Gateway connection." } } private var connectionPage: some View { VStack(alignment: .leading, spacing: 20) { SettingsPageHeader( title: "Connection", subtitle: "Choose where the Gateway runs and how this Mac app reaches it.") self.connectionStatusPanel self.gatewayModeGroup switch self.state.connectionMode { case .unconfigured: EmptyView() case .local: self.localGatewayGroup case .remote: self.remoteCard } } } private var activeBinding: Binding { Binding( get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) } private func updateActiveWork(active: Bool) { guard !self.isPreview else { return } if active { self.refreshGatewayStatus() if self.page == .connection { self.gatewayDiscovery.start() } } else { self.gatewayDiscovery.stop() } } private var connectionStatusPanel: some View { HStack(alignment: .center, spacing: 14) { ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(self.connectionStatusTint.opacity(0.18)) Image(systemName: self.connectionStatusIcon) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(self.connectionStatusTint) } .frame(width: 46, height: 46) VStack(alignment: .leading, spacing: 4) { Text(self.connectionStatusTitle) .font(.headline) Text(self.connectionStatusSubtitle) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 18) if let ping = ControlChannel.shared.lastPingMs { Text("\(Int(ping)) ms") .font(.caption.weight(.semibold)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.green.opacity(0.16), in: Capsule()) .foregroundStyle(.green) } } .padding(.horizontal, 16) .padding(.vertical, 14) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(.white.opacity(0.06)) } } private var connectionStatusIcon: String { switch self.state.connectionMode { case .local: "desktopcomputer" case .remote: self.state.remoteTransport == .ssh ? "point.3.connected.trianglepath.dotted" : "network" case .unconfigured: "questionmark.circle" } } private var connectionStatusTint: Color { switch ControlChannel.shared.state { case .connected: .green case .connecting, .disconnected, .degraded: .orange } } private var connectionStatusTitle: String { switch self.state.connectionMode { case .local: "Local Gateway" case .remote: self.state.remoteTransport == .ssh ? "Remote Gateway via SSH" : "Remote Gateway direct" case .unconfigured: "Gateway not configured" } } private var connectionStatusSubtitle: String { switch self.state.connectionMode { case .local: return "OpenClaw starts and monitors the Gateway on this Mac." case .remote: let target = self.state.remoteTransport == .ssh ? self.state.remoteTarget : self.state.remoteUrl let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return "Enter a remote endpoint so this Mac app can attach cleanly." } return "\(self.controlStatusLine) · \(trimmed)" case .unconfigured: return "Choose local or remote before the app can attach to a Gateway." } } private var gatewayModeGroup: some View { SettingsCardGroup("Gateway") { SettingsCardRow( title: "OpenClaw runs", subtitle: "Pick whether this app owns a local Gateway or attaches to another host.", showsDivider: self.state.connectionMode == .unconfigured) { Picker("Gateway location", selection: self.$state.connectionMode) { Text("Not configured").tag(AppState.ConnectionMode.unconfigured) Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Remote (another host)").tag(AppState.ConnectionMode.remote) } .pickerStyle(.menu) .labelsHidden() .frame(width: 260, alignment: .trailing) } if self.state.connectionMode == .unconfigured { SettingsCardRow( title: "Setup needed", subtitle: "Local is best for this Mac. Remote is best when the Gateway already runs on a Mac Studio or server.", showsDivider: false) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } } } private var localGatewayGroup: some View { VStack(alignment: .leading, spacing: 20) { SettingsCardGroup("Local Gateway") { if !self.isNixMode { self.gatewayInstallerCard } self.healthRow .padding(.horizontal, 14) .padding(.vertical, 11) } TailscaleIntegrationSection( connectionMode: self.state.connectionMode, isPaused: self.state.isPaused) } } private var remoteCard: some View { VStack(alignment: .leading, spacing: 20) { SettingsCardGroup("Remote Access") { self.remoteTransportRow if self.state.remoteTransport == .ssh { self.remoteSshRow } else { self.remoteDirectRow } self.remoteTokenRow } SettingsCardGroup("Discovery & Status") { self.remoteDiscoveryRow self.remoteStatusRow self.controlChannelRow self.remoteTipRow } if self.state.remoteTransport == .ssh { self.remoteAdvancedGroup } } .transition(.opacity) } private var remoteDiscoveryRow: some View { VStack(alignment: .leading, spacing: 8) { Text("Nearby gateways") .font(.callout.weight(.medium)) GatewayDiscoveryInlineList( discovery: self.gatewayDiscovery, currentTarget: self.state.remoteTarget, currentUrl: self.state.remoteUrl, transport: self.state.remoteTransport) { gateway in self.applyDiscoveredGateway(gateway) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 11) .overlay(alignment: .bottom) { Divider() .padding(.leading, 14) } } @ViewBuilder private var remoteStatusRow: some View { if self.remoteStatus != .idle { SettingsCardRow(title: "Remote test") { self.remoteStatusView } } } private var controlChannelRow: some View { SettingsCardRow(title: "Control channel", subtitle: self.controlChannelSubtitle) { Text(self.controlStatusLine) .font(.caption.weight(.semibold)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(self.connectionStatusTint.opacity(0.16), in: Capsule()) .foregroundStyle(self.connectionStatusTint) } } private var controlChannelSubtitle: String? { var parts: [String] = [] if let ping = ControlChannel.shared.lastPingMs { parts.append("Ping \(Int(ping)) ms") } if let hb = HeartbeatStore.shared.lastEvent { let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000)) parts.append("Last heartbeat \(hb.status) · \(ageText)") } if let authLabel = ControlChannel.shared.authSourceLabel { parts.append(authLabel) } return parts.isEmpty ? nil : parts.joined(separator: "\n") } private var remoteTipRow: some View { SettingsCardRow( title: "Recommended setup", subtitle: self.state.remoteTransport == .ssh ? "Use Tailscale plus an SSH tunnel for stable private access." : "Use Tailscale Serve so the gateway has a valid HTTPS certificate.", showsDivider: false) { Image(systemName: "lightbulb.fill") .foregroundStyle(.yellow) } } private var remoteAdvancedGroup: some View { SettingsCardGroup("Advanced") { DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { VStack(alignment: .leading, spacing: 12) { self.advancedTextField( "Identity file", placeholder: "/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) self.advancedTextField( "Project root", placeholder: "/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) self.advancedTextField( "CLI path", placeholder: "/Applications/OpenClaw.app/.../openclaw", text: self.$state.remoteCliPath) } .padding(.top, 10) } label: { Text("SSH command details") .font(.callout.weight(.medium)) } .padding(.horizontal, 14) .padding(.vertical, 11) } } private func advancedTextField(_ title: String, placeholder: String, text: Binding) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) TextField(placeholder, text: text) .textFieldStyle(.roundedBorder) } } private var remoteTransportRow: some View { SettingsCardRow( title: "Transport", subtitle: "SSH keeps the Gateway private; direct is best for HTTPS or Tailscale Serve.") { 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: 320) } } private var remoteSshRow: some View { let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) let canTest = !trimmedTarget.isEmpty && validationMessage == nil return VStack(alignment: .leading, spacing: 0) { SettingsCardRow(title: "SSH target", subtitle: "User and host for the remote Gateway machine.") { TextField("user@host[:22]", text: self.$state.remoteTarget) .textFieldStyle(.roundedBorder) .frame(width: Self.remoteFieldWidth) self.remoteTestButton(disabled: !canTest) } if let validationMessage { Text(validationMessage) .font(.caption) .foregroundStyle(.red) .padding(.horizontal, 14) .padding(.bottom, 10) } } } private var remoteDirectRow: some View { SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) .textFieldStyle(.roundedBorder) .frame(width: Self.remoteFieldWidth) self.remoteTestButton( disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text("Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } private var remoteTokenRow: some View { VStack(alignment: .leading, spacing: 0) { SettingsCardRow( title: "Gateway token", subtitle: "Used when the remote gateway requires token auth.", showsDivider: false) { SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(width: Self.remoteSecretFieldWidth) } if self.state.remoteTokenUnsupported { Text( "The current gateway.remote.token value is not plain text. " + "OpenClaw for macOS cannot use it directly; " + "enter a plaintext token here to replace it.") .font(.caption) .foregroundStyle(.orange) .padding(.horizontal, 14) .padding(.bottom, 10) } } } private func remoteTestButton(disabled: Bool) -> some View { Button { Task { await self.testRemote() } } label: { if self.remoteStatus == .checking { ProgressView().controlSize(.small) } else { Text("Test remote") } } .buttonStyle(.borderedProminent) .frame(minWidth: 116) .disabled(self.remoteStatus == .checking || disabled) } private var controlStatusLine: String { switch ControlChannel.shared.state { case .connected: "Connected" case .connecting: "Connecting…" case .disconnected: "Disconnected" case let .degraded(msg): msg } } @ViewBuilder private var remoteStatusView: some View { switch self.remoteStatus { case .idle: EmptyView() case .checking: Text("Testing…") .font(.caption) .foregroundStyle(.secondary) case let .ok(success): VStack(alignment: .leading, spacing: 2) { Label(success.title, systemImage: "checkmark.circle.fill") .font(.caption) .foregroundStyle(.green) if let detail = success.detail { Text(detail) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } case let .failed(message): Text(message) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } private var gatewayInstallerCard: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { Circle() .fill(self.gatewayStatusColor) .frame(width: 10, height: 10) Text(self.gatewayStatus.message) .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) } if let gatewayVersion = self.gatewayStatus.gatewayVersion, let required = self.gatewayStatus.requiredGateway, gatewayVersion != required { Text("Installed: \(gatewayVersion) · Required: \(required)") .font(.caption) .foregroundStyle(.secondary) } else if let gatewayVersion = self.gatewayStatus.gatewayVersion { Text("Gateway \(gatewayVersion) detected") .font(.caption) .foregroundStyle(.secondary) } if let node = self.gatewayStatus.nodeVersion { Text("Node \(node)") .font(.caption) .foregroundStyle(.secondary) } if case let .attachedExisting(details) = self.gatewayManager.status { Text(details ?? "Using existing gateway instance") .font(.caption) .foregroundStyle(.secondary) } if let failure = self.gatewayManager.lastFailureReason { Text("Last failure: \(failure)") .font(.caption) .foregroundStyle(.red) } Button("Recheck") { self.refreshGatewayStatus() } .buttonStyle(.bordered) Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).") .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } .padding(12) .background(Color.gray.opacity(0.08)) .cornerRadius(10) } private func refreshGatewayStatus() { Task { let status = await Task.detached(priority: .utility) { GatewayEnvironment.check() }.value self.gatewayStatus = status } } private var gatewayStatusColor: Color { switch self.gatewayStatus.kind { case .ok: .green case .checking: .secondary case .missingNode, .missingGateway, .incompatible, .error: .orange } } private var healthCard: some View { let snapshot = self.healthStore.snapshot return VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Circle() .fill(self.healthStore.state.tint) .frame(width: 10, height: 10) Text(self.healthStore.summaryLine) .font(.callout.weight(.semibold)) } if let snap = snapshot { let linkId = snap.channelOrder?.first(where: { if let summary = snap.channels[$0] { return summary.linked != nil } return false }) ?? snap.channels.keys.first(where: { if let summary = snap.channels[$0] { return summary.linked != nil } return false }) let linkLabel = linkId.flatMap { snap.channelLabels?[$0] } ?? linkId?.capitalized ?? "Link channel" let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs } Text("\(linkLabel) auth age: \(healthAgeString(linkAge))") .font(.caption) .foregroundStyle(.secondary) Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)") .font(.caption) .foregroundStyle(.secondary) if let recent = snap.sessions.recent.first { let lastActivity = recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown" Text("Last activity: \(recent.key) \(lastActivity)") .font(.caption) .foregroundStyle(.secondary) } Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))") .font(.caption) .foregroundStyle(.secondary) } else if let error = self.healthStore.lastError { Text(error) .font(.caption) .foregroundStyle(.red) } else { Text("Health check pending…") .font(.caption) .foregroundStyle(.secondary) } HStack(spacing: 12) { Button { Task { await self.healthStore.refresh(onDemand: true) } } label: { if self.healthStore.isRefreshing { ProgressView().controlSize(.small) } else { Label("Run Health Check", systemImage: "arrow.clockwise") } } .disabled(self.healthStore.isRefreshing) Divider().frame(height: 18) Button { self.revealLogs() } label: { Label("Reveal Logs", systemImage: "doc.text.magnifyingglass") } } } .padding(12) .background(Color.gray.opacity(0.08)) .cornerRadius(10) } } private enum RemoteStatus: Equatable { case idle case checking case ok(RemoteGatewayProbeSuccess) case failed(String) } extension GeneralSettings { private var healthRow: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 10) { Circle() .fill(self.healthStore.state.tint) .frame(width: 10, height: 10) Text(self.healthStore.summaryLine) .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) } if let detail = self.healthStore.detailLine { Text(detail) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 10) { Button("Retry now") { Task { await HealthStore.shared.refresh(onDemand: true) } } .disabled(self.healthStore.isRefreshing) Button("Open logs") { self.revealLogs() } .buttonStyle(.link) .foregroundStyle(.secondary) } .font(.caption) } } @MainActor func testRemote() async { self.remoteStatus = .checking switch await RemoteGatewayProbe.run() { case let .ready(success): self.remoteStatus = .ok(success) case let .authIssue(issue): self.remoteStatus = .failed(issue.statusMessage) case let .failed(message): self.remoteStatus = .failed(message) } } private func revealLogs() { let target = LogLocator.bestLogFile() if let target { NSWorkspace.shared.selectFile( target.path, inFileViewerRootedAtPath: target.deletingLastPathComponent().path) return } let alert = NSAlert() alert.messageText = "Log file not found" alert.informativeText = """ Looked for openclaw logs in /tmp/openclaw/. Run a health check or send a message to generate activity, then try again. """ alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state) } } private func healthAgeString(_ ms: Double?) -> String { guard let ms else { return "unknown" } return msToAge(ms) } #if DEBUG struct GeneralSettings_Previews: PreviewProvider { static var previews: some View { GeneralSettings(state: .preview) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .environment(TailscaleService.shared) } } @MainActor extension GeneralSettings { static func exerciseForTesting() { let state = AppState(preview: true) state.connectionMode = .remote state.remoteTransport = .ssh state.remoteTarget = "user@host:2222" state.remoteUrl = "wss://gateway.example.ts.net" state.remoteToken = "example-token" state.remoteIdentity = "/tmp/id_ed25519" state.remoteProjectRoot = "/tmp/openclaw" state.remoteCliPath = "/tmp/openclaw" let view = GeneralSettings(state: state) view.gatewayStatus = GatewayEnvironmentStatus( kind: .ok, nodeVersion: "1.0.0", gatewayVersion: "1.0.0", requiredGateway: nil, message: "Gateway ready") view.remoteStatus = .failed("SSH failed") view.showRemoteAdvanced = true _ = view.body state.connectionMode = .unconfigured _ = view.body state.connectionMode = .local view.gatewayStatus = GatewayEnvironmentStatus( kind: .error("Gateway offline"), nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Gateway offline") _ = view.body } } #endif