Files
clawdbot/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift
2025-12-17 19:15:19 +00:00

153 lines
5.4 KiB
Swift

import AppKit
import SwiftUI
@MainActor
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@State private var statusText: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode == .remote {
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
.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([PiOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
}
.buttonStyle(.bordered)
}
Text(PiOAuthStore.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 == .remote || 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)
Button("Connect") {
Task { await self.finishOAuth() }
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode == .remote || 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()
}
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
}
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 }
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
}
}