macOS: auto-fill Anthropic OAuth from clipboard

This commit is contained in:
Peter Steinberger
2025-12-18 23:15:08 +00:00
parent 3146f8bdbc
commit 3780f3152c
4 changed files with 190 additions and 10 deletions

View File

@@ -1,4 +1,5 @@
import AppKit
import Combine
import SwiftUI
@MainActor
@@ -10,6 +11,11 @@ struct AnthropicAuthControls: View {
@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) {
@@ -81,6 +87,16 @@ struct AnthropicAuthControls: View {
.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() }
}
@@ -101,6 +117,9 @@ struct AnthropicAuthControls: View {
.onAppear {
self.refresh()
}
.onReceive(Self.clipboardPoll) { _ in
self.pollClipboardIfNeeded()
}
}
private func refresh() {
@@ -132,13 +151,13 @@ struct AnthropicAuthControls: View {
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] : ""
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: code, state: state, verifier: pkce.verifier)
let creds = try await AnthropicOAuth.exchangeCode(code: parsed.code, state: parsed.state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
@@ -148,4 +167,29 @@ struct AnthropicAuthControls: View {
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() }
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
enum AnthropicOAuthCodeState {
struct Parsed: Equatable {
let code: String
let state: String
}
/// Extracts a `code#state` payload from arbitrary text.
///
/// Supports:
/// - raw `code#state`
/// - OAuth callback URLs containing `code=` and `state=` query params
/// - surrounding text/backticks from instructions pages
static func extract(from raw: String) -> String? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
if text.isEmpty { return nil }
if let fromURL = self.extractFromURL(text) { return fromURL }
if let fromToken = self.extractFromToken(text) { return fromToken }
return nil
}
static func parse(from raw: String) -> Parsed? {
guard let extracted = self.extract(from: raw) else { return nil }
let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
let code = parts.first ?? ""
let state = parts.count > 1 ? parts[1] : ""
guard !code.isEmpty, !state.isEmpty else { return nil }
return Parsed(code: code, state: state)
}
private static func extractFromURL(_ text: String) -> String? {
// Users might copy the callback URL from the browser address bar.
guard let components = URLComponents(string: text),
let items = components.queryItems,
let code = items.first(where: { $0.name == "code" })?.value,
let state = items.first(where: { $0.name == "state" })?.value,
!code.isEmpty, !state.isEmpty
else { return nil }
return "\(code)#\(state)"
}
private static func extractFromToken(_ text: String) -> String? {
// Base64url-ish tokens; keep this fairly strict to avoid false positives.
let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = re.firstMatch(in: text, range: range),
match.numberOfRanges == 3,
let full = Range(match.range(at: 0), in: text)
else { return nil }
return String(text[full])
}
}

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdisIPC
import Combine
import Observation
import SwiftUI
@@ -59,6 +60,9 @@ struct OnboardingView: View {
@State private var anthropicAuthBusy = false
@State private var anthropicAuthConnected = false
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var anthropicAuthAutoDetectClipboard = true
@State private var anthropicAuthAutoConnectClipboard = true
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
@State private var monitoringAuth = false
@State private var authMonitorTask: Task<Void, Never>?
@State private var identityName: String = ""
@@ -78,6 +82,8 @@ struct OnboardingView: View {
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let anthropicAuthPageIndex = 2
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
private let permissionsPageIndex = 5
private var pageOrder: [Int] {
if self.state.connectionMode == .remote {
@@ -407,6 +413,16 @@ struct OnboardingView: View {
TextField("code#state", text: self.$anthropicAuthCode)
.textFieldStyle(.roundedBorder)
Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
.font(.caption)
.foregroundStyle(.secondary)
.disabled(self.anthropicAuthBusy)
Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
.font(.caption)
.foregroundStyle(.secondary)
.disabled(self.anthropicAuthBusy)
Button("Connect") {
Task { await self.finishAnthropicOAuth() }
}
@@ -415,6 +431,9 @@ struct OnboardingView: View {
self.anthropicAuthBusy ||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.onReceive(Self.clipboardPoll) { _ in
self.pollAnthropicClipboardIfNeeded()
}
}
self.onboardingCard(spacing: 8, padding: 12) {
@@ -463,13 +482,13 @@ struct OnboardingView: View {
self.anthropicAuthBusy = true
defer { self.anthropicAuthBusy = false }
let trimmed = self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
return
}
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
let creds = try await AnthropicOAuth.exchangeCode(code: parsed.code, state: parsed.state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
@@ -478,6 +497,31 @@ struct OnboardingView: View {
}
}
private func pollAnthropicClipboardIfNeeded() {
guard self.currentPage == self.anthropicAuthPageIndex else { return }
guard self.anthropicAuthPKCE != nil else { return }
guard !self.anthropicAuthBusy else { return }
guard self.anthropicAuthAutoDetectClipboard else { return }
let pb = NSPasteboard.general
let changeCount = pb.changeCount
guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
self.anthropicAuthLastPasteboardChangeCount = 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.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
let next = "\(parsed.code)#\(parsed.state)"
if self.anthropicAuthCode != next {
self.anthropicAuthCode = next
self.anthropicAuthStatus = "Detected `code#state` from clipboard."
}
guard self.anthropicAuthAutoConnectClipboard else { return }
Task { await self.finishAnthropicOAuth() }
}
private func refreshAnthropicOAuthStatus() {
let status = PiOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status