diff --git a/ClaudeMeter/App/AppModel.swift b/ClaudeMeter/App/AppModel.swift index 6fa6b23..fb5e84c 100644 --- a/ClaudeMeter/App/AppModel.swift +++ b/ClaudeMeter/App/AppModel.swift @@ -55,7 +55,7 @@ final class AppModel { self.settingsRepository = settingsRepository self.keychainRepository = keychainRepository - let networkService = NetworkService() + let networkService = WebViewNetworkService() let cacheRepository = CacheRepository() let usageService = usageService ?? UsageService( networkService: networkService, @@ -142,8 +142,9 @@ final class AppModel { } let organizations = try await usageService.fetchOrganizations(sessionKey: sessionKey) - guard let firstOrg = organizations.first, - let orgUUID = firstOrg.organizationUUID else { + // Prefer organization with chat capability (Claude.ai usage), fall back to first + guard let chatOrg = organizations.first(where: { $0.hasChatCapability }) ?? organizations.first, + let orgUUID = chatOrg.organizationUUID else { throw AppError.organizationNotFound } diff --git a/ClaudeMeter/Models/Errors/NetworkError.swift b/ClaudeMeter/Models/Errors/NetworkError.swift index b9e7b38..4450661 100644 --- a/ClaudeMeter/Models/Errors/NetworkError.swift +++ b/ClaudeMeter/Models/Errors/NetworkError.swift @@ -16,6 +16,7 @@ enum NetworkError: LocalizedError { case httpError(statusCode: Int) case decodingFailed(underlyingError: Error) case networkUnavailable + case timeout var errorDescription: String? { switch self { @@ -33,6 +34,8 @@ enum NetworkError: LocalizedError { return "Failed to parse server response" case .networkUnavailable: return "No internet connection" + case .timeout: + return "Request timed out" } } } diff --git a/ClaudeMeter/Models/Organization.swift b/ClaudeMeter/Models/Organization.swift index c389bd4..2a686f9 100644 --- a/ClaudeMeter/Models/Organization.swift +++ b/ClaudeMeter/Models/Organization.swift @@ -18,14 +18,23 @@ struct Organization: Codable, Equatable, Sendable { /// Organization display name let name: String + /// Organization capabilities (e.g., "api", "chat", "claude_max") + let capabilities: [String]? + enum CodingKeys: String, CodingKey { case id case uuid case name + case capabilities } /// Convert uuid string to UUID var organizationUUID: UUID? { UUID(uuidString: uuid) } + + /// Check if this organization has Claude.ai chat capability + var hasChatCapability: Bool { + capabilities?.contains("chat") ?? false + } } diff --git a/ClaudeMeter/Services/Protocols/NetworkServiceProtocol.swift b/ClaudeMeter/Services/Protocols/NetworkServiceProtocol.swift index 16e16b6..51378bc 100644 --- a/ClaudeMeter/Services/Protocols/NetworkServiceProtocol.swift +++ b/ClaudeMeter/Services/Protocols/NetworkServiceProtocol.swift @@ -14,7 +14,7 @@ enum HTTPMethod: String { } /// Protocol for network operations -protocol NetworkServiceProtocol: Actor { +protocol NetworkServiceProtocol: Sendable { /// Perform a generic HTTP request func request( _ endpoint: String, diff --git a/ClaudeMeter/Services/UsageService.swift b/ClaudeMeter/Services/UsageService.swift index 71b3e82..c761b13 100644 --- a/ClaudeMeter/Services/UsageService.swift +++ b/ClaudeMeter/Services/UsageService.swift @@ -65,8 +65,9 @@ actor UsageService: UsageServiceProtocol { } else { // Fetch organizations to get ID let orgs = try await fetchOrganizations() - guard let firstOrg = orgs.first, - let uuid = firstOrg.organizationUUID else { + // Prefer organization with chat capability (Claude.ai usage), fall back to first + guard let chatOrg = orgs.first(where: { $0.hasChatCapability }) ?? orgs.first, + let uuid = chatOrg.organizationUUID else { throw AppError.organizationNotFound } organizationId = uuid diff --git a/ClaudeMeter/Services/WebViewNetworkService.swift b/ClaudeMeter/Services/WebViewNetworkService.swift new file mode 100644 index 0000000..56a4563 --- /dev/null +++ b/ClaudeMeter/Services/WebViewNetworkService.swift @@ -0,0 +1,259 @@ +// +// WebViewNetworkService.swift +// ClaudeMeter +// +// Created by Edd on 2025-11-14. +// + +import Foundation +import WebKit +import os + +/// Network service using WKWebView to bypass Cloudflare bot protection +/// WKWebView uses the same TLS stack as Safari, so Cloudflare accepts its requests +@MainActor +final class WebViewNetworkService: NSObject, NetworkServiceProtocol { + nonisolated static let logger = Logger(subsystem: "com.claudemeter", category: "WebViewNetworkService") + + private var webView: WKWebView? + private var continuation: CheckedContinuation? + private var currentSessionKey: String? + private let timeoutSeconds: Double = 30 + private let maxChallengeWaitSeconds: Double = 15 + private var challengeRetryCount = 0 + private let maxChallengeRetries = 30 + + override init() { + super.init() + } + + /// Perform a generic HTTP request using WKWebView + func request( + _ endpoint: String, + method: HTTPMethod, + sessionKey: String + ) async throws -> T { + let data = try await performRequest(endpoint, method: method, sessionKey: sessionKey) + + // Decode response + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: data) + } catch { + let responseBody = String(data: data, encoding: .utf8) ?? "" + Self.logger.error("Failed to decode response from \(endpoint): \(error.localizedDescription)\nResponse: \(responseBody)") + throw NetworkError.decodingFailed(underlyingError: error) + } + } + + private func performRequest( + _ endpoint: String, + method: HTTPMethod, + sessionKey: String + ) async throws -> Data { + // Validate HTTPS + guard endpoint.hasPrefix("https://") else { + throw NetworkError.invalidURL + } + + guard let url = URL(string: endpoint) else { + throw NetworkError.invalidURL + } + + Self.logger.info("Making request to: \(endpoint)") + + // Store session key for cookie injection + currentSessionKey = sessionKey + + // Reset challenge retry count + challengeRetryCount = 0 + + // Create or reuse WebView + let webView = getOrCreateWebView() + + // Set the session key cookie + let cookie = HTTPCookie(properties: [ + .domain: ".claude.ai", + .path: "/", + .name: "sessionKey", + .value: sessionKey, + .secure: true, + .expires: Date().addingTimeInterval(86400 * 30) + ])! + + await webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) + + // Load the URL and wait for response + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + // Set up timeout + Task { @MainActor in + try? await Task.sleep(for: .seconds(self.timeoutSeconds)) + if self.continuation != nil { + self.continuation?.resume(throwing: NetworkError.timeout) + self.continuation = nil + } + } + + webView.load(URLRequest(url: url)) + } + } + + private func getOrCreateWebView() -> WKWebView { + if let existing = webView { + return existing + } + + let config = WKWebViewConfiguration() + config.websiteDataStore = WKWebsiteDataStore.default() + + // Set up preferences + let prefs = WKWebpagePreferences() + prefs.allowsContentJavaScript = true + config.defaultWebpagePreferences = prefs + + let wv = WKWebView(frame: .zero, configuration: config) + wv.navigationDelegate = self + wv.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" + + self.webView = wv + return wv + } + + private func extractJSON() { + guard let webView = webView else { + continuation?.resume(throwing: NetworkError.invalidResponse) + continuation = nil + return + } + + // Try to get raw JSON content - first check for pre tag (raw JSON view), then body text + let script = """ + (function() { + // Try pre tag first (raw JSON response) + var pre = document.querySelector('pre'); + if (pre) return pre.innerText; + // Fall back to body text + return document.body.innerText; + })() + """ + webView.evaluateJavaScript(script) { [weak self] result, error in + guard let self = self else { return } + + if let error = error { + Self.logger.error("JavaScript evaluation failed: \(error.localizedDescription)") + self.continuation?.resume(throwing: NetworkError.invalidResponse) + self.continuation = nil + return + } + + guard let text = result as? String else { + self.continuation?.resume(throwing: NetworkError.invalidResponse) + self.continuation = nil + return + } + + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + + + // Check if the response looks like JSON (starts with [ or {) + let looksLikeJSON = trimmed.hasPrefix("[") || trimmed.hasPrefix("{") + + // Check for Cloudflare challenge page + let isChallengePage = text.contains("Just a moment") || + text.contains("Enable JavaScript") || + text.contains("Checking your browser") || + text.isEmpty + + + if isChallengePage || !looksLikeJSON { + // Still on challenge page or page not ready, retry + self.challengeRetryCount += 1 + + if self.challengeRetryCount < self.maxChallengeRetries { + Self.logger.info("Waiting for Cloudflare challenge to complete (attempt \(self.challengeRetryCount)/\(self.maxChallengeRetries))") + Task { + try? await Task.sleep(for: .milliseconds(500)) + self.extractJSON() + } + return + } else { + Self.logger.error("Cloudflare challenge did not complete in time") + self.continuation?.resume(throwing: NetworkError.httpError(statusCode: 403)) + self.continuation = nil + return + } + } + + // Reset retry count for next request + self.challengeRetryCount = 0 + + guard let data = trimmed.data(using: .utf8) else { + self.continuation?.resume(throwing: NetworkError.invalidResponse) + self.continuation = nil + return + } + + Self.logger.info("Successfully extracted JSON response") + self.continuation?.resume(returning: data) + self.continuation = nil + } + } +} + +// MARK: - WKNavigationDelegate + +extension WebViewNetworkService: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // Small delay to ensure page is fully rendered + Task { + try? await Task.sleep(for: .milliseconds(100)) + self.extractJSON() + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + Self.logger.error("Navigation failed: \(error.localizedDescription)") + self.continuation?.resume(throwing: NetworkError.networkUnavailable) + self.continuation = nil + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + Self.logger.error("Provisional navigation failed: \(error.localizedDescription)") + self.continuation?.resume(throwing: NetworkError.networkUnavailable) + self.continuation = nil + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if let httpResponse = navigationResponse.response as? HTTPURLResponse { + let statusCode = httpResponse.statusCode + + if statusCode == 401 { + self.continuation?.resume(throwing: NetworkError.authenticationFailed) + self.continuation = nil + decisionHandler(.cancel) + return + } + + if statusCode == 429 { + self.continuation?.resume(throwing: NetworkError.rateLimitExceeded) + self.continuation = nil + decisionHandler(.cancel) + return + } + + // Log non-2xx responses but allow them to proceed (Cloudflare might serve 403 then redirect) + if !(200...299).contains(statusCode) { + Self.logger.warning("HTTP \(statusCode) response, allowing navigation to continue") + } + } + + decisionHandler(.allow) + } +}