File size: 8,822 Bytes
4fc4790
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import AppKit
import Combine
import SwiftUI

@MainActor
struct AnthropicAuthControls: View {
    let connectionMode: AppState.ConnectionMode

    @State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
    @State private var pkce: AnthropicOAuth.PKCE?
    @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: 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)"
        }
    }

    @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 }

        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