Files
steipete--codexbar/Sources/CodexBar/PreferencesDebugPane.swift
T
2026-07-13 12:22:33 +08:00

564 lines
24 KiB
Swift

import AppKit
import CodexBarCore
import SwiftUI
@MainActor
struct DebugPane: View {
@Bindable var settings: SettingsStore
@Bindable var store: UsageStore
@AppStorage("debugFileLoggingEnabled") private var debugFileLoggingEnabled = false
@State private var currentLogProvider: UsageProvider = .codex
@State private var currentFetchProvider: UsageProvider = .codex
@State private var isLoadingLog = false
@State private var logText: String = ""
@State private var isClearingCostCache = false
@State private var costCacheStatus: String?
@State private var cookieCacheStatus: String?
#if DEBUG
@State private var currentErrorProvider: UsageProvider = .codex
@State private var simulatedErrorText: String = """
Simulated error for testing layout.
Second line.
Third line.
Fourth line.
"""
#endif
var body: some View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 20) {
SettingsSection(title: L("section_logging")) {
PreferenceToggleRow(
title: L("enable_file_logging"),
subtitle: String(format: L("enable_file_logging_subtitle"), self.fileLogPath),
binding: self.$debugFileLoggingEnabled)
.onChange(of: self.debugFileLoggingEnabled) { _, newValue in
if self.settings.debugFileLoggingEnabled != newValue {
self.settings.debugFileLoggingEnabled = newValue
}
}
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(L("verbosity_title"))
.font(.body)
Text(L("verbosity_subtitle"))
.font(.footnote)
.foregroundStyle(.tertiary)
}
Spacer()
Picker(L("Verbosity"), selection: self.$settings.debugLogLevel) {
ForEach(CodexBarLog.Level.allCases) { level in
Text(level.displayName).tag(level)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(maxWidth: 160)
}
Button {
NSWorkspace.shared.open(CodexBarLog.fileLogURL)
} label: {
Label(L("open_log_file"), systemImage: "doc.text.magnifyingglass")
}
.controlSize(.small)
}
SettingsSection {
PreferenceToggleRow(
title: L("force_animation_next_refresh"),
subtitle: L("force_animation_next_refresh_subtitle"),
binding: self.$store.debugForceAnimation)
}
SettingsSection(
title: L("section_loading_animations"),
caption: L("loading_animations_caption"))
{
Picker(L("Animation pattern"), selection: self.animationPatternBinding) {
Text(L("animation_random_default")).tag(nil as LoadingPattern?)
ForEach(LoadingPattern.allCases) { pattern in
Text(pattern.displayName).tag(Optional(pattern))
}
}
.pickerStyle(.radioGroup)
Button(L("replay_selected_animation")) {
self.replaySelectedAnimation()
}
.keyboardShortcut(.defaultAction)
Button {
NotificationCenter.default.post(name: .codexbarDebugBlinkNow, object: nil)
} label: {
Label(L("blink_now"), systemImage: "eyes")
}
.controlSize(.small)
}
SettingsSection(
title: L("section_probe_logs"),
caption: L("probe_logs_caption"))
{
Picker(L("Provider"), selection: self.$currentLogProvider) {
Text("Codex").tag(UsageProvider.codex)
Text("Claude").tag(UsageProvider.claude)
Text("Cursor").tag(UsageProvider.cursor)
Text("Augment").tag(UsageProvider.augment)
Text("Amp").tag(UsageProvider.amp)
Text("Ollama").tag(UsageProvider.ollama)
}
.pickerStyle(.segmented)
.frame(width: 460)
HStack(spacing: 12) {
Button { self.loadLog(self.currentLogProvider) } label: {
Label(L("fetch_log"), systemImage: "arrow.clockwise")
}
.disabled(self.isLoadingLog)
Button { self.copyToPasteboard(self.logText) } label: {
Label(L("copy"), systemImage: "doc.on.doc")
}
.disabled(self.logText.isEmpty)
Button { self.saveLog(self.currentLogProvider) } label: {
Label(L("save_to_file"), systemImage: "externaldrive.badge.plus")
}
.disabled(self.isLoadingLog && self.logText.isEmpty)
if self.currentLogProvider == .claude {
Button { self.loadClaudeDump() } label: {
Label(L("load_parse_dump"), systemImage: "doc.text.magnifyingglass")
}
.disabled(self.isLoadingLog)
}
}
Button {
self.settings.rerunProviderDetection()
self.loadLog(self.currentLogProvider)
} label: {
Label(L("rerun_provider_autodetect"), systemImage: "dot.radiowaves.left.and.right")
}
.controlSize(.small)
ZStack(alignment: .topLeading) {
ScrollView {
Text(self.displayedLog)
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(minHeight: 160, maxHeight: 220)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
if self.isLoadingLog {
ProgressView()
.progressViewStyle(.circular)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding()
}
}
}
SettingsSection(
title: L("section_fetch_strategy"),
caption: L("fetch_strategy_caption"))
{
Picker(L("Provider"), selection: self.$currentFetchProvider) {
ForEach(UsageProvider.allCases, id: \.self) { provider in
Text(provider.rawValue.capitalized).tag(provider)
}
}
.pickerStyle(.menu)
.frame(width: 240)
ScrollView {
Text(self.fetchAttemptsText(for: self.currentFetchProvider))
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(minHeight: 120, maxHeight: 220)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
}
if !self.settings.debugDisableKeychainAccess {
SettingsSection(
title: L("section_openai_cookies"),
caption: L("openai_cookies_caption"))
{
HStack(spacing: 12) {
Button {
self.copyToPasteboard(self.store.openAIDashboardCookieImportDebugLog ?? "")
} label: {
Label(L("copy"), systemImage: "doc.on.doc")
}
.disabled((self.store.openAIDashboardCookieImportDebugLog ?? "").isEmpty)
}
ScrollView {
Text(
self.store.openAIDashboardCookieImportDebugLog?.isEmpty == false
? (self.store.openAIDashboardCookieImportDebugLog ?? "")
: L("no_log_yet"))
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(minHeight: 120, maxHeight: 180)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
}
}
SettingsSection(
title: L("section_caches"),
caption: L("caches_caption"))
{
let isTokenRefreshActive = self.store.isTokenRefreshInFlight(for: .codex)
|| self.store.isTokenRefreshInFlight(for: .claude)
HStack(spacing: 12) {
Button {
Task { await self.clearCostCache() }
} label: {
Label(L("clear_cost_cache"), systemImage: "trash")
}
.disabled(self.isClearingCostCache || isTokenRefreshActive)
if let status = self.costCacheStatus {
Text(status)
.font(.footnote)
.foregroundStyle(.tertiary)
}
}
HStack(spacing: 12) {
Button {
self.clearCookieCache()
} label: {
Label(L("clear_cookie_cache"), systemImage: "trash")
}
if let status = self.cookieCacheStatus {
Text(status)
.font(.footnote)
.foregroundStyle(.tertiary)
}
}
}
SettingsSection(
title: L("section_notifications"),
caption: L("notifications_caption"))
{
Picker(L("Provider"), selection: self.$currentLogProvider) {
Text("Codex").tag(UsageProvider.codex)
Text("Claude").tag(UsageProvider.claude)
}
.pickerStyle(.segmented)
.frame(width: 240)
HStack(spacing: 12) {
Button {
self.postSessionNotification(.depleted, provider: self.currentLogProvider)
} label: {
Label(L("post_depleted"), systemImage: "bell.badge")
}
.controlSize(.small)
Button {
self.postSessionNotification(.restored, provider: self.currentLogProvider)
} label: {
Label(L("post_restored"), systemImage: "bell")
}
.controlSize(.small)
}
}
SettingsSection(
title: L("section_cli_sessions"),
caption: L("cli_sessions_caption"))
{
PreferenceToggleRow(
title: L("keep_cli_sessions_alive"),
subtitle: L("keep_cli_sessions_alive_subtitle"),
binding: self.$settings.debugKeepCLISessionsAlive)
Button {
Task {
await CLIProbeSessionResetter.resetAll()
}
} label: {
Label(L("reset_cli_sessions"), systemImage: "arrow.counterclockwise")
}
.controlSize(.small)
}
#if DEBUG
SettingsSection(
title: L("section_error_simulation"),
caption: L("error_simulation_caption"))
{
Picker(L("Provider"), selection: self.$currentErrorProvider) {
Text("Codex").tag(UsageProvider.codex)
Text("Claude").tag(UsageProvider.claude)
Text("Gemini").tag(UsageProvider.gemini)
Text("Antigravity").tag(UsageProvider.antigravity)
Text("Augment").tag(UsageProvider.augment)
Text("Amp").tag(UsageProvider.amp)
Text("T3 Chat").tag(UsageProvider.t3chat)
Text("Ollama").tag(UsageProvider.ollama)
}
.pickerStyle(.segmented)
.frame(width: 360)
TextField(L("Simulated error text"), text: self.$simulatedErrorText, axis: .vertical)
.lineLimit(4)
HStack(spacing: 12) {
Button {
self.store._setErrorForTesting(
self.simulatedErrorText,
provider: self.currentErrorProvider)
} label: {
Label(L("set_menu_error"), systemImage: "exclamationmark.triangle")
}
.controlSize(.small)
Button {
self.store._setErrorForTesting(nil, provider: self.currentErrorProvider)
} label: {
Label(L("clear_menu_error"), systemImage: "xmark.circle")
}
.controlSize(.small)
}
let supportsTokenError = self.currentErrorProvider == .codex || self.currentErrorProvider == .claude
HStack(spacing: 12) {
Button {
self.store._setTokenErrorForTesting(
self.simulatedErrorText,
provider: self.currentErrorProvider)
} label: {
Label(L("set_cost_error"), systemImage: "banknote")
}
.controlSize(.small)
.disabled(!supportsTokenError)
Button {
self.store._setTokenErrorForTesting(nil, provider: self.currentErrorProvider)
} label: {
Label(L("clear_cost_error"), systemImage: "xmark.circle")
}
.controlSize(.small)
.disabled(!supportsTokenError)
}
}
#endif
SettingsSection(
title: L("section_cli_paths"),
caption: L("cli_paths_caption"))
{
self.binaryRow(title: L("codex_binary"), value: self.store.pathDebugInfo.codexBinary)
self.binaryRow(title: L("claude_binary"), value: self.store.pathDebugInfo.claudeBinary)
VStack(alignment: .leading, spacing: 6) {
Text(L("effective_path"))
.font(.callout.weight(.semibold))
ScrollView {
Text(
self.store.pathDebugInfo.effectivePATH.isEmpty
? L("unavailable")
: self.store.pathDebugInfo.effectivePATH)
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
}
.frame(minHeight: 60, maxHeight: 110)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
}
if let loginPATH = self.store.pathDebugInfo.loginShellPATH {
VStack(alignment: .leading, spacing: 6) {
Text(L("login_shell_path"))
.font(.callout.weight(.semibold))
ScrollView {
Text(loginPATH)
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
}
.frame(minHeight: 60, maxHeight: 110)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
private var fileLogPath: String {
CodexBarLog.fileLogURL.path
}
private var animationPatternBinding: Binding<LoadingPattern?> {
Binding(
get: { self.settings.debugLoadingPattern },
set: { self.settings.debugLoadingPattern = $0 })
}
private func replaySelectedAnimation() {
var userInfo: [AnyHashable: Any] = [:]
if let pattern = self.settings.debugLoadingPattern {
userInfo["pattern"] = pattern.rawValue
}
NotificationCenter.default.post(
name: .codexbarDebugReplayAllAnimations,
object: nil,
userInfo: userInfo.isEmpty ? nil : userInfo)
self.store.replayLoadingAnimation(duration: 4)
}
private var displayedLog: String {
if self.logText.isEmpty {
return self.isLoadingLog ? L("loading") : L("no_log_yet_fetch")
}
return self.logText
}
private func loadLog(_ provider: UsageProvider) {
self.isLoadingLog = true
Task {
let text = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ProviderRefreshContext.$current.withValue(.regular) {
await self.store.debugLog(for: provider)
}
}
await MainActor.run {
self.logText = text
self.isLoadingLog = false
}
}
}
private func saveLog(_ provider: UsageProvider) {
Task {
if self.logText.isEmpty {
self.isLoadingLog = true
let text = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ProviderRefreshContext.$current.withValue(.regular) {
await self.store.debugLog(for: provider)
}
}
await MainActor.run { self.logText = text }
self.isLoadingLog = false
}
_ = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ProviderRefreshContext.$current.withValue(.regular) {
await self.store.dumpLog(toFileFor: provider)
}
}
}
}
private func copyToPasteboard(_ text: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
private func binaryRow(title: String, value: String?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.callout.weight(.semibold))
Text(value ?? L("not_found"))
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(value == nil ? .secondary : .primary)
}
}
private func loadClaudeDump() {
self.isLoadingLog = true
Task {
let text = await self.store.debugClaudeDump()
await MainActor.run {
self.logText = text
self.isLoadingLog = false
}
}
}
private func postSessionNotification(_ transition: SessionQuotaTransition, provider: UsageProvider) {
SessionQuotaNotifier().post(transition: transition, provider: provider, badge: 1)
}
private func clearCostCache() async {
guard !self.isClearingCostCache else { return }
self.isClearingCostCache = true
self.costCacheStatus = nil
defer { self.isClearingCostCache = false }
if let error = await self.store.clearCostUsageCache() {
self.costCacheStatus = "Failed: \(error)"
return
}
self.costCacheStatus = L("cleared")
}
private func clearCookieCache() {
let summary = CookieHeaderCache.clearAllDetailed()
if summary.failedCount > 0 {
self.cookieCacheStatus = "Cookie cache cleanup failed for \(summary.failedCount) "
+ "operation\(summary.failedCount == 1 ? "" : "s")."
} else if summary.clearedCount > 0 {
self.cookieCacheStatus = "Cleared \(summary.clearedCount) "
+ "provider\(summary.clearedCount == 1 ? "" : "s")."
} else {
self.cookieCacheStatus = "No cached cookies found."
}
}
private func fetchAttemptsText(for provider: UsageProvider) -> String {
let attempts = self.store.fetchAttempts(for: provider)
guard !attempts.isEmpty else { return L("no_fetch_attempts") }
return attempts.map { attempt in
let kind = Self.fetchKindLabel(attempt.kind)
var line = "\(attempt.strategyID) (\(kind))"
line += attempt.wasAvailable ? " available" : " unavailable"
if let error = attempt.errorDescription, !error.isEmpty {
line += " error=\(error)"
}
return line
}.joined(separator: "\n")
}
private static func fetchKindLabel(_ kind: ProviderFetchKind) -> String {
switch kind {
case .cli: "cli"
case .web: "web"
case .oauth: "oauth"
case .apiToken: "api"
case .localProbe: "local"
case .webDashboard: "web"
}
}
}