Skip to content

Commit a77e699

Browse files
authored
Merge pull request #6 from phucbm/feature/wkwebview-login
fix(app): bypass Cloudflare 403 via hidden WKWebView
2 parents 74fb2a1 + 4588de8 commit a77e699

5 files changed

Lines changed: 190 additions & 63 deletions

File tree

.claude/docs/architecture.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
- **Entry:** `app/Sources/AppDelegate.swift``@main`, sets up NSStatusItem, FloatingPanel, AccountsManager, global hotkey
77
- **Views:** `app/Sources/Views/` — SwiftUI views rendered inside NSHostingController
88
- **Data model:** `app/Sources/Account.swift`, `app/Sources/AccountsManager.swift` — fetches usage from claude.ai internal API, stores in UserDefaults
9+
- **Network:** `app/Sources/WebFetcher.swift` — singleton hidden WKWebView that handles all API calls; bypasses Cloudflare bot protection by using WebKit's TLS stack; requests are serialized (one at a time), queue capped at 20
910
- **Theme:** `app/Sources/Theme.swift` — colors and fonts shared across views
1011
- **Build output:** `app/build/ClaudeUsageMonitor.app` — universal binary (arm64 + x86_64)
1112
- **Fonts:** `app/Fonts/JetBrainsMono-*.ttf` — bundled, used for menu bar badges and UI
1213

1314
**Data flow:**
14-
`AccountsManager.fetchAllAccounts()` → HTTP with session cookie → parses usage JSON → updates `Account` model → `refreshMenuBar()` renders badge image → SwiftUI views reflect state via `@Published`
15+
`AccountsManager.fetchAllAccounts()``WebFetcher.shared.fetch()` → injects cookies into hidden WKWebView → `callAsyncJavaScript` fetch call → parses usage JSON → updates `Account` model → `refreshMenuBar()` renders badge image → SwiftUI views reflect state via `@Published`
16+
17+
**Why WebFetcher (not URLSession):**
18+
Claude.ai's usage API is protected by Cloudflare. URLSession is fingerprinted as a non-browser and blocked (HTTP 403). WebFetcher uses a single hidden WKWebView — requests go through WebKit's native networking stack, matching the TLS/HTTP2 fingerprint Cloudflare expects. ~50MB RAM cost while app is running.
1519

1620
**Key behaviors:**
1721
- Auto-refresh every 300 seconds via `Timer.scheduledTimer`

app/Sources/AccountsManager.swift

Lines changed: 22 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -114,81 +114,41 @@ class AccountsManager: ObservableObject {
114114
let cookie = accounts[index].cookie
115115
guard !cookie.isEmpty else { accounts[index].errorMessage = "Cookie not set"; return }
116116

117+
guard let orgId = extractOrgId(from: cookie) else {
118+
accounts[index].errorMessage = "Could not get org ID"
119+
return
120+
}
121+
117122
isLoading = true
118123
accounts[index].errorMessage = nil
119124

120-
fetchOrganizationId(cookie: cookie) { [weak self] orgId in
121-
guard let self else { return }
122-
guard let orgId else {
123-
DispatchQueue.main.async {
124-
if let idx = self.accounts.firstIndex(where: { $0.id == accountId }) {
125-
self.accounts[idx].errorMessage = "Could not get org ID"
126-
}
127-
self.isLoading = false
125+
WebFetcher.shared.fetch(orgId: orgId, cookieString: cookie) { [weak self] result in
126+
DispatchQueue.main.async {
127+
guard let self else { return }
128+
self.isLoading = false
129+
guard let idx = self.accounts.firstIndex(where: { $0.id == accountId }) else { return }
130+
switch result {
131+
case .success(let data):
132+
self.accounts[idx].billingStatus = .ok
133+
self.parseUsageData(data, accountIndex: idx)
134+
case .failure(let error):
135+
let code = (error as NSError).code
136+
self.accounts[idx].billingStatus = Self.billingStatus(for: code)
137+
self.accounts[idx].errorMessage = "HTTP \(code)"
128138
}
129-
return
139+
self.delegate?.refreshMenuBar()
130140
}
131-
self.fetchUsageWithOrgId(orgId, cookie: cookie, accountId: accountId)
132141
}
133142
}
134143

135-
private func fetchOrganizationId(cookie: String, completion: @escaping (String?) -> Void) {
144+
private func extractOrgId(from cookie: String) -> String? {
136145
for part in cookie.components(separatedBy: ";") {
137146
let t = part.trimmingCharacters(in: .whitespaces)
138147
if t.hasPrefix("lastActiveOrg=") {
139-
completion(t.replacingOccurrences(of: "lastActiveOrg=", with: ""))
140-
return
148+
return t.replacingOccurrences(of: "lastActiveOrg=", with: "")
141149
}
142150
}
143-
guard let url = URL(string: "https://claude.ai/api/bootstrap") else { completion(nil); return }
144-
var req = URLRequest(url: url)
145-
req.setValue(cookie, forHTTPHeaderField: "Cookie")
146-
URLSession.shared.dataTask(with: req) { data, _, _ in
147-
guard let data,
148-
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
149-
let acc = json["account"] as? [String: Any],
150-
let orgId = acc["lastActiveOrgId"] as? String else { completion(nil); return }
151-
completion(orgId)
152-
}.resume()
153-
}
154-
155-
private func fetchUsageWithOrgId(_ orgId: String, cookie: String, accountId: String) {
156-
guard let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage") else {
157-
DispatchQueue.main.async {
158-
if let idx = self.accounts.firstIndex(where: { $0.id == accountId }) {
159-
self.accounts[idx].errorMessage = "Invalid URL"
160-
}
161-
self.isLoading = false
162-
}
163-
return
164-
}
165-
var req = URLRequest(url: url)
166-
req.setValue(cookie, forHTTPHeaderField: "Cookie")
167-
req.setValue("*/*", forHTTPHeaderField: "Accept")
168-
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
169-
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
170-
req.setValue("https://claude.ai", forHTTPHeaderField: "Referer")
171-
req.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
172-
req.setValue("claude.ai", forHTTPHeaderField: "authority")
173-
174-
URLSession.shared.dataTask(with: req) { [weak self] data, response, error in
175-
DispatchQueue.main.async {
176-
guard let self else { return }
177-
self.isLoading = false
178-
guard let idx = self.accounts.firstIndex(where: { $0.id == accountId }) else { return }
179-
if error != nil { self.accounts[idx].errorMessage = "Network error"; self.delegate?.refreshMenuBar(); return }
180-
guard let http = response as? HTTPURLResponse else { self.accounts[idx].errorMessage = "Invalid response"; return }
181-
if http.statusCode == 200, let data {
182-
self.accounts[idx].billingStatus = .ok
183-
self.parseUsageData(data, accountIndex: idx)
184-
} else {
185-
self.accounts[idx].billingStatus = Self.billingStatus(for: http.statusCode)
186-
self.accounts[idx].errorMessage = self.accounts[idx].billingStatus == .ok
187-
? "HTTP \(http.statusCode)" : nil
188-
}
189-
self.delegate?.refreshMenuBar()
190-
}
191-
}.resume()
151+
return nil
192152
}
193153

194154
private func parseUsageData(_ data: Data, accountIndex: Int) {

app/Sources/Views/AccountCard.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ struct AccountCard: View {
108108
.foregroundColor(billingColor)
109109
.lineLimit(2)
110110
.fixedSize(horizontal: false, vertical: true)
111+
Spacer()
111112
}
112113
.padding(.horizontal, 12)
113114
.padding(.vertical, 8)

app/Sources/WebFetcher.swift

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import WebKit
2+
import Foundation
3+
4+
class WebFetcher: NSObject, WKNavigationDelegate {
5+
static let shared = WebFetcher()
6+
7+
private var webView: WKWebView!
8+
private var isReady = false
9+
private var isBusy = false
10+
private var taskQueue: [(orgId: String, cookieString: String, completion: (Result<Data, Error>) -> Void)] = []
11+
12+
private override init() {
13+
super.init()
14+
DispatchQueue.main.async { self.setup() }
15+
}
16+
17+
private func setup() {
18+
let config = WKWebViewConfiguration()
19+
config.websiteDataStore = .nonPersistent()
20+
webView = WKWebView(frame: .zero, configuration: config)
21+
webView.navigationDelegate = self
22+
}
23+
24+
// MARK: - Public
25+
26+
private let maxQueueSize = 20
27+
28+
func fetch(orgId: String, cookieString: String, completion: @escaping (Result<Data, Error>) -> Void) {
29+
DispatchQueue.main.async {
30+
guard self.taskQueue.count < self.maxQueueSize else {
31+
completion(.failure(NSError(domain: "WebFetcher", code: -2, userInfo: [NSLocalizedDescriptionKey: "Queue full"])))
32+
return
33+
}
34+
self.taskQueue.append((orgId, cookieString, completion))
35+
self.drainQueue()
36+
}
37+
}
38+
39+
// MARK: - WKNavigationDelegate
40+
41+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
42+
guard let host = webView.url?.host, host.contains("claude.ai") else { return }
43+
isReady = true
44+
drainQueue()
45+
}
46+
47+
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
48+
if !isReady {
49+
isReady = true
50+
drainQueue()
51+
}
52+
}
53+
54+
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
55+
if !isReady {
56+
isReady = true
57+
drainQueue()
58+
}
59+
}
60+
61+
// MARK: - Queue
62+
63+
private func drainQueue() {
64+
guard !isBusy, !taskQueue.isEmpty else { return }
65+
isBusy = true
66+
let task = taskQueue.removeFirst()
67+
executeTask(task)
68+
}
69+
70+
private func executeTask(_ task: (orgId: String, cookieString: String, completion: (Result<Data, Error>) -> Void)) {
71+
injectCookies(task.cookieString) {
72+
if self.isReady {
73+
self.performFetch(orgId: task.orgId, completion: task.completion)
74+
} else {
75+
// Navigate to claude.ai first, then fetch on didFinish
76+
self.taskQueue.insert(task, at: 0)
77+
self.isBusy = false
78+
self.isReady = false
79+
self.webView.load(URLRequest(url: URL(string: "https://claude.ai")!))
80+
}
81+
}
82+
}
83+
84+
private func injectCookies(_ cookieString: String, completion: @escaping () -> Void) {
85+
let store = webView.configuration.websiteDataStore.httpCookieStore
86+
store.getAllCookies { existing in
87+
let group = DispatchGroup()
88+
for cookie in existing {
89+
group.enter()
90+
store.delete(cookie) { group.leave() }
91+
}
92+
group.notify(queue: .main) {
93+
let cookies: [HTTPCookie] = cookieString
94+
.components(separatedBy: ";")
95+
.compactMap { part in
96+
let t = part.trimmingCharacters(in: .whitespaces)
97+
let kv = t.components(separatedBy: "=")
98+
guard kv.count >= 2 else { return nil }
99+
let name = kv[0].trimmingCharacters(in: .whitespaces)
100+
let value = kv.dropFirst().joined(separator: "=")
101+
return HTTPCookie(properties: [
102+
.name: name,
103+
.value: value,
104+
.domain: ".claude.ai",
105+
.path: "/",
106+
.secure: "TRUE"
107+
])
108+
}
109+
let g2 = DispatchGroup()
110+
for cookie in cookies {
111+
g2.enter()
112+
store.setCookie(cookie) { g2.leave() }
113+
}
114+
g2.notify(queue: .main) { completion() }
115+
}
116+
}
117+
}
118+
119+
private func performFetch(orgId: String, completion: @escaping (Result<Data, Error>) -> Void) {
120+
let js = """
121+
const r = await fetch('/api/organizations/\(orgId)/usage', {
122+
credentials: 'include',
123+
headers: {
124+
'accept': '*/*',
125+
'anthropic-client-platform': 'web_claude_ai',
126+
'anthropic-client-version': '1.0.0',
127+
'anthropic-client-sha': 'ec58f908c65bf1b6cd36aab8c717a3c78597ac3b',
128+
'sec-fetch-dest': 'empty',
129+
'sec-fetch-mode': 'cors',
130+
'sec-fetch-site': 'same-origin'
131+
}
132+
});
133+
const body = await r.text();
134+
return {status: r.status, body: body};
135+
"""
136+
webView.callAsyncJavaScript(js, arguments: [:], in: nil, in: .page) { [weak self] result in
137+
guard let self else { return }
138+
defer {
139+
self.isBusy = false
140+
self.drainQueue()
141+
}
142+
switch result {
143+
case .success(let value):
144+
guard let dict = value as? [String: Any],
145+
let status = dict["status"] as? Int,
146+
let body = dict["body"] as? String else {
147+
completion(.failure(NSError(domain: "WebFetcher", code: -1)))
148+
return
149+
}
150+
if status == 200, let data = body.data(using: .utf8) {
151+
completion(.success(data))
152+
} else {
153+
completion(.failure(NSError(domain: "WebFetcher", code: status)))
154+
}
155+
case .failure(let error):
156+
completion(.failure(error))
157+
}
158+
}
159+
}
160+
}

app/build.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ swiftc -parse-as-library -o "$APP_PATH/Contents/MacOS/ClaudeUsageMonitor_arm64"
4343
$SOURCES \
4444
-framework SwiftUI \
4545
-framework AppKit \
46+
-framework WebKit \
4647
-target arm64-apple-macos12.0
4748

4849
# Compile for x86_64
4950
swiftc -parse-as-library -o "$APP_PATH/Contents/MacOS/ClaudeUsageMonitor_x86_64" \
5051
$SOURCES \
5152
-framework SwiftUI \
5253
-framework AppKit \
54+
-framework WebKit \
5355
-target x86_64-apple-macos12.0
5456

5557
# Universal binary

0 commit comments

Comments
 (0)