2025-12-17 19:14:54 +00:00
|
|
|
|
import AppKit
|
2025-12-18 23:15:08 +00:00
|
|
|
|
import Combine
|
2025-12-17 19:14:54 +00:00
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
struct AnthropicAuthControls: View {
|
|
|
|
|
|
let connectionMode: AppState.ConnectionMode
|
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
|
@State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
2025-12-17 19:14:54 +00:00
|
|
|
|
@State private var pkce: AnthropicOAuth.PKCE?
|
|
|
|
|
|
@State private var code: String = ""
|
|
|
|
|
|
@State private var busy = false
|
|
|
|
|
|
@State private var statusText: String?
|
2025-12-18 23:15:08 +00:00
|
|
|
|
@State private var autoDetectClipboard = true
|
|
|
|
|
|
@State private var autoConnectClipboard = true
|
|
|
|
|
|
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
|
|
|
|
|
|
2025-12-24 17:42:14 +01:00
|
|
|
|
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
|
|
|
|
|
if ProcessInfo.processInfo.isRunningTests {
|
|
|
|
|
|
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
|
|
|
|
|
}
|
|
|
|
|
|
return Timer.publish(every: 0.4, on: .main, in: .common)
|
|
|
|
|
|
.autoconnect()
|
|
|
|
|
|
.eraseToAnyPublisher()
|
|
|
|
|
|
}()
|
2025-12-17 19:14:54 +00:00
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
2025-12-20 02:20:48 +01:00
|
|
|
|
if self.connectionMode != .local {
|
2025-12-22 21:02:48 +00:00
|
|
|
|
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
|
Circle()
|
|
|
|
|
|
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
|
|
|
|
|
.frame(width: 8, height: 8)
|
|
|
|
|
|
Text(self.oauthStatus.shortDescription)
|
|
|
|
|
|
.font(.footnote.weight(.semibold))
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
Button("Reveal") {
|
2026-01-30 03:15:10 +01:00
|
|
|
|
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.bordered)
|
2026-01-30 03:15:10 +01:00
|
|
|
|
.disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path))
|
2025-12-17 19:14:54 +00:00
|
|
|
|
|
|
|
|
|
|
Button("Refresh") {
|
|
|
|
|
|
self.refresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.bordered)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
|
Text(OpenClawOAuthStore.oauthURL().path)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
.font(.caption.monospaced())
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.truncationMode(.middle)
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
|
|
|
|
|
|
HStack(spacing: 12) {
|
|
|
|
|
|
Button {
|
|
|
|
|
|
self.startOAuth()
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
if self.busy {
|
|
|
|
|
|
ProgressView().controlSize(.small)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.borderedProminent)
|
2025-12-20 02:20:48 +01:00
|
|
|
|
.disabled(self.connectionMode != .local || self.busy)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
|
|
|
|
|
|
if self.pkce != nil {
|
|
|
|
|
|
Button("Cancel") {
|
|
|
|
|
|
self.pkce = nil
|
|
|
|
|
|
self.code = ""
|
|
|
|
|
|
self.statusText = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.bordered)
|
|
|
|
|
|
.disabled(self.busy)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if self.pkce != nil {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
|
Text("Paste `code#state`")
|
|
|
|
|
|
.font(.footnote.weight(.semibold))
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
|
|
|
|
|
|
TextField("code#state", text: self.$code)
|
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
|
.disabled(self.busy)
|
|
|
|
|
|
|
2025-12-18 23:15:08 +00:00
|
|
|
|
Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.disabled(self.busy)
|
|
|
|
|
|
|
|
|
|
|
|
Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.disabled(self.busy)
|
|
|
|
|
|
|
2025-12-17 19:14:54 +00:00
|
|
|
|
Button("Connect") {
|
|
|
|
|
|
Task { await self.finishOAuth() }
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.bordered)
|
2025-12-20 02:20:48 +01:00
|
|
|
|
.disabled(self.busy || self.connectionMode != .local || self.code
|
2026-01-15 14:40:57 +00:00
|
|
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
.isEmpty)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let statusText, !statusText.isEmpty {
|
|
|
|
|
|
Text(statusText)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.onAppear {
|
|
|
|
|
|
self.refresh()
|
|
|
|
|
|
}
|
2025-12-18 23:15:08 +00:00
|
|
|
|
.onReceive(Self.clipboardPoll) { _ in
|
|
|
|
|
|
self.pollClipboardIfNeeded()
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func refresh() {
|
2026-01-30 03:15:10 +01:00
|
|
|
|
let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
|
|
|
|
|
self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
2025-12-22 21:02:48 +00:00
|
|
|
|
if imported != nil {
|
|
|
|
|
|
self.statusText = "Imported existing OAuth credentials."
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func startOAuth() {
|
|
|
|
|
|
guard self.connectionMode == .local else { return }
|
|
|
|
|
|
guard !self.busy else { return }
|
|
|
|
|
|
self.busy = true
|
|
|
|
|
|
defer { self.busy = false }
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
let pkce = try AnthropicOAuth.generatePKCE()
|
|
|
|
|
|
self.pkce = pkce
|
|
|
|
|
|
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
|
|
|
|
|
NSWorkspace.shared.open(url)
|
|
|
|
|
|
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
private func finishOAuth() async {
|
|
|
|
|
|
guard self.connectionMode == .local else { return }
|
|
|
|
|
|
guard !self.busy else { return }
|
|
|
|
|
|
guard let pkce = self.pkce else { return }
|
|
|
|
|
|
self.busy = true
|
|
|
|
|
|
defer { self.busy = false }
|
|
|
|
|
|
|
2025-12-18 23:15:08 +00:00
|
|
|
|
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
|
|
|
|
|
|
self.statusText = "OAuth failed: missing or invalid code/state."
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
|
|
|
|
|
|
do {
|
2025-12-19 17:52:26 +01:00
|
|
|
|
let creds = try await AnthropicOAuth.exchangeCode(
|
|
|
|
|
|
code: parsed.code,
|
|
|
|
|
|
state: parsed.state,
|
|
|
|
|
|
verifier: pkce.verifier)
|
2026-01-30 03:15:10 +01:00
|
|
|
|
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
2025-12-17 19:14:54 +00:00
|
|
|
|
self.refresh()
|
|
|
|
|
|
self.pkce = nil
|
|
|
|
|
|
self.code = ""
|
2026-01-30 03:15:10 +01:00
|
|
|
|
self.statusText = "Connected. OpenClaw can now use Claude via OAuth."
|
2025-12-17 19:14:54 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-18 23:15:08 +00:00
|
|
|
|
|
|
|
|
|
|
private func pollClipboardIfNeeded() {
|
|
|
|
|
|
guard self.connectionMode == .local else { return }
|
|
|
|
|
|
guard self.pkce != nil else { return }
|
|
|
|
|
|
guard !self.busy else { return }
|
|
|
|
|
|
guard self.autoDetectClipboard else { return }
|
|
|
|
|
|
|
|
|
|
|
|
let pb = NSPasteboard.general
|
|
|
|
|
|
let changeCount = pb.changeCount
|
|
|
|
|
|
guard changeCount != self.lastPasteboardChangeCount else { return }
|
|
|
|
|
|
self.lastPasteboardChangeCount = changeCount
|
|
|
|
|
|
|
|
|
|
|
|
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
|
|
|
|
|
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
|
|
|
|
|
guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
|
|
|
|
|
|
|
|
|
|
|
|
let next = "\(parsed.code)#\(parsed.state)"
|
|
|
|
|
|
if self.code != next {
|
|
|
|
|
|
self.code = next
|
|
|
|
|
|
self.statusText = "Detected `code#state` from clipboard."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard self.autoConnectClipboard else { return }
|
|
|
|
|
|
Task { await self.finishOAuth() }
|
|
|
|
|
|
}
|
2025-12-17 19:14:54 +00:00
|
|
|
|
}
|
2025-12-24 17:42:14 +01:00
|
|
|
|
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
extension AnthropicAuthControls {
|
|
|
|
|
|
init(
|
|
|
|
|
|
connectionMode: AppState.ConnectionMode,
|
2026-01-30 03:15:10 +01:00
|
|
|
|
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus,
|
2025-12-24 17:42:14 +01:00
|
|
|
|
pkce: AnthropicOAuth.PKCE? = nil,
|
|
|
|
|
|
code: String = "",
|
|
|
|
|
|
busy: Bool = false,
|
|
|
|
|
|
statusText: String? = nil,
|
|
|
|
|
|
autoDetectClipboard: Bool = true,
|
|
|
|
|
|
autoConnectClipboard: Bool = true)
|
|
|
|
|
|
{
|
|
|
|
|
|
self.connectionMode = connectionMode
|
|
|
|
|
|
self._oauthStatus = State(initialValue: oauthStatus)
|
|
|
|
|
|
self._pkce = State(initialValue: pkce)
|
|
|
|
|
|
self._code = State(initialValue: code)
|
|
|
|
|
|
self._busy = State(initialValue: busy)
|
|
|
|
|
|
self._statusText = State(initialValue: statusText)
|
|
|
|
|
|
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
|
|
|
|
|
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
|
|
|
|
|
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|