ClaudeChecker v1.0.1
@@ -0,0 +1,345 @@
|
|||||||
|
<?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>archiveVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>classes</key>
|
||||||
|
<dict/>
|
||||||
|
<key>objectVersion</key>
|
||||||
|
<string>77</string>
|
||||||
|
<key>objects</key>
|
||||||
|
<dict>
|
||||||
|
<key>AAAAAA007</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA107</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA008</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA108</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA001</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA101</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA002</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA102</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA003</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA103</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA004</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA104</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA005</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA105</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA006</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA106</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA107</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>UpdateManager.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA108</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>UpdateNotificationWindow.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA101</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>ClaudeCheckerApp.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA102</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>ContentView.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA103</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>Models.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA104</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>UsageViewModel.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA105</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>LoginView.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA106</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>folder.assetcatalog</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>Assets.xcassets</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA200</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXGroup</string>
|
||||||
|
<key>children</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA300</string>
|
||||||
|
</array>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA300</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXGroup</string>
|
||||||
|
<key>children</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA101</string>
|
||||||
|
<string>AAAAAA102</string>
|
||||||
|
<string>AAAAAA103</string>
|
||||||
|
<string>AAAAAA104</string>
|
||||||
|
<string>AAAAAA105</string>
|
||||||
|
<string>AAAAAA106</string>
|
||||||
|
<string>AAAAAA107</string>
|
||||||
|
<string>AAAAAA108</string>
|
||||||
|
</array>
|
||||||
|
<key>path</key>
|
||||||
|
<string>ClaudeChecker</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA400</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXNativeTarget</string>
|
||||||
|
<key>buildConfigurationList</key>
|
||||||
|
<string>AAAAAA500</string>
|
||||||
|
<key>buildPhases</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA600</string>
|
||||||
|
<string>AAAAAA650</string>
|
||||||
|
</array>
|
||||||
|
<key>buildRules</key>
|
||||||
|
<array/>
|
||||||
|
<key>dependencies</key>
|
||||||
|
<array/>
|
||||||
|
<key>name</key>
|
||||||
|
<string>ClaudeChecker</string>
|
||||||
|
<key>productName</key>
|
||||||
|
<string>ClaudeChecker</string>
|
||||||
|
<key>productType</key>
|
||||||
|
<string>com.apple.product-type.application</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA500</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>XCConfigurationList</string>
|
||||||
|
<key>buildConfigurations</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA501</string>
|
||||||
|
<string>AAAAAA502</string>
|
||||||
|
</array>
|
||||||
|
<key>defaultConfigurationIsVisible</key>
|
||||||
|
<string>0</string>
|
||||||
|
<key>defaultConfigurationName</key>
|
||||||
|
<string>Release</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA501</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>XCBuildConfiguration</string>
|
||||||
|
<key>buildSettings</key>
|
||||||
|
<dict>
|
||||||
|
<key>SWIFT_VERSION</key>
|
||||||
|
<string>5.9</string>
|
||||||
|
<key>MACOSX_DEPLOYMENT_TARGET</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>PRODUCT_NAME</key>
|
||||||
|
<string>ClaudeChecker</string>
|
||||||
|
<key>PRODUCT_BUNDLE_IDENTIFIER</key>
|
||||||
|
<string>com.claudechecker.app</string>
|
||||||
|
<key>INFOPLIST_FILE</key>
|
||||||
|
<string>ClaudeChecker/Info.plist</string>
|
||||||
|
<key>ALWAYS_SEARCH_USER_PATHS</key>
|
||||||
|
<string>NO</string>
|
||||||
|
<key>USE_HEADERMAP</key>
|
||||||
|
<string>NO</string>
|
||||||
|
<key>SWIFT_EMIT_LOC_STRINGS</key>
|
||||||
|
<string>YES</string>
|
||||||
|
<key>ASSETCATALOG_COMPILER_APPICON_NAME</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
</dict>
|
||||||
|
<key>name</key>
|
||||||
|
<string>Debug</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA502</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>XCBuildConfiguration</string>
|
||||||
|
<key>buildSettings</key>
|
||||||
|
<dict>
|
||||||
|
<key>SWIFT_VERSION</key>
|
||||||
|
<string>5.9</string>
|
||||||
|
<key>MACOSX_DEPLOYMENT_TARGET</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>PRODUCT_NAME</key>
|
||||||
|
<string>ClaudeChecker</string>
|
||||||
|
<key>PRODUCT_BUNDLE_IDENTIFIER</key>
|
||||||
|
<string>com.claudechecker.app</string>
|
||||||
|
<key>INFOPLIST_FILE</key>
|
||||||
|
<string>ClaudeChecker/Info.plist</string>
|
||||||
|
<key>ALWAYS_SEARCH_USER_PATHS</key>
|
||||||
|
<string>NO</string>
|
||||||
|
<key>USE_HEADERMAP</key>
|
||||||
|
<string>NO</string>
|
||||||
|
<key>SWIFT_EMIT_LOC_STRINGS</key>
|
||||||
|
<string>YES</string>
|
||||||
|
<key>ASSETCATALOG_COMPILER_APPICON_NAME</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
</dict>
|
||||||
|
<key>name</key>
|
||||||
|
<string>Release</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA600</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXSourcesBuildPhase</string>
|
||||||
|
<key>buildActionMask</key>
|
||||||
|
<string>2147483647</string>
|
||||||
|
<key>files</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA001</string>
|
||||||
|
<string>AAAAAA002</string>
|
||||||
|
<string>AAAAAA003</string>
|
||||||
|
<string>AAAAAA004</string>
|
||||||
|
<string>AAAAAA005</string>
|
||||||
|
<string>AAAAAA007</string>
|
||||||
|
<string>AAAAAA008</string>
|
||||||
|
</array>
|
||||||
|
<key>runOnlyForDeploymentPostprocessing</key>
|
||||||
|
<string>0</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA650</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXResourcesBuildPhase</string>
|
||||||
|
<key>buildActionMask</key>
|
||||||
|
<string>2147483647</string>
|
||||||
|
<key>files</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA006</string>
|
||||||
|
</array>
|
||||||
|
<key>runOnlyForDeploymentPostprocessing</key>
|
||||||
|
<string>0</string>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA700</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXProject</string>
|
||||||
|
<key>buildConfigurationList</key>
|
||||||
|
<string>AAAAAA800</string>
|
||||||
|
<key>compatibilityVersion</key>
|
||||||
|
<string>Xcode 15.0</string>
|
||||||
|
<key>mainGroup</key>
|
||||||
|
<string>AAAAAA200</string>
|
||||||
|
<key>targets</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA400</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<key>AAAAAA800</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>XCConfigurationList</string>
|
||||||
|
<key>buildConfigurations</key>
|
||||||
|
<array>
|
||||||
|
<string>AAAAAA501</string>
|
||||||
|
<string>AAAAAA502</string>
|
||||||
|
</array>
|
||||||
|
<key>defaultConfigurationIsVisible</key>
|
||||||
|
<string>0</string>
|
||||||
|
<key>defaultConfigurationName</key>
|
||||||
|
<string>Release</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>rootObject</key>
|
||||||
|
<string>AAAAAA700</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "16x16",
|
||||||
|
"filename": "icon_16.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "16x16",
|
||||||
|
"filename": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "32x32",
|
||||||
|
"filename": "icon_32.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "32x32",
|
||||||
|
"filename": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "128x128",
|
||||||
|
"filename": "icon_128.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "128x128",
|
||||||
|
"filename": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "256x256",
|
||||||
|
"filename": "icon_256.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "256x256",
|
||||||
|
"filename": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "512x512",
|
||||||
|
"filename": "icon_512.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "512x512",
|
||||||
|
"filename": "[email protected]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "MenubarTemplate.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": { "author": "xcode", "version": 1 },
|
||||||
|
"properties": { "template-rendering-intent": "template" }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1,225 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ClaudeCheckerApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
var body: some Scene {
|
||||||
|
Settings { EmptyView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
var statusItem: NSStatusItem?
|
||||||
|
var popover: NSPopover?
|
||||||
|
var usageViewModel = UsageViewModel()
|
||||||
|
var updateManager = UpdateManager()
|
||||||
|
var refreshTimer: Timer?
|
||||||
|
var cookiePrimerView: WKWebView?
|
||||||
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
|
// Hidden WKWebView to prime the shared cookie store
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
|
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
|
||||||
|
|
||||||
|
// Status item
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
if let button = statusItem?.button {
|
||||||
|
setMenubarIcon(button: button)
|
||||||
|
button.action = #selector(handleClick)
|
||||||
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||||
|
button.target = self
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popover
|
||||||
|
popover = NSPopover()
|
||||||
|
popover?.contentSize = NSSize(width: 480, height: 640)
|
||||||
|
popover?.behavior = .transient
|
||||||
|
popover?.animates = true
|
||||||
|
popover?.contentViewController = NSHostingController(
|
||||||
|
rootView: ContentView()
|
||||||
|
.environmentObject(usageViewModel)
|
||||||
|
.environmentObject(updateManager)
|
||||||
|
)
|
||||||
|
|
||||||
|
// React to limits or menubar toggle changes
|
||||||
|
usageViewModel.$limits
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
usageViewModel.$showInMenuBar
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Refresh timer
|
||||||
|
scheduleTimer(interval: usageViewModel.refreshInterval)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: .showPopover, object: nil, queue: .main) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in self?.showPopover() }
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: .updateDetected, object: nil, queue: .main) { [weak self] note in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let version = note.object as? String ?? ""
|
||||||
|
let notes = self.updateManager.releaseNotes
|
||||||
|
UpdateNotificationWindowController.showUpdate(
|
||||||
|
version: version,
|
||||||
|
notes: notes,
|
||||||
|
near: self.statusItem
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: .limitWarning, object: nil, queue: .main) { [weak self] note in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, let info = note.userInfo,
|
||||||
|
let windowName = info["windowName"] as? String,
|
||||||
|
let percent = info["percent"] as? Int else { return }
|
||||||
|
UpdateNotificationWindowController.showLimitWarning(windowName: windowName, percent: percent, near: self.statusItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: .limitReset, object: nil, queue: .main) { [weak self] note in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, let windowName = note.userInfo?["windowName"] as? String else { return }
|
||||||
|
UpdateNotificationWindowController.showLimitReset(windowName: windowName, near: self.statusItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: .closePopover, object: nil, queue: .main) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.popover?.performClose(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: .refreshIntervalChanged, object: nil, queue: .main) { [weak self] note in
|
||||||
|
if let interval = note.object as? TimeInterval {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.scheduleTimer(interval: interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-show compact notification if relaunched after an update
|
||||||
|
let flagFile = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".claudechecker_just_updated")
|
||||||
|
if FileManager.default.fileExists(atPath: flagFile.path) {
|
||||||
|
try? FileManager.default.removeItem(at: flagFile)
|
||||||
|
updateManager.justUpdated = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||||
|
UpdateNotificationWindowController.show(
|
||||||
|
version: version,
|
||||||
|
notes: "ClaudeChecker is now up to date.",
|
||||||
|
near: self?.statusItem
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch after cookie primer + update check
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
await usageViewModel.refresh()
|
||||||
|
await updateManager.checkForUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setMenubarIcon(button: NSStatusBarButton) {
|
||||||
|
// Use template image so macOS auto-adapts to dark/light menu bar
|
||||||
|
if let img = NSImage(named: "MenubarTemplate") {
|
||||||
|
img.isTemplate = true
|
||||||
|
img.size = NSSize(width: 18, height: 18)
|
||||||
|
button.image = img
|
||||||
|
button.imageScaling = .scaleProportionallyDown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleTimer(interval: TimeInterval) {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
Task {
|
||||||
|
await self?.usageViewModel.refresh()
|
||||||
|
await self?.updateManager.checkForUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStatusItem() {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
let limits = usageViewModel.limits
|
||||||
|
let show = usageViewModel.showInMenuBar && limits.first?.isLive == true
|
||||||
|
|
||||||
|
if show {
|
||||||
|
let fh = limits.first(where: { $0.window == .fiveHour })
|
||||||
|
let sd = limits.first(where: { $0.window == .sevenDay })
|
||||||
|
let fhStr = fh.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
||||||
|
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
||||||
|
button.image = nil
|
||||||
|
button.attributedTitle = NSAttributedString(string: "")
|
||||||
|
button.title = "◔ \(fhStr) \(sdStr)"
|
||||||
|
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
||||||
|
} else {
|
||||||
|
button.title = ""
|
||||||
|
button.attributedTitle = NSAttributedString(string: "")
|
||||||
|
button.font = NSFont.systemFont(ofSize: 12)
|
||||||
|
setMenubarIcon(button: button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleClick() {
|
||||||
|
guard let event = NSApp.currentEvent else { return }
|
||||||
|
|
||||||
|
if event.type == .rightMouseUp {
|
||||||
|
let menu = NSMenu()
|
||||||
|
let showItem = NSMenuItem(title: "Show ClaudeChecker", action: #selector(showPopover), keyEquivalent: "")
|
||||||
|
showItem.target = self
|
||||||
|
menu.addItem(showItem)
|
||||||
|
menu.addItem(.separator())
|
||||||
|
let quitItem = NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")
|
||||||
|
quitItem.target = self
|
||||||
|
menu.addItem(quitItem)
|
||||||
|
statusItem?.menu = menu
|
||||||
|
statusItem?.button?.performClick(nil)
|
||||||
|
DispatchQueue.main.async { self.statusItem?.menu = nil }
|
||||||
|
} else {
|
||||||
|
togglePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func showPopover() {
|
||||||
|
guard let button = statusItem?.button, let popover else { return }
|
||||||
|
if !popover.isShown {
|
||||||
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePopover() {
|
||||||
|
guard let button = statusItem?.button, let popover else { return }
|
||||||
|
if popover.isShown {
|
||||||
|
popover.performClose(nil)
|
||||||
|
} else {
|
||||||
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func quitApp() {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?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>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>ClaudeChecker</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.1</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>20</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright © 2026. All rights reserved.</string>
|
||||||
|
<key>NSMainStoryboardFile</key>
|
||||||
|
<string></string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>api.anthropic.com</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
// MARK: - Login Web View
|
||||||
|
|
||||||
|
struct LoginWebView: NSViewRepresentable {
|
||||||
|
let onAuthenticated: () -> Void
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
|
|
||||||
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
webView.navigationDelegate = context.coordinator
|
||||||
|
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: WKWebView, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onAuthenticated: onAuthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
|
let onAuthenticated: () -> Void
|
||||||
|
|
||||||
|
init(onAuthenticated: @escaping () -> Void) {
|
||||||
|
self.onAuthenticated = onAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
|
let hasSession = cookies.contains {
|
||||||
|
$0.domain.contains("claude.ai") &&
|
||||||
|
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
||||||
|
}
|
||||||
|
if hasSession {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.onAuthenticated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login Sheet View
|
||||||
|
|
||||||
|
struct LoginSheetView: View {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
let onDone: () -> Void
|
||||||
|
@State private var authenticated = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { isPresented = false }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
LoginWebView {
|
||||||
|
authenticated = true
|
||||||
|
// Auto-dismiss and refresh after brief delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||||
|
isPresented = false
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 460, height: 560)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
struct AgentLimit: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var agent: AgentType
|
||||||
|
var window: WindowType
|
||||||
|
var usedPercent: Double
|
||||||
|
var timeRemaining: String
|
||||||
|
var resetDate: Date
|
||||||
|
var projectedHit: ProjectedHit
|
||||||
|
var burnRate: Double
|
||||||
|
var burnHistory: [Double]
|
||||||
|
var isLive: Bool
|
||||||
|
|
||||||
|
var usageLabel: String {
|
||||||
|
switch usedPercent {
|
||||||
|
case ..<30: return "low"
|
||||||
|
case 30..<70: return "med"
|
||||||
|
default: return "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentType: String {
|
||||||
|
case claude = "Claude"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WindowType: String {
|
||||||
|
case fiveHour = "5h"
|
||||||
|
case sevenDay = "7d"
|
||||||
|
var label: String { rawValue }
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .fiveHour: return "5-hour"
|
||||||
|
case .sevenDay: return "7-day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectedHit {
|
||||||
|
case afterReset
|
||||||
|
case at(Date)
|
||||||
|
|
||||||
|
var display: String {
|
||||||
|
switch self {
|
||||||
|
case .afterReset: return "after reset"
|
||||||
|
case .at(let date):
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
let cal = Calendar.current
|
||||||
|
if cal.isDateInToday(date) { fmt.dateFormat = "'today' h:mm a" }
|
||||||
|
else if cal.isDateInTomorrow(date) { fmt.dateFormat = "'tmw' h:mm a" }
|
||||||
|
else { fmt.dateFormat = "MMM d, h:mm a" }
|
||||||
|
return fmt.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API Response — /api/organizations/{id}/usage
|
||||||
|
|
||||||
|
struct UsageResponse: Decodable {
|
||||||
|
let fiveHour: UsageWindow?
|
||||||
|
let sevenDay: UsageWindow?
|
||||||
|
let extraUsage: ExtraUsage?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fiveHour = "five_hour"
|
||||||
|
case sevenDay = "seven_day"
|
||||||
|
case extraUsage = "extra_usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
fiveHour = try c.decodeIfPresent(UsageWindow.self, forKey: .fiveHour)
|
||||||
|
sevenDay = try c.decodeIfPresent(UsageWindow.self, forKey: .sevenDay)
|
||||||
|
extraUsage = try c.decodeIfPresent(ExtraUsage.self, forKey: .extraUsage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UsageWindow: Codable {
|
||||||
|
let utilization: Double
|
||||||
|
let resetsAt: String?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case utilization
|
||||||
|
case resetsAt = "resets_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtraUsage: Codable {
|
||||||
|
let isEnabled: Bool
|
||||||
|
let currency: String?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case isEnabled = "is_enabled"
|
||||||
|
case currency
|
||||||
|
}
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
isEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .isEnabled)) ?? false
|
||||||
|
currency = try? c.decodeIfPresent(String.self, forKey: .currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrepaidCredits: Decodable {
|
||||||
|
let amount: Double?
|
||||||
|
let currency: String?
|
||||||
|
enum CodingKeys: String, CodingKey { case amount, currency }
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
amount = try? c.decodeIfPresent(Double.self, forKey: .amount)
|
||||||
|
currency = try? c.decodeIfPresent(String.self, forKey: .currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OverageSpendLimit: Decodable {
|
||||||
|
let isEnabled: Bool
|
||||||
|
let usedCredits: Double?
|
||||||
|
let monthlyCreditLimit: Double?
|
||||||
|
let currency: String?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case isEnabled = "is_enabled"
|
||||||
|
case usedCredits = "used_credits"
|
||||||
|
case monthlyCreditLimit = "monthly_credit_limit"
|
||||||
|
case currency
|
||||||
|
}
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
isEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .isEnabled)) ?? false
|
||||||
|
usedCredits = try? c.decodeIfPresent(Double.self, forKey: .usedCredits)
|
||||||
|
monthlyCreditLimit = try? c.decodeIfPresent(Double.self, forKey: .monthlyCreditLimit)
|
||||||
|
currency = try? c.decodeIfPresent(String.self, forKey: .currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Bootstrap models removed — org ID is configured directly)
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Version Info
|
||||||
|
|
||||||
|
struct VersionInfo: Codable {
|
||||||
|
let version: String
|
||||||
|
let url: String
|
||||||
|
let notes: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UpdateError: LocalizedError {
|
||||||
|
case downloadFailed
|
||||||
|
case unzipFailed
|
||||||
|
case appNotFound
|
||||||
|
case replaceFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .downloadFailed: return "Download failed. Check your connection."
|
||||||
|
case .unzipFailed: return "Could not unpack the update."
|
||||||
|
case .appNotFound: return "Could not find ClaudeChecker.app in the update."
|
||||||
|
case .replaceFailed(let r): return "Install failed: \(r)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Update Manager
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class UpdateManager: ObservableObject {
|
||||||
|
|
||||||
|
static let versionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version.json"
|
||||||
|
static let betaVersionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-beta.json"
|
||||||
|
|
||||||
|
@Published var updateAvailable = false
|
||||||
|
@Published var latestVersion = ""
|
||||||
|
@Published var releaseNotes = ""
|
||||||
|
@Published var downloadURL = ""
|
||||||
|
@Published var betaAvailable = false
|
||||||
|
@Published var latestBetaVersion = ""
|
||||||
|
@Published var isDownloading = false
|
||||||
|
@Published var downloadProgress: Double = 0
|
||||||
|
@Published var statusMessage = ""
|
||||||
|
@Published var updateError: String? = nil
|
||||||
|
@Published var updateComplete = false
|
||||||
|
@Published var justUpdated = false
|
||||||
|
|
||||||
|
private var notifiedVersion: String = ""
|
||||||
|
|
||||||
|
@Published var betaChannel: Bool = false {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(betaChannel, forKey: "beta_channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentVersion: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
betaChannel = UserDefaults.standard.bool(forKey: "beta_channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Check
|
||||||
|
|
||||||
|
func checkForUpdates() async {
|
||||||
|
print("[UpdateManager] Checking for updates... current=\(currentVersion)")
|
||||||
|
async let stableResult = fetchVersion(from: Self.versionURL)
|
||||||
|
async let betaResult = fetchVersion(from: Self.betaVersionURL)
|
||||||
|
|
||||||
|
if let stable = await stableResult {
|
||||||
|
print("[UpdateManager] Stable remote=\(stable.version), isNewer=\(isNewer(stable.version, than: currentVersion))")
|
||||||
|
if isNewer(stable.version, than: currentVersion) {
|
||||||
|
latestVersion = stable.version
|
||||||
|
releaseNotes = stable.notes ?? ""
|
||||||
|
downloadURL = stable.url
|
||||||
|
updateAvailable = true
|
||||||
|
print("[UpdateManager] updateAvailable set to true")
|
||||||
|
if notifiedVersion != stable.version {
|
||||||
|
notifiedVersion = stable.version
|
||||||
|
NotificationCenter.default.post(name: .updateDetected, object: stable.version)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateAvailable = false
|
||||||
|
latestVersion = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[UpdateManager] Stable fetch returned nil")
|
||||||
|
updateAvailable = false
|
||||||
|
latestVersion = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if let beta = await betaResult {
|
||||||
|
print("[UpdateManager] Beta remote=\(beta.version)")
|
||||||
|
if isNewer(beta.version, than: currentVersion) {
|
||||||
|
latestBetaVersion = beta.version
|
||||||
|
betaAvailable = true
|
||||||
|
if betaChannel && isNewer(beta.version, than: latestVersion) {
|
||||||
|
latestVersion = beta.version
|
||||||
|
releaseNotes = beta.notes ?? ""
|
||||||
|
downloadURL = beta.url
|
||||||
|
updateAvailable = true
|
||||||
|
if notifiedVersion != beta.version {
|
||||||
|
notifiedVersion = beta.version
|
||||||
|
NotificationCenter.default.post(name: .updateDetected, object: beta.version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
betaAvailable = false
|
||||||
|
latestBetaVersion = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[UpdateManager] Beta fetch returned nil")
|
||||||
|
betaAvailable = false
|
||||||
|
latestBetaVersion = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchVersion(from urlString: String) async -> VersionInfo? {
|
||||||
|
// Add timestamp to bust GitHub's 5-minute CDN cache
|
||||||
|
let cacheBusted = urlString + "?t=\(Int(Date().timeIntervalSince1970))"
|
||||||
|
guard let url = URL(string: cacheBusted) else {
|
||||||
|
print("[UpdateManager] Invalid URL: \(urlString)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
print("[UpdateManager] Fetch \(urlString) -> HTTP \(status)")
|
||||||
|
guard status == 200 else { return nil }
|
||||||
|
let info = try JSONDecoder().decode(VersionInfo.self, from: data)
|
||||||
|
print("[UpdateManager] Decoded version: \(info.version)")
|
||||||
|
return info
|
||||||
|
} catch {
|
||||||
|
print("[UpdateManager] Fetch error: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Download & Install
|
||||||
|
|
||||||
|
func downloadAndInstall() async {
|
||||||
|
guard let src = URL(string: downloadURL) else { return }
|
||||||
|
isDownloading = true
|
||||||
|
updateError = nil
|
||||||
|
updateComplete = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 1. Set up temp dir
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("CCUpdate_\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
|
let zipURL = tempDir.appendingPathComponent("ClaudeChecker.zip")
|
||||||
|
|
||||||
|
// 2. Download with progress
|
||||||
|
statusMessage = "Downloading…"
|
||||||
|
let (asyncBytes, response) = try await URLSession.shared.bytes(from: src)
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw UpdateError.downloadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = Double(response.expectedContentLength)
|
||||||
|
var received: Double = 0
|
||||||
|
var buffer = Data()
|
||||||
|
if total > 0 { buffer.reserveCapacity(Int(total)) }
|
||||||
|
|
||||||
|
for try await byte in asyncBytes {
|
||||||
|
buffer.append(byte)
|
||||||
|
received += 1
|
||||||
|
if Int(received) % 50_000 == 0 && total > 0 {
|
||||||
|
downloadProgress = (received / total) * 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try buffer.write(to: zipURL)
|
||||||
|
downloadProgress = 0.75
|
||||||
|
|
||||||
|
// 3. Unzip
|
||||||
|
statusMessage = "Unpacking…"
|
||||||
|
let unzipDir = tempDir.appendingPathComponent("unzipped")
|
||||||
|
try FileManager.default.createDirectory(at: unzipDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let unzip = Process()
|
||||||
|
unzip.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||||
|
unzip.arguments = ["-q", "-o", zipURL.path, "-d", unzipDir.path]
|
||||||
|
try unzip.run()
|
||||||
|
unzip.waitUntilExit()
|
||||||
|
guard unzip.terminationStatus == 0 else { throw UpdateError.unzipFailed }
|
||||||
|
downloadProgress = 0.85
|
||||||
|
|
||||||
|
// 4. Find the .app
|
||||||
|
guard let newApp = findApp(in: unzipDir) else { throw UpdateError.appNotFound }
|
||||||
|
|
||||||
|
// 5. Determine install target — same location as currently running app
|
||||||
|
let currentApp = Bundle.main.bundleURL
|
||||||
|
let installTarget = currentApp
|
||||||
|
|
||||||
|
statusMessage = "Installing…"
|
||||||
|
downloadProgress = 0.9
|
||||||
|
|
||||||
|
// 6. Write installer script to home dir (persists after app quits)
|
||||||
|
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let scriptURL = homeDir.appendingPathComponent(".claudechecker_update.sh")
|
||||||
|
let logURL = homeDir.appendingPathComponent(".claudechecker_update.log")
|
||||||
|
|
||||||
|
let newAppPath = newApp.path
|
||||||
|
let targetPath = installTarget.path
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
#!/bin/bash
|
||||||
|
exec > "\(logURL.path)" 2>&1
|
||||||
|
echo "=== ClaudeChecker Updater $(date) ==="
|
||||||
|
echo "Source: \(newAppPath)"
|
||||||
|
echo "Target: \(targetPath)"
|
||||||
|
|
||||||
|
# Wait for the old ClaudeChecker process to fully exit
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if ! pgrep -x "ClaudeChecker" > /dev/null 2>&1; then
|
||||||
|
echo "Process exited after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -d "\(newAppPath)" ]; then
|
||||||
|
echo "ERROR: source not found"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Replacing app..."
|
||||||
|
rm -rf "\(targetPath)"
|
||||||
|
/usr/bin/ditto "\(newAppPath)" "\(targetPath)"
|
||||||
|
|
||||||
|
if [ ! -d "\(targetPath)" ]; then
|
||||||
|
echo "ERROR: ditto failed"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod -R 755 "\(targetPath)"
|
||||||
|
xattr -cr "\(targetPath)" 2>/dev/null || true
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "Relaunching..."
|
||||||
|
# Write flag file so app knows it just updated
|
||||||
|
touch "$HOME/.claudechecker_just_updated"
|
||||||
|
open "\(targetPath)"
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
rm -rf "\(tempDir.path)"
|
||||||
|
rm -f "\(scriptURL.path)"
|
||||||
|
echo "Done."
|
||||||
|
"""
|
||||||
|
|
||||||
|
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: NSNumber(value: Int16(0o755))],
|
||||||
|
ofItemAtPath: scriptURL.path)
|
||||||
|
|
||||||
|
downloadProgress = 1.0
|
||||||
|
statusMessage = "Installed! Relaunching…"
|
||||||
|
updateComplete = true
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 800_000_000)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let launcher = Process()
|
||||||
|
launcher.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||||
|
launcher.arguments = ["-c", "nohup /bin/bash '\(scriptURL.path)' &"]
|
||||||
|
launcher.standardInput = FileHandle.nullDevice
|
||||||
|
launcher.standardOutput = FileHandle.nullDevice
|
||||||
|
launcher.standardError = FileHandle.nullDevice
|
||||||
|
try launcher.run()
|
||||||
|
} catch {
|
||||||
|
let fallback = Process()
|
||||||
|
fallback.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||||
|
fallback.arguments = [scriptURL.path]
|
||||||
|
try? fallback.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate — belt and braces approach
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
// If NSApp.terminate gets blocked by a delegate, exit() guarantees quit
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch let e as UpdateError {
|
||||||
|
updateError = e.localizedDescription
|
||||||
|
isDownloading = false
|
||||||
|
statusMessage = ""
|
||||||
|
// Log path for debugging: ~/.claudechecker_update.log
|
||||||
|
} catch {
|
||||||
|
updateError = error.localizedDescription
|
||||||
|
isDownloading = false
|
||||||
|
statusMessage = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func findApp(in dir: URL) -> URL? {
|
||||||
|
// Check direct children first
|
||||||
|
if let contents = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: dir, includingPropertiesForKeys: [.isDirectoryKey]) {
|
||||||
|
if let app = contents.first(where: { $0.pathExtension == "app" }) { return app }
|
||||||
|
}
|
||||||
|
// Then recurse
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: dir, includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]) else { return nil }
|
||||||
|
for case let url as URL in enumerator where url.pathExtension == "app" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isNewer(_ version: String, than current: String) -> Bool {
|
||||||
|
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } }
|
||||||
|
let a = toInts(version), b = toInts(current)
|
||||||
|
for i in 0..<max(a.count, b.count) {
|
||||||
|
let av = i < a.count ? a[i] : 0
|
||||||
|
let bv = i < b.count ? b[i] : 0
|
||||||
|
if av != bv { return av > bv }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Compact update notification window (shown on relaunch after update)
|
||||||
|
|
||||||
|
class UpdateNotificationWindowController: NSWindowController {
|
||||||
|
static var shared: UpdateNotificationWindowController?
|
||||||
|
static var sharedLimit: UpdateNotificationWindowController?
|
||||||
|
|
||||||
|
// Called after successful install + relaunch
|
||||||
|
static func show(version: String, notes: String, near statusItem: NSStatusItem?) {
|
||||||
|
showWindow(version: version, subtitle: notes.isEmpty ? "ClaudeChecker is up to date." : notes, near: statusItem, isUpdate: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a new update is detected while running
|
||||||
|
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
|
||||||
|
showWindow(version: version, subtitle: notes.isEmpty ? "Tap to update now." : notes, near: statusItem, isUpdate: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
|
||||||
|
let icon = percent >= 95 ? "exclamationmark.circle.fill" : "exclamationmark.triangle.fill"
|
||||||
|
let color = percent >= 95 ? Color.red : Color.orange
|
||||||
|
let title = "Claude \(windowName) limit at \(percent)%"
|
||||||
|
let body = percent >= 95 ? "Almost out of quota." : "Approaching your limit."
|
||||||
|
showLimitWindow(title: title, subtitle: body, badgeIcon: icon, badgeColor: color, near: statusItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func showLimitReset(windowName: String, near statusItem: NSStatusItem?) {
|
||||||
|
showLimitWindow(
|
||||||
|
title: "Claude \(windowName) limit reset",
|
||||||
|
subtitle: "Your quota has been reset.",
|
||||||
|
badgeIcon: "arrow.clockwise.circle.fill",
|
||||||
|
badgeColor: .green,
|
||||||
|
near: statusItem
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func showLimitWindow(title: String, subtitle: String, badgeIcon: String, badgeColor: Color, near statusItem: NSStatusItem?) {
|
||||||
|
sharedLimit?.close()
|
||||||
|
let content = UpdateNotificationView(
|
||||||
|
version: "", subtitle: subtitle, isUpdate: false,
|
||||||
|
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor
|
||||||
|
) {
|
||||||
|
sharedLimit?.close()
|
||||||
|
sharedLimit = nil
|
||||||
|
}
|
||||||
|
let controller = makeWindow(content: content)
|
||||||
|
position(window: controller.window!, near: statusItem, existingWindow: shared?.window)
|
||||||
|
sharedLimit = controller
|
||||||
|
controller.showWindow(nil)
|
||||||
|
autoDismiss(controller: controller, slot: .limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
|
||||||
|
shared?.close()
|
||||||
|
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) {
|
||||||
|
shared?.close()
|
||||||
|
shared = nil
|
||||||
|
}
|
||||||
|
let controller = makeWindow(content: content)
|
||||||
|
position(window: controller.window!, near: statusItem, existingWindow: nil)
|
||||||
|
shared = controller
|
||||||
|
controller.showWindow(nil)
|
||||||
|
autoDismiss(controller: controller, slot: .update)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeWindow<V: View>(content: V) -> UpdateNotificationWindowController {
|
||||||
|
let hostingView = NSHostingView(rootView: content)
|
||||||
|
hostingView.frame = NSRect(x: 0, y: 0, width: 340, height: 1000)
|
||||||
|
let height = max(80, hostingView.fittingSize.height)
|
||||||
|
let size = NSSize(width: 340, height: height)
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(origin: .zero, size: size),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.level = .statusBar
|
||||||
|
window.hasShadow = false
|
||||||
|
hostingView.frame = NSRect(origin: .zero, size: size)
|
||||||
|
window.contentView = hostingView
|
||||||
|
return UpdateNotificationWindowController(window: window)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func position(window: NSWindow, near statusItem: NSStatusItem?, existingWindow: NSWindow?) {
|
||||||
|
if let button = statusItem?.button,
|
||||||
|
let buttonWindow = button.window,
|
||||||
|
let screen = buttonWindow.screen {
|
||||||
|
let buttonFrame = buttonWindow.convertToScreen(button.frame)
|
||||||
|
let x = buttonFrame.midX - 170
|
||||||
|
let h = window.frame.height
|
||||||
|
let yOffset: CGFloat = existingWindow.map { $0.frame.height + 8 } ?? 0
|
||||||
|
let y = buttonFrame.minY - h - 8 - yOffset
|
||||||
|
let clampedX = max(8, min(x, screen.frame.maxX - 348))
|
||||||
|
window.setFrameOrigin(NSPoint(x: clampedX, y: y))
|
||||||
|
} else {
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum NotificationSlot { case update, limit }
|
||||||
|
|
||||||
|
private static func autoDismiss(controller: UpdateNotificationWindowController, slot: NotificationSlot) {
|
||||||
|
let window = controller.window!
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.3
|
||||||
|
window.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
controller.close()
|
||||||
|
switch slot {
|
||||||
|
case .update: if shared === controller { shared = nil }
|
||||||
|
case .limit: if sharedLimit === controller { sharedLimit = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification View
|
||||||
|
|
||||||
|
struct UpdateNotificationView: View {
|
||||||
|
let version: String
|
||||||
|
let subtitle: String
|
||||||
|
let isUpdate: Bool
|
||||||
|
var titleText: String? = nil
|
||||||
|
var badgeIcon: String? = nil
|
||||||
|
var badgeColor: Color? = nil
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
private var effectiveTitle: String {
|
||||||
|
titleText ?? (isUpdate ? "Update available — v\(version)" : "Updated to v\(version) ✓")
|
||||||
|
}
|
||||||
|
private var effectiveBadgeIcon: String {
|
||||||
|
badgeIcon ?? (isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill")
|
||||||
|
}
|
||||||
|
private var effectiveBadgeColor: Color {
|
||||||
|
badgeColor ?? (isUpdate ? .blue : .green)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.cornerRadius(10)
|
||||||
|
|
||||||
|
Image(systemName: effectiveBadgeIcon)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(effectiveBadgeColor)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
|
||||||
|
.offset(x: 4, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(effectiveTitle)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Button(action: onDismiss) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.frame(width: 340, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 13)
|
||||||
|
.fill(Color(nsColor: .windowBackgroundColor))
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class UsageViewModel: ObservableObject {
|
||||||
|
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
|
||||||
|
@Published var limits: [AgentLimit] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var lastUpdated: Date?
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var extraUsage: ExtraUsage?
|
||||||
|
@Published var prepaidCredits: PrepaidCredits?
|
||||||
|
@Published var overageSpendLimit: OverageSpendLimit?
|
||||||
|
@Published var planLabel: String = "Claude"
|
||||||
|
@Published var isNotAuthenticated: Bool = false
|
||||||
|
@Published var isSignedIn: Bool = false
|
||||||
|
@Published var userEmail: String = ""
|
||||||
|
@Published var triggerLogin: Bool = false
|
||||||
|
@Published var showInMenuBar: Bool = true {
|
||||||
|
didSet { UserDefaults.standard.set(showInMenuBar, forKey: "show_in_menubar") }
|
||||||
|
}
|
||||||
|
@Published var refreshInterval: TimeInterval = 60 {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(refreshInterval, forKey: "refresh_interval")
|
||||||
|
NotificationCenter.default.post(name: .refreshIntervalChanged, object: refreshInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var burnHistoryStore: [String: [Double]] = [:]
|
||||||
|
private let maxHistorySamples = 24
|
||||||
|
private var cachedOrgId: String?
|
||||||
|
private var previousPercents: [String: Double] = [:]
|
||||||
|
private var firedThresholds: [String: Set<Int>] = [:]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
||||||
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||||
|
refreshInterval = saved > 0 ? saved : 60
|
||||||
|
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||||
|
loadPlaceholderData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async {
|
||||||
|
let store = WKWebsiteDataStore.default()
|
||||||
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||||
|
let records = await store.dataRecords(ofTypes: types)
|
||||||
|
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
|
||||||
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||||
|
isSignedIn = false
|
||||||
|
isNotAuthenticated = true
|
||||||
|
lastUpdated = nil
|
||||||
|
userEmail = ""
|
||||||
|
limits = []
|
||||||
|
extraUsage = nil
|
||||||
|
prepaidCredits = nil
|
||||||
|
overageSpendLimit = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
isNotAuthenticated = false
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let orgId = cachedOrgId!
|
||||||
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
|
async let emailFetch = fetchUserEmail()
|
||||||
|
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
||||||
|
if let email { userEmail = email }
|
||||||
|
limits = buildLimits(from: usage)
|
||||||
|
extraUsage = usage.extraUsage
|
||||||
|
prepaidCredits = prepaid
|
||||||
|
overageSpendLimit = overage
|
||||||
|
planLabel = "Pro"
|
||||||
|
lastUpdated = Date()
|
||||||
|
isSignedIn = true
|
||||||
|
|
||||||
|
for i in limits.indices {
|
||||||
|
let key = limits[i].window.rawValue
|
||||||
|
var history = burnHistoryStore[key] ?? []
|
||||||
|
history.append(limits[i].usedPercent)
|
||||||
|
if history.count > maxHistorySamples { history.removeFirst() }
|
||||||
|
burnHistoryStore[key] = history
|
||||||
|
if history.count > 1 { limits[i].burnHistory = history }
|
||||||
|
}
|
||||||
|
checkLimitNotifications(for: limits)
|
||||||
|
} catch AppError.notAuthenticated {
|
||||||
|
isNotAuthenticated = true
|
||||||
|
errorMessage = "Not signed in"
|
||||||
|
} catch let error as DecodingError {
|
||||||
|
switch error {
|
||||||
|
case .keyNotFound(let key, let ctx):
|
||||||
|
errorMessage = "Missing key '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
|
||||||
|
case .typeMismatch(let type, let ctx):
|
||||||
|
errorMessage = "Type mismatch (\(type)) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
|
||||||
|
case .valueNotFound(let type, let ctx):
|
||||||
|
errorMessage = "Value not found (\(type)) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
|
||||||
|
default:
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
|
||||||
|
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
|
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
||||||
|
guard http.statusCode == 200 else { throw AppError.networkError }
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchUserEmail() async throws -> String? {
|
||||||
|
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let account = json["account"] as? [String: Any],
|
||||||
|
let email = account["email_address"] as? String else { return nil }
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
|
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||||
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Build limits
|
||||||
|
|
||||||
|
private func buildLimits(from usage: UsageResponse) -> [AgentLimit] {
|
||||||
|
let now = Date()
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
|
||||||
|
func parseDate(_ str: String?) -> Date {
|
||||||
|
guard let str else { return now.addingTimeInterval(3600) }
|
||||||
|
return iso.date(from: str) ?? now.addingTimeInterval(3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeLeft(until reset: Date) -> String {
|
||||||
|
let diff = max(0, reset.timeIntervalSince(now))
|
||||||
|
if diff == 0 { return "resetting..." }
|
||||||
|
let h = Int(diff / 3600)
|
||||||
|
let m = Int((diff.truncatingRemainder(dividingBy: 3600)) / 60)
|
||||||
|
if h >= 24 { return "\(h/24)d \(h%24)h" }
|
||||||
|
return "\(h)h \(m)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
func projected(pct: Double, windowHours: Double, reset: Date) -> ProjectedHit {
|
||||||
|
guard pct > 0 else { return .afterReset }
|
||||||
|
let rate = pct / windowHours
|
||||||
|
guard rate > 0 else { return .afterReset }
|
||||||
|
let hit = now.addingTimeInterval(((100 - pct) / rate) * 3600)
|
||||||
|
return hit > reset ? .afterReset : .at(hit)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: [AgentLimit] = []
|
||||||
|
|
||||||
|
if let fh = usage.fiveHour {
|
||||||
|
let reset = parseDate(fh.resetsAt)
|
||||||
|
let pct = min(100, max(0, fh.utilization))
|
||||||
|
result.append(AgentLimit(
|
||||||
|
agent: .claude, window: .fiveHour,
|
||||||
|
usedPercent: pct,
|
||||||
|
timeRemaining: timeLeft(until: reset),
|
||||||
|
resetDate: reset,
|
||||||
|
projectedHit: projected(pct: pct, windowHours: 5, reset: reset),
|
||||||
|
burnRate: pct / 5.0,
|
||||||
|
burnHistory: burnHistoryStore["5h"] ?? [pct],
|
||||||
|
isLive: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sd = usage.sevenDay {
|
||||||
|
let reset = parseDate(sd.resetsAt)
|
||||||
|
let pct = min(100, max(0, sd.utilization))
|
||||||
|
result.append(AgentLimit(
|
||||||
|
agent: .claude, window: .sevenDay,
|
||||||
|
usedPercent: pct,
|
||||||
|
timeRemaining: timeLeft(until: reset),
|
||||||
|
resetDate: reset,
|
||||||
|
projectedHit: projected(pct: pct, windowHours: 7*24, reset: reset),
|
||||||
|
burnRate: pct / (7*24.0),
|
||||||
|
burnHistory: burnHistoryStore["7d"] ?? [pct],
|
||||||
|
isLive: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Limit notifications
|
||||||
|
|
||||||
|
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
||||||
|
let thresholds = [80, 95]
|
||||||
|
|
||||||
|
for limit in limits {
|
||||||
|
let key = limit.window.rawValue
|
||||||
|
let curr = limit.usedPercent
|
||||||
|
let prev = previousPercents[key]
|
||||||
|
var fired = firedThresholds[key] ?? []
|
||||||
|
|
||||||
|
// Threshold warnings (80% and 95%)
|
||||||
|
for t in thresholds {
|
||||||
|
let threshold = Double(t)
|
||||||
|
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
||||||
|
fired.insert(t)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .limitWarning,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset detection: was high, now low
|
||||||
|
if let prev, prev > 50, curr < 20 {
|
||||||
|
fired.removeAll()
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .limitReset,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["windowName": limit.window.displayName]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
firedThresholds[key] = fired
|
||||||
|
previousPercents[key] = curr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPlaceholderData() {
|
||||||
|
let now = Date()
|
||||||
|
limits = [
|
||||||
|
AgentLimit(agent: .claude, window: .fiveHour,
|
||||||
|
usedPercent: 0, timeRemaining: "—",
|
||||||
|
resetDate: now.addingTimeInterval(3600),
|
||||||
|
projectedHit: .afterReset, burnRate: 0,
|
||||||
|
burnHistory: [], isLive: false),
|
||||||
|
AgentLimit(agent: .claude, window: .sevenDay,
|
||||||
|
usedPercent: 0, timeRemaining: "—",
|
||||||
|
resetDate: now.addingTimeInterval(7*24*3600),
|
||||||
|
projectedHit: .afterReset, burnRate: 0,
|
||||||
|
burnHistory: [], isLive: false),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppError: LocalizedError {
|
||||||
|
case notAuthenticated
|
||||||
|
case networkError
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||||
|
case .networkError: return "Network error fetching usage data."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
|
||||||
|
static let closePopover = Notification.Name("closePopover")
|
||||||
|
static let showPopover = Notification.Name("showPopover")
|
||||||
|
static let updateDetected = Notification.Name("updateDetected")
|
||||||
|
static let limitWarning = Notification.Name("limitWarning")
|
||||||
|
static let limitReset = Notification.Name("limitReset")
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ClaudeChecker",
|
||||||
|
platforms: [.macOS(.v13)],
|
||||||
|
products: [
|
||||||
|
.executable(name: "ClaudeChecker", targets: ["ClaudeChecker"])
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "ClaudeChecker",
|
||||||
|
path: "ClaudeChecker",
|
||||||
|
resources: [
|
||||||
|
.process("Info.plist")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/superdooper86/claudechecker/main/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png" width="128" height="128" alt="ClaudeChecker icon" />
|
||||||
|
|
||||||
|
# ClaudeChecker
|
||||||
|
|
||||||
|
**A native macOS menubar app for monitoring your Claude AI usage limits in real time.**
|
||||||
|
|
||||||
|
[](https://www.apple.com/macos/)
|
||||||
|
[](https://swift.org)
|
||||||
|
[](https://github.com/superdooper86/claudechecker/releases)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ClaudeChecker sits quietly in your menubar and tracks your Claude API quota usage across both the 5-hour and 7-day windows — so you always know how much runway you have left before hitting a rate limit.
|
||||||
|
|
||||||
|
No API key required. It reads directly from your `claude.ai` session using a built-in browser login — the same data the Claude web app uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/main-v2.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td>
|
||||||
|
<td align="center"><img src="screenshots/settings-v2.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update banner" /><br/><sub>Update available</sub></td>
|
||||||
|
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>System notification</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/notif-limit-80.png" width="280" alt="80% limit warning" /><br/><sub>80% limit warning</sub></td>
|
||||||
|
<td align="center"><img src="screenshots/notif-limit-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
**Menubar display**<br/>
|
||||||
|
<img src="screenshots/menubar-icon.png" height="22" alt="Menubar icon" />
|
||||||
|
|
||||||
|
<img src="screenshots/menubar-percent.png" height="22" alt="Menubar percentage" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 📊 Usage Monitoring
|
||||||
|
- **Real-time quota tracking** for both 5-hour and 7-day windows
|
||||||
|
- Circular gauge with percentage, time remaining, and reset time for each window
|
||||||
|
- **Extra usage credits** display (monthly limit vs used, with colour-coded progress bar)
|
||||||
|
- Usage level badge — Low / Med / High — at a glance
|
||||||
|
|
||||||
|
### 🖥 Menubar Display
|
||||||
|
- Compact **percentage display** directly in the menubar: `◔ 22% 35%`
|
||||||
|
- Toggle between percentage view and icon-only in Settings
|
||||||
|
- Auto-updates after every refresh cycle
|
||||||
|
|
||||||
|
### 🔄 Auto Refresh
|
||||||
|
- Configurable refresh interval: **1 / 2 / 3 / 4 / 5 / 10 minutes**
|
||||||
|
- Update check runs alongside every data refresh
|
||||||
|
- Persists your chosen interval across restarts
|
||||||
|
|
||||||
|
### 🔐 Authentication
|
||||||
|
- Built-in login via `WKWebView` — sign into claude.ai once, session persists
|
||||||
|
- No API key, no credentials stored beyond the macOS WKWebsiteDataStore
|
||||||
|
- Re-authenticate any time from Settings → Sign in again
|
||||||
|
|
||||||
|
### 🔔 Notifications
|
||||||
|
- **Limit warnings** — floating panel appears near the menubar icon when a window hits **80%** (orange) or **95%** (red)
|
||||||
|
- **Limit reset** — notified when a quota window resets and usage drops back to low
|
||||||
|
- **Update available** — notified when a new version is detected
|
||||||
|
- **Post-update confirmation** — compact toast confirms the new version on first launch
|
||||||
|
- All notifications auto-dismiss after 8 seconds, or can be closed manually
|
||||||
|
|
||||||
|
### 🔄 Auto Update
|
||||||
|
- **Automatic update detection** — checks for new versions on launch and on each refresh cycle
|
||||||
|
- One-click install: downloads, unpacks, replaces, and **relaunches automatically**
|
||||||
|
- **Beta channel** — opt in to early access builds via Settings toggle
|
||||||
|
|
||||||
|
### 📋 Session Diary
|
||||||
|
- Sparkline chart showing quota burn history over the session
|
||||||
|
- Average burn rate calculated from the 5-hour window
|
||||||
|
|
||||||
|
### ⚙️ Settings
|
||||||
|
- Authentication management
|
||||||
|
- Menubar display toggle
|
||||||
|
- Refresh interval picker
|
||||||
|
- Beta channel toggle
|
||||||
|
- About section with version, build info, and update check
|
||||||
|
|
||||||
|
### 🖱 Right-Click Menu
|
||||||
|
- Right-click the menubar icon for quick **Show** and **Quit** options
|
||||||
|
- ✕ button hides the panel (app stays running in menubar)
|
||||||
|
- Full quit via right-click → Quit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- macOS 13 Ventura or later
|
||||||
|
- Xcode 15+ (to build from source)
|
||||||
|
- A claude.ai account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Download (recommended)
|
||||||
|
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
|
||||||
|
2. Download `ClaudeChecker.zip` from the latest release
|
||||||
|
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
|
||||||
|
4. Open from Applications — it will appear in your menubar
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/superdooper86/claudechecker.git
|
||||||
|
open ClaudeChecker/ClaudeChecker.xcodeproj
|
||||||
|
```
|
||||||
|
Then in Xcode: set your Team under Signing & Capabilities, then **⌘R** to run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First Run
|
||||||
|
|
||||||
|
1. Click the menubar icon (gauge symbol)
|
||||||
|
2. If prompted — click **Sign In** and log into claude.ai in the built-in browser
|
||||||
|
3. Your usage data will appear within a few seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
ClaudeChecker reads from the following `claude.ai` API endpoints (authenticated via your session):
|
||||||
|
|
||||||
|
| Endpoint | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `/api/bootstrap` | Organisation UUID and account email |
|
||||||
|
| `/api/organizations/{id}/usage` | 5-hour and 7-day quota windows |
|
||||||
|
| `/api/organizations/{id}/overage_spend_limit` | Extra usage spent and monthly limit |
|
||||||
|
| `/api/organizations/{id}/prepaid/credits` | Current credit balance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- No data leaves your machine except to `claude.ai` (your own session) and `raw.githubusercontent.com` (version check)
|
||||||
|
- Session stored in macOS `WKWebsiteDataStore.default()` — same as Safari
|
||||||
|
- No analytics, no tracking, no ads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built by
|
||||||
|
|
||||||
|
**superdooper86 & claude**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<sub>ClaudeChecker is not affiliated with or endorsed by Anthropic.</sub>
|
||||||
|
</div>
|
||||||
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.1",
|
||||||
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.0.1/ClaudeChecker.zip",
|
||||||
|
"notes": "Update banner can now be dismissed. Fixes update state not clearing correctly."
|
||||||
|
}
|
||||||