diff --git a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift index ede230625..b774126cf 100644 --- a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift +++ b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift @@ -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() } + } } diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuthCodeState.swift b/apps/macos/Sources/Clawdis/AnthropicOAuthCodeState.swift new file mode 100644 index 000000000..9ba2c0e55 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AnthropicOAuthCodeState.swift @@ -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..? @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 diff --git a/apps/macos/Tests/ClawdisIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/ClawdisIPCTests/AnthropicOAuthCodeStateTests.swift new file mode 100644 index 000000000..ee649f225 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/AnthropicOAuthCodeStateTests.swift @@ -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")) + } +} +