Files
clawdbot/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift
2026-01-27 12:21:02 +00:00

235 lines
8.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Combine
import SwiftUI
@MainActor
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: MoltbotOAuthStore.AnthropicOAuthStatus = MoltbotOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@State private var statusText: String?
@State private var autoDetectClipboard = true
@State private var autoConnectClipboard = true
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
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()
}()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode != .local {
Text("Gateway isnt running locally; OAuth must be created on the gateway host.")
.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") {
NSWorkspace.shared.activateFileViewerSelecting([MoltbotOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager().fileExists(atPath: MoltbotOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
}
.buttonStyle(.bordered)
}
Text(MoltbotOAuthStore.oauthURL().path)
.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)
.disabled(self.connectionMode != .local || self.busy)
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)
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)
Button("Connect") {
Task { await self.finishOAuth() }
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
}
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear {
self.refresh()
}
.onReceive(Self.clipboardPoll) { _ in
self.pollClipboardIfNeeded()
}
}
private func refresh() {
let imported = MoltbotOAuthStore.importLegacyAnthropicOAuthIfNeeded()
self.oauthStatus = MoltbotOAuthStore.anthropicOAuthStatus()
if imported != nil {
self.statusText = "Imported existing OAuth credentials."
}
}
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 }
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
self.statusText = "OAuth failed: missing or invalid code/state."
return
}
do {
let creds = try await AnthropicOAuth.exchangeCode(
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try MoltbotOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Moltbot can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
}
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() }
}
}
#if DEBUG
extension AnthropicAuthControls {
init(
connectionMode: AppState.ConnectionMode,
oauthStatus: MoltbotOAuthStore.AnthropicOAuthStatus,
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