Files
openclaw/apps/macos/Sources/OpenClaw/GeneralSettings.swift
2026-05-18 15:37:36 +01:00

894 lines
32 KiB
Swift

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<Bool> {
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<String>) -> 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