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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.0] - 2026-05-19

### Added

- Import Claude sessions from browsers signed in to claude.ai
- Accept pasted Cookie headers containing `sessionKey` during setup

## [1.3.2] - 2026-05-18

### Added
Expand Down Expand Up @@ -143,6 +150,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Smart notifications with configurable alerts at warning and critical thresholds (defaults: 75% and 90%)
- Auto-refresh with automatic usage updates every 1-10 minutes (customizable)

[1.4.0]: https://github.com/eddmann/ClaudeMeter/compare/v1.3.2...v1.4.0
[1.3.2]: https://github.com/eddmann/ClaudeMeter/compare/v1.3.1...v1.3.2
[1.3.1]: https://github.com/eddmann/ClaudeMeter/compare/v1.3.0...v1.3.1
[1.3.0]: https://github.com/eddmann/ClaudeMeter/compare/v1.2.1...v1.3.0
Expand Down
21 changes: 19 additions & 2 deletions ClaudeMeter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

/* Begin PBXBuildFile section */
940ECFCED2930E18C9024CDB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 1E869117452041001C7AF869 /* SnapshotTesting */; };
B4C1B3B12ECA700100000001 /* SweetCookieKit in Frameworks */ = {isa = PBXBuildFile; productRef = B4C1B3B02ECA700100000001 /* SweetCookieKit */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -51,6 +52,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B4C1B3B12ECA700100000001 /* SweetCookieKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -119,6 +121,7 @@
);
name = ClaudeMeter;
packageProductDependencies = (
B4C1B3B02ECA700100000001 /* SweetCookieKit */,
);
productName = ClaudeMeter;
productReference = 6814B1072EC74F6000B4B5C3 /* ClaudeMeter.app */;
Expand Down Expand Up @@ -154,6 +157,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
7C6391426CABA70B39395D98 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */,
B4C1B3AF2ECA700100000001 /* XCRemoteSwiftPackageReference "SweetCookieKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6814B1082EC74F6000B4B5C3 /* Products */;
Expand Down Expand Up @@ -380,7 +384,7 @@
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ANGUD7343N;
ENABLE_APP_SANDBOX = YES;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
Expand Down Expand Up @@ -426,7 +430,7 @@
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ANGUD7343N;
ENABLE_APP_SANDBOX = YES;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
Expand Down Expand Up @@ -504,6 +508,14 @@
minimumVersion = 1.18.7;
};
};
B4C1B3AF2ECA700100000001 /* XCRemoteSwiftPackageReference "SweetCookieKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/steipete/SweetCookieKit";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.4.1;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -512,6 +524,11 @@
package = 7C6391426CABA70B39395D98 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */;
productName = SnapshotTesting;
};
B4C1B3B02ECA700100000001 /* SweetCookieKit */ = {
isa = XCSwiftPackageProductDependency;
package = B4C1B3AF2ECA700100000001 /* XCRemoteSwiftPackageReference "SweetCookieKit" */;
productName = SweetCookieKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6814B0FF2EC74F6000B4B5C3 /* Project object */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions ClaudeMeter/App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
//
// AppDelegate.swift
// ClaudeMeter
//
// Created by Edd on 2026-01-14.
//

import AppKit

/// App delegate to manage menu bar lifecycle.
Expand All @@ -28,6 +21,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
#endif

func applicationDidFinishLaunching(_ notification: Notification) {
SessionKeyImportPromptCoordinator.install()

guard let appModel else {
let fallbackModel = AppModel()
self.appModel = fallbackModel
Expand Down
23 changes: 15 additions & 8 deletions ClaudeMeter/App/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
//
// AppModel.swift
// ClaudeMeter
//
// Created by Edd on 2026-01-09.
//

import AppKit
import Foundation
import Observation
Expand Down Expand Up @@ -35,6 +28,7 @@ final class AppModel {
@ObservationIgnored private let keychainRepository: KeychainRepositoryProtocol
@ObservationIgnored private let usageService: UsageServiceProtocol
@ObservationIgnored private let notificationService: NotificationServiceProtocol
@ObservationIgnored private let sessionKeyImportService: SessionKeyImportServiceProtocol

// MARK: - Private

Expand All @@ -50,10 +44,12 @@ final class AppModel {
settingsRepository: SettingsRepositoryProtocol = SettingsRepository(),
keychainRepository: KeychainRepositoryProtocol = KeychainRepository(),
usageService: UsageServiceProtocol? = nil,
notificationService: NotificationServiceProtocol? = nil
notificationService: NotificationServiceProtocol? = nil,
sessionKeyImportService: SessionKeyImportServiceProtocol = SessionKeyImportService()
) {
self.settingsRepository = settingsRepository
self.keychainRepository = keychainRepository
self.sessionKeyImportService = sessionKeyImportService

let networkService = NetworkService()
let cacheRepository = CacheRepository()
Expand Down Expand Up @@ -159,6 +155,17 @@ final class AppModel {
return true
}

func importAndSaveSessionKey() async throws -> ImportedSessionKey {
let imported = try await sessionKeyImportService.importSessionKey()
let isValid = try await validateAndSaveSessionKey(imported.value)

guard isValid else {
throw SessionKeyImportError.invalidImportedSessionKey
}

return imported
}

func clearSessionKey() async throws {
try await keychainRepository.delete(account: "default")
settings.cachedOrganizationId = nil
Expand Down
52 changes: 52 additions & 0 deletions ClaudeMeter/App/SessionKeyImportPromptCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AppKit
import SweetCookieKit

/// Presents ClaudeMeter-owned context before macOS shows browser Safe Storage prompts.
enum SessionKeyImportPromptCoordinator {
private static let promptLock = NSLock()

static func install() {
BrowserCookieKeychainPromptHandler.handler = { context in
presentBrowserCookiePrompt(context)
}
}

private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) {
let message = [
"ClaudeMeter will ask macOS Keychain for \"\(context.label)\" so it can decrypt your Claude browser session cookie.",
"Click OK to continue, then allow the macOS Keychain prompt.",
].joined(separator: " ")

presentAlert(
title: "Keychain Access Required",
message: message
)
}

private static func presentAlert(title: String, message: String) {
promptLock.lock()
defer { promptLock.unlock() }

if Thread.isMainThread {
MainActor.assumeIsolated {
showAlert(title: title, message: message)
}
return
}

DispatchQueue.main.sync {
MainActor.assumeIsolated {
showAlert(title: title, message: message)
}
}
}

@MainActor
private static func showAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")
_ = alert.runModal()
}
}
37 changes: 28 additions & 9 deletions ClaudeMeter/Models/SessionKey.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
//
// SessionKey.swift
// ClaudeMeter
//
// Created by Edd on 2025-11-14.
//

import Foundation

/// Errors that can occur when working with session keys
Expand Down Expand Up @@ -34,9 +27,12 @@ struct SessionKey: Equatable, Sendable {
/// Organization associated with this key (cached)
var organizationId: UUID?

/// Throwing initializer that validates format
/// Throwing initializer that validates format.
/// Accepts a raw `sk-ant-*` value or a Cookie header containing `sessionKey=sk-ant-*`.
init(_ value: String) throws {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmed = Self.extractSessionKeyValue(from: value) else {
throw SessionKeyError.invalidFormat
}

guard trimmed.hasPrefix("sk-ant-") else {
throw SessionKeyError.invalidFormat
Expand All @@ -48,4 +44,27 @@ struct SessionKey: Equatable, Sendable {

self.value = trimmed
}

static func extractSessionKeyValue(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

if trimmed.hasPrefix("sk-ant-") {
return trimmed
}

let pattern = #"(?i)(?:^|[;\s])sessionKey\s*=\s*([^;\s'"]+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return nil
}

let range = NSRange(trimmed.startIndex..<trimmed.endIndex, in: trimmed)
guard let match = regex.firstMatch(in: trimmed, range: range),
match.numberOfRanges >= 2,
let captureRange = Range(match.range(at: 1), in: trimmed) else {
return nil
}

return String(trimmed[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
6 changes: 0 additions & 6 deletions ClaudeMeter/Resources/ClaudeMeter.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.claudemeter</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// Browser import result for a Claude session key.
struct ImportedSessionKey: Equatable, Sendable {
let value: String
let sourceDescription: String
}

/// Errors that can occur while importing a session key from browser cookies.
enum SessionKeyImportError: LocalizedError {
case noSessionKeyFound
case accessDenied
case safariAccessDenied
case browserKeychainAccessDenied(String)
case invalidImportedSessionKey

var errorDescription: String? {
switch self {
case .noSessionKeyFound:
return "No Claude browser session found. Sign in to claude.ai and try again."
case .accessDenied:
return "ClaudeMeter could not access browser cookies. Allow the macOS prompt or paste your session."
case .safariAccessDenied:
return "Safari needs Full Disk Access to import cookies. Use Chrome/Arc/Brave or paste your session."
case .browserKeychainAccessDenied(let browserName):
return "Allow \(browserName) Safe Storage in Keychain, or paste your session."
case .invalidImportedSessionKey:
return "The imported Claude session key could not be validated."
}
}

var offersFullDiskAccessSettings: Bool {
if case .safariAccessDenied = self {
return true
}
return false
}
}

/// Protocol for importing Claude session keys from local browser data.
protocol SessionKeyImportServiceProtocol: Actor {
func importSessionKey() async throws -> ImportedSessionKey
}
Loading