153 lines
5.4 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|
|
|