diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b82d5..b550ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/ClaudeMeter.xcodeproj/project.pbxproj b/ClaudeMeter.xcodeproj/project.pbxproj index 705dba2..b5968d5 100644 --- a/ClaudeMeter.xcodeproj/project.pbxproj +++ b/ClaudeMeter.xcodeproj/project.pbxproj @@ -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 */ @@ -51,6 +52,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B4C1B3B12ECA700100000001 /* SweetCookieKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,6 +121,7 @@ ); name = ClaudeMeter; packageProductDependencies = ( + B4C1B3B02ECA700100000001 /* SweetCookieKit */, ); productName = ClaudeMeter; productReference = 6814B1072EC74F6000B4B5C3 /* ClaudeMeter.app */; @@ -154,6 +157,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 7C6391426CABA70B39395D98 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, + B4C1B3AF2ECA700100000001 /* XCRemoteSwiftPackageReference "SweetCookieKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6814B1082EC74F6000B4B5C3 /* Products */; @@ -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; @@ -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; @@ -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 */ @@ -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 */; diff --git a/ClaudeMeter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ClaudeMeter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f6aa36f..75d7e83 100644 --- a/ClaudeMeter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ClaudeMeter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "09b08963887ce6eac26952e8acb25256a8ec102f2b5528ae68a97fa296723712", + "originHash" : "3284ecb1cbad5b2024b1f47bbf82c5f129c6b513fadf8b3fe61111963fd58c83", "pins" : [ + { + "identity" : "sweetcookiekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/SweetCookieKit", + "state" : { + "revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd", + "version" : "0.4.1" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/ClaudeMeter/App/AppDelegate.swift b/ClaudeMeter/App/AppDelegate.swift index f7d0a97..69ffda6 100644 --- a/ClaudeMeter/App/AppDelegate.swift +++ b/ClaudeMeter/App/AppDelegate.swift @@ -1,10 +1,3 @@ -// -// AppDelegate.swift -// ClaudeMeter -// -// Created by Edd on 2026-01-14. -// - import AppKit /// App delegate to manage menu bar lifecycle. @@ -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 diff --git a/ClaudeMeter/App/AppModel.swift b/ClaudeMeter/App/AppModel.swift index 7fcece2..0e75de6 100644 --- a/ClaudeMeter/App/AppModel.swift +++ b/ClaudeMeter/App/AppModel.swift @@ -1,10 +1,3 @@ -// -// AppModel.swift -// ClaudeMeter -// -// Created by Edd on 2026-01-09. -// - import AppKit import Foundation import Observation @@ -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 @@ -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() @@ -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 diff --git a/ClaudeMeter/App/SessionKeyImportPromptCoordinator.swift b/ClaudeMeter/App/SessionKeyImportPromptCoordinator.swift new file mode 100644 index 0000000..61df458 --- /dev/null +++ b/ClaudeMeter/App/SessionKeyImportPromptCoordinator.swift @@ -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() + } +} diff --git a/ClaudeMeter/Models/SessionKey.swift b/ClaudeMeter/Models/SessionKey.swift index 1b566af..14dc01b 100644 --- a/ClaudeMeter/Models/SessionKey.swift +++ b/ClaudeMeter/Models/SessionKey.swift @@ -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 @@ -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 @@ -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..= 2, + let captureRange = Range(match.range(at: 1), in: trimmed) else { + return nil + } + + return String(trimmed[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) + } } diff --git a/ClaudeMeter/Resources/ClaudeMeter.entitlements b/ClaudeMeter/Resources/ClaudeMeter.entitlements index e816b9a..fb11bb5 100644 --- a/ClaudeMeter/Resources/ClaudeMeter.entitlements +++ b/ClaudeMeter/Resources/ClaudeMeter.entitlements @@ -2,12 +2,6 @@ - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - keychain-access-groups $(AppIdentifierPrefix)com.claudemeter diff --git a/ClaudeMeter/Services/Protocols/SessionKeyImportServiceProtocol.swift b/ClaudeMeter/Services/Protocols/SessionKeyImportServiceProtocol.swift new file mode 100644 index 0000000..c4ae786 --- /dev/null +++ b/ClaudeMeter/Services/Protocols/SessionKeyImportServiceProtocol.swift @@ -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 +} diff --git a/ClaudeMeter/Services/SessionKeyImportService.swift b/ClaudeMeter/Services/SessionKeyImportService.swift new file mode 100644 index 0000000..67df2a4 --- /dev/null +++ b/ClaudeMeter/Services/SessionKeyImportService.swift @@ -0,0 +1,98 @@ +import Foundation +import os +import SweetCookieKit + +/// Imports Claude session keys from local browser cookies. +actor SessionKeyImportService: SessionKeyImportServiceProtocol { + private static let logger = Logger(subsystem: "com.claudemeter", category: "SessionKeyImportService") + + private let cookieClient: BrowserCookieClient + private let browserImportOrder: [Browser] + + init( + cookieClient: BrowserCookieClient = BrowserCookieClient(), + browserImportOrder: [Browser] = Browser.defaultImportOrder + ) { + self.cookieClient = cookieClient + self.browserImportOrder = browserImportOrder + } + + func importSessionKey() async throws -> ImportedSessionKey { + let query = BrowserCookieQuery(domains: ["claude.ai"], domainMatch: .suffix) + var sawAccessDenied = false + var sawSafariAccessDenied = false + var deniedKeychainBrowserName: String? + + for browser in browserImportOrder { + do { + let sources = try cookieClient.records(matching: query, in: browser) + for source in sources { + if let imported = try importedSessionKey(from: source) { + return imported + } + } + } catch let error as BrowserCookieError { + switch error { + case .accessDenied: + sawAccessDenied = true + if browser == .safari { + sawSafariAccessDenied = true + } else if browser.usesKeychainForCookieDecryption { + deniedKeychainBrowserName = browser.displayName + } + case .notFound, .loadFailed: + break + } + Self.logger.debug("Browser cookie import failed for \(browser.displayName): \(error.localizedDescription)") + } catch { + Self.logger.debug("Browser cookie import failed for \(browser.displayName): \(error.localizedDescription)") + } + } + + if let deniedKeychainBrowserName { + throw SessionKeyImportError.browserKeychainAccessDenied(deniedKeychainBrowserName) + } + if sawSafariAccessDenied { + throw SessionKeyImportError.safariAccessDenied + } + if sawAccessDenied { + throw SessionKeyImportError.accessDenied + } + throw SessionKeyImportError.noSessionKeyFound + } + + private func importedSessionKey(from source: BrowserCookieStoreRecords) throws -> ImportedSessionKey? { + guard let cookie = source.records.first(where: { $0.name == "sessionKey" }) else { + return nil + } + + let sessionKey = try SessionKey(cookie.value) + return ImportedSessionKey( + value: sessionKey.value, + sourceDescription: source.label + ) + } +} + +private extension Browser { + var usesKeychainForCookieDecryption: Bool { + switch self { + case .safari, .firefox, .zen: + return false + case .chrome, .chromeBeta, .chromeCanary, + .arc, .arcBeta, .arcCanary, + .chatgptAtlas, + .chromium, + .yandex, + .brave, .braveBeta, .braveNightly, + .edge, .edgeBeta, .edgeCanary, + .helium, + .vivaldi, + .dia, + .comet: + return true + @unknown default: + return true + } + } +} diff --git a/ClaudeMeter/Utilities/SystemSettingsOpener.swift b/ClaudeMeter/Utilities/SystemSettingsOpener.swift new file mode 100644 index 0000000..d6bc291 --- /dev/null +++ b/ClaudeMeter/Utilities/SystemSettingsOpener.swift @@ -0,0 +1,21 @@ +import AppKit +import Foundation + +enum SystemSettingsOpener { + static func openFullDiskAccess() { + openSystemSettings( + "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", + fallback: "x-apple.systempreferences:com.apple.preference.security" + ) + } + + private static func openSystemSettings(_ urlString: String, fallback: String) { + if let url = URL(string: urlString), NSWorkspace.shared.open(url) { + return + } + + if let fallbackURL = URL(string: fallback) { + NSWorkspace.shared.open(fallbackURL) + } + } +} diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 41d41ad..4fd2588 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -1,22 +1,16 @@ -// -// SettingsView.swift -// ClaudeMeter -// -// Created by Edd on 2025-11-14. -// - import SwiftUI import ServiceManagement import AppKit -/// Settings view with tabbed interface struct SettingsView: View { @Bindable var appModel: AppModel @State private var sessionKey: String = "" @State private var isSessionKeyShown: Bool = false @State private var isValidatingSessionKey: Bool = false + @State private var isImportingSessionKey: Bool = false @State private var sessionKeyValidationMessage: String? + @State private var offersFullDiskAccessSettings: Bool = false @State private var hasSessionKeyValidationSucceeded: Bool = false @State private var isSendingTestNotification: Bool = false @@ -75,16 +69,28 @@ struct SettingsView: View { .padding(24) } - // MARK: - Session Key Section + // MARK: - Claude Session Section private var sessionKeySection: some View { VStack(alignment: .leading, spacing: 12) { - Text("Session Key") - .font(.subheadline) + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Claude Session") + .font(.subheadline) + + Text("Import from browser or paste your Claude session") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() - Text("Your Claude.ai session key authenticates API requests. Find this in your browser's cookies.") - .font(.caption) - .foregroundStyle(.secondary) + Label(sessionKey.isEmpty ? "Not configured" : "Saved in Keychain", systemImage: sessionKey.isEmpty ? "exclamationmark.circle" : "checkmark.circle") + .font(.caption) + .foregroundStyle(sessionKey.isEmpty ? Color.secondary : Color.green) + } HStack { if isSessionKeyShown { @@ -114,27 +120,62 @@ struct SettingsView: View { } HStack { - Button("Validate & Save") { + Button("Save") { Task { await validateAndSaveSessionKey() } } .controlSize(.small) - .disabled(sessionKey.isEmpty || isValidatingSessionKey) + .disabled(sessionKey.isEmpty || isSessionKeyBusy) - if isValidatingSessionKey { + Button("Import from Browser") { + Task { + await importAndSaveSessionKey() + } + } + .controlSize(.small) + .disabled(isSessionKeyBusy) + + if isSessionKeyBusy { ProgressView() .controlSize(.small) } - if let message = sessionKeyValidationMessage { - Label(message, systemImage: hasSessionKeyValidationSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill") + if let message = sessionKeyValidationMessage, hasSessionKeyValidationSucceeded { + Label(message, systemImage: "checkmark.circle.fill") .font(.caption) - .foregroundStyle(hasSessionKeyValidationSucceeded ? .green : .red) + .foregroundStyle(.green) + .lineLimit(1) + .truncationMode(.tail) } Spacer() } + + if let message = sessionKeyValidationMessage, !hasSessionKeyValidationSucceeded { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .frame(width: 16) + + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if offersFullDiskAccessSettings { + Button("Open Full Disk Access") { + SystemSettingsOpener.openFullDiskAccess() + } + .controlSize(.small) + .padding(.leading, 22) + } + } + } } .padding() .background(.quaternary.opacity(0.3)) @@ -200,7 +241,7 @@ struct SettingsView: View { Text("Menu Bar Icon Style") .font(.subheadline) - Text("Choose how the usage indicator appears in menu bar") + Text("Choose how ClaudeMeter appears in the menu bar") .font(.caption) .foregroundStyle(.secondary) } @@ -481,7 +522,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) - Text("Monitor your Claude.ai usage limits.") + Text("Monitor your Claude.ai usage limits") .font(.caption) .foregroundStyle(.secondary) } @@ -504,6 +545,10 @@ struct SettingsView: View { // MARK: - Actions + private var isSessionKeyBusy: Bool { + isValidatingSessionKey || isImportingSessionKey + } + private func loadSettings() { Task { @MainActor in sessionKey = await appModel.loadSessionKey() ?? "" @@ -534,6 +579,7 @@ struct SettingsView: View { isValidatingSessionKey = true sessionKeyValidationMessage = nil + offersFullDiskAccessSettings = false hasSessionKeyValidationSucceeded = false do { @@ -554,24 +600,61 @@ struct SettingsView: View { } } catch let error as SessionKeyError { sessionKeyValidationMessage = error.localizedDescription + offersFullDiskAccessSettings = false hasSessionKeyValidationSucceeded = false } catch { sessionKeyValidationMessage = "Validation failed: \(error.localizedDescription)" + offersFullDiskAccessSettings = false hasSessionKeyValidationSucceeded = false } isValidatingSessionKey = false } + @MainActor + private func importAndSaveSessionKey() async { + isImportingSessionKey = true + sessionKeyValidationMessage = nil + offersFullDiskAccessSettings = false + hasSessionKeyValidationSucceeded = false + + do { + let imported = try await appModel.importAndSaveSessionKey() + sessionKey = imported.value + sessionKeyValidationMessage = "Imported from \(imported.sourceDescription)" + hasSessionKeyValidationSucceeded = true + offersFullDiskAccessSettings = false + + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + sessionKeyValidationMessage = nil + offersFullDiskAccessSettings = false + hasSessionKeyValidationSucceeded = false + } + } catch let error as SessionKeyImportError { + sessionKeyValidationMessage = error.localizedDescription + offersFullDiskAccessSettings = error.offersFullDiskAccessSettings + hasSessionKeyValidationSucceeded = false + } catch { + sessionKeyValidationMessage = error.localizedDescription + offersFullDiskAccessSettings = false + hasSessionKeyValidationSucceeded = false + } + + isImportingSessionKey = false + } + private func clearSessionKey() { Task { @MainActor in do { try await appModel.clearSessionKey() sessionKey = "" sessionKeyValidationMessage = nil + offersFullDiskAccessSettings = false hasSessionKeyValidationSucceeded = false } catch { sessionKeyValidationMessage = "Failed to clear: \(error.localizedDescription)" + offersFullDiskAccessSettings = false hasSessionKeyValidationSucceeded = false } } diff --git a/ClaudeMeter/Views/Setup/SetupWizardView.swift b/ClaudeMeter/Views/Setup/SetupWizardView.swift index 5bb2ed8..06a0ef7 100644 --- a/ClaudeMeter/Views/Setup/SetupWizardView.swift +++ b/ClaudeMeter/Views/Setup/SetupWizardView.swift @@ -1,24 +1,18 @@ -// -// SetupWizardView.swift -// ClaudeMeter -// -// Created by Edd on 2025-11-14. -// - import SwiftUI import AppKit -/// Setup wizard view for initial configuration struct SetupWizardView: View { @Bindable var appModel: AppModel @State private var sessionKeyInput: String = "" @State private var isValidating: Bool = false + @State private var isImporting: Bool = false @State private var errorMessage: String? + @State private var offersFullDiskAccessSettings: Bool = false @State private var hasValidationSucceeded: Bool = false var body: some View { - VStack(spacing: 24) { + VStack(spacing: 22) { // Header VStack(spacing: 8) { if let appIcon = NSImage(named: NSImage.applicationIconName) { @@ -43,16 +37,16 @@ struct SetupWizardView: View { // Session Key Input VStack(alignment: .leading, spacing: 8) { - Text("Claude Session Key") + Text("Claude Session") .font(.headline) SecureField("sk-ant-...", text: $sessionKeyInput) .textFieldStyle(.roundedBorder) - .disabled(isValidating) + .disabled(isBusy) .accessibilityLabel("Session key input field") - .accessibilityHint("Enter your Claude session key starting with sk-ant-") + .accessibilityHint("Enter your Claude session key or paste a Cookie header containing sessionKey") - Text("Find your session key in Claude.ai browser cookies") + Text("Import from a browser signed in to claude.ai, or paste your session") .font(.caption) .foregroundColor(.secondary) @@ -61,7 +55,7 @@ struct SetupWizardView: View { HStack(spacing: 4) { Image(systemName: isFormatValid ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundColor(isFormatValid ? .green : .red) - Text(isFormatValid ? "Format valid" : "Invalid format (must start with sk-ant-)") + Text(isFormatValid ? "Session format valid" : "Invalid session format") .font(.caption) .foregroundColor(isFormatValid ? .green : .red) } @@ -71,12 +65,26 @@ struct SetupWizardView: View { // Error Message if let errorMessage = errorMessage { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(errorMessage) - .font(.callout) - .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .frame(width: 20) + Text(errorMessage) + .font(.callout) + .foregroundColor(.orange) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if offersFullDiskAccessSettings { + Button("Open Full Disk Access") { + SystemSettingsOpener.openFullDiskAccess() + } + .controlSize(.small) + .padding(.leading, 28) + } } .padding(12) .background(Color.orange.opacity(0.1)) @@ -102,32 +110,85 @@ struct SetupWizardView: View { Spacer() - // Continue Button - Button(action: { - Task { - await validateAndSave() + // Actions + VStack(spacing: 8) { + Button(action: { + Task { + await importAndSave() + } + }) { + HStack { + if isImporting { + ProgressView() + .controlSize(.small) + } + Text(isImporting ? "Importing..." : "Import from Browser") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) } - }) { - HStack { - Text(isValidating ? "Validating..." : "Continue") + .buttonStyle(.borderedProminent) + .disabled(isBusy) + .accessibilityLabel(isImporting ? "Importing session key" : "Import session key from browser") + .accessibilityHint("Finds your Claude session key in local browser cookies and validates it") + + Button(action: { + Task { + await validateAndSave() + } + }) { + HStack { + Text(isValidating ? "Validating..." : "Continue Manually") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .buttonStyle(.bordered) + .disabled(!isFormatValid || isBusy) + .accessibilityLabel(isValidating ? "Validating session key" : "Continue with manual setup") + .accessibilityHint("Validates your session key and completes setup") } - .allowsHitTesting(isFormatValid && !isValidating) - .buttonStyle(.borderedProminent) .padding(.horizontal, 32) .padding(.bottom, 32) - .accessibilityLabel(isValidating ? "Validating session key" : "Continue with setup") - .accessibilityHint("Validates your session key and completes setup") } - .frame(width: 360, height: 420) + .frame(width: 370, height: 460) + .background(Color(nsColor: .windowBackgroundColor)) } // MARK: - Validation + private var isBusy: Bool { + isValidating || isImporting + } + private var isFormatValid: Bool { let trimmed = sessionKeyInput.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.hasPrefix("sk-ant-") && trimmed.count > 10 + guard let value = SessionKey.extractSessionKeyValue(from: trimmed) else { return false } + return value.hasPrefix("sk-ant-") && value.count > 10 + } + + @MainActor + private func importAndSave() async { + isImporting = true + errorMessage = nil + offersFullDiskAccessSettings = false + hasValidationSucceeded = false + + do { + let imported = try await appModel.importAndSaveSessionKey() + sessionKeyInput = imported.value + hasValidationSucceeded = true + } catch let error as SessionKeyImportError { + errorMessage = error.localizedDescription + offersFullDiskAccessSettings = error.offersFullDiskAccessSettings + } catch let error as NetworkError { + errorMessage = "Network error: \(error.localizedDescription)" + } catch let error as AppError { + errorMessage = error.localizedDescription + } catch { + errorMessage = "Import failed: \(error.localizedDescription)" + } + + isImporting = false } @MainActor @@ -140,6 +201,7 @@ struct SetupWizardView: View { isValidating = true errorMessage = nil + offersFullDiskAccessSettings = false hasValidationSucceeded = false do { diff --git a/ClaudeMeterTests/AppModelTests.swift b/ClaudeMeterTests/AppModelTests.swift index d8c05ca..363e660 100644 --- a/ClaudeMeterTests/AppModelTests.swift +++ b/ClaudeMeterTests/AppModelTests.swift @@ -1,10 +1,3 @@ -// -// AppModelTests.swift -// ClaudeMeterTests -// -// Created by Edd on 2026-01-09. -// - import XCTest @testable import ClaudeMeter @@ -222,6 +215,76 @@ final class AppModelTests: XCTestCase { XCTAssertEqual(appModel.usageData, expectedUsage) } + func test_importingSessionKey_savesImportedKeyAndLoadsData() async throws { + let expectedUsage = makeUsageData(percentage: TestConstants.sessionPercentage) + let organization = Organization( + id: 1, + uuid: TestConstants.organizationUUIDString, + name: "Test Org" + ) + let usageService = UsageServiceStub( + fetchUsageResult: .success(expectedUsage), + organizations: [organization], + isSessionKeyValid: true + ) + let notificationService = NotificationServiceSpy() + let settingsRepository = SettingsRepositoryFake() + let keychainRepository = KeychainRepositoryFake() + let importService = SessionKeyImportServiceStub(result: .success(ImportedSessionKey( + value: TestConstants.sessionKeyValue, + sourceDescription: "Chrome Default" + ))) + + let appModel = AppModel( + settingsRepository: settingsRepository, + keychainRepository: keychainRepository, + usageService: usageService, + notificationService: notificationService, + sessionKeyImportService: importService + ) + + let imported = try await appModel.importAndSaveSessionKey() + let savedKey = try await keychainRepository.retrieve(account: "default") + + XCTAssertEqual(imported.sourceDescription, "Chrome Default") + XCTAssertEqual(savedKey, TestConstants.sessionKeyValue) + XCTAssertTrue(appModel.isSetupComplete) + XCTAssertEqual(appModel.usageData, expectedUsage) + } + + func test_importingSessionKey_whenImportedKeyInvalid_staysInSetup() async throws { + let usageService = UsageServiceStub( + fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage)), + organizations: [], + isSessionKeyValid: false + ) + let notificationService = NotificationServiceSpy() + let settingsRepository = SettingsRepositoryFake() + let keychainRepository = KeychainRepositoryFake() + let importService = SessionKeyImportServiceStub(result: .success(ImportedSessionKey( + value: TestConstants.sessionKeyValue, + sourceDescription: "Chrome Default" + ))) + + let appModel = AppModel( + settingsRepository: settingsRepository, + keychainRepository: keychainRepository, + usageService: usageService, + notificationService: notificationService, + sessionKeyImportService: importService + ) + + do { + _ = try await appModel.importAndSaveSessionKey() + XCTFail("Expected invalidImportedSessionKey to be thrown") + } catch SessionKeyImportError.invalidImportedSessionKey { + XCTAssertFalse(appModel.isSetupComplete) + XCTAssertNil(appModel.usageData) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + func test_userWithValidSessionKeyWithoutOrganization_staysInSetup() async { let usageService = UsageServiceStub( fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage)), diff --git a/ClaudeMeterTests/SessionKeyTests.swift b/ClaudeMeterTests/SessionKeyTests.swift new file mode 100644 index 0000000..a1c7635 --- /dev/null +++ b/ClaudeMeterTests/SessionKeyTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import ClaudeMeter + +final class SessionKeyTests: XCTestCase { + func test_sessionKey_acceptsRawSessionKey() throws { + let sessionKey = try SessionKey(" \(TestConstants.sessionKeyValue) ") + + XCTAssertEqual(sessionKey.value, TestConstants.sessionKeyValue) + } + + func test_sessionKey_extractsValueFromCookieHeader() throws { + let sessionKey = try SessionKey("Cookie: foo=bar; sessionKey=\(TestConstants.sessionKeyValue); other=value") + + XCTAssertEqual(sessionKey.value, TestConstants.sessionKeyValue) + } + + func test_sessionKey_rejectsCookieHeaderWithoutSessionKey() { + XCTAssertThrowsError(try SessionKey("Cookie: foo=bar")) + } +} diff --git a/ClaudeMeterTests/TestDoubles/SessionKeyImportServiceStub.swift b/ClaudeMeterTests/TestDoubles/SessionKeyImportServiceStub.swift new file mode 100644 index 0000000..5528a74 --- /dev/null +++ b/ClaudeMeterTests/TestDoubles/SessionKeyImportServiceStub.swift @@ -0,0 +1,19 @@ +import Foundation +@testable import ClaudeMeter + +actor SessionKeyImportServiceStub: SessionKeyImportServiceProtocol { + let result: Result + + init(result: Result) { + self.result = result + } + + func importSessionKey() async throws -> ImportedSessionKey { + switch result { + case .success(let imported): + return imported + case .failure(let error): + throw error + } + } +} diff --git a/README.md b/README.md index 8316d1f..4eb2797 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ ClaudeMeter sends native macOS notifications when you reach warning or critical ### Settings -Configure your session key, refresh interval, icon style, and notification thresholds: +Configure your Claude session, refresh interval, icon style, and notification thresholds:

Settings - General @@ -76,12 +76,20 @@ The app is signed and notarized by Apple, so it will open without any security w 1. ClaudeMeter appears in your menu bar as a gauge icon 2. The setup wizard will guide you through initial configuration -3. Enter your Claude session key (found in Claude.ai browser cookies) -4. The app validates your key and begins monitoring usage +3. Import from a browser signed in to [claude.ai](https://claude.ai), or paste your session manually +4. The app validates your session and begins monitoring usage -### Finding Your Session Key +### Claude Session Setup -Your Claude session key is stored in your browser cookies: +ClaudeMeter can import your existing Claude session from local browser cookies. Sign in to [claude.ai](https://claude.ai) in a supported browser, then choose **Import from Browser** in the setup wizard or Settings. + +Chrome, Arc, Brave, Edge, and other Chromium browsers may ask for browser Safe Storage Keychain access so ClaudeMeter can decrypt cookies. Safari cookies are protected by macOS and may require Full Disk Access. + +If browser import is unavailable, paste your session manually. ClaudeMeter accepts either a raw `sk-ant-...` session key or a Cookie header containing `sessionKey=...`. + +#### Manual Session Setup + +Your Claude session key is stored in your browser cookies. **Chrome/Edge:** @@ -170,7 +178,8 @@ Then configure Claude Code's `~/.claude/settings.json`: ## Requirements - macOS 14.0 (Sonoma) or later -- Active Claude.ai account with session key +- Active Claude.ai account with a browser session or session key +- For browser import, a supported browser signed in to [claude.ai](https://claude.ai) ## Building from Source @@ -201,6 +210,7 @@ This application accesses Claude's web API using browser-based authentication me **Data storage:** - Session keys are stored securely in macOS Keychain (encrypted, device-local only) +- Browser import reads local browser cookies to extract your Claude session, then stores only the session key in Keychain - Usage data is cached locally (unencrypted, contains usage percentages only) - No data is sent to third-party servers or collected by the developer diff --git a/docs/settings-general.png b/docs/settings-general.png index 6e88b50..3fecd6e 100644 Binary files a/docs/settings-general.png and b/docs/settings-general.png differ diff --git a/docs/setup-wizard.png b/docs/setup-wizard.png index 7ea2e68..424bfad 100644 Binary files a/docs/setup-wizard.png and b/docs/setup-wizard.png differ