diff --git a/ContextPanel.xcodeproj/project.pbxproj b/ContextPanel.xcodeproj/project.pbxproj index dd75dfa..96bffab 100644 --- a/ContextPanel.xcodeproj/project.pbxproj +++ b/ContextPanel.xcodeproj/project.pbxproj @@ -31,7 +31,6 @@ B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D8515D047F5A8117DB762C /* SnapshotStore.swift */; }; B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */; }; BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; }; - BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */; }; C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */; }; C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */; }; E223E001AF708A36F7976205 /* WidgetDisplayPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDCF9F49A90CA3576BF2792 /* WidgetDisplayPreferences.swift */; }; @@ -119,7 +118,6 @@ 45E8D3C3E45C1B6B3FACE828 /* SnapshotRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotRefreshService.swift; sourceTree = ""; }; 51474C30B520517CB01103A9 /* ClaudeStatuslineSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeStatuslineSetup.swift; sourceTree = ""; }; 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; - 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWebUsage.swift; sourceTree = ""; }; 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitProbe.swift; sourceTree = ""; }; 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastModeForecast.swift; sourceTree = ""; }; 8D6350007C73BEF27D871AB0 /* ContextPanel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ContextPanel.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -183,7 +181,6 @@ B8862B929FE544FCB2C3D06B /* CapacityPool.swift */, E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */, 51474C30B520517CB01103A9 /* ClaudeStatuslineSetup.swift */, - 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */, 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */, 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */, 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */, @@ -433,7 +430,6 @@ 198493F605C07AB87B0BDFA9 /* CapacityPool.swift in Sources */, 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */, 7BA3559FC81F11DF2B9493F9 /* ClaudeStatuslineSetup.swift in Sources */, - BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */, 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */, C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */, 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */, diff --git a/Package.swift b/Package.swift index bd3afce..5893345 100644 --- a/Package.swift +++ b/Package.swift @@ -40,10 +40,6 @@ let package = Package( name: "ClaudeStatuslineSetup", targets: ["ClaudeStatuslineSetup"] ), - .executable( - name: "ClaudeWebUsageProbe", - targets: ["ClaudeWebUsageProbe"] - ), .executable( name: "SnapshotStoreProbe", targets: ["SnapshotStoreProbe"] @@ -83,10 +79,6 @@ let package = Package( name: "ClaudeStatuslineSetup", dependencies: ["ContextPanelCore"] ), - .executableTarget( - name: "ClaudeWebUsageProbe", - dependencies: ["ContextPanelCore"] - ), .executableTarget( name: "SnapshotStoreProbe", dependencies: ["ContextPanelCore"] diff --git a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift deleted file mode 100644 index aa91d20..0000000 --- a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift +++ /dev/null @@ -1,583 +0,0 @@ -import ContextPanelCore -import AppKit -import SwiftUI -import UniformTypeIdentifiers -import WebKit - -@main -struct ClaudeWebUsageProbeApp: App { - init() { - NSApplication.shared.setActivationPolicy(.regular) - DispatchQueue.main.async { - NSApplication.shared.activate(ignoringOtherApps: true) - } - } - - var body: some Scene { - WindowGroup { - ClaudeUsageProbeRootView() - .frame(minWidth: 1180, minHeight: 760) - } - } -} - -struct ClaudeUsageProbeRootView: View { - @StateObject private var model = ClaudeUsageProbeModel() - - var body: some View { - HStack(spacing: 0) { - sidebar - .frame(width: 390) - .padding(18) - .background(Color(nsColor: .controlBackgroundColor)) - - Divider() - - ClaudeProbeWebView(model: model) - } - .fileExporter( - isPresented: $model.isExportingReport, - document: ProbeReportDocument(report: model.reportMarkdown), - contentType: .plainText, - defaultFilename: "claude-web-usage-probe-report.md" - ) { _ in } - } - - private var sidebar: some View { - VStack(alignment: .leading, spacing: 14) { - header - controls - status - Divider() - capturedLimits - sanitizedFields - Spacer() - safetyFooter - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Claude Usage Probe") - .font(.system(size: 24, weight: .semibold)) - Text("Log in to Claude in this window, open Usage, then capture official subscription windows from the authenticated page context.") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - } - - private var controls: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Button("Open Usage") { model.openUsagePage() } - Button("Reload") { model.reload() } - Button("Export") { model.exportReport() } - } - - HStack { - Button("Save Snapshot") { model.saveSnapshot() } - .disabled(model.limits.isEmpty) - Button("Clear") { model.clear() } - } - } - } - - private var status: some View { - VStack(alignment: .leading, spacing: 6) { - Label(model.statusText, systemImage: model.statusIcon) - .font(.system(size: 12, weight: .medium)) - Text(model.currentURLText) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.tertiary) - .lineLimit(2) - } - .foregroundStyle(model.hasCapturedUsage ? .primary : .secondary) - } - - private var capturedLimits: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Captured windows") - .font(.system(size: 12, weight: .semibold)) - .textCase(.uppercase) - .foregroundStyle(.secondary) - Spacer() - Text("\(model.limits.count)") - .font(.system(.caption, design: .monospaced, weight: .medium)) - .foregroundStyle(.secondary) - } - - if model.limits.isEmpty { - ContentUnavailableView( - "No usage windows yet", - systemImage: "gauge.with.dots.needle.67percent", - description: Text("Complete Claude login or verification, then wait for the Usage page to load.") - ) - .frame(maxHeight: 220) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - ForEach(model.limits) { limit in - ClaudeUsageLimitRow(limit: limit) - } - } - } - .frame(maxHeight: 280) - } - } - } - - private var sanitizedFields: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Sanitized fields") - .font(.system(size: 12, weight: .semibold)) - .textCase(.uppercase) - .foregroundStyle(.secondary) - Spacer() - Text("\(model.fieldPaths.count)") - .font(.system(.caption, design: .monospaced, weight: .medium)) - .foregroundStyle(.secondary) - } - - if model.fieldPaths.isEmpty { - Text("No Claude usage response fields captured yet.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(model.fieldPaths.prefix(18), id: \.self) { field in - Text(field) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxHeight: 160) - } - } - } - - private var safetyFooter: some View { - VStack(alignment: .leading, spacing: 6) { - Label("Login stays inside the visible web session.", systemImage: "person.crop.circle.badge.checkmark") - Label("Only percent windows, reset times, and field paths leave the page.", systemImage: "lock.shield") - Label("No cookies, auth headers, tokens, local storage, emails, org IDs, or raw bodies are stored.", systemImage: "eye.slash") - } - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } -} - -struct ClaudeUsageLimitRow: View { - let limit: UsageLimit - - var body: some View { - VStack(alignment: .leading, spacing: 7) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 2) { - Text(limit.displayLabel) - .font(.system(size: 13, weight: .semibold)) - Text(limit.contextLabel) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - Spacer() - Text(percentText) - .font(.system(size: 18, weight: .semibold, design: .rounded)) - } - - ProgressView(value: limit.usageRatio ?? 0) - .tint(tint) - - Text(resetText) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.tertiary) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - private var percentText: String { - guard let ratio = limit.usageRatio else { return "?" } - return "\(Int((ratio * 100).rounded()))%" - } - - private var resetText: String { - guard let resetsAt = limit.resetsAt else { return "reset unknown" } - return "resets " + resetsAt.formatted(date: .abbreviated, time: .shortened) - } - - private var tint: Color { - switch limit.status { - case .limited: - .red - case .close: - .orange - case .healthy: - .green - default: - .secondary - } - } -} - -@MainActor -final class ClaudeUsageProbeModel: ObservableObject { - @Published var limits: [UsageLimit] = [] - @Published var fieldPaths: [String] = [] - @Published var statusText = "Waiting for Claude usage response" - @Published var statusIcon = "clock" - @Published var currentURLText = "" - @Published var isExportingReport = false - - private let snapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory()) - - private lazy var navigationDelegate = ClaudeUsageNavigationDelegate(owner: self) - - lazy var webView: WKWebView = { - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .default() - configuration.userContentController.add(ClaudeUsageScriptHandler(owner: self), name: "claudeUsageProbe") - configuration.userContentController.addUserScript( - WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) - ) - - let view = WKWebView(frame: .zero, configuration: configuration) - view.navigationDelegate = navigationDelegate - return view - }() - - init() { - openUsagePage() - } - - var hasCapturedUsage: Bool { !limits.isEmpty } - - var reportMarkdown: String { - var lines = [ - "# Claude Web Usage Probe Report", - "", - "- Captured: \(Date().ISO8601Format())", - "- Windows: \(limits.count)", - "- Sanitized fields: \(fieldPaths.count)", - "", - "## Captured Windows", - ] - - if limits.isEmpty { - lines.append("- No usage windows captured.") - } else { - for limit in limits { - let percent = limit.used.map { "\($0)%" } ?? "unknown" - let reset = limit.resetsAt?.ISO8601Format() ?? "unknown reset" - lines.append("- \(limit.displayLabel) / \(limit.contextLabel): \(percent), resets \(reset)") - } - } - - lines.append(contentsOf: [ - "", - "## Sanitized Fields", - ]) - - if fieldPaths.isEmpty { - lines.append("- No field paths captured.") - } else { - lines.append(contentsOf: fieldPaths.map { "- `\($0)`" }) - } - - lines.append(contentsOf: [ - "", - "## Redactions", - "- cookies", - "- authorization headers", - "- bearer tokens", - "- Keychain credentials", - "- OAuth tokens", - "- local storage", - "- account and organization identifiers", - "- emails", - "- raw response bodies", - "- transcripts and prompt/response content", - ]) - - return lines.joined(separator: "\n") - } - - func openUsagePage() { - load("https://claude.ai/settings/usage") - } - - func reload() { - statusText = "Reloading Claude usage page" - statusIcon = "arrow.clockwise" - webView.reload() - } - - func clear() { - limits = [] - fieldPaths = [] - statusText = "Waiting for Claude usage response" - statusIcon = "clock" - } - - func exportReport() { - isExportingReport = true - } - - func saveSnapshot() { - guard !limits.isEmpty else { return } - do { - try saveCurrentSnapshot() - statusText = "Saved sanitized Claude usage snapshot" - statusIcon = "checkmark.circle" - } catch { - statusText = "Save failed: \(error.localizedDescription)" - statusIcon = "exclamationmark.triangle" - } - } - - fileprivate func record(payload: [String: Any]) { - let windows = payload["windows"] as? [String: Any] ?? [:] - let fields = payload["fields"] as? [String] ?? [] - let wrapped = ["rate_limits": windows] - - do { - let data = try JSONSerialization.data(withJSONObject: wrapped) - let parsedLimits = try ClaudeWebUsageParser.usageLimits( - from: data, - accountID: "claude-web", - accountName: "Claude Web", - observedAt: Date() - ) - guard !parsedLimits.isEmpty else { - statusText = "Usage response found, but no percent windows were present" - statusIcon = "questionmark.circle" - return - } - - limits = parsedLimits - fieldPaths = Array(Set(fields)).sorted() - saveSnapshotAfterCapture() - } catch { - statusText = "Capture failed: \(error.localizedDescription)" - statusIcon = "exclamationmark.triangle" - } - } - - private func saveSnapshotAfterCapture() { - do { - try saveCurrentSnapshot() - statusText = "Captured and saved Claude subscription usage" - statusIcon = "checkmark.circle.fill" - } catch { - statusText = "Captured Claude usage; save failed: \(error.localizedDescription)" - statusIcon = "exclamationmark.triangle" - } - } - - private func saveCurrentSnapshot() throws { - let report = ProviderConnectorReport( - provider: .anthropic, - accountID: "claude-web", - accountName: "Claude Web", - generatedAt: Date(), - limits: limits, - status: .healthy - ) - try snapshotStore.saveMerged( - refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]), - savedAt: Date() - ) - } - - fileprivate func updateCurrentURL(_ url: URL?) { - currentURLText = url?.absoluteString ?? "" - if let host = url?.host, host.contains("claude.ai"), limits.isEmpty { - statusText = "Claude page loaded; waiting for usage API" - statusIcon = "network" - } - } - - private func load(_ rawURL: String) { - guard let url = URL(string: rawURL) else { return } - statusText = "Opening Claude usage page" - statusIcon = "safari" - webView.load(URLRequest(url: url)) - } - - private static let networkProbeScript = #""" - (() => { - if (window.__contextPanelClaudeUsageProbeInstalled) return; - window.__contextPanelClaudeUsageProbeInstalled = true; - - const windowKeys = new Set([ - 'five_hour', - 'seven_day', - 'seven_day_opus', - 'seven_day_sonnet', - 'seven_day_oauth_apps' - ]); - const fieldKeys = new Set([ - 'used_percentage', - 'remaining_percentage', - 'utilization', - 'resets_at', - 'reset_at' - ]); - - function isUsageURL(rawUrl) { - try { - const url = new URL(rawUrl, window.location.href); - return /^\/api\/organizations\/[^/]+\/usage$/.test(url.pathname); - } catch (_) { - return false; - } - } - - function sanitizeWindow(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const sanitized = {}; - for (const key of fieldKeys) { - const raw = value[key]; - if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw; - } - return Object.keys(sanitized).length ? sanitized : null; - } - - function collectWindows(value, out = {}) { - if (!value || typeof value !== 'object') return out; - if (Array.isArray(value)) { - value.slice(0, 3).forEach(item => collectWindows(item, out)); - return out; - } - for (const [key, child] of Object.entries(value)) { - if (windowKeys.has(key)) { - const sanitized = sanitizeWindow(child); - if (sanitized) out[key] = sanitized; - } - collectWindows(child, out); - } - return out; - } - - function collectFields(value, prefix = '', out = new Set()) { - if (!value || typeof value !== 'object' || out.size > 80) return out; - if (Array.isArray(value)) { - value.slice(0, 3).forEach(item => collectFields(item, prefix, out)); - return out; - } - for (const [key, child] of Object.entries(value)) { - const path = prefix ? `${prefix}.${key}` : key; - if (windowKeys.has(key) || fieldKeys.has(key) || key === 'rate_limits' || key === 'usage') out.add(path); - collectFields(child, path, out); - } - return out; - } - - function post(payload) { - try { window.webkit.messageHandlers.claudeUsageProbe.postMessage(payload); } - catch (_) {} - } - - function inspect(url, contentType, text) { - if (!isUsageURL(url) || !/json/i.test(contentType || '')) return; - try { - const parsed = JSON.parse(String(text || '')); - const windows = collectWindows(parsed); - if (!Object.keys(windows).length) return; - post({ windows, fields: Array.from(collectFields(parsed)).slice(0, 80) }); - } catch (_) {} - } - - const originalFetch = window.fetch; - if (originalFetch) { - window.fetch = async function(input, init) { - const response = await originalFetch.apply(this, arguments); - try { - const clone = response.clone(); - const url = typeof input === 'string' ? input : (input && input.url) || ''; - clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {}); - } catch (_) {} - return response; - }; - } - - const originalOpen = XMLHttpRequest.prototype.open; - const originalSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function(method, url) { - this.__cpClaudeUsageUrl = url; - return originalOpen.apply(this, arguments); - }; - XMLHttpRequest.prototype.send = function() { - this.addEventListener('load', function() { - try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); } - catch (_) {} - }); - return originalSend.apply(this, arguments); - }; - })(); - """# -} - -final class ClaudeUsageScriptHandler: NSObject, WKScriptMessageHandler { - weak var owner: ClaudeUsageProbeModel? - - init(owner: ClaudeUsageProbeModel) { - self.owner = owner - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let payload = message.body as? [String: Any] else { return } - Task { @MainActor [weak owner = self.owner] in - owner?.record(payload: payload) - } - } -} - -final class ClaudeUsageNavigationDelegate: NSObject, WKNavigationDelegate { - weak var owner: ClaudeUsageProbeModel? - - init(owner: ClaudeUsageProbeModel) { - self.owner = owner - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Task { @MainActor [weak webView, weak owner] in - owner?.updateCurrentURL(webView?.url) - } - } -} - -struct ClaudeProbeWebView: NSViewRepresentable { - @ObservedObject var model: ClaudeUsageProbeModel - - func makeNSView(context: Context) -> WKWebView { - model.webView - } - - func updateNSView(_ nsView: WKWebView, context: Context) {} -} - -struct ProbeReportDocument: FileDocument { - static var readableContentTypes: [UTType] { [.plainText] } - - var report: String - - init(report: String) { - self.report = report - } - - init(configuration: ReadConfiguration) throws { - report = "" - } - - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: Data(report.utf8)) - } -} diff --git a/Sources/ContextPanelCore/ClaudeWebUsage.swift b/Sources/ContextPanelCore/ClaudeWebUsage.swift deleted file mode 100644 index 6a46f04..0000000 --- a/Sources/ContextPanelCore/ClaudeWebUsage.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation - -public enum ClaudeWebUsageParser { - public static func usageLimits( - from data: Data, - accountID: String, - accountName: String, - observedAt: Date - ) throws -> [UsageLimit] { - let payload = try JSONSerialization.jsonObject(with: data) - guard let root = payload as? [String: Any] else { return [] } - - let windows: [(key: String, label: String, model: String?)] = [ - ("five_hour", "5-hour", nil), - ("seven_day", "7-day", nil), - ("seven_day_opus", "7-day", "Opus"), - ("seven_day_sonnet", "7-day", "Sonnet"), - ("seven_day_oauth_apps", "7-day", "OAuth apps"), - ] - - return windows.compactMap { window in - guard let object = findObject(named: window.key, in: root) else { return nil } - let usedPercentage = percentValue(for: ["used_percentage", "utilization"], in: object) - let remainingPercentage = percentValue(for: ["remaining_percentage"], in: object) - let used = usedPercentage ?? remainingPercentage.map { max(0, 100 - $0) } - guard let used else { return nil } - - let roundedUsed = min(max(Int(used.rounded()), 0), 100) - return UsageLimit( - id: "anthropic:\(accountID):claude-web:\(window.key)", - provider: .anthropic, - accountID: accountID, - accountName: accountName, - label: "Claude \(window.label)", - windowLabel: window.label, - modelLabel: window.model ?? "Claude subscription", - unit: .percent, - used: roundedUsed, - limit: 100, - resetsAt: resetDate(in: object), - lastUpdatedAt: observedAt, - confidence: .observed, - note: "source: Claude web usage endpoint; authenticated web session required" - ) - } - } - - public static func sanitizedUsageFields(from data: Data) throws -> [String] { - let payload = try JSONSerialization.jsonObject(with: data) - return Array(collectUsageFields(payload).sorted()) - } - - private static func findObject(named key: String, in value: Any) -> [String: Any]? { - if let dictionary = value as? [String: Any] { - if let found = dictionary[key] as? [String: Any] { - return found - } - for child in dictionary.values { - if let found = findObject(named: key, in: child) { - return found - } - } - } else if let array = value as? [Any] { - for child in array { - if let found = findObject(named: key, in: child) { - return found - } - } - } - return nil - } - - private static func percentValue(for keys: [String], in object: [String: Any]) -> Double? { - for key in keys { - guard let raw = numericValue(object[key]) else { continue } - return raw <= 1 ? raw * 100 : raw - } - return nil - } - - private static func resetDate(in object: [String: Any]) -> Date? { - guard let raw = object["resets_at"] ?? object["reset_at"] else { return nil } - if let number = numericValue(raw) { - return Date(timeIntervalSince1970: number > 10_000_000_000 ? number / 1000 : number) - } - if let string = raw as? String { - return ISO8601DateFormatter().date(from: string) - } - return nil - } - - private static func numericValue(_ value: Any?) -> Double? { - switch value { - case let value as Double: - value - case let value as Int: - Double(value) - case let value as NSNumber: - value.doubleValue - case let value as String: - Double(value) - default: - nil - } - } - - private static func collectUsageFields(_ value: Any, prefix: String = "", output: Set = []) -> Set { - var output = output - let allowed = [ - "five_hour", - "seven_day", - "seven_day_opus", - "seven_day_sonnet", - "seven_day_oauth_apps", - "used_percentage", - "remaining_percentage", - "utilization", - "resets_at", - "reset_at", - "rate_limits", - "usage", - ] - - if let dictionary = value as? [String: Any] { - for (key, child) in dictionary { - let path = prefix.isEmpty ? key : "\(prefix).\(key)" - if allowed.contains(key) { - output.insert(path) - } - output = collectUsageFields(child, prefix: path, output: output) - } - } else if let array = value as? [Any] { - for child in array.prefix(3) { - output = collectUsageFields(child, prefix: prefix, output: output) - } - } - - return output - } -} diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index e1d2bac..efc22e4 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -3,7 +3,6 @@ import AppKit import ServiceManagement import SwiftUI import WidgetKit -import WebKit @main struct ContextPanelPreviewApp: App { @@ -66,10 +65,6 @@ struct AppRoot: View { .frame(minWidth: 760) } .tint(CPTheme.accent) - .sheet(isPresented: $model.isClaudeWebCapturePresented) { - ClaudeWebCaptureSheet(model: model) - .frame(minWidth: 980, minHeight: 680) - } } } @@ -392,12 +387,6 @@ struct AccountsSidebar: View { } .disabled(model.isRefreshing) - Button { - model.openClaudeWebCapture() - } label: { - Label("Claude Web", systemImage: "gauge.with.dots.needle.67percent") - .frame(maxWidth: .infinity) - } } .buttonStyle(.bordered) .controlSize(.large) @@ -1146,7 +1135,6 @@ final class ContextPanelAppModel: ObservableObject { @Published private(set) var configuredAccounts: [LocalProviderAccountConfiguration] = [] @Published private(set) var fastModeForecastSettings: FastModeForecastSettings = .defaultSettings @Published private(set) var isRefreshing = false - @Published var isClaudeWebCapturePresented = false @Published private(set) var errorMessage: String? @Published private(set) var lastRefreshAt: Date? @@ -1213,48 +1201,6 @@ final class ContextPanelAppModel: ObservableObject { errorMessage = ConnectorRedactor.redact(message) } - func openClaudeWebCapture() { - isClaudeWebCapturePresented = true - } - - func closeClaudeWebCapture() { - isClaudeWebCapturePresented = false - } - - func saveClaudeWebLimits(_ limits: [UsageLimit]) { - guard !limits.isEmpty else { return } - Task { await saveClaudeWebLimitsAsync(limits) } - } - - private func saveClaudeWebLimitsAsync(_ limits: [UsageLimit]) async { - let savedAt = Date() - let report = ProviderConnectorReport( - provider: .anthropic, - accountID: "claude-web", - accountName: "Claude Web", - generatedAt: savedAt, - limits: limits, - status: .healthy - ) - do { - let decision = try await refreshRunner.saveMerged( - refreshResult: ConnectorRefreshResult(generatedAt: savedAt, reports: [report]), - savedAt: savedAt, - retryFor: .seconds(5) - ) - guard case .refreshed = decision else { - setError("Snapshot is refreshing. Try saving Claude Web usage again in a moment.") - return - } - lastRefreshAt = savedAt - loadSnapshot() - WidgetCenter.shared.reloadAllTimelines() - } catch { - storeStatus = .failure - errorMessage = error.localizedDescription - } - } - func relativeTime(_ date: Date) -> String { let seconds = max(Int(Date().timeIntervalSince(date)), 0) if seconds < 60 { return "just now" } @@ -1395,289 +1341,6 @@ struct StatusMark: View { } } -struct ClaudeWebCaptureSheet: View { - @ObservedObject var model: ContextPanelAppModel - @StateObject private var captureModel = ClaudeWebCaptureModel() - - var body: some View { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 8) { - Text("Claude Web") - .font(.system(size: 22, weight: .semibold)) - Text("Complete Claude verification here. The app captures only official usage windows from the Usage page.") - .font(.system(size: 13)) - .foregroundStyle(CPTheme.secondaryText) - } - - HStack { - Button("Open Usage") { captureModel.openUsagePage() } - Button("Reload") { captureModel.reload() } - Spacer() - Button("Done") { model.closeClaudeWebCapture() } - } - - Label(captureModel.statusText, systemImage: captureModel.statusIcon) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(captureModel.limits.isEmpty ? CPTheme.secondaryText : CPTheme.primaryText) - - Divider() - - Text("Captured windows") - .font(.system(size: 11, weight: .semibold)) - .textCase(.uppercase) - .foregroundStyle(CPTheme.secondaryText) - - if captureModel.limits.isEmpty { - ContentUnavailableView( - "Waiting for Claude usage", - systemImage: "network", - description: Text("The sheet auto-saves when Claude's usage endpoint returns percent windows.") - ) - .frame(maxHeight: 220) - } else { - ScrollView { - VStack(spacing: 8) { - ForEach(captureModel.limits) { limit in - ClaudeWebCaptureLimitRow(limit: limit) - } - } - } - } - - Spacer() - - VStack(alignment: .leading, spacing: 6) { - Label("No cookies, tokens, headers, IDs, emails, local storage, or raw bodies are stored.", systemImage: "lock.shield") - Label("Saved rows are merged with OpenAI and Gemini instead of replacing them.", systemImage: "square.stack.3d.up") - } - .font(.system(size: 11)) - .foregroundStyle(CPTheme.secondaryText) - } - .frame(width: 330) - .padding(18) - .background(CPTheme.surface) - - Divider() - - ClaudeWebCaptureWebView(model: captureModel) - } - .onReceive(captureModel.$limits) { limits in - guard !limits.isEmpty else { return } - model.saveClaudeWebLimits(limits) - } - } -} - -struct ClaudeWebCaptureLimitRow: View { - let limit: UsageLimit - - var body: some View { - VStack(alignment: .leading, spacing: 7) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(limit.displayLabel) - .font(.system(size: 13, weight: .semibold)) - Text(limit.contextLabel) - .font(.system(size: 11)) - .foregroundStyle(CPTheme.secondaryText) - } - Spacer() - Text(limit.compactUsageText) - .font(.system(size: 18, weight: .semibold, design: .rounded)) - } - CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) - Text(limit.resetText) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(CPTheme.tertiaryText) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(CPTheme.background) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay(CPTheme.stroke(cornerRadius: 8)) - } -} - -@MainActor -final class ClaudeWebCaptureModel: ObservableObject { - @Published var limits: [UsageLimit] = [] - @Published var statusText = "Opening Claude usage page" - @Published var statusIcon = "safari" - - private lazy var navigationDelegate = ClaudeWebCaptureNavigationDelegate(owner: self) - - lazy var webView: WKWebView = { - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .default() - configuration.userContentController.add(ClaudeWebCaptureScriptHandler(owner: self), name: "claudeUsageCapture") - configuration.userContentController.addUserScript( - WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) - ) - let view = WKWebView(frame: .zero, configuration: configuration) - view.navigationDelegate = navigationDelegate - return view - }() - - init() { - openUsagePage() - } - - func openUsagePage() { - statusText = "Opening Claude usage page" - statusIcon = "safari" - webView.load(URLRequest(url: URL(string: "https://claude.ai/settings/usage")!)) - } - - func reload() { - statusText = "Reloading Claude usage page" - statusIcon = "arrow.clockwise" - webView.reload() - } - - fileprivate func record(payload: [String: Any]) { - let windows = payload["windows"] as? [String: Any] ?? [:] - let wrapped = ["rate_limits": windows] - do { - let data = try JSONSerialization.data(withJSONObject: wrapped) - let parsed = try ClaudeWebUsageParser.usageLimits( - from: data, - accountID: "claude-web", - accountName: "Claude Web", - observedAt: Date() - ) - guard !parsed.isEmpty else { return } - limits = parsed - statusText = "Captured and saved Claude web usage" - statusIcon = "checkmark.circle.fill" - } catch { - statusText = "Capture failed: \(error.localizedDescription)" - statusIcon = "exclamationmark.triangle" - } - } - - fileprivate func didFinishNavigation(url: URL?) { - if let host = url?.host, host.contains("claude.ai"), limits.isEmpty { - statusText = "Claude page loaded; waiting for usage API" - statusIcon = "network" - } - } - - private static let networkProbeScript = #""" - (() => { - if (window.__contextPanelClaudeUsageCaptureInstalled) return; - window.__contextPanelClaudeUsageCaptureInstalled = true; - - const windowKeys = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet', 'seven_day_oauth_apps']); - const fieldKeys = new Set(['used_percentage', 'remaining_percentage', 'utilization', 'resets_at', 'reset_at']); - - function isUsageURL(rawUrl) { - try { return /^\/api\/organizations\/[^/]+\/usage$/.test(new URL(rawUrl, window.location.href).pathname); } - catch (_) { return false; } - } - - function sanitizeWindow(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const sanitized = {}; - for (const key of fieldKeys) { - const raw = value[key]; - if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw; - } - return Object.keys(sanitized).length ? sanitized : null; - } - - function collectWindows(value, out = {}) { - if (!value || typeof value !== 'object') return out; - if (Array.isArray(value)) { - value.slice(0, 3).forEach(item => collectWindows(item, out)); - return out; - } - for (const [key, child] of Object.entries(value)) { - if (windowKeys.has(key)) { - const sanitized = sanitizeWindow(child); - if (sanitized) out[key] = sanitized; - } - collectWindows(child, out); - } - return out; - } - - function post(payload) { - try { window.webkit.messageHandlers.claudeUsageCapture.postMessage(payload); } - catch (_) {} - } - - function inspect(url, contentType, text) { - if (!isUsageURL(url) || !/json/i.test(contentType || '')) return; - try { - const windows = collectWindows(JSON.parse(String(text || ''))); - if (Object.keys(windows).length) post({ windows }); - } catch (_) {} - } - - const originalFetch = window.fetch; - if (originalFetch) { - window.fetch = async function(input, init) { - const response = await originalFetch.apply(this, arguments); - try { - const clone = response.clone(); - const url = typeof input === 'string' ? input : (input && input.url) || ''; - clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {}); - } catch (_) {} - return response; - }; - } - - const originalOpen = XMLHttpRequest.prototype.open; - const originalSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function(method, url) { - this.__cpClaudeUsageUrl = url; - return originalOpen.apply(this, arguments); - }; - XMLHttpRequest.prototype.send = function() { - this.addEventListener('load', function() { - try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); } - catch (_) {} - }); - return originalSend.apply(this, arguments); - }; - })(); - """# -} - -final class ClaudeWebCaptureScriptHandler: NSObject, WKScriptMessageHandler { - weak var owner: ClaudeWebCaptureModel? - - init(owner: ClaudeWebCaptureModel) { - self.owner = owner - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let payload = message.body as? [String: Any] else { return } - Task { @MainActor [weak owner] in owner?.record(payload: payload) } - } -} - -final class ClaudeWebCaptureNavigationDelegate: NSObject, WKNavigationDelegate { - weak var owner: ClaudeWebCaptureModel? - - init(owner: ClaudeWebCaptureModel) { - self.owner = owner - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Task { @MainActor [weak webView, weak owner] in owner?.didFinishNavigation(url: webView?.url) } - } -} - -struct ClaudeWebCaptureWebView: NSViewRepresentable { - @ObservedObject var model: ClaudeWebCaptureModel - - func makeNSView(context: Context) -> WKWebView { model.webView } - - func updateNSView(_ nsView: WKWebView, context: Context) {} -} - struct Sparkline: View { let values: [Double] diff --git a/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift deleted file mode 100644 index 3e90790..0000000 --- a/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Testing - -@testable import ContextPanelCore - -@Test func claudeWebUsageParserBuildsSubscriptionPercentWindows() throws { - let payload = #""" - { - "rate_limits": { - "five_hour": { - "used_percentage": 42.4, - "resets_at": 1778109600 - }, - "seven_day": { - "remaining_percentage": 30, - "resets_at": "2026-05-08T01:00:00Z" - }, - "seven_day_opus": { - "utilization": 0.91, - "resets_at": 1778192400000 - } - }, - "account_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38" - } - """#.data(using: .utf8)! - - let limits = try ClaudeWebUsageParser.usageLimits( - from: payload, - accountID: "claude-local", - accountName: "Claude Max", - observedAt: Date(timeIntervalSince1970: 1) - ) - - #expect(limits.count == 3) - #expect(limits[0].label == "Claude 5-hour") - #expect(limits[0].windowLabel == "5-hour") - #expect(limits[0].used == 42) - #expect(limits[0].confidence == .observed) - #expect(limits[1].used == 70) - #expect(limits[2].modelLabel == "Opus") - #expect(limits[2].used == 91) -} - -@Test func claudeWebUsageSanitizerReturnsOnlyAllowedUsageFields() throws { - let payload = #""" - { - "rate_limits": { - "five_hour": { "used_percentage": 12, "resets_at": 1778109600 } - }, - "email": "chris@example.com", - "organization_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38" - } - """#.data(using: .utf8)! - - let fields = try ClaudeWebUsageParser.sanitizedUsageFields(from: payload) - - #expect(fields.contains("rate_limits")) - #expect(fields.contains("rate_limits.five_hour")) - #expect(fields.contains("rate_limits.five_hour.used_percentage")) - #expect(!fields.contains { $0.localizedCaseInsensitiveContains("email") }) - #expect(!fields.contains { $0.localizedCaseInsensitiveContains("uuid") }) -} diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift index 104e9aa..b5b3189 100644 --- a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift +++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift @@ -77,10 +77,10 @@ import Testing let claudeRefresh = ConnectorRefreshResult(generatedAt: second, reports: [ ProviderConnectorReport( provider: .anthropic, - accountID: "claude-web", - accountName: "Claude Web", + accountID: "claude-local", + accountName: "Claude", generatedAt: second, - limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 3, savedAt: second)] + limits: [usageLimit(provider: .anthropic, accountID: "claude-local", used: 3, savedAt: second)] ) ]) @@ -318,10 +318,10 @@ import Testing let savedAt = Date(timeIntervalSince1970: 800) let report = ProviderConnectorReport( provider: .anthropic, - accountID: "claude-web", - accountName: "Claude Web", + accountID: "claude-local", + accountName: "Claude", generatedAt: savedAt, - limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 20, savedAt: savedAt)], + limits: [usageLimit(provider: .anthropic, accountID: "claude-local", used: 20, savedAt: savedAt)], status: .healthy ) @@ -338,7 +338,7 @@ import Testing ) #expect(decision != .skippedAlreadyRunning) - #expect(primary.loadCurrent().snapshot?.snapshot.limits.first?.accountID == "claude-web") + #expect(primary.loadCurrent().snapshot?.snapshot.limits.first?.accountID == "claude-local") } @Test func snapshotRefreshRunnerSerializesManualSavesWithRefreshLock() async throws { @@ -358,10 +358,10 @@ import Testing let savedAt = Date(timeIntervalSince1970: 700) let report = ProviderConnectorReport( provider: .anthropic, - accountID: "claude-web", - accountName: "Claude Web", + accountID: "claude-local", + accountName: "Claude", generatedAt: savedAt, - limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 20, savedAt: savedAt)], + limits: [usageLimit(provider: .anthropic, accountID: "claude-local", used: 20, savedAt: savedAt)], status: .healthy ) diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index dbcec75..7cd76ce 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -216,19 +216,9 @@ blocked by Cloudflare before login/session reuse, so Context Panel has not yet proven it can call this endpoint without a user-visible web login context. We should not extract browser cookies, Keychain credentials, OAuth tokens, local storage, raw response bodies, transcripts, account UUIDs, or emails to force the -call. The next safe implementation path is a Claude web usage probe that runs in -a user-visible embedded web session and records only sanitized fields such as -`five_hour`, `seven_day`, `used_percentage`, `remaining_percentage`, -`utilization`, and `resets_at`. - -The local `ClaudeWebUsageProbe` executable implements that path. It opens -Claude's usage page in a visible WebKit session, lets the user complete login or -Cloudflare verification normally, observes only `/api/organizations/*/usage` -responses, and reduces the page response to whitelisted usage windows before -Swift receives anything. Saving from the probe writes normalized percent/reset -rows to Context Panel's snapshot store; it does not persist cookies, -authorization headers, tokens, local storage, account UUIDs, organization UUIDs, -emails, or raw response bodies. +call. Context Panel does not include a Claude Web capture path; Claude usage +should come from local status-line/cache sources that do not embed a web session +in the app. No safe persisted local Claude Desktop file/cache containing official subscription percentages was found. The remaining research target is an diff --git a/docs/release.md b/docs/release.md index b2d7433..1bda4bf 100644 --- a/docs/release.md +++ b/docs/release.md @@ -176,10 +176,6 @@ Useful variants: ```sh scripts/package-macos-app.sh --debug scripts/package-macos-app.sh --identity - -scripts/package-macos-app.sh \ - --product ClaudeWebUsageProbe \ - --display-name "Claude Usage Probe" \ - --bundle-id com.shinycomputers.contextpanel.claudeprobe ``` ## Current Constraints