mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 17:12:30 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
1018 lines
41 KiB
Swift
1018 lines
41 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
struct DebugSettings: View {
|
|
@Bindable var state: AppState
|
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
|
private let labelColumnWidth: CGFloat = 140
|
|
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
|
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
|
private let gatewayManager = GatewayProcessManager.shared
|
|
private let healthStore = HealthStore.shared
|
|
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
|
@State private var launchAgentWriteError: String?
|
|
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
|
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
|
@State private var sessionStoreSaveError: String?
|
|
@State private var debugSendInFlight = false
|
|
@State private var debugSendStatus: String?
|
|
@State private var debugSendError: String?
|
|
@State private var portCheckInFlight = false
|
|
@State private var portReports: [DebugActions.PortReport] = []
|
|
@State private var portKillStatus: String?
|
|
@State private var tunnelResetInFlight = false
|
|
@State private var tunnelResetStatus: String?
|
|
@State private var pendingKill: DebugActions.PortListener?
|
|
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
|
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
|
|
|
@State private var canvasSessionKey: String = "main"
|
|
@State private var canvasStatus: String?
|
|
@State private var canvasError: String?
|
|
@State private var canvasEvalJS: String = "document.title"
|
|
@State private var canvasEvalResult: String?
|
|
@State private var canvasSnapshotPath: String?
|
|
|
|
init(state: AppState = AppStateStore.shared) {
|
|
self.state = state
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
self.header
|
|
|
|
self.overviewSection
|
|
self.launchdSection
|
|
self.appInfoSection
|
|
self.gatewaySection
|
|
self.logsSection
|
|
self.portsSection
|
|
self.pathsSection
|
|
self.quickActionsSection
|
|
self.canvasSection
|
|
self.experimentsSection
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.settingsDetailContent()
|
|
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
|
}
|
|
.task {
|
|
guard !self.isPreview else { return }
|
|
self.loadSessionStorePath()
|
|
}
|
|
.alert(item: self.$pendingKill) { listener in
|
|
Alert(
|
|
title: Text("Kill \(listener.command) (\(listener.pid))?"),
|
|
message: Text("This process looks expected for the current mode. Kill anyway?"),
|
|
primaryButton: .destructive(Text("Kill")) {
|
|
Task { await self.killConfirmed(listener.pid) }
|
|
},
|
|
secondaryButton: .cancel())
|
|
}
|
|
}
|
|
|
|
private var launchdSection: some View {
|
|
GroupBox("Gateway startup") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
|
|
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
|
|
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
|
|
if self.launchAgentWriteError != nil {
|
|
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
|
return
|
|
}
|
|
}
|
|
|
|
Text(
|
|
"When enabled, OpenClaw won't install or manage \(gatewayLaunchdLabel). " +
|
|
"It will only attach to an existing Gateway.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let launchAgentWriteError {
|
|
Text(launchAgentWriteError)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Debug")
|
|
.font(.title3.weight(.semibold))
|
|
Text("Tools for diagnosing local issues (Gateway, ports, logs, Canvas).")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var overviewSection: some View {
|
|
HStack(spacing: 12) {
|
|
DebugMetricCard(
|
|
title: "App Health",
|
|
value: self.healthStore.state.debugTitle,
|
|
icon: "heart.text.square",
|
|
tint: self.healthStore.state.tint,
|
|
subtitle: self.healthStore.summaryLine)
|
|
|
|
DebugMetricCard(
|
|
title: "Gateway",
|
|
value: self.gatewayManager.status.label,
|
|
icon: "antenna.radiowaves.left.and.right",
|
|
tint: self.gatewayManager.status.debugTint,
|
|
subtitle: self.canRestartGateway ? "Local process" : "Remote connection")
|
|
|
|
DebugMetricCard(
|
|
title: "App PID",
|
|
value: "\(ProcessInfo.processInfo.processIdentifier)",
|
|
icon: "number.square",
|
|
tint: .blue,
|
|
subtitle: Bundle.main.bundleURL.lastPathComponent)
|
|
}
|
|
}
|
|
|
|
private func gridLabel(_ text: String) -> some View {
|
|
Text(text)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
|
}
|
|
|
|
private var appInfoSection: some View {
|
|
GroupBox("App") {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Health")
|
|
HStack(spacing: 8) {
|
|
Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10)
|
|
Text(self.healthStore.summaryLine)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("CLI")
|
|
let loc = CLIInstaller.installedLocation()
|
|
Text(loc ?? "missing")
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(loc == nil ? Color.red : Color.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("PID")
|
|
Text("\(ProcessInfo.processInfo.processIdentifier)")
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Binary path")
|
|
Text(Bundle.main.bundlePath)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var gatewaySection: some View {
|
|
GroupBox("Gateway") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Status")
|
|
HStack(spacing: 8) {
|
|
Text(self.gatewayManager.status.label)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
let key = DeepLinkHandler.currentKey()
|
|
HStack(spacing: 8) {
|
|
Text("Key")
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
|
Text(key)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Button("Copy") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(key, forType: .string)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Button("Copy sample URL") {
|
|
let msg = "Hello from deep link"
|
|
let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg
|
|
let url = "openclaw://agent?message=\(encoded)&key=\(key)"
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(url, forType: .string)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
Text("Deep links (openclaw://…) are always enabled; the key controls unattended runs.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Stdout / stderr")
|
|
.font(.caption.weight(.semibold))
|
|
ScrollView {
|
|
Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log)
|
|
.font(.caption.monospaced())
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
.frame(height: 130)
|
|
.background(.black.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.06))
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
if self.canRestartGateway {
|
|
Button("Restart Gateway") { DebugActions.restartGateway() }
|
|
}
|
|
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
|
|
Spacer(minLength: 0)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var logsSection: some View {
|
|
GroupBox("Logs") {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Pino log")
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
Button("Open") { DebugActions.openLog() }
|
|
.buttonStyle(.bordered)
|
|
Text(DebugActions.pinoLogPath())
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
}
|
|
}
|
|
|
|
GridRow {
|
|
self.gridLabel("App logging")
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
|
ForEach(AppLogLevel.allCases) { level in
|
|
Text(level.title).tag(level.rawValue)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.labelsHidden()
|
|
.help("Controls the macOS app log verbosity.")
|
|
|
|
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
|
.toggleStyle(.checkbox)
|
|
.help(
|
|
"Writes a rotating, local-only log under ~/Library/Logs/OpenClaw/. " +
|
|
"Enable only while actively debugging.")
|
|
|
|
HStack(spacing: 8) {
|
|
Button("Open folder") {
|
|
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Button("Clear") {
|
|
Task { try? await DiagnosticsFileLog.shared.clear() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
Text(DiagnosticsFileLog.logFileURL().path)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var portsSection: some View {
|
|
GroupBox("Ports") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
Text("Port diagnostics")
|
|
.font(.caption.weight(.semibold))
|
|
if self.portCheckInFlight { ProgressView().controlSize(.small) }
|
|
Spacer()
|
|
Button("Check gateway ports") {
|
|
Task { await self.runPortCheck() }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(self.portCheckInFlight)
|
|
Button("Reset SSH tunnel") {
|
|
Task { await self.resetGatewayTunnel() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(self.tunnelResetInFlight || !self.isRemoteMode)
|
|
}
|
|
|
|
if let portKillStatus {
|
|
Text(portKillStatus)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
if let tunnelResetStatus {
|
|
Text(tunnelResetStatus)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if self.portReports.isEmpty, !self.portCheckInFlight {
|
|
Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(self.portReports) { report in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Port \(report.port)")
|
|
.font(.footnote.weight(.semibold))
|
|
Text(report.summary)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
ForEach(report.listeners) { listener in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 8) {
|
|
Text("\(listener.command) (\(listener.pid))")
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(listener.expected ? .secondary : Color.red)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Button("Kill") {
|
|
self.requestKill(listener)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
Text(listener.fullCommand)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
}
|
|
.padding(6)
|
|
.background(Color.secondary.opacity(0.05))
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
.padding(8)
|
|
.background(Color.secondary.opacity(0.08))
|
|
.cornerRadius(6)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var pathsSection: some View {
|
|
GroupBox("Paths") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("OpenClaw project root")
|
|
.font(.caption.weight(.semibold))
|
|
HStack(spacing: 8) {
|
|
TextField("Path to openclaw repo", text: self.$gatewayRootInput)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.caption.monospaced())
|
|
.onSubmit { self.saveRelayRoot() }
|
|
Button("Save") { self.saveRelayRoot() }
|
|
.buttonStyle(.borderedProminent)
|
|
Button("Reset") {
|
|
let def = FileManager().homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Projects/openclaw").path
|
|
self.gatewayRootInput = def
|
|
self.saveRelayRoot()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
Text("Used for pnpm/node fallback and PATH population when launching the gateway.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Session store")
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
TextField("Path", text: self.$sessionStorePath)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.caption.monospaced())
|
|
.frame(width: 360)
|
|
Button("Save") { self.saveSessionStorePath() }
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
if let sessionStoreSaveError {
|
|
Text(sessionStoreSaveError)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Used by the CLI session loader; stored in ~/.openclaw/openclaw.json.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var quickActionsSection: some View {
|
|
GroupBox("Quick actions") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
Button("Send Test Notification") {
|
|
Task { await DebugActions.sendTestNotification() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button("Open Agent Events") {
|
|
DebugActions.openAgentEventsWindow()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Button {
|
|
Task { await self.sendVoiceDebug() }
|
|
} label: {
|
|
Label(
|
|
self.debugSendInFlight ? "Sending debug voice…" : "Send debug voice",
|
|
systemImage: self.debugSendInFlight ? "bolt.horizontal.circle" : "waveform")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(self.debugSendInFlight)
|
|
|
|
if !self.debugSendInFlight {
|
|
if let debugSendStatus {
|
|
Text(debugSendStatus)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else if let debugSendError {
|
|
Text(debugSendError)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
} else {
|
|
Text(
|
|
"""
|
|
Uses the Voice Wake path: forwards over SSH when configured,
|
|
otherwise runs locally via rpc.
|
|
""")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(
|
|
"Note: macOS may require restarting OpenClaw after enabling Accessibility or Screen Recording.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Button {
|
|
LaunchdManager.startOpenClaw()
|
|
} label: {
|
|
Label("Restart OpenClaw", systemImage: "arrow.counterclockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Button("Restart app") { DebugActions.restartApp() }
|
|
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
|
Button("Reveal app in Finder") { self.revealApp() }
|
|
Spacer(minLength: 0)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var canvasSection: some View {
|
|
GroupBox("Canvas") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Enable/disable Canvas in General settings.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("Session", text: self.$canvasSessionKey)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.caption.monospaced())
|
|
.frame(width: 160)
|
|
Button("Show panel") {
|
|
Task { await self.canvasPresent() }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
Button("Hide panel") {
|
|
CanvasManager.shared.hideAll()
|
|
self.canvasStatus = "hidden"
|
|
self.canvasError = nil
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Button("Write sample page") {
|
|
Task { await self.canvasWriteSamplePage() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("Eval JS", text: self.$canvasEvalJS)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.caption.monospaced())
|
|
.frame(maxWidth: 520)
|
|
Button("Eval") {
|
|
Task { await self.canvasEval() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Button("Snapshot") {
|
|
Task { await self.canvasSnapshot() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
if let canvasStatus {
|
|
Text(canvasStatus)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
if let canvasEvalResult {
|
|
Text("eval → \(canvasEvalResult)")
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
}
|
|
if let canvasSnapshotPath {
|
|
HStack(spacing: 8) {
|
|
Text("snapshot → \(canvasSnapshotPath)")
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
Button("Reveal") {
|
|
NSWorkspace.shared
|
|
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
if let canvasError {
|
|
Text(canvasError)
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
} else {
|
|
Text("Tip: the session directory is returned by “Show panel”.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var experimentsSection: some View {
|
|
GroupBox("Experiments") {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Icon override")
|
|
Picker("", selection: self.bindingOverride) {
|
|
ForEach(IconOverrideSelection.allCases) { option in
|
|
Text(option.label).tag(option.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.frame(maxWidth: 280, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Chat")
|
|
Text("Native SwiftUI")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func runPortCheck() async {
|
|
self.portCheckInFlight = true
|
|
self.portKillStatus = nil
|
|
let reports = await DebugActions.checkGatewayPorts()
|
|
self.portReports = reports
|
|
self.portCheckInFlight = false
|
|
}
|
|
|
|
@MainActor
|
|
private func resetGatewayTunnel() async {
|
|
self.tunnelResetInFlight = true
|
|
self.tunnelResetStatus = nil
|
|
let result = await DebugActions.resetGatewayTunnel()
|
|
switch result {
|
|
case let .success(message):
|
|
self.tunnelResetStatus = message
|
|
case let .failure(err):
|
|
self.tunnelResetStatus = err.localizedDescription
|
|
}
|
|
await self.runPortCheck()
|
|
self.tunnelResetInFlight = false
|
|
}
|
|
|
|
@MainActor
|
|
private func requestKill(_ listener: DebugActions.PortListener) {
|
|
if listener.expected {
|
|
self.pendingKill = listener
|
|
} else {
|
|
Task { await self.killConfirmed(listener.pid) }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func killConfirmed(_ pid: Int32) async {
|
|
let result = await DebugActions.killProcess(Int(pid))
|
|
switch result {
|
|
case .success:
|
|
self.portKillStatus = "Sent kill to \(pid)."
|
|
await self.runPortCheck()
|
|
case let .failure(err):
|
|
self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
private func sendVoiceDebug() async {
|
|
await MainActor.run {
|
|
self.debugSendInFlight = true
|
|
self.debugSendError = nil
|
|
self.debugSendStatus = nil
|
|
}
|
|
|
|
let result = await DebugActions.sendDebugVoice()
|
|
|
|
await MainActor.run {
|
|
self.debugSendInFlight = false
|
|
switch result {
|
|
case let .success(message):
|
|
self.debugSendStatus = message
|
|
self.debugSendError = nil
|
|
case let .failure(error):
|
|
self.debugSendStatus = nil
|
|
self.debugSendError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private func revealApp() {
|
|
let url = Bundle.main.bundleURL
|
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
}
|
|
|
|
private func saveRelayRoot() {
|
|
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
|
|
}
|
|
|
|
private func loadSessionStorePath() {
|
|
let parsed = OpenClawConfigFile.loadDict()
|
|
guard
|
|
let session = parsed["session"] as? [String: Any],
|
|
let path = session["store"] as? String
|
|
else {
|
|
self.sessionStorePath = SessionLoader.defaultStorePath
|
|
return
|
|
}
|
|
self.sessionStorePath = path
|
|
}
|
|
|
|
private func saveSessionStorePath() {
|
|
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var root = OpenClawConfigFile.loadDict()
|
|
|
|
var session = root["session"] as? [String: Any] ?? [:]
|
|
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
|
|
root["session"] = session
|
|
|
|
guard OpenClawConfigFile.saveDict(root) else {
|
|
self.sessionStoreSaveError = "Config write rejected to protect gateway auth/mode."
|
|
return
|
|
}
|
|
self.sessionStoreSaveError = nil
|
|
}
|
|
|
|
private var bindingOverride: Binding<String> {
|
|
Binding {
|
|
self.iconOverrideRaw
|
|
} set: { newValue in
|
|
self.iconOverrideRaw = newValue
|
|
if let selection = IconOverrideSelection(rawValue: newValue) {
|
|
Task { @MainActor in
|
|
AppStateStore.shared.iconOverride = selection
|
|
WorkActivityStore.shared.resolveIconState(override: selection)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isRemoteMode: Bool {
|
|
CommandResolver.connectionSettings().mode == .remote
|
|
}
|
|
|
|
private var canRestartGateway: Bool {
|
|
self.state.connectionMode == .local
|
|
}
|
|
}
|
|
|
|
extension DebugSettings {
|
|
// MARK: - Canvas debug actions
|
|
|
|
@MainActor
|
|
private func canvasPresent() async {
|
|
self.canvasError = nil
|
|
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
do {
|
|
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
|
self.canvasStatus = "dir: \(dir)"
|
|
} catch {
|
|
self.canvasError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func canvasWriteSamplePage() async {
|
|
self.canvasError = nil
|
|
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
do {
|
|
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
|
let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false)
|
|
let now = ISO8601DateFormatter().string(from: Date())
|
|
let html = """
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Canvas Debug</title>
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
html,body { height:100%; margin:0; background:#0b1020; color:#e5e7eb; }
|
|
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.wrap { padding:16px; }
|
|
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
|
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08);
|
|
border:1px solid rgba(255,255,255,.12); }
|
|
button { background:#22c55e; color:#04110a; border:0; border-radius:10px;
|
|
padding:8px 10px; font-weight:700; cursor:pointer; }
|
|
button:active { transform: translateY(1px); }
|
|
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06);
|
|
border:1px solid rgba(255,255,255,.1); }
|
|
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
|
|
.box { grid-column: span 4; height:80px; border-radius:14px;
|
|
background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25));
|
|
border:1px solid rgba(255,255,255,.12); }
|
|
.muted { color: rgba(229,231,235,.7); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="row">
|
|
<div class="pill">Canvas Debug</div>
|
|
<div class="pill muted">generated: \(now)</div>
|
|
<div class="pill muted">userAgent: <span id="ua"></span></div>
|
|
<button id="btn">Click me</button>
|
|
<div class="pill">count: <span id="count">0</span></div>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="muted">This is a local file served by the WKURLSchemeHandler.</div>
|
|
<div class="grid">
|
|
<div class="box"></div><div class="box"></div><div class="box"></div>
|
|
<div class="box"></div><div class="box"></div><div class="box"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
document.getElementById('ua').textContent = navigator.userAgent;
|
|
let n = 0;
|
|
document.getElementById('btn').addEventListener('click', () => {
|
|
n++;
|
|
document.getElementById('count').textContent = String(n);
|
|
document.title = 'Canvas Debug (' + n + ')';
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
try html.write(to: url, atomically: true, encoding: .utf8)
|
|
self.canvasStatus = "wrote: \(url.path)"
|
|
_ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
|
} catch {
|
|
self.canvasError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func canvasEval() async {
|
|
self.canvasError = nil
|
|
self.canvasEvalResult = nil
|
|
do {
|
|
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let result = try await CanvasManager.shared.eval(
|
|
sessionKey: session.isEmpty ? "main" : session,
|
|
javaScript: self.canvasEvalJS)
|
|
self.canvasEvalResult = result
|
|
} catch {
|
|
self.canvasError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func canvasSnapshot() async {
|
|
self.canvasError = nil
|
|
self.canvasSnapshotPath = nil
|
|
do {
|
|
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let path = try await CanvasManager.shared.snapshot(
|
|
sessionKey: session.isEmpty ? "main" : session,
|
|
outPath: nil)
|
|
self.canvasSnapshotPath = path
|
|
} catch {
|
|
self.canvasError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
configuration.label
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
configuration.content
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.quaternary.opacity(0.34), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.055))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct DebugMetricCard: View {
|
|
let title: String
|
|
let value: String
|
|
let icon: String
|
|
let tint: Color
|
|
let subtitle: String
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: self.icon)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(self.tint)
|
|
.frame(width: 34, height: 34)
|
|
.background(self.tint.opacity(0.18), in: Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(self.title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Text(self.value)
|
|
.font(.callout.weight(.semibold))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
Text(self.subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.background(.quaternary.opacity(0.28), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.055))
|
|
}
|
|
}
|
|
}
|
|
|
|
extension HealthState {
|
|
fileprivate var debugTitle: String {
|
|
switch self {
|
|
case .unknown: "Unknown"
|
|
case .ok: "Healthy"
|
|
case .linkingNeeded: "Needs Link"
|
|
case .degraded: "Degraded"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension GatewayProcessManager.Status {
|
|
fileprivate var debugTint: Color {
|
|
switch self {
|
|
case .running, .attachedExisting: .green
|
|
case .starting: .orange
|
|
case .failed: .red
|
|
case .stopped: .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
struct DebugSettings_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
DebugSettings(state: .preview)
|
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
extension DebugSettings {
|
|
static func exerciseForTesting() async {
|
|
let view = DebugSettings(state: .preview)
|
|
view.gatewayRootInput = "/tmp/openclaw"
|
|
view.sessionStorePath = "/tmp/sessions.json"
|
|
view.sessionStoreSaveError = "Save failed"
|
|
view.debugSendInFlight = true
|
|
view.debugSendStatus = "Sent"
|
|
view.debugSendError = "Failed"
|
|
view.portCheckInFlight = true
|
|
view.portReports = [
|
|
DebugActions.PortReport(
|
|
port: GatewayEnvironment.gatewayPort(),
|
|
expected: "Gateway websocket (node/tsx)",
|
|
status: .missing("Missing"),
|
|
listeners: []),
|
|
]
|
|
view.portKillStatus = "Killed"
|
|
view.pendingKill = DebugActions.PortListener(
|
|
pid: 1,
|
|
command: "node",
|
|
fullCommand: "node",
|
|
user: nil,
|
|
expected: true)
|
|
view.canvasSessionKey = "main"
|
|
view.canvasStatus = "Canvas ok"
|
|
view.canvasError = "Canvas error"
|
|
view.canvasEvalJS = "document.title"
|
|
view.canvasEvalResult = "Canvas"
|
|
view.canvasSnapshotPath = "/tmp/snapshot.png"
|
|
|
|
_ = view.body
|
|
_ = view.header
|
|
_ = view.overviewSection
|
|
_ = view.appInfoSection
|
|
_ = view.gatewaySection
|
|
_ = view.logsSection
|
|
_ = view.portsSection
|
|
_ = view.pathsSection
|
|
_ = view.quickActionsSection
|
|
_ = view.canvasSection
|
|
_ = view.experimentsSection
|
|
_ = view.gridLabel("Test")
|
|
|
|
view.loadSessionStorePath()
|
|
}
|
|
}
|
|
#endif
|