Spaces:
Running
Running
| import AppKit | |
| import Combine | |
| import SwiftUI | |
| struct AnthropicAuthControls: View { | |
| let connectionMode: AppState.ConnectionMode | |
| private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus() | |
| private var pkce: AnthropicOAuth.PKCE? | |
| private var code: String = "" | |
| private var busy = false | |
| private var statusText: String? | |
| private var autoDetectClipboard = true | |
| private var autoConnectClipboard = true | |
| 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 isn’t 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([OpenClawOAuthStore.oauthURL()]) | |
| } | |
| .buttonStyle(.bordered) | |
| .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path)) | |
| Button("Refresh") { | |
| self.refresh() | |
| } | |
| .buttonStyle(.bordered) | |
| } | |
| Text(OpenClawOAuthStore.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 = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() | |
| self.oauthStatus = OpenClawOAuthStore.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)" | |
| } | |
| } | |
| 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 OpenClawOAuthStore.saveAnthropicOAuth(creds) | |
| self.refresh() | |
| self.pkce = nil | |
| self.code = "" | |
| self.statusText = "Connected. OpenClaw 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: OpenClawOAuthStore.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 | |