macOS: auto-fill Anthropic OAuth from clipboard
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -10,6 +11,11 @@ struct AnthropicAuthControls: View {
|
|||||||
@State private var code: String = ""
|
@State private var code: String = ""
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var statusText: String?
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -81,6 +87,16 @@ struct AnthropicAuthControls: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.disabled(self.busy)
|
.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") {
|
Button("Connect") {
|
||||||
Task { await self.finishOAuth() }
|
Task { await self.finishOAuth() }
|
||||||
}
|
}
|
||||||
@@ -101,6 +117,9 @@ struct AnthropicAuthControls: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
self.refresh()
|
self.refresh()
|
||||||
}
|
}
|
||||||
|
.onReceive(Self.clipboardPoll) { _ in
|
||||||
|
self.pollClipboardIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refresh() {
|
private func refresh() {
|
||||||
@@ -132,13 +151,13 @@ struct AnthropicAuthControls: View {
|
|||||||
self.busy = true
|
self.busy = true
|
||||||
defer { self.busy = false }
|
defer { self.busy = false }
|
||||||
|
|
||||||
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
|
||||||
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
|
self.statusText = "OAuth failed: missing or invalid code/state."
|
||||||
let code = splits.first ?? ""
|
return
|
||||||
let state = splits.count > 1 ? splits[1] : ""
|
}
|
||||||
|
|
||||||
do {
|
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)
|
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
self.pkce = nil
|
self.pkce = nil
|
||||||
@@ -148,4 +167,29 @@ struct AnthropicAuthControls: View {
|
|||||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
apps/macos/Sources/Clawdis/AnthropicOAuthCodeState.swift
Normal file
60
apps/macos/Sources/Clawdis/AnthropicOAuthCodeState.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
|
import Combine
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ struct OnboardingView: View {
|
|||||||
@State private var anthropicAuthBusy = false
|
@State private var anthropicAuthBusy = false
|
||||||
@State private var anthropicAuthConnected = false
|
@State private var anthropicAuthConnected = false
|
||||||
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
|
@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 monitoringAuth = false
|
||||||
@State private var authMonitorTask: Task<Void, Never>?
|
@State private var authMonitorTask: Task<Void, Never>?
|
||||||
@State private var identityName: String = ""
|
@State private var identityName: String = ""
|
||||||
@@ -78,6 +82,8 @@ struct OnboardingView: View {
|
|||||||
private let contentHeight: CGFloat = 520
|
private let contentHeight: CGFloat = 520
|
||||||
private let connectionPageIndex = 1
|
private let connectionPageIndex = 1
|
||||||
private let anthropicAuthPageIndex = 2
|
private let anthropicAuthPageIndex = 2
|
||||||
|
|
||||||
|
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||||
private let permissionsPageIndex = 5
|
private let permissionsPageIndex = 5
|
||||||
private var pageOrder: [Int] {
|
private var pageOrder: [Int] {
|
||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
@@ -407,6 +413,16 @@ struct OnboardingView: View {
|
|||||||
TextField("code#state", text: self.$anthropicAuthCode)
|
TextField("code#state", text: self.$anthropicAuthCode)
|
||||||
.textFieldStyle(.roundedBorder)
|
.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") {
|
Button("Connect") {
|
||||||
Task { await self.finishAnthropicOAuth() }
|
Task { await self.finishAnthropicOAuth() }
|
||||||
}
|
}
|
||||||
@@ -415,6 +431,9 @@ struct OnboardingView: View {
|
|||||||
self.anthropicAuthBusy ||
|
self.anthropicAuthBusy ||
|
||||||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
|
.onReceive(Self.clipboardPoll) { _ in
|
||||||
|
self.pollAnthropicClipboardIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onboardingCard(spacing: 8, padding: 12) {
|
self.onboardingCard(spacing: 8, padding: 12) {
|
||||||
@@ -463,13 +482,13 @@ struct OnboardingView: View {
|
|||||||
self.anthropicAuthBusy = true
|
self.anthropicAuthBusy = true
|
||||||
defer { self.anthropicAuthBusy = false }
|
defer { self.anthropicAuthBusy = false }
|
||||||
|
|
||||||
let trimmed = self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
|
||||||
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
|
self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
|
||||||
let code = splits.first ?? ""
|
return
|
||||||
let state = splits.count > 1 ? splits[1] : ""
|
}
|
||||||
|
|
||||||
do {
|
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)
|
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||||
self.refreshAnthropicOAuthStatus()
|
self.refreshAnthropicOAuthStatus()
|
||||||
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
|
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() {
|
private func refreshAnthropicOAuthStatus() {
|
||||||
let status = PiOAuthStore.anthropicOAuthStatus()
|
let status = PiOAuthStore.anthropicOAuthStatus()
|
||||||
self.anthropicAuthDetectedStatus = status
|
self.anthropicAuthDetectedStatus = status
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite
|
||||||
|
struct AnthropicOAuthCodeStateTests {
|
||||||
|
@Test
|
||||||
|
func parsesRawToken() {
|
||||||
|
let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
|
||||||
|
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func parsesBacktickedToken() {
|
||||||
|
let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
|
||||||
|
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func parsesCallbackURL() {
|
||||||
|
let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
|
||||||
|
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
||||||
|
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func extractsFromSurroundingText() {
|
||||||
|
let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
|
||||||
|
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
||||||
|
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user