Add Xcode project source for v1.6
@@ -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,208 @@
|
|||||||
|
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: 500)
|
||||||
|
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: .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.6</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>6</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,99 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 monthlyLimit: Double
|
||||||
|
let usedCredits: Double
|
||||||
|
let utilization: Double
|
||||||
|
let currency: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case isEnabled = "is_enabled"
|
||||||
|
case monthlyLimit = "monthly_limit"
|
||||||
|
case usedCredits = "used_credits"
|
||||||
|
case utilization
|
||||||
|
case currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Bootstrap models removed — org ID is configured directly)
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
let wasAvailable = updateAvailable
|
||||||
|
latestVersion = stable.version
|
||||||
|
releaseNotes = stable.notes ?? ""
|
||||||
|
downloadURL = stable.url
|
||||||
|
updateAvailable = true
|
||||||
|
print("[UpdateManager] updateAvailable set to true")
|
||||||
|
// Only notify once — when first detected
|
||||||
|
if !wasAvailable {
|
||||||
|
NotificationCenter.default.post(name: .updateDetected, object: stable.version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[UpdateManager] Stable fetch returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[UpdateManager] Beta fetch returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,120 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Compact update notification window (shown on relaunch after update)
|
||||||
|
|
||||||
|
class UpdateNotificationWindowController: NSWindowController {
|
||||||
|
static var shared: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hosting = NSHostingController(rootView: content)
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 340, height: 80),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.level = .statusBar
|
||||||
|
window.hasShadow = false
|
||||||
|
window.contentViewController = hosting
|
||||||
|
|
||||||
|
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 y = buttonFrame.minY - 80 - 8
|
||||||
|
let clampedX = max(8, min(x, screen.frame.maxX - 348))
|
||||||
|
window.setFrameOrigin(NSPoint(x: clampedX, y: y))
|
||||||
|
} else {
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = UpdateNotificationWindowController(window: window)
|
||||||
|
shared = controller
|
||||||
|
controller.showWindow(nil)
|
||||||
|
|
||||||
|
// Auto-dismiss after 8 seconds
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.3
|
||||||
|
window.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
controller.close()
|
||||||
|
if shared === controller { shared = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification View
|
||||||
|
|
||||||
|
struct UpdateNotificationView: View {
|
||||||
|
let version: String
|
||||||
|
let subtitle: String
|
||||||
|
let isUpdate: Bool
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
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: isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(isUpdate ? .blue : .green)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
|
||||||
|
.offset(x: 4, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(isUpdate ? "Update available — v\(version)" : "Updated to v\(version) ✓")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
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, height: 80)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 13)
|
||||||
|
.fill(Color(nsColor: .windowBackgroundColor))
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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 planLabel: String = "Claude"
|
||||||
|
@Published var isNotAuthenticated: Bool = false
|
||||||
|
@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?
|
||||||
|
|
||||||
|
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 refresh() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
isNotAuthenticated = false
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let orgId = cachedOrgId!
|
||||||
|
let usage = try await fetchUsage(orgId: orgId)
|
||||||
|
limits = buildLimits(from: usage)
|
||||||
|
extraUsage = usage.extraUsage
|
||||||
|
planLabel = "Pro"
|
||||||
|
lastUpdated = Date()
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||