Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions Claude Monitor Widget/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
10 changes: 10 additions & 0 deletions Claude Monitor Widget/Claude_Monitor_Widget.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
22 changes: 22 additions & 0 deletions Claude Monitor Widget/Claude_Monitor_Widget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Claude_Monitor_Widget.swift
// Claude Monitor Widget
//

import SwiftUI
import WidgetKit

/// The Claude Monitor widget displaying API usage.
struct Claude_Monitor_Widget: Widget {
let kind: String = "Claude_Monitor_Widget"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: UsageTimelineProvider()) { entry in
UsageWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Claude Usage")
.description("Monitor your Claude API usage limits.")
.supportedFamilies([.systemMedium])
}
}
14 changes: 14 additions & 0 deletions Claude Monitor Widget/Claude_Monitor_WidgetBundle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Claude_Monitor_WidgetBundle.swift
// Claude Monitor Widget
//

import SwiftUI
import WidgetKit

@main
struct Claude_Monitor_WidgetBundle: WidgetBundle {
var body: some Widget {
Claude_Monitor_Widget()
}
}
11 changes: 11 additions & 0 deletions Claude Monitor Widget/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
21 changes: 21 additions & 0 deletions Claude Monitor Widget/Models/UsageLimit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// UsageLimit.swift
// Claude Monitor Widget
//

import Foundation

/// A usage limit representing a specific quota bucket from the Claude API.
struct UsageLimit: Identifiable {
/// A unique identifier for this limit, typically matching the API field name.
let id: String

/// A localized display title for the limit (e.g., "Current session", "All models").
let title: String

/// The utilization percentage as a value between 0.0 and 1.0.
let utilization: Double

/// The date when this limit will reset, or `nil` if unknown.
let resetsAt: Date?
}
46 changes: 46 additions & 0 deletions Claude Monitor Widget/Models/UsageResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// UsageResponse.swift
// Claude Monitor Widget
//

import Foundation

/// The raw JSON response from the Anthropic OAuth usage API endpoint.
struct UsageResponse: Codable {
/// The 5-hour rolling session limit.
let fiveHour: UsageBucket?

/// The 7-day combined usage limit across all models.
let sevenDay: UsageBucket?

/// The 7-day usage limit for OAuth applications.
let sevenDayOauthApps: UsageBucket?

/// The 7-day usage limit specifically for Claude Opus.
let sevenDayOpus: UsageBucket?

/// The 7-day usage limit specifically for Claude Sonnet.
let sevenDaySonnet: UsageBucket?

enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDayOauthApps = "seven_day_oauth_apps"
case sevenDayOpus = "seven_day_opus"
case sevenDaySonnet = "seven_day_sonnet"
}
}

/// A single usage bucket from the API response.
struct UsageBucket: Codable {
/// The current utilization as a percentage (0-100).
let utilization: Double

/// The ISO 8601 timestamp when this limit will reset, or `nil` if not applicable.
let resetsAt: String?

enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}
19 changes: 19 additions & 0 deletions Claude Monitor Widget/RefreshIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// RefreshIntent.swift
// Claude Monitor Widget
//

import AppIntents
import WidgetKit

/// An App Intent that refreshes the widget's data.
struct RefreshIntent: AppIntent {
static let title: LocalizedStringResource = "Refresh Usage"
static let description: IntentDescription = "Refreshes the Claude usage data"

func perform() async throws -> some IntentResult {
// Trigger a timeline refresh
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
66 changes: 66 additions & 0 deletions Claude Monitor Widget/Services/ClaudeAPIService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// ClaudeAPIService.swift
// Claude Monitor Widget
//

import Foundation

/// Errors that can occur when communicating with the Claude API.
enum APIError: Error, LocalizedError {
case noToken
case invalidURL
case httpError(statusCode: Int, message: String?)
case decodingError(Error)
case networkError(Error)

var errorDescription: String? {
"Could not access the Claude API."
}
}

/// A service for communicating with the Anthropic Claude API.
final class ClaudeAPIService: Sendable {
static let shared = ClaudeAPIService()

private let baseURL = "https://api.anthropic.com"
private let usagePath = "/api/oauth/usage"

private init() {}

/// Fetches the current usage data from the Claude API.
func fetchUsage(token: String) async throws -> UsageResponse {
guard let url = URL(string: baseURL + usagePath) else {
throw APIError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
request.setValue("claude-code/2.0.32", forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Accept")

do {
let (data, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError(NSError(domain: "Invalid response", code: -1))
}

guard (200...299).contains(httpResponse.statusCode) else {
let message = String(data: data, encoding: .utf8)
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
}

do {
return try JSONDecoder().decode(UsageResponse.self, from: data)
} catch {
throw APIError.decodingError(error)
}
} catch let error as APIError {
throw error
} catch {
throw APIError.networkError(error)
}
}
}
131 changes: 131 additions & 0 deletions Claude Monitor Widget/Services/KeychainService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//
// KeychainService.swift
// Claude Monitor Widget
//

import Foundation
import Security

/// Errors that can occur when accessing the macOS Keychain.
enum KeychainError: Error {
case itemNotFound
case unexpectedStatus(OSStatus)
case invalidData
}

/// The source of an OAuth token.
enum TokenSource: String {
case claudeCode
case manual
}

/// A service for secure token retrieval using the macOS Keychain.
/// This widget version only reads tokens; it cannot write them.
final class KeychainService: Sendable {
static let shared = KeychainService()

private let appServiceName = "codes.tim.Claude-Monitor"
private let appAccountName = "api-token"
private let claudeCodeServiceName = "Claude Code-credentials"

private init() {}

/// Reads the OAuth token from Claude Code's Keychain entry.
func readClaudeCodeToken() throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: claudeCodeServiceName,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}

guard let data = result as? Data,
let jsonString = String(data: data, encoding: .utf8)
else {
throw KeychainError.invalidData
}

return try parseClaudeCodeCredentials(jsonString)
}

private func parseClaudeCodeCredentials(_ json: String) throws -> String {
guard let data = json.data(using: .utf8) else {
throw KeychainError.invalidData
}

struct Credentials: Codable {
let claudeAiOauth: OAuth

struct OAuth: Codable {
let accessToken: String
}
}

let credentials = try JSONDecoder().decode(Credentials.self, from: data)
return credentials.claudeAiOauth.accessToken
}

/// Reads the manually-entered token from the app's Keychain entry.
/// Note: Without keychain access groups, this may not work in widget context.
func readManualToken() throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: appServiceName,
kSecAttrAccount as String: appAccountName,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}

guard let data = result as? Data,
let token = String(data: data, encoding: .utf8)
else {
throw KeychainError.invalidData
}

return token
}

/// Resolves the best available token.
func resolveToken(preferredSource: TokenSource) -> (token: String, source: TokenSource)? {
switch preferredSource {
case .claudeCode:
if let token = try? readClaudeCodeToken() {
return (token, .claudeCode)
}
case .manual:
if let token = try? readManualToken() {
return (token, .manual)
}
}

// Fallback: try the other source
if let token = try? readClaudeCodeToken() {
return (token, .claudeCode)
}
if let token = try? readManualToken() {
return (token, .manual)
}

return nil
}
}
Loading