196 lines
7.2 KiB
Swift
196 lines
7.2 KiB
Swift
import AppKit
|
|
import Combine
|
|
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?
|
|
@State private var autoDetectClipboard = true
|
|
@State private var autoConnectClipboard = true
|
|
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
|
|
|
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
|
|
|
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)
|
|
|
|
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 == .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()
|
|
}
|
|
.onReceive(Self.clipboardPoll) { _ in
|
|
self.pollClipboardIfNeeded()
|
|
}
|
|
}
|
|
|
|
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 }
|
|
|
|
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 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)"
|
|
}
|
|
}
|
|
|
|
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() }
|
|
}
|
|
}
|