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() } } }