diff --git a/CHANGELOG.md b/CHANGELOG.md index d9249f2..27ea492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,57 @@ ## [Не выпущено] +### Добавлено + +- **Два аккаунта Claude в одной панели.** Claude Code различает аккаунты + конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит + свой токен, не выбивая первый. Как только каталог появляется, в заголовке + панели встают два знака Claude: выбранный горит фирменным оранжевым, второй + гаснет до серого, а кто из них кто — говорит подсказка с живым адресом + аккаунта из `claude auth status`. Выбор переживает перезапуск. У каждого + аккаунта свой лимит, снимок, подобранный бюджет, отсечка счёта и журнал + уведомлений — файлы второго лежат рядом с приставкой `secondary-`. Общими их + сделать было нельзя: обновление одного стирало бы снимок другого. + Каталога второго дома нет — нет и переключателя, всё работает как раньше. +- **Раздел «Аккаунты» на вкладке «Доступ»**: оба дома, путь каждого и кто в + него вошёл. «Дом не заведён», «не вошли» и адрес — три разных положения, и + склеивать их в одно «нет данных» нельзя: делать в них надо разное. +- **Флаг `--account=primary|secondary`** выбирает аккаунт, в том числе + для `--json`. + +### Исправлено + +- **Вкладка «Доступ» показывала «не вошли» у аккаунта, в котором вы работаете.** + Состояние дома выяснялось вызовом `claude auth status` с + `CLAUDE_CONFIG_DIR`, а эта переменная, выставленная даже в тот же самый путь + `~/.claude`, заставляет Claude Code отвечать `loggedIn: false`: ячейку + учётных данных выбирает не путь, а факт установки переменной. Теперь она + ставится только для нестандартного дома, а унаследованная из окружения + снимается — приложение могли запустить из оболочки, где она уже выставлена. +- **Второй аккаунт показывал «в этот аккаунт ещё не вошли», хотя вход был.** + Токен второго дома искали перебором записей Keychain со сверкой + `organizationUuid` внутри каждой — а Claude Code 2.1.251 это поле из записи + убрал: там остался один `claudeAiOauth` с токенами и тарифом. Сверять стало + не с чем, и подходящей записи не находилось никогда. Мешал и фильтр имён: он + брал только записи, кончающиеся на `-credentials`, а запись второго дома + называется `Claude Code-credentials-034e8c6f`. Теперь имя записи выводится из + пути конфиг-дома — так же, как его собирает сам Claude Code, — и формулу + сторожит тест с живым вектором. Промах формулы (сменится схема именования в + CLI) даст «нет доступа», а не чужой токен: имя зависит от дома, а не от + аккаунта. +- **Метка аккаунта, по которой замечают вход другим аккаунтом, снова + собирается.** Она складывалась из `organizationUuid` записи Keychain и вместе + с ним пропала — отсечка счёта перестала бы замечать смену аккаунта у обоих. + Организацию теперь называет `claude auth status` для этого дома. Форма метки + прежняя, так что накопленный счёт на месте: `2a420a73·pro` в `state.json` + осталось тем же значением. +- **Незнакомое значение `activeAccount` больше не стоит всего конфига.** + Разбор поля бросал исключение, а оно обрывало чтение файла целиком — человек + молча терял все свои настройки разом. Повод не выдуманный: сборка с Codex + писала в этот ключ `claude`, и такие файлы лежат на дисках. Теперь + незнакомый аккаунт стоит только самого себя и сводится к первому. + + ## [0.2.0] — 2026-08-23 ### Добавлено diff --git a/Package.swift b/Package.swift index be299ca..23f9093 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,11 @@ let package = Package( .executableTarget( name: "ClaudeWeekApp", - dependencies: ["ClaudeWeekCore"] + dependencies: ["ClaudeWeekCore"], + // Знак Claude в переключателе аккаунтов: подходящего SF Symbol у + // сервиса нет, а рисовать его в коде — держать вторую копию + // фирменного знака, которая разойдётся с настоящим. + resources: [.process("Assets")] ), // XCTest и swift-testing без Xcode недоступны, поэтому проверки — diff --git a/README.md b/README.md index 5914f03..0d2f2ed 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,28 @@ Code сбрасывает каждым обновлением токена, и --- +## Два аккаунта + +Аккаунтов у Claude Code может быть два, и панель показывает оба. Различает их +конфиг-дом: обычная установка живёт в `~/.claude`, второй заводится каталогом +`~/.claude-b` и переменной `CLAUDE_CONFIG_DIR` — у каждого свои транскрипты и +своя запись Keychain, поэтому оба токена держатся одновременно, и вход одним +не выбивает другой. + +Как только второй каталог появляется, в заголовке панели встают два знака +Claude. Выбранный аккаунт горит фирменным оранжевым, второй гаснет до серого; +кто из них кто, говорит подсказка при наведении — там стоит живой адрес +аккаунта, а не порядковый номер. Выбор переживает перезапуск. У каждого +аккаунта свой лимит, свой снимок и своя отсечка счёта, и переключатель меняет +их все разом. В `--json` тот же выбор делает флаг `--account=primary|secondary`. + +Вкладка **«Доступ»** в настройках показывает оба аккаунта: где лежит дом +каждого и кто в него вошёл. Ни одного каталога нет — переключателя тоже нет, +и программа работает ровно как раньше, с одним аккаунтом. + +Про то, как завести второй дом, — в +[docs/USAGE.md](docs/USAGE.md#два-аккаунта). + ## Смена аккаунта Вошли другим аккаунтом — рабочим вместо домашнего — и счёт начинается заново. @@ -238,7 +260,7 @@ Keychain с той, на которой ведётся счёт, и при ра ```bash swift build # обе цели -swift run ClaudeWeekTests # 477 проверок: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 541 проверка: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekApp/AccountPicker.swift b/Sources/ClaudeWeekApp/AccountPicker.swift new file mode 100644 index 0000000..e6ad47d --- /dev/null +++ b/Sources/ClaudeWeekApp/AccountPicker.swift @@ -0,0 +1,113 @@ +import AppKit +import SwiftUI +import ClaudeWeekCore + +/// Два знака в заголовке панели — два аккаунта Claude. Выбор хранится в +/// конфиге, но сам вид получает только значение и действие: отдельного +/// локального состояния, способного разойтись с реально загруженным +/// аккаунтом, нет. +struct AccountPicker: View { + let selection: UsageAccount + /// Чем подписан каждый аккаунт в подсказке — адрес из `claude auth status`, + /// пока он не получен, порядковое название. + let titles: [UsageAccount: String] + let onSelect: (UsageAccount) -> Void + + @Environment(\.strings) private var s + + var body: some View { + HStack(spacing: 5) { + ForEach(UsageAccount.allCases, id: \.self) { account in + AccountButton( + account: account, + isSelected: account == selection, + title: titles[account] ?? account.fallbackTitle(s.lang), + onSelect: onSelect + ) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(s.pick("Аккаунт", "Account")) + // Заголовок собран по базовой линии текста, а у картинки она проходит + // по нижней кромке — знаки вставали над строкой. Выдаём за базовую + // линию собственный центр, поднятый на столько же, на сколько центр + // строки стоит над baseline: знаки встают ровно посередине надписи. + .alignmentGuide(.firstTextBaseline) { + $0[VerticalAlignment.center] + Theme.textCenterAboveBaseline + } + } +} + +private struct AccountButton: View { + let account: UsageAccount + let isSelected: Bool + let title: String + let onSelect: (UsageAccount) -> Void + + @Environment(\.palette) private var palette + @Environment(\.strings) private var s + + var body: some View { + Button { + onSelect(account) + } label: { + // Ни подложки, ни рамки: в заголовке это знаки, а не органы + // управления. Что выбрано, говорит цвет; поле вокруг знака + // добавлено только ради попадания мышью. + providerIcon + .padding(2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // Системное кольцо фокуса рисует вокруг знака рамку, которой здесь + // быть не должно: после клика подсвечены оба аккаунта сразу. + .focusEffectDisabled() + .help(help) + .accessibilityLabel(help) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + /// У Claude нет подходящего SF Symbol: знак лежит в ресурсах таргета как + /// шаблонный PNG — силуэт держит альфа-канал, цвета в файле нет. Красит + /// его `iconColor`, поэтому знак живёт в любой теме. + @ViewBuilder + private var providerIcon: some View { + if let url = Bundle.module.url(forResource: "Claude", withExtension: "png"), + let image = NSImage(contentsOf: url) { + // Шаблонным знак делает сама картинка: SwiftUI на macOS красит + // `Image(nsImage:)` по `foregroundStyle` только тогда, когда + // `isTemplate` стоит у NSImage, — одного `renderingMode` мало. + Image(nsImage: template(image)) + .resizable() + .scaledToFit() + .foregroundStyle(iconColor) + .frame(width: 13, height: 13) + } else { + Image(systemName: "questionmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(palette.secondaryText.color) + .frame(width: 13, height: 13) + } + } + + private func template(_ image: NSImage) -> NSImage { + image.isTemplate = true + return image + } + + /// Знаки у обоих аккаунтов одинаковые — сервис-то один, — поэтому + /// выбранный различает цвет: он горит фирменным оранжевым, невыбранный + /// гаснет до приглушённого серого. Кто из них кто, говорит подсказка: там + /// стоит живой адрес аккаунта, а не порядковый номер. + private var iconColor: Color { + isSelected + ? Color(red: 0.83, green: 0.42, blue: 0.30) + : palette.secondaryText.color.opacity(0.55) + } + + private var help: String { + isSelected + ? s.pick("Показан \(title)", "Showing \(title)") + : s.pick("Показать \(title)", "Show \(title)") + } +} diff --git a/Sources/ClaudeWeekApp/Assets/Claude.png b/Sources/ClaudeWeekApp/Assets/Claude.png new file mode 100644 index 0000000..7006ffd Binary files /dev/null and b/Sources/ClaudeWeekApp/Assets/Claude.png differ diff --git a/Sources/ClaudeWeekApp/CLI.swift b/Sources/ClaudeWeekApp/CLI.swift index 4813b20..1aadabf 100644 --- a/Sources/ClaudeWeekApp/CLI.swift +++ b/Sources/ClaudeWeekApp/CLI.swift @@ -12,6 +12,7 @@ enum CLI { ClaudeWeek запустить приложение в строке меню ClaudeWeek --json напечатать состояние недели в JSON и выйти ClaudeWeek --provider=X источник данных: official, local или auto + ClaudeWeek --account=X чей лимит: primary или secondary ClaudeWeek --config=ПУТЬ свой файл конфигурации ClaudeWeek --calibrate=N подогнать локальную оценку под официальные N % (число берётся из /usage внутри Claude Code) @@ -26,7 +27,9 @@ enum CLI { /// Флаги без значения и префиксы флагов со значением. По ним же отличаем /// опечатку от каталога у `--icon` и `--screenshot`: те не начинаются с «-». static let flags = ["--help", "-h", "--verbose", "--json", "--icon", "--screenshot", "--update"] - static let flagPrefixes = ["--config=", "--provider=", "--calibrate=", "--lang="] + static let flagPrefixes = [ + "--config=", "--provider=", "--account=", "--calibrate=", "--lang=", + ] static func isKnown(_ argument: String) -> Bool { flags.contains(argument) || flagPrefixes.contains { argument.hasPrefix($0) } @@ -264,7 +267,17 @@ enum CLI { var snapshot: UsageSnapshot? var failure: Error? do { - snapshot = try await ResolvingProvider(config: config).fetch() + // Тем же путём, что и приложение: у аккаунта свой дом, свои + // транскрипты и своя запись Keychain, и `--json` обязан показывать + // ровно то же, что видно в строке меню. + let status = await AccountDirectory.shared.status( + of: AccountLocation(account: config.activeAccount, config: config) + ) + snapshot = try await ResolvingProvider.forAccount( + config.activeAccount, + config: config, + status: status + ).fetch() } catch { failure = error } @@ -272,7 +285,13 @@ enum CLI { // Окно официального источника, а без него — рассчитанное по конфигу. let window = snapshot?.window ?? WeekWindow(containing: now, config: config) - let local = LocalProvider(config: config) + let location = AccountLocation(account: config.activeAccount, config: config) + let local = LocalProvider( + config: config, + root: location.projectsRoot, + indexURL: location.indexURL, + stateURL: location.countingStateURL + ) // Локальный скан нужен и при официальном источнике: он даёт стоимость // и диагностику обхода, которых в ответе API нет. @@ -286,7 +305,10 @@ enum CLI { // Бюджет мог быть подобран автоматически по официальному проценту — // он лежит в кеше, а не в конфиге. - let budget = try? await local.budget(for: usage, override: Store.loadCache()?.weeklyBudget) + let budget = try? await local.budget( + for: usage, + override: Store.loadCache(from: location.cacheURL)?.weeklyBudget + ) var percent: Output.Percent? if let snapshot { diff --git a/Sources/ClaudeWeekApp/PanelModel.swift b/Sources/ClaudeWeekApp/PanelModel.swift index 3d32b88..ef00ad0 100644 --- a/Sources/ClaudeWeekApp/PanelModel.swift +++ b/Sources/ClaudeWeekApp/PanelModel.swift @@ -37,6 +37,18 @@ final class PanelModel { var snapshot: UsageSnapshot? var status: PanelStatus = .loading var isRefreshing = false + /// Чем подписан каждый аккаунт в подсказке переключателя — адрес из + /// `claude auth status`. Пусто, пока ответ не получен: подписи тогда + /// берутся порядковые. + var accountTitles: [UsageAccount: String] = [:] + /// Переключатель показываем, только когда второй дом заведён. Признак + /// вычисляет контроллер по файловой системе — держать под это настройку + /// значило бы завести вторую копию того же факта. + var showsAccountPicker = false + /// Почему смотреть не на что. Ставится там, где причина известна заранее + /// и точнее общего отказа: в аккаунт просто не вошли, и ждать данных + /// бессмысленно — серые заглушки читались бы как вечная загрузка. + var notice: String? /// Тикает раз в минуту, чтобы «до сброса» не врало. var now: Date = Date() @@ -198,6 +210,7 @@ final class PanelModel { /// свой, иначе демо-снимок «протухал» бы прямо на картинке для README. func apply(_ snapshot: UsageSnapshot, at moment: Date = Date()) { self.snapshot = snapshot + notice = nil now = moment let age = moment.timeIntervalSince(snapshot.fetchedAt) status = age > PanelModel.freshFor ? .stale : .ready diff --git a/Sources/ClaudeWeekApp/PopoverView.swift b/Sources/ClaudeWeekApp/PopoverView.swift index 0c2b895..839ba4a 100644 --- a/Sources/ClaudeWeekApp/PopoverView.swift +++ b/Sources/ClaudeWeekApp/PopoverView.swift @@ -17,6 +17,7 @@ struct PopoverView: View { @State private var showsSourceText = false var onRefresh: () -> Void = {} + var onAccountChange: (UsageAccount) -> Void = { _ in } var onSettings: () -> Void = {} var onQuit: () -> Void = {} @@ -65,6 +66,8 @@ struct PopoverView: View { } else { weekRow(snapshot) } + } else if let notice = model.notice { + noticeRow(notice) } else { placeholders } @@ -144,6 +147,14 @@ struct PopoverView: View { : palette.primaryText.color ) + if model.showsAccountPicker { + AccountPicker( + selection: model.config.activeAccount, + titles: model.accountTitles, + onSelect: onAccountChange + ) + } + Spacer(minLength: 4) if !showsSessionRow { @@ -297,6 +308,17 @@ struct PopoverView: View { } } + /// Причина известна — говорим её словами и на месте строк, а не рисуем + /// заглушки: серые полосы обещают данные, которых не будет. + private func noticeRow(_ text: String) -> some View { + Text(text) + .font(Theme.captionFont) + .foregroundStyle(palette.secondaryText.color) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + /// Первый запуск без кеша: те же семь строк, чтобы панель не прыгала, /// когда придут данные. private var placeholders: some View { diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index 1677f68..ceb4e35 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -896,8 +896,40 @@ private struct AccessSettings: View { private var s: L10n { model.config.strings } + /// Три разных положения, и путать их нельзя: каталога нет — аккаунт не + /// заводили, каталог есть и выхода нет — в него не вошли, вход есть — + /// показываем, кто именно. Советы человеку в первых двух случаях разные. + private func state(of row: AccountRow) -> String { + guard row.exists else { return s.pick("дом не заведён", "no config home") } + guard row.status.loggedIn else { return s.pick("не вошли", "not signed in") } + return row.status.title(fallback: s.pick("вошли", "signed in")) + } + var body: some View { Form { + Section(s.pick("Аккаунты", "Accounts")) { + ForEach(model.accountRows) { row in + LabeledContent(row.account.fallbackTitle(s.lang)) { + VStack(alignment: .trailing, spacing: 1) { + Text(state(of: row)) + .foregroundStyle(row.status.loggedIn ? .primary : .secondary) + .textSelection(.enabled) + Text(row.home) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .multilineTextAlignment(.trailing) + } + } + + Text(s.pick( + "У каждого аккаунта свой лимит, свой снимок и своя отсечка счёта — переключатель меняет их все разом.", + "Each account has its own limit, its own snapshot and its own counting cut-off — the switch changes all of them at once." + )) + .settingsHint() + } + Section(s.pick("Аккаунт и счёт", "Account and count")) { LabeledContent(s.pick("Сейчас в ключе", "Currently in the key")) { Text(model.account ?? s.pick("не читается", "cannot be read")) diff --git a/Sources/ClaudeWeekApp/SettingsWindow.swift b/Sources/ClaudeWeekApp/SettingsWindow.swift index a8f19a8..f650da3 100644 --- a/Sources/ClaudeWeekApp/SettingsWindow.swift +++ b/Sources/ClaudeWeekApp/SettingsWindow.swift @@ -42,6 +42,11 @@ final class SettingsModel { private(set) var account: String? /// С какого момента ведётся локальный счёт; nil — с начала окна. private(set) var countingSince: Date? + /// Оба аккаунта: где живёт дом каждого и кто в него вошёл. Заводится + /// здесь, а не в конфиге, потому что это не настройка, а наблюдение — + /// отвечает `claude auth status`, и переспросить его дешевле, чем держать + /// вторую копию, которая разойдётся после ближайшего входа. + private(set) var accountRows: [AccountRow] = [] /// Обновление идёт мимо конфига, и состояние у него общее с панелью и /// меню — сюда приходит тот же контроллер, а не его копия. @@ -77,24 +82,56 @@ final class SettingsModel { /// перерисовку вкладки незачем, а между показами плист мог снести /// `uninstall.sh` или рука. func refreshDiagnostics() { - let cache = Store.loadCache() + // Всё ниже — про аккаунт, показанный в панели: у второго и снимок, и + // отсечка счёта, и запись Keychain свои. + let location = AccountLocation(account: config.activeAccount, config: config) + let cache = Store.loadCache(from: location.cacheURL) pickedBudget = cache?.weeklyBudget // Момент из кеша мог остаться в прошлой неделе — проекция вперёд // целыми неделями даёт тот, который наступит следующим. officialReset = cache?.projectedWindow(at: Date(), config: config)?.end launchAtLogin = LoginItem.isEnabled - countingSince = Store.loadState().countFrom + countingSince = Store.loadState(from: location.countingStateURL).countFrom // В режиме «только локальная оценка» Keychain не читаем и здесь: // окно настроек — не повод нарушить обещание, данное на этой же // вкладке. Строка про аккаунт тогда просто не показывается. account = config.provider == .local ? nil - : (try? KeychainCredentials().load())?.accountMark + : (try? ResolvingProvider.credentials( + for: config.activeAccount, + config: config, + status: accountRows.first { $0.account == config.activeAccount }?.status ?? .signedOut + ).load())?.accountMark + reloadAccountRows() // Разрешение на уведомления снимают там же, где выдали, — в системных // настройках, мимо этого окна. Спрашиваем систему на каждый показ. notifications.refresh() } + /// Перечитывает, кто вошёл в каждый дом. Кеш ответов сбрасываем: окно + /// настроек открывают как раз затем, чтобы увидеть последствия входа, + /// и старый ответ здесь был бы ровно тем, за чем сюда не приходили. + private func reloadAccountRows() { + let config = config + Task { [weak self] in + await AccountDirectory.shared.forget() + var rows: [AccountRow] = [] + for account in UsageAccount.allCases { + let location = AccountLocation(account: account, config: config) + let status = location.exists + ? await AccountDirectory.shared.status(of: location) + : AccountStatus.signedOut + rows.append(AccountRow( + account: account, + home: location.home.path, + exists: location.exists, + status: status + )) + } + self?.accountRows = rows + } + } + /// Состояние возвращаем не из галочки, а из самого агента: включить его /// удаётся не всегда, и переключатель, оставшийся стоять после неудачи, /// обещал бы автозапуск, которого нет. @@ -202,6 +239,19 @@ final class SettingsModel { } } +/// Один аккаунт в таблице настроек: где его дом и кто в него вошёл. +struct AccountRow: Identifiable, Sendable { + let account: UsageAccount + let home: String + /// Каталог дома есть на диске. Отдельно от `status.loggedIn`: дома нет — + /// аккаунт не заводили вовсе, дом есть и выход — в него не вошли, и + /// советы человеку в этих двух случаях разные. + let exists: Bool + let status: AccountStatus + + var id: UsageAccount { account } +} + /// Обычное окно приложения: с рамкой, в Mission Control, закрывается по ⌘W. /// Панель для этого не годится — в ней невозможно ни выделить текст поля, /// ни оставить настройки открытыми, пока смотришь на строку меню. diff --git a/Sources/ClaudeWeekApp/StatusItemController.swift b/Sources/ClaudeWeekApp/StatusItemController.swift index e7eb033..0d9369c 100644 --- a/Sources/ClaudeWeekApp/StatusItemController.swift +++ b/Sources/ClaudeWeekApp/StatusItemController.swift @@ -12,6 +12,16 @@ final class StatusItemController: NSObject { /// у AppKit-частей нет окружения SwiftUI, но конфиг у них общий. private var s: L10n { model.strings } private var provider: any UsageProvider + /// Снимок каждого аккаунта живёт отдельно: переключение не должно на + /// мгновение подписывать цифры одного аккаунта именем другого. + private var snapshots: [UsageAccount: UsageSnapshot] = [:] + /// Ответ старого провайдера может прийти уже после переключения аккаунта. + /// Поколение делает такой ответ безвредным, не отменяя сетевой запрос. + private var refreshGeneration = 0 + /// Кто вошёл в каждый дом — по ответу `claude auth status`. Нужен и до + /// первого запроса: без организации второй аккаунт не найдёт свою запись + /// Keychain, а не найдя, не должен брать чужую. + private var accountStatuses: [UsageAccount: AccountStatus] = [:] private let update = UpdateController() private let notifications = NotificationController() @@ -38,7 +48,11 @@ final class StatusItemController: NSObject { init(config: Config = ConfigStore.load(), configURL: URL = ConfigStore.fileURL) { self.configURL = configURL model = PanelModel(config: config) - provider = ResolvingProvider(config: config) + provider = ResolvingProvider.forAccount( + config.activeAccount, + config: config, + status: .signedOut + ) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) super.init() @@ -60,6 +74,84 @@ final class StatusItemController: NSObject { } restoreFromCache() render() + // Кто вошёл в дома, спрашиваем до первого запроса: провайдер второго + // аккаунта без этого ответа не знает, какую запись Keychain брать. + loadAccountStatuses { [weak self] in self?.refresh() } + } + + /// Спрашивает Claude Code, кто вошёл в каждый дом, и пересобирает + /// провайдер под ответ. Дома, которого нет на диске, не касаемся: это и + /// есть признак «второго аккаунта не завели». + private func loadAccountStatuses(then act: (() -> Void)? = nil) { + let config = model.config + Task { [weak self] in + var found: [UsageAccount: AccountStatus] = [:] + for account in UsageAccount.allCases { + let location = AccountLocation(account: account, config: config) + guard location.exists else { continue } + found[account] = await AccountDirectory.shared.status(of: location) + } + guard let self else { return } + adopt(found) + act?() + } + } + + private func adopt(_ statuses: [UsageAccount: AccountStatus]) { + accountStatuses = statuses + model.showsAccountPicker = AccountLocation(account: .secondary, config: model.config).exists + model.accountTitles = UsageAccount.allCases.reduce(into: [:]) { titles, account in + let fallback = account.fallbackTitle(s.lang) + titles[account] = statuses[account]?.title(fallback: fallback) ?? fallback + } + provider = Self.makeProvider(config: model.config, status: statuses[model.config.activeAccount]) + render() + } + + private static func makeProvider(config: Config, status: AccountStatus?) -> any UsageProvider { + ResolvingProvider.forAccount( + config.activeAccount, + config: config, + status: status ?? .signedOut + ) + } + + /// Меняет аккаунт панели целиком. Прошлый снимок запоминаем, чтобы + /// обратный щелчок был мгновенным, а отсутствующие данные не подменяем + /// цифрами другого аккаунта даже на время нового запроса. + private func selectAccount(_ account: UsageAccount) { + let previous = model.config.activeAccount + guard account != previous else { return } + + if let snapshot = model.snapshot { + snapshots[previous] = snapshot + } + + refreshGeneration += 1 + model.isRefreshing = false + model.expandsWeek = false + model.showsModels = false + model.notice = nil + + var config = model.config + config.activeAccount = account + model.config = config + provider = Self.makeProvider(config: config, status: accountStatuses[account]) + + let now = Date() + if let snapshot = snapshots[account], snapshot.window.contains(now) { + model.apply(snapshot, at: now) + } else { + snapshots[account] = nil + model.snapshot = nil + model.status = .loading + model.now = now + } + + settings?.adopt(config) + queueConfigWrite(config) + render() + scheduleResetRefresh() refresh() } @@ -67,7 +159,9 @@ final class StatusItemController: NSObject { /// секунду-другую, и всё это время строка меню иначе висела бы пустой. /// Возраст подписывает сама модель — правило свежести одно на всех. private func restoreFromCache() { - guard let cache = Store.loadCache(), cache.isFresh(at: Date()) else { return } + let location = AccountLocation(account: model.config.activeAccount, config: model.config) + guard let cache = Store.loadCache(from: location.cacheURL), + cache.isFresh(at: Date()) else { return } let snapshot = cache.snapshot(config: model.config) model.apply(snapshot) // Кеш — такой же повод сказать про лимит, как свежий ответ: машину @@ -102,6 +196,7 @@ final class StatusItemController: NSObject { model: model, update: update, onRefresh: { [weak self] in self?.refresh() }, + onAccountChange: { [weak self] account in self?.selectAccount(account) }, onSettings: { [weak self] in self?.openSettings() }, onQuit: { NSApp.terminate(nil) } ) @@ -281,12 +376,17 @@ final class StatusItemController: NSObject { dropdown.pin(from: button) } - private func applyFromSettings(_ config: Config) { + private func applyFromSettings(_ incoming: Config) { + // Окно настроек аккаунтом не управляет. Пока оно открыто, переключатель + // панели мог сработать, и старая копия конфига не должна его вернуть. + var config = incoming + config.activeAccount = model.config.activeAccount let providerChanged = config.provider != model.config.provider || config.timeZone != model.config.timeZone || config.weekStart != model.config.weekStart || config.workHours != model.config.workHours || config.weeklyBudget != model.config.weeklyBudget + || config.accounts != model.config.accounts // Интервал живёт в таймере, а не в провайдере: не перезапустив таймер, // новый интервал ждал бы перезапуска приложения. let intervalChanged = config.refreshInterval != model.config.refreshInterval @@ -301,8 +401,26 @@ final class StatusItemController: NSObject { render() if notificationsEnabled { notifications.apply(config.notifications) } - // Ползунок шлёт по десятку изменений в секунду — на диск ходим - // с задержкой, применяя к панели каждое сразу. + queueConfigWrite(config) + + guard providerChanged else { + if intervalChanged { startTimers() } + return + } + refreshGeneration += 1 + model.isRefreshing = false + // Дом аккаунта мог поменяться — тогда и вошедший в него другой, и + // прежний ответ `auth status` про него больше ничего не значит. + loadAccountStatuses { [weak self] in + self?.startTimers() + self?.refresh() + } + } + + /// Ползунок шлёт по десятку изменений в секунду — на диск ходим с + /// задержкой, применяя к панели каждое сразу. Тем же путём сохраняется и + /// выбранный аккаунт, поэтому конкурирующих записей конфига нет. + private func queueConfigWrite(_ config: Config) { saveTask?.cancel() pendingConfigWrite = config saveTask = Task { [weak self] in @@ -310,14 +428,6 @@ final class StatusItemController: NSObject { guard !Task.isCancelled else { return } self?.flushConfigWrite() } - - guard providerChanged else { - if intervalChanged { startTimers() } - return - } - provider = ResolvingProvider(config: config) - startTimers() - refresh() } /// Пишет отложенную правку немедленно, если она ещё не легла на диск. @@ -344,13 +454,30 @@ final class StatusItemController: NSObject { /// отсечку он перечитывает на каждом обходе транскриптов, а вот показать /// панели старый снимок после сброса было бы прямым враньём. private func resetCounting() { - let account = (try? KeychainCredentials().load())?.accountMark + let active = model.config.activeAccount + let location = AccountLocation(account: active, config: model.config) + // Метку берём у того аккаунта, чей счёт сбрасываем, а не у первого: + // иначе отсечка второго подписалась бы чужой организацией и на + // следующем же обходе сочлась за смену аккаунта. + let mark = ResolvingProvider.credentials( + for: active, + config: model.config, + status: accountStatuses[active] ?? .signedOut + ) + let account = (try? mark.load())?.accountMark do { - try Store.resetCounting(at: Date(), account: account) + try Store.resetCounting( + at: Date(), + account: account, + stateURL: location.countingStateURL, + cacheURL: location.cacheURL, + alertsURL: location.alertsURL + ) } catch { Log.warn("не смог начать счёт заново: \(error)") return } + snapshots[active] = nil model.snapshot = nil render() refresh() @@ -362,7 +489,17 @@ final class StatusItemController: NSObject { var config = config config.provider = .official do { - let snapshot = try await ResolvingProvider(config: config).fetch() + // Проверяем тот аккаунт, что показан в панели: кнопка отвечает на + // «а ходит ли оно по моему ключу», и ответ про соседний аккаунт + // был бы хуже молчания. + let status = await AccountDirectory.shared.status( + of: AccountLocation(account: config.activeAccount, config: config) + ) + let snapshot = try await ResolvingProvider.forAccount( + config.activeAccount, + config: config, + status: status + ).fetch() // Метод статический, окружения у него нет — язык берём из того же // конфига, с которым проверяют доступ. let spent = Formatting.percent(snapshot.usedPercent) @@ -512,14 +649,34 @@ final class StatusItemController: NSObject { guard config != model.config else { return } Log.info("конфиг изменился, применяю") let notificationsEnabled = config.notifications.enabled && !model.config.notifications.enabled + let previousAccount = model.config.activeAccount + if let snapshot = model.snapshot { + snapshots[previousAccount] = snapshot + } model.config = config + if config.activeAccount != previousAccount { + let now = Date() + model.notice = nil + if let snapshot = snapshots[config.activeAccount], snapshot.window.contains(now) { + model.apply(snapshot, at: now) + } else { + model.snapshot = nil + model.status = .loading + model.now = now + } + } update.strings = config.strings if notificationsEnabled { notifications.apply(config.notifications) } settings?.adopt(config) applyAppearance() - provider = ResolvingProvider(config: config) - startTimers() - refresh() + refreshGeneration += 1 + model.isRefreshing = false + loadAccountStatuses { [weak self] in + self?.startTimers() + self?.render() + self?.scheduleResetRefresh() + self?.refresh() + } } // MARK: Обновление @@ -528,22 +685,56 @@ final class StatusItemController: NSObject { guard !model.isRefreshing else { return } model.isRefreshing = true let provider = provider + let account = model.config.activeAccount + let generation = refreshGeneration Task { [weak self] in + let result: Result do { - let snapshot = try await provider.fetch() - self?.model.apply(snapshot) - if let self { notifications.consider(snapshot, config: model.config) } + result = .success(try await provider.fetch()) } catch { + result = .failure(error) + } + + // Пока запрос шёл, могли переключить аккаунт или сменить конфиг. + // Отменять сетевой вызов ради этого не нужно — достаточно не + // принимать его ответ. + guard let self, + generation == refreshGeneration, + account == model.config.activeAccount else { return } + + switch result { + case .success(let snapshot): + snapshots[account] = snapshot + model.apply(snapshot) + notifications.consider(snapshot, config: model.config) + case .failure(let error): Log.warn("не смог обновить данные: \(error)") - self?.model.apply(error: error) + model.apply(error: error) + // Отказ у аккаунта, в который не входили, — не поломка, а + // ровно то, чего и следовало ждать. Общее «нет данных» тут + // прячет причину, которую человек может устранить за минуту. + if accountStatuses[account]?.loggedIn == false, model.snapshot == nil { + model.notice = signInNotice(for: account) + } } - self?.model.isRefreshing = false - self?.render() + model.isRefreshing = false + render() // Окно снимка могло смениться — переставляем будильник под него. // Отсюда же он ставится и в первый раз: `init` заканчивается // обновлением, а до его ответа окна может не быть вовсе. - self?.scheduleResetRefresh() + scheduleResetRefresh() } } + + /// Что делать, чтобы аккаунт заработал. Путь дома называем прямо: он же + /// уходит в `CLAUDE_CONFIG_DIR`, и подставлять его человеку по памяти — + /// лишний шаг к ошибке. + private func signInNotice(for account: UsageAccount) -> String { + let home = AccountLocation(account: account, config: model.config).home.path + return s.pick( + "В этот аккаунт ещё не вошли. Запустите Claude Code с CLAUDE_CONFIG_DIR=\(home) и выполните /login.", + "This account is not signed in yet. Start Claude Code with CLAUDE_CONFIG_DIR=\(home) and run /login." + ) + } } diff --git a/Sources/ClaudeWeekApp/Theme.swift b/Sources/ClaudeWeekApp/Theme.swift index 9c553d4..23c087d 100644 --- a/Sources/ClaudeWeekApp/Theme.swift +++ b/Sources/ClaudeWeekApp/Theme.swift @@ -261,6 +261,15 @@ enum Theme { /// Сноски — на два кегля мельче основного текста, как в меню. static let captionFontSize = menuFontSize - 2 + /// Насколько центр строки текста стоит выше её базовой линии — полусумма + /// подъёма и спуска шрифта меню (`descender` отрицательный). Нужна там, + /// где по базовой линии выравнивают не текст: картинка отдаёт за базовую + /// линию свою нижнюю кромку и без поправки встаёт над строкой. + static let textCenterAboveBaseline: CGFloat = { + let font = NSFont.menuFont(ofSize: 0) + return (font.ascender + font.descender) / 2 + }() + static let titleFont = Font.system(size: menuFontSize, weight: .semibold).monospacedDigit() static let dayFont = Font.system(size: menuFontSize).monospacedDigit() /// Подпись текущих суток: тот же кегль, но полужирная. Одного цвета ей не diff --git a/Sources/ClaudeWeekApp/main.swift b/Sources/ClaudeWeekApp/main.swift index 0bb55b3..1f288df 100644 --- a/Sources/ClaudeWeekApp/main.swift +++ b/Sources/ClaudeWeekApp/main.swift @@ -31,6 +31,14 @@ if let raw = arguments.first(where: { $0.hasPrefix("--provider=") })? config.provider = choice } +if let raw = arguments.first(where: { $0.hasPrefix("--account=") })? + .dropFirst("--account=".count) { + guard let choice = UsageAccount(rawValue: String(raw)) else { + exit(CLI.complain("неизвестный аккаунт «\(raw)»: бывают primary и secondary")) + } + config.activeAccount = choice +} + // Язык из аргумента перекрывает конфиг и на один запуск, и на снимки: так // английские картинки для документации снимаются, ничего не переключая руками. if let raw = arguments.first(where: { $0.hasPrefix("--lang=") })? diff --git a/Sources/ClaudeWeekCore/Accounts.swift b/Sources/ClaudeWeekCore/Accounts.swift new file mode 100644 index 0000000..6510ab3 --- /dev/null +++ b/Sources/ClaudeWeekCore/Accounts.swift @@ -0,0 +1,282 @@ +import Foundation + +/// Разложенные пути одного аккаунта: где его транскрипты и где файлы, которыми +/// ClaudeWeek ведёт по нему счёт. +/// +/// Состояние аккаунтов лежит порознь намеренно. Общий `cache.json` позволил бы +/// последнему обновлению одного аккаунта стереть снимок другого, а общий +/// `state.json` — отдать отсечку счёта не тому лимиту. Первый аккаунт при этом +/// сохраняет прежние имена файлов: у него уже есть накопленный счёт, и переезд +/// на новое имя обнулил бы его на ровном месте. +public struct AccountLocation: Sendable, Equatable { + public let account: UsageAccount + /// Конфиг-дом Claude Code — то же, что уходит в `CLAUDE_CONFIG_DIR`. + public let home: URL + + public init(account: UsageAccount, home: URL) { + self.account = account + self.home = home + } + + public init(account: UsageAccount, config: Config) { + self.init(account: account, home: AccountLocation.expand(config.accounts.home(account))) + } + + /// Каталог транскриптов — то, по чему считает `LocalProvider`. + public var projectsRoot: URL { + home.appendingPathComponent("projects", isDirectory: true) + } + + /// Запасной путь к кредам: на части установок Claude Code кладёт их файлом, + /// а не в Keychain. Файл лежит в самом доме, поэтому у каждого аккаунта он + /// свой без всяких настроек. + public var credentialsFileURL: URL { + home.appendingPathComponent(".credentials.json") + } + + /// Дом существует. Единственный признак, по которому решается, показывать + /// ли переключатель: заводить для этого отдельный флаг в конфиге значило бы + /// держать вторую копию того же факта, и она бы разошлась. + public var exists: Bool { + var isDirectory: ObjCBool = false + let found = FileManager.default.fileExists(atPath: home.path, isDirectory: &isDirectory) + return found && isDirectory.boolValue + } + + private var filePrefix: String { + switch account { + case .primary: "" + case .secondary: "secondary-" + } + } + + private func stateFile(_ name: String) -> URL { + Store.directory.appendingPathComponent(filePrefix + name) + } + + public var cacheURL: URL { stateFile("cache.json") } + public var alertsURL: URL { stateFile("alerts.json") } + public var countingStateURL: URL { stateFile("state.json") } + public var indexURL: URL { stateFile("index.json") } + + /// Дом, который Claude Code берёт сам, когда `CLAUDE_CONFIG_DIR` не задан. + /// Значим не только как путь: спрашивать про него надо именно без + /// переменной — см. `AccountDirectory.read(home:)`. + public static var defaultHome: URL { + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude", isDirectory: true) + } + + /// Дом стандартный — тот, что достаётся установке Claude Code из коробки. + public var isDefaultHome: Bool { + home.standardizedFileURL.path == AccountLocation.defaultHome.standardizedFileURL.path + } + + /// `~` в пути конфига раскрываем сами: путь пишет человек, а `URL` тильду + /// не понимает и молча заводит каталог с именем «~». + public static func expand(_ path: String) -> URL { + let trimmed = path.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return URL(fileURLWithPath: NSHomeDirectory()) } + return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath) + } +} + +/// Кто вошёл в этот конфиг-дом. Отвечает сам Claude Code — `claude auth status` +/// печатает JSON, и другого источника, знающего про дом, у нас нет: по записи +/// Keychain дом не определить, а по транскриптам аккаунт не различить вовсе. +public struct AccountStatus: Sendable, Equatable { + public let loggedIn: Bool + public let email: String? + public let organizationId: String? + public let subscriptionType: String? + + public init( + loggedIn: Bool, + email: String? = nil, + organizationId: String? = nil, + subscriptionType: String? = nil + ) { + self.loggedIn = loggedIn + self.email = email + self.organizationId = organizationId + self.subscriptionType = subscriptionType + } + + public static let signedOut = AccountStatus(loggedIn: false) + + /// Чем аккаунт подписывают в интерфейсе: адрес короче и понятнее UUID, + /// а при его отсутствии — хотя бы тариф. + public func title(fallback: String) -> String { + if let email, !email.isEmpty { return email } + if let subscriptionType, !subscriptionType.isEmpty { return subscriptionType } + return fallback + } +} + +/// Спрашивает Claude Code, кто вошёл в указанный дом. +/// +/// Запускается процесс, поэтому ответ кешируется: панель обновляется раз в +/// минуту, а вход и выход случаются раз в месяц. Кеш сбрасывает тот, кто знает +/// повод — окно настроек по кнопке проверки и переключение аккаунта. +public actor AccountDirectory { + public static let shared = AccountDirectory() + + private var cache: [URL: AccountStatus] = [:] + + public init() {} + + public func status(of location: AccountLocation) async -> AccountStatus { + if let known = cache[location.home] { return known } + let status = AccountDirectory.read(home: location.home) + cache[location.home] = status + return status + } + + public func forget() { + cache.removeAll() + } + + /// Ищем исполняемый файл там же, куда его кладёт установщик Claude Code, и + /// только потом в PATH: у приложения из Dock окружение обрезанное, и на + /// PATH там рассчитывать нельзя. + static func executable() -> URL? { + var candidates = [ + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".local/bin/claude"), + URL(fileURLWithPath: "/opt/homebrew/bin/claude"), + URL(fileURLWithPath: "/usr/local/bin/claude"), + ] + if let path = ProcessInfo.processInfo.environment["PATH"] { + candidates += path.split(separator: ":").map { + URL(fileURLWithPath: String($0)).appendingPathComponent("claude") + } + } + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + static func read(home: URL) -> AccountStatus { + guard let executable = executable() else { + Log.debug("не нашёл исполняемый файл claude — состояние аккаунта неизвестно") + return .signedOut + } + + let process = Process() + process.executableURL = executable + process.arguments = ["auth", "status"] + var environment = ProcessInfo.processInfo.environment + // Переменную ставим только для нестандартного дома, и это не + // придирка. `CLAUDE_CONFIG_DIR=~/.claude` — тот же самый путь, что + // берётся по умолчанию, — даёт `loggedIn: false`, тогда как без + // переменной тот же дом отвечает `true` (проверено на 2.1.251). + // Значит ячейку учётных данных выбирает не путь сам по себе, а факт + // установки переменной. Ставя её всегда, мы объявляли бы невошедшим + // аккаунт, в котором человек сидит прямо сейчас. + // + // Унаследованное значение при этом обязательно убрать: приложение + // могли запустить из оболочки, где оно уже выставлено, — например из + // сеанса под вторым аккаунтом. + if AccountLocation(account: .primary, home: home).isDefaultHome { + environment.removeValue(forKey: "CLAUDE_CONFIG_DIR") + } else { + environment["CLAUDE_CONFIG_DIR"] = home.path + } + process.environment = environment + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + Log.debug("claude auth status не запустился: \(error.localizedDescription)") + return .signedOut + } + + // Панель обновляется в фоне; повисший на чём-нибудь процесс не должен + // забирать её с собой. + let timeout = DispatchWorkItem { + if process.isRunning { process.terminate() } + } + DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: timeout) + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + timeout.cancel() + + // Код возврата не проверяем: у невошедшего аккаунта он единица, но + // ответ при этом осмысленный и разбирается тем же путём. Судим по + // содержимому, а не по коду. + return parse(output) + } + + /// Форма ответа — не публичный контракт, поэтому недостающие поля здесь + /// норма, а не ошибка: без адреса аккаунт подпишется тарифом, без тарифа — + /// номером, и переключатель продолжит работать. + public static func parse(_ data: Data) -> AccountStatus { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .signedOut + } + guard root["loggedIn"] as? Bool == true else { return .signedOut } + return AccountStatus( + loggedIn: true, + email: root["email"] as? String, + organizationId: root["orgId"] as? String, + subscriptionType: root["subscriptionType"] as? String + ) + } +} + +public extension ResolvingProvider { + /// Провайдер для одного аккаунта: свои транскрипты, свои файлы счёта, свои + /// креды. + /// + /// Аккаунту без входа даём `SignedOutCredentials`, а не `nil`: `nil` здесь + /// значит «Keychain по умолчанию», и второй дом молча показал бы цифры + /// первого. + static func forAccount( + _ account: UsageAccount, + config: Config, + status: AccountStatus + ) -> ResolvingProvider { + let location = AccountLocation(account: account, config: config) + let credentials = credentials(for: account, config: config, status: status) + + return ResolvingProvider( + config: config, + credentials: credentials, + cacheURL: location.cacheURL, + localRoot: location.projectsRoot, + indexURL: location.indexURL, + stateURL: location.countingStateURL, + alertsURL: location.alertsURL + ) + } + + /// Откуда берётся токен аккаунта. Отдельно от фабрики, потому что тем же + /// ответом подписывается отсечка счёта: спрашивать «чей это аккаунт» надо + /// у того же источника, из которого потом читают токен. + /// + /// Оба аккаунта ходят одной дорогой — за записью, названной по их дому. + /// Стандартному дому та же формула отдаёт имя, которое Claude Code завёл + /// при установке, так что первому аккаунту отдельная ветка не нужна; зато + /// первый с переставленным домом перестал читать чужую запись. + static func credentials( + for account: UsageAccount, + config: Config, + status: AccountStatus + ) -> CredentialsSource { + let location = AccountLocation(account: account, config: config) + // Второй аккаунт без входа не читает ничего. Его дом можно вписать в + // конфиг руками, в том числе тот же, что у первого, — и тогда он + // показал бы чужие цифры под своим именем. Первого такой проверкой не + // ограничиваем: без найденного `claude` состояние домов неизвестно, а + // гасить из-за этого панель, работавшую до всякой второй учётки, — хуже. + if account == .secondary, !status.loggedIn { return SignedOutCredentials() } + + return HomeKeychainCredentials( + home: location.home, + fileURL: location.credentialsFileURL, + // Принадлежность приписываем снаружи: в записи Keychain её с + // 2.1.251 нет, а без неё отсечка счёта не заметит смены аккаунта. + organizationUuid: status.organizationId + ) + } +} diff --git a/Sources/ClaudeWeekCore/Config.swift b/Sources/ClaudeWeekCore/Config.swift index bbfc7a1..cf333be 100644 --- a/Sources/ClaudeWeekCore/Config.swift +++ b/Sources/ClaudeWeekCore/Config.swift @@ -6,6 +6,57 @@ public enum ProviderPreference: String, Codable, Sendable, CaseIterable { case auto } +/// Какой из двух аккаунтов Claude сейчас показан в панели. Это не выбор +/// источника внутри аккаунта: `provider` по-прежнему решает, брать процент у +/// сервера или считать локально. Здесь переключаются два независимых входа в +/// Claude Code — у каждого свой конфиг-дом, своя запись Keychain, свои +/// транскрипты и свой недельный лимит. +public enum UsageAccount: String, Codable, Sendable, CaseIterable, Hashable { + case primary + case secondary + + /// Подпись, когда живого адреса аккаунта ещё нет: до входа спрашивать + /// `claude auth status` не о чем, а назвать аккаунт в подсказке надо. + public func fallbackTitle(_ lang: Lang) -> String { + let l = L10n(lang) + switch self { + case .primary: return l.pick("Первый аккаунт", "First account") + case .secondary: return l.pick("Второй аккаунт", "Second account") + } + } +} + +/// Где живут аккаунты Claude Code. +/// +/// Конфиг-дом — единственное, что про аккаунт нужно знать: из него берутся и +/// транскрипты, и запись Keychain с токеном, и ответ `claude auth status`. +/// Имя записи Keychain здесь не хранится намеренно — оно выводилось бы вторым +/// экземпляром той же правды и разошлось бы с домом молча; как его находят, +/// описано в `KeychainCredentials.resolveService`. +public struct AccountsConfig: Codable, Sendable, Equatable { + /// Дом первого аккаунта; у установки Claude Code по умолчанию `~/.claude`. + public var primaryHome: String + /// Дом второго — то же значение, что уходит в `CLAUDE_CONFIG_DIR`. + /// Несуществующий каталог значит «второго аккаунта нет»: переключатель + /// тогда не показывается вовсе, и настраивать его для этого не нужно. + public var secondaryHome: String + + public init( + primaryHome: String = "~/.claude", + secondaryHome: String = "~/.claude-b" + ) { + self.primaryHome = primaryHome + self.secondaryHome = secondaryHome + } + + public func home(_ account: UsageAccount) -> String { + switch account { + case .primary: primaryHome + case .secondary: secondaryHome + } + } +} + public enum MenuBarStyle: String, Codable, Sendable, CaseIterable { case percent case compact @@ -318,6 +369,10 @@ public struct Config: Codable, Sendable, Equatable { public var timeZone: String /// Секунды между опросами, не меньше `minimumRefreshInterval`. public var refreshInterval: TimeInterval + /// Аккаунт, выбранный кнопками в заголовке панели. + public var activeAccount: UsageAccount + /// Дома аккаунтов Claude Code. + public var accounts: AccountsConfig public var provider: ProviderPreference /// Часы, между которыми растёт план. Вне их он стоит: недельный лимит /// раскладывается по рабочему времени, а не по астрономическому. @@ -360,6 +415,8 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: 0, timeZone: "", refreshInterval: 300, + activeAccount: .primary, + accounts: AccountsConfig(), provider: .auto, // Не `WorkHours.default` — та (11–24) остаётся запасным значением для // сломанных чисел в `WorkHours.validated()` и опорной точкой во всех @@ -383,6 +440,8 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: Int, timeZone: String, refreshInterval: TimeInterval, + activeAccount: UsageAccount = .primary, + accounts: AccountsConfig = AccountsConfig(), provider: ProviderPreference, workHours: WorkHours = WorkHours.default, menuBarStyle: MenuBarStyle, @@ -400,6 +459,8 @@ public struct Config: Codable, Sendable, Equatable { self.resetMinute = resetMinute self.timeZone = timeZone self.refreshInterval = refreshInterval + self.activeAccount = activeAccount + self.accounts = accounts self.provider = provider self.workHours = workHours self.menuBarStyle = menuBarStyle @@ -424,6 +485,13 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: try c.decodeIfPresent(Int.self, forKey: .resetMinute) ?? d.resetMinute, timeZone: try c.decodeIfPresent(String.self, forKey: .timeZone) ?? d.timeZone, refreshInterval: try c.decodeIfPresent(TimeInterval.self, forKey: .refreshInterval) ?? d.refreshInterval, + // Не `decodeIfPresent`: тот бросает на незнакомом значении, а + // бросок здесь стоит всего конфига — разбор файла обрывается, и + // человек молча теряет все свои настройки разом. Повод не + // выдуманный: сборка с Codex писала сюда `claude`, и такие файлы + // лежат на дисках. Незнакомый аккаунт — это первый. + activeAccount: (try? c.decode(UsageAccount.self, forKey: .activeAccount)) ?? d.activeAccount, + accounts: (try? c.decode(AccountsConfig.self, forKey: .accounts)) ?? d.accounts, provider: try c.decodeIfPresent(ProviderPreference.self, forKey: .provider) ?? d.provider, workHours: try c.decodeIfPresent(WorkHours.self, forKey: .workHours) ?? d.workHours, menuBarStyle: try c.decodeIfPresent(MenuBarStyle.self, forKey: .menuBarStyle) ?? d.menuBarStyle, diff --git a/Sources/ClaudeWeekCore/Keychain.swift b/Sources/ClaudeWeekCore/Keychain.swift index e1cf29f..dffbe6d 100644 --- a/Sources/ClaudeWeekCore/Keychain.swift +++ b/Sources/ClaudeWeekCore/Keychain.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Security @@ -5,22 +6,27 @@ import Security /// токен, которым уже пользуется Claude Code на этой машине, и потому видит /// ровно тот аккаунт, что показывает `/usage`. /// -/// Форма записи (проверено на 2.1.221, 2026-08-04): +/// Форма записи (проверено на 2.1.251, 2026-08-31): /// ``` -/// { "claudeAiOauth": { "accessToken": "sk-ant-oat01-…", "expiresAt": 1786…, -/// "refreshToken": …, "scopes": […], -/// "subscriptionType": … }, -/// "organizationUuid": … } +/// { "claudeAiOauth": { "accessToken": "sk-ant-oat01-…", "expiresAt": 1788…, +/// "refreshToken": …, "refreshTokenExpiresAt": …, +/// "scopes": […], "subscriptionType": …, +/// "rateLimitTier": … } } /// ``` +/// На 2.1.221 рядом с `claudeAiOauth` лежал ещё и `organizationUuid`; в 2.1.251 +/// его нет ни на одном уровне. Кому принадлежит токен, запись больше не +/// говорит — это знает только `claude auth status`. public struct OAuthCredentials: Sendable, Equatable { public let accessToken: String /// Момент истечения; nil — поля не было. public let expiresAt: Date? public let subscriptionType: String? - /// Организация, которой принадлежит аккаунт. Единственное в записи, что - /// переживает обновление токена: сам токен меняется раз в час, и отличить - /// по нему рабочий вход от домашнего нельзя, а этот UUID держится, пока - /// не вошли другим аккаунтом. + /// Организация, которой принадлежит аккаунт. Отличить аккаунт по самому + /// токену нельзя — он меняется раз в час, — а этот UUID держится, пока не + /// вошли другим аккаунтом. + /// + /// В записи Keychain его с 2.1.251 нет, поэтому приходит он обычно + /// снаружи, от `claude auth status`, — см. `attributed(to:)`. public let organizationUuid: String? public init( @@ -50,12 +56,37 @@ public struct OAuthCredentials: Sendable, Equatable { guard let organizationUuid, !organizationUuid.isEmpty else { return nil } return "\(organizationUuid.prefix(8))·\(subscriptionType ?? "—")" } + + /// Те же креды, но с проставленной принадлежностью. Нужно там, где + /// организацию назвал не Keychain, а `claude auth status`. + public func attributed(to organizationUuid: String) -> OAuthCredentials { + OAuthCredentials( + accessToken: accessToken, + expiresAt: expiresAt, + subscriptionType: subscriptionType, + organizationUuid: organizationUuid + ) + } } public protocol CredentialsSource: Sendable { func load() throws -> OAuthCredentials } +/// Кредов у аккаунта нет — в него ещё не вошли. +/// +/// Нужен именно отдельный тип, а не `nil`: там, где источник кредов +/// необязателен, `nil` означает «возьми Keychain по умолчанию», то есть запись +/// первого аккаунта. Для второго дома это тихо подставило бы чужой токен и +/// показало чужие цифры под его именем. +public struct SignedOutCredentials: CredentialsSource { + public init() {} + + public func load() throws -> OAuthCredentials { + throw UsageError.unauthorized + } +} + /// Читает запись Keychain, куда Claude Code кладёт свои OAuth-креды. /// /// Обновлением токена ClaudeWeek не занимается принципиально: refresh-цикл — @@ -231,3 +262,73 @@ public struct KeychainCredentials: CredentialsSource { return Date(timeIntervalSince1970: raw > 1e11 ? raw / 1000 : raw) } } + +/// Креды конфиг-дома, чья запись Keychain названа не по умолчанию. +/// +/// Аккаунты Claude Code различает конфиг-дом, и токен каждого дома лежит в +/// своей записи. Имя записи Claude Code собирает из пути дома: +/// `Claude Code-credentials-<первые 8 hex sha256 пути>`, а стандартному дому +/// оставляет имя без приставки (наблюдалось на 2.1.251: +/// `sha256("/Users/greem4/.claude-b")` начинается на `034e8c6f`, и запись +/// второго дома называется `Claude Code-credentials-034e8c6f`). +/// +/// Раньше запись искали иначе — перебором всех записей Claude Code со +/// сверкой `organizationUuid` внутри каждой. Способ умер вместе с полем: +/// в 2.1.251 запись состоит из одного `claudeAiOauth`, и `organizationUuid` +/// в ней нет ни на одном уровне. Сверять стало нечем, и сопоставление по +/// организации молча переставало находить хоть что-нибудь — второй аккаунт +/// показывал «в этот аккаунт ещё не вошли», хотя вход был. +/// +/// Имя по формуле — догадка о чужой внутренней детали, и это осознанная +/// цена. Опасность, из-за которой формулу отвергали раньше, здесь не +/// возникает: имя выводится из **дома**, а не из аккаунта, поэтому смена +/// схемы именования в CLI даёт «записи нет» — честное «нет доступа», — а не +/// чужой токен под нашим именем. +public struct HomeKeychainCredentials: CredentialsSource { + /// Конфиг-дом аккаунта — то же, что уходит в `CLAUDE_CONFIG_DIR`. + private let home: URL + /// Запасной путь: креды файлом в самом доме аккаунта. + private let fileURL: URL + /// Чей это дом — организация из `claude auth status`. + /// + /// Приписывается снаружи, потому что в самой записи принадлежности + /// больше нет, а `accountMark` без неё не собрать: отсечка счёта + /// перестала бы замечать вход другим аккаунтом. + private let organizationUuid: String? + + public init(home: URL, fileURL: URL, organizationUuid: String? = nil) { + self.home = home + self.fileURL = fileURL + self.organizationUuid = organizationUuid + } + + public func load() throws -> OAuthCredentials { + let credentials = try KeychainCredentials( + service: HomeKeychainCredentials.service(for: home), + fileURL: fileURL + ).load() + // Своё в записи всегда важнее приписанного: вернётся поле в запись — + // возьмём его, и приписывать станет нечего. + guard credentials.organizationUuid == nil, let organizationUuid else { return credentials } + return credentials.attributed(to: organizationUuid) + } + + /// Имя записи Keychain, в которой Claude Code держит токен этого дома. + /// + /// Путь берётся приведённым: `~/.claude-b` и `~/.claude-b/` — один дом, и + /// хвостовой слеш в конфиге не должен уводить нас к несуществующей записи. + public static func service(for home: URL) -> String { + let base = KeychainCredentials.defaultService + let path = home.standardizedFileURL.path + guard path != AccountLocation.defaultHome.standardizedFileURL.path else { return base } + return "\(base)-\(digest(path))" + } + + /// Первые четыре байта sha256 пути, шестнадцатеричной строкой. + static func digest(_ path: String) -> String { + SHA256.hash(data: Data(path.utf8)) + .prefix(4) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Sources/ClaudeWeekTests/AccountTests.swift b/Sources/ClaudeWeekTests/AccountTests.swift new file mode 100644 index 0000000..9058261 --- /dev/null +++ b/Sources/ClaudeWeekTests/AccountTests.swift @@ -0,0 +1,216 @@ +import Foundation +import ClaudeWeekCore + +func runAccountTests(_ t: Harness) { + t.suite("пути аккаунта") { + var config = Config.default + config.accounts = AccountsConfig(primaryHome: "~/.claude", secondaryHome: "~/.claude-b") + + let primary = AccountLocation(account: .primary, config: config) + let secondary = AccountLocation(account: .secondary, config: config) + + t.equal(primary.home.path, NSHomeDirectory() + "/.claude", "тильда первого дома раскрыта") + t.equal(secondary.home.path, NSHomeDirectory() + "/.claude-b", "тильда второго дома раскрыта") + t.equal( + primary.projectsRoot.path, + NSHomeDirectory() + "/.claude/projects", + "транскрипты первого аккаунта" + ) + t.equal( + secondary.projectsRoot.path, + NSHomeDirectory() + "/.claude-b/projects", + "транскрипты второго аккаунта берутся из его дома, а не из общего" + ) + + // Первый аккаунт обязан сохранить прежние имена файлов: у работающей + // установки в них лежит накопленный счёт, и переезд обнулил бы его. + t.equal(primary.cacheURL.lastPathComponent, "cache.json", "кеш первого аккаунта не переименован") + t.equal(primary.alertsURL.lastPathComponent, "alerts.json", "журнал первого не переименован") + t.equal(primary.countingStateURL.lastPathComponent, "state.json", "счёт первого не переименован") + t.equal(primary.indexURL.lastPathComponent, "index.json", "индекс первого не переименован") + + t.equal(secondary.cacheURL.lastPathComponent, "secondary-cache.json", "кеш второго отдельный") + t.equal(secondary.alertsURL.lastPathComponent, "secondary-alerts.json", "журнал второго отдельный") + t.equal(secondary.countingStateURL.lastPathComponent, "secondary-state.json", "счёт второго отдельный") + t.equal(secondary.indexURL.lastPathComponent, "secondary-index.json", "индекс второго отдельный") + + t.check(primary.cacheURL != secondary.cacheURL, "снимки аккаунтов не в одном файле") + t.check( + primary.credentialsFileURL != secondary.credentialsFileURL, + "запасной файл кредов у каждого аккаунта свой" + ) + + // Стандартный дом надо уметь отличать: про него `claude auth status` + // спрашивают БЕЗ CLAUDE_CONFIG_DIR. С переменной, выставленной в тот + // же самый путь, он отвечает «не вошли» — ячейку учётных данных + // выбирает не путь, а факт установки переменной (проверено на 2.1.251). + t.check(primary.isDefaultHome, "~/.claude опознан как стандартный дом") + t.check(!secondary.isDefaultHome, "~/.claude-b стандартным не считается") + t.equal( + AccountLocation.defaultHome.path, + NSHomeDirectory() + "/.claude", + "стандартный дом — тот, что Claude Code берёт сам" + ) + // Путь с хвостовым слешем — тот же дом: сравнение идёт по + // приведённому виду, иначе запись в конфиге решала бы, вошли мы или нет. + let slashed = AccountLocation(account: .primary, home: AccountLocation.expand("~/.claude/")) + t.check(slashed.isDefaultHome, "хвостовой слеш не делает дом нестандартным") + + // Пустой путь не должен превращаться в каталог с именем «~» или в + // корень: такой дом просто не существует, и переключатель не покажем. + let empty = AccountLocation(account: .secondary, home: AccountLocation.expand(" ")) + t.equal(empty.home.path, NSHomeDirectory(), "пустой путь сводится к домашнему каталогу") + } + + t.suite("состояние аккаунта") { + let signedIn = Data(""" + {"loggedIn": true, "authMethod": "claude.ai", "email": "kto@to.ru", + "orgId": "2a420a73-2872-4640-a99e-ed027d474338", "subscriptionType": "pro"} + """.utf8) + let status = AccountDirectory.parse(signedIn) + t.check(status.loggedIn, "вход распознан") + t.equal(status.email, "kto@to.ru", "адрес разобран") + t.equal(status.organizationId, "2a420a73-2872-4640-a99e-ed027d474338", "организация разобрана") + t.equal(status.title(fallback: "Второй"), "kto@to.ru", "подписью служит адрес") + + let signedOut = AccountDirectory.parse(Data(#"{"loggedIn": false, "authMethod": "none"}"#.utf8)) + t.check(!signedOut.loggedIn, "выход распознан") + t.equal(signedOut.organizationId, nil, "у вышедшего нет организации") + t.equal(signedOut.title(fallback: "Второй"), "Второй", "без адреса подпись порядковая") + + // Мусор вместо ответа не должен читаться как удачный вход: иначе + // второй аккаунт полез бы в Keychain, ничего не зная о своём доме. + t.check(!AccountDirectory.parse(Data("не json".utf8)).loggedIn, "мусор — не вход") + t.check(!AccountDirectory.parse(Data()).loggedIn, "пустой ответ — не вход") + + // Тариф выручает, когда адреса в ответе не оказалось. + let noEmail = AccountDirectory.parse(Data(#"{"loggedIn": true, "subscriptionType": "max"}"#.utf8)) + t.equal(noEmail.title(fallback: "Второй"), "max", "без адреса подписью служит тариф") + } + + t.suite("имя записи Keychain") { + // Вектор снят с живой машины на Claude Code 2.1.251: дом + // /Users/greem4/.claude-b держит токен в записи + // «Claude Code-credentials-034e8c6f». Тест сторожит ровно это + // соответствие — разойдётся наша формула с тем, как называет записи + // CLI, и мы узнаем здесь, а не по надписи «в этот аккаунт ещё не + // вошли» у аккаунта, в который человек вошёл. + t.equal( + HomeKeychainCredentials.service(for: URL(fileURLWithPath: "/Users/greem4/.claude-b")), + "Claude Code-credentials-034e8c6f", + "имя записи выведено из пути дома" + ) + t.equal( + HomeKeychainCredentials.service(for: AccountLocation.defaultHome), + KeychainCredentials.defaultService, + "стандартному дому достаётся имя, которое Claude Code завёл при установке" + ) + + let second = AccountLocation.expand("~/.claude-b") + t.equal( + HomeKeychainCredentials.service(for: AccountLocation.expand("~/.claude-b/")), + HomeKeychainCredentials.service(for: second), + "хвостовой слеш не уводит к несуществующей записи" + ) + t.check( + HomeKeychainCredentials.service(for: second) + != HomeKeychainCredentials.service(for: AccountLocation.expand("~/.claude-c")), + "разным домам достаются разные записи" + ) + } + + t.suite("метка аккаунта") { + // Организация в записи Keychain больше не лежит, и метку собирает то, + // что сказал `claude auth status`. Форма при этом обязана остаться + // прежней: в state.json работающих установок записано «2a420a73·pro», + // и смена источника не должна сойти за смену аккаунта — иначе счёт + // обнулился бы у всех разом на первом же обновлении. + let bare = OAuthCredentials(accessToken: "t", expiresAt: nil, subscriptionType: "pro") + t.equal(bare.accountMark, nil, "без организации метки нет") + + let marked = bare.attributed(to: "2a420a73-2872-4640-a99e-ed027d474338") + t.equal(marked.accountMark, "2a420a73·pro", "метка собрана из организации и тарифа") + t.equal(marked.accessToken, "t", "токен пережил приписывание") + } + + t.suite("креды аккаунта") { + var config = Config.default + config.accounts = AccountsConfig(primaryHome: "~/.claude", secondaryHome: "~/.claude-b") + + // Второй аккаунт без входа обязан отдавать «нет авторизации», а не + // молча читать запись первого: иначе панель показала бы чужие цифры + // под его именем — худшее из возможных поведений. + let signedOut = ResolvingProvider.credentials( + for: .secondary, + config: config, + status: .signedOut + ) + var refused = false + do { + _ = try signedOut.load() + } catch { + refused = true + } + t.check(refused, "невошедший второй аккаунт не отдаёт чужой токен") + t.check(signedOut is SignedOutCredentials, "и делает это явным типом, а не nil") + + let signedIn = ResolvingProvider.credentials( + for: .secondary, + config: config, + status: AccountStatus(loggedIn: true, organizationId: "org-2") + ) + t.check(signedIn is HomeKeychainCredentials, "вошедший второй читает запись своего дома") + t.check( + HomeKeychainCredentials.service(for: AccountLocation(account: .secondary, config: config).home) + != KeychainCredentials.defaultService, + "и это не запись первого аккаунта" + ) + + let primary = ResolvingProvider.credentials( + for: .primary, + config: config, + status: .signedOut + ) + t.check(primary is HomeKeychainCredentials, "первый аккаунт читает запись своего дома") + t.equal( + HomeKeychainCredentials.service(for: AccountLocation(account: .primary, config: config).home), + KeychainCredentials.defaultService, + "и у стандартного дома это прежнее имя записи" + ) + } + + t.suite("аккаунт в конфиге") { + // Конфиг без новых ключей — обычный случай при обновлении: аккаунт + // должен оказаться первым, а дома — стандартными. + let old = Data(#"{"resetHour": 16, "provider": "auto"}"#.utf8) + let decoded = try JSONDecoder().decode(Config.self, from: old) + t.equal(decoded.activeAccount, .primary, "старый конфиг открывается на первом аккаунте") + t.equal(decoded.accounts.primaryHome, "~/.claude", "дом первого по умолчанию") + t.equal(decoded.accounts.secondaryHome, "~/.claude-b", "дом второго по умолчанию") + + // Сборка с Codex писала сюда `claude`, и такие файлы лежат на дисках. + // Незнакомое значение обязано стоить только самого поля: бросок здесь + // обрывает разбор всего файла, и человек молча теряет все настройки. + let fromCodexBuild = Data(#"{"activeAccount": "claude", "resetHour": 9, "weeklyBudget": 42}"#.utf8) + let survived = try JSONDecoder().decode(Config.self, from: fromCodexBuild) + t.equal(survived.activeAccount, .primary, "незнакомый аккаунт сводится к первому") + t.equal(survived.resetHour, 9, "соседние настройки пережили незнакомое значение") + t.close(survived.weeklyBudget, 42, "бюджет пережил незнакомое значение") + + let brokenHomes = Data(#"{"accounts": "не объект", "resetHour": 7}"#.utf8) + let repaired = try JSONDecoder().decode(Config.self, from: brokenHomes) + t.equal(repaired.accounts.primaryHome, "~/.claude", "битые дома сводятся к умолчанию") + t.equal(repaired.resetHour, 7, "и не уносят с собой остальной конфиг") + + // И обратно: выбранный аккаунт обязан пережить перезапуск. + var config = Config.default + config.activeAccount = .secondary + config.accounts = AccountsConfig(primaryHome: "~/.claude", secondaryHome: "~/.work") + let roundTrip = try JSONDecoder().decode(Config.self, from: JSONEncoder().encode(config)) + t.equal(roundTrip.activeAccount, .secondary, "выбранный аккаунт пережил запись и чтение") + t.equal(roundTrip.accounts.secondaryHome, "~/.work", "дом второго пережил запись и чтение") + + t.equal(config.accounts.home(.primary), "~/.claude", "дом первого берётся по имени аккаунта") + t.equal(config.accounts.home(.secondary), "~/.work", "дом второго берётся по имени аккаунта") + } +} diff --git a/Sources/ClaudeWeekTests/main.swift b/Sources/ClaudeWeekTests/main.swift index 3f549b8..c475a1c 100644 --- a/Sources/ClaudeWeekTests/main.swift +++ b/Sources/ClaudeWeekTests/main.swift @@ -4,6 +4,7 @@ import ClaudeWeekCore let t = Harness() runConfigTests(t) +runAccountTests(t) runWeekWindowTests(t) runPlanTests(t) runFormattingTests(t) diff --git a/docs/API.md b/docs/API.md index 4cfff0c..574a0c5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -62,16 +62,22 @@ found or is malformed`; заголовки `anthropic-beta: oauth-2025-04-20` и "rateLimitTier": "…", "scopes": ["…"], "subscriptionType": "max" - }, - "organizationUuid": "…" + } } ``` `expiresAt` — миллисекунды (значения меньше `1e11` трактуем как секунды). Токен живёт около часа. -`organizationUuid` читается тоже — из него и `subscriptionType` складывается -**метка аккаунта** (`OAuthCredentials.accountMark`, вида `7f3a1b2c·max`). Токен +**Кому принадлежит токен, запись больше не говорит.** На 2.1.221 рядом с +`claudeAiOauth` лежал `organizationUuid`; на 2.1.251 его нет ни на одном +уровне записи. Поле читается по-прежнему — вернётся, возьмём его, — но +принадлежность приписывает снаружи `HomeKeychainCredentials`: организацию +называет `claude auth status` для этого конфиг-дома, тариф берётся из самой +записи. + +Из них и складывается **метка аккаунта** (`OAuthCredentials.accountMark`, вида +`7f3a1b2c·max`). Токен меняется раз в час, а она держится, пока не вошли другим аккаунтом, — и это единственное, чем смена аккаунта замечается вообще: в транскриптах `~/.claude/projects` маркера аккаунта нет, а сервер отвечает по токену и о @@ -79,6 +85,15 @@ found or is malformed`; заголовки `anthropic-beta: oauth-2025-04-20` и ([USAGE.md](USAGE.md#смена-аккаунта)). Метка в `state.json` хранится целиком, сам UUID — нет: сравнивать хватает её. +**Имя записи задаёт конфиг-дом.** Стандартному дому достаётся +`Claude Code-credentials`, любому другому — то же имя с приставкой из первых +восьми hex-символов sha256 пути дома: `/Users/…/.claude-b` → +`Claude Code-credentials-034e8c6f` (проверено на 2.1.251). Формула сторожится +тестом с этим самым вектором. Она — догадка о чужой внутренней детали, и цена +её названа честно: сменится схема именования в CLI — записи не найдётся, и +аккаунт покажет «нет доступа». Чужой токен под своим именем при этом не +всплывёт: имя выводится из дома, а не из аккаунта. + **Обновлением токена ClaudeWeek не занимается.** Refresh-цикл — дело Claude Code; два процесса, наперегонки меняющие одну запись Keychain, теряют токен. Мы перечитываем запись перед каждым запросом и на `401` уходим на локальную diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8fe804..af71eb3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,6 +79,15 @@ SwiftUI` — значит, расчёт просочился в UI или нао `shapeIsEstimate` (разбивка). Это разные вещи, и смешивать их нельзя. - **«Что лежит в кеше»** → `CachedUsage`: проценты, границы окна, подобранный `weeklyBudget`, `officialWindowEnd` и последняя сессия. +- **«Откуда программа знает про второй аккаунт»** → `Accounts.swift`: + `AccountLocation` раскладывает конфиг-дом на пути (транскрипты, кеш, отсечка, + индекс), `AccountDirectory` спрашивает `claude auth status`, кто в этот дом + вошёл. Токен берёт `HomeKeychainCredentials`: имя записи Keychain Claude Code + собирает из пути дома, и мы собираем так же — стандартному дому + `Claude Code-credentials`, прочим то же имя с приставкой из первых восьми + hex-символов sha256 пути. Аккаунту без входа достаётся + `SignedOutCredentials`, а не `nil`: `nil` в `ResolvingProvider` означает + «Keychain по умолчанию», то есть запись первого аккаунта. ### Инварианты ядра @@ -413,7 +422,7 @@ swift build # оба таргета # объявления: ключи kSecUseAuthenticationUI в Keychain.swift оставлены # намеренно, замены им нет, и группа понижена обратно до предупреждения. swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration -swift run ClaudeWeekTests # 477 проверок, без сети и без UI +swift run ClaudeWeekTests # 541 проверка, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) ./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app @@ -504,6 +513,17 @@ Intel — собирает у себя, `install.sh` соберёт нативн ### Грабли +- **`CLAUDE_CONFIG_DIR`, выставленный в тот же самый путь, «разлогинивает» + аккаунт.** `claude auth status` без переменной отвечает `loggedIn: true`, а + с `CLAUDE_CONFIG_DIR=~/.claude` — то есть с тем же путём, который берётся по + умолчанию, — уже `loggedIn: false` (2.1.251). Ячейку учётных данных выбирает + не путь сам по себе, а факт установки переменной. Поэтому + `AccountDirectory.read(home:)` ставит её **только для нестандартного дома**, + а унаследованную из окружения — снимает: приложение могли запустить из + оболочки, где она уже выставлена. Ставя её всегда, программа объявляла бы + невошедшим тот аккаунт, в котором человек работает прямо сейчас. +- **Код возврата `claude auth status` — не признак поломки.** У невошедшего + аккаунта это единица, но JSON в ответе осмысленный. Судим по содержимому. - **Keychain спрашивает доступ после каждой пересборки** — разрешение привязано к designated requirement, а у ad-hoc подписи это хеш бинаря. Лечится постоянным сертификатом: `./scripts/signing-cert.sh` один раз, дальше @@ -593,8 +613,12 @@ Intel — собирает у себя, `install.sh` соберёт нативн | `~/.config/claude-week/alerts.json` | о каких порогах уведомления уже говорили: окна лимитов, последние объявленные проценты, момент последнего баннера. Удаление безопасно — вернётся одно повторное уведомление | | `~/.config/claude-week/state.json` | с какого момента считается локальный расход и на каком аккаунте: `countFrom` и метка организации. Отдельно от кеша намеренно — тот перезаписывается каждым обновлением, а отсечка обязана его пережить. Удаление вернёт в счёт расход прежнего аккаунта | | `~/.config/claude-week/index.json` | индекс прочитанных транскриптов (инкрементальное чтение); схема 2 — записи хранят семейство модели и токены, из них считается разбивка. Индекс прошлой схемы не переносится, а отстраивается заново: версия проверяется до разбора записей | +| `~/.config/claude-week/secondary-*.json` | то же самое для второго аккаунта: `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json`, `secondary-index.json`. Порознь намеренно — общий кеш позволил бы обновлению одного аккаунта стереть снимок другого, а общая отсечка отдала бы счёт не тому лимиту. У первого аккаунта имена прежние: в них лежит накопленный счёт, и переезд обнулил бы его | | `~/.claude/projects/**/*.jsonl` | транскрипты Claude Code — вход локального источника | +| `~/.claude-b/` | конфиг-дом второго аккаунта, если он заведён: свои транскрипты в `projects/`, своя запись Keychain. Путь задаётся ключом `accounts.secondaryHome`; каталога нет — переключателя в панели тоже нет | | `~/Library/Logs/ClaudeWeek.log` | лог запущенного через LaunchAgent приложения | | `~/Library/LaunchAgents/com.greem4.claudeweek.plist` | автозапуск | | `~/Applications/ClaudeWeek.app` | установленная копия | -| Keychain `Claude Code-credentials` | токен Claude Code (только читаем) | +| Keychain `Claude Code-credentials` | токен Claude Code первого аккаунта (только читаем) | +| Keychain, запись второго аккаунта | имя Claude Code собирает из пути конфиг-дома и не публикует: `Claude Code-credentials-<первые 8 hex sha256 пути>` (2.1.251). Собираем так же и сторожим тестом с живым вектором. Прежний способ — искать запись по `organizationUuid` внутри неё — умер вместе с полем: в 2.1.251 запись состоит из одного `claudeAiOauth` | +| Принадлежность токена | из записи Keychain она пропала (см. выше), и называет её только `claude auth status` — `orgId` для этого дома. Из него и `subscriptionType` собирается метка, по которой замечают вход другим аккаунтом | diff --git a/docs/PLAN.md b/docs/PLAN.md index e7c352c..e124584 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -653,7 +653,7 @@ assert-хелперов, запускаемый `swift run ClaudeWeekTests`; н = падение. Когда/если появится Xcode, таргет переносится на swift-testing без изменения самих проверок. -Что покрыто (477 проверок, `swift run ClaudeWeekTests`): +Что покрыто (541 проверка, `swift run ClaudeWeekTests`): 1. **Окно недели.** Момент ровно на сбросе; за минуту до и после; полсекунды до; воскресный полдень; смена таймзоны между вызовами; другой день сброса. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d1cbcd8..364bcf3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,10 +13,10 @@ - [Соседи по полке](#соседи-по-полке) — что умеют похожие программы - [Заметно сразу](#заметно-сразу) — 1–2 - [Заметно в углах](#заметно-в-углах) — 3–8 -- [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–11 +- [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–10 - [Что сознательно не делаем](#что-сознательно-не-делаем) - [Что закрыл аудит](#что-закрыл-аудит-2026-08-05) — журналы 2026-08-05, 06 и 07 -- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16 и 17 +- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16, 17 и 31 --- @@ -186,24 +186,7 @@ XCTest без Xcode недоступен, поэтому проверки нап *Страховка:* `swift scripts/probe-usage.swift` печатает живой ответ целиком; запасной путь через `limits[]` уже есть. -### 10. Только один аккаунт за раз - -Токен один и всегда из Claude Code: поля «свой токен» в приложении нет — -`/api/oauth/usage` принимает только токен сеанса, остальные отвергает с `401` -(разбор в [API.md](API.md#токен)). Виджет показывает тот аккаунт, под которым -сейчас работает Claude Code, и показать два рядом — рабочий и личный — нельзя. - -Смена аккаунта при этом уже не врёт: программа замечает её по метке -организации из Keychain и начинает счёт заново, а вручную то же делает кнопка -«Начать счёт заново» ([USAGE.md](USAGE.md#смена-аккаунта)). Чего нет — истории -по каждому аккаунту: после переключения прежние цифры не сохраняются, а -отбрасываются. - -*Что делать:* хранить состояние по метке аккаунта, а не одним файлом, и -переключатель в панели. Работы на день, но польза сомнительна, пока никто не -попросил. - -### 11. Сборки не нотаризованы +### 10. Сборки не нотаризованы Лицензия (MIT) и CI появились, релиз собирается по тегу и выкладывается `.dmg` под Apple Silicon; Intel-образа в релизах нет вовсе — там сборка из @@ -336,3 +319,10 @@ requirement не менялся и приложение держалось в с | Было | Стало | |---|---| | на двух мониторах, поставленных один над другим, панель вставала не под строкой меню, а у нижнего края соседнего экрана — оторванная от значка на пол-экрана. Строка меню в полноэкранном режиме прячется, и её окно вместе со значком уезжает вверх на свою высоту: у вплотную стоящих мониторов этих 30 точек хватает, чтобы значок оказался на территории соседа, а `anchor.screen` честно ответил «соседний». Дальше панель клэмпилась по чужим границам — замер совпал с расчётом до точки: значок `y1080…1110` → панель `y1088…1416`, где `1088 = 1080 + 8` | экран выбирает `DropdownPanel.screen(for:)` по признаку, который сдвиг строки меню переживает: она всегда прижата к верхней кромке своего экрана, значит значок принадлежит тому из накрывающих его по горизонтали мониторов, чья верхняя кромка ближе. Верхняя кромка панели вдобавок ограничена `screen.maxY` — спрятанная строка меню уводила бы за край и её. Разбор и замеры — [SPACES.md](SPACES.md), §7; проба `probe-panel.swift --displays` | + +### 2026-08-31 + +| Было | Стало | +|---|---| +| виджет показывал ровно один аккаунт — тот, под которым сейчас работает Claude Code, — и поставить рядом рабочий с личным было нельзя. Прежние цифры при переключении не сохранялись, а отбрасывались (бывший пункт 10) | два аккаунта в панели. Claude Code различает их конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит свой токен, не выбивая первый. Появился каталог — в заголовке встают два знака Claude; выбранный горит оранжевым, второй гаснет до серого, а кто из них кто, говорит подсказка с живым адресом из `claude auth status`. Состояние хранится по аккаунту, а не одним файлом: у второго свои `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json` и `secondary-index.json`, поэтому обратное переключение мгновенно и ничего не теряет. Имя записи Keychain второго дома Claude Code не публикует, но собирает его из пути дома (`Claude Code-credentials-<первые 8 hex sha256 пути>`) — ClaudeWeek повторяет ту же формулу, и сторожит её тест с живым вектором. Искать запись по `organizationUuid` внутри неё, как было задумано сперва, больше нельзя: 2.1.251 убрал это поле из записи, и принадлежность токена теперь называет только `claude auth status`. Каталога нет — нет и переключателя. Осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно | +| `claude auth status` спрашивали всегда с `CLAUDE_CONFIG_DIR`, и вкладка «Доступ» показывала «не вошли» у аккаунта, в котором человек работает прямо сейчас | переменная ставится только для нестандартного дома. Выставленная даже в тот же самый путь `~/.claude`, она заставляет Claude Code отвечать `loggedIn: false` — ячейку учётных данных выбирает не путь, а факт установки переменной (2.1.251). Унаследованная из окружения снимается: приложение могли запустить из оболочки под вторым аккаунтом. Грабли записаны в [ARCHITECTURE.md](ARCHITECTURE.md#грабли) | diff --git a/docs/USAGE.md b/docs/USAGE.md index f39ca2d..c1dcc6e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -7,6 +7,7 @@ ## Оглавление - [Авторизация и доступ](#авторизация-и-доступ) +- [Два аккаунта](#два-аккаунта) - [Калибровка](#калибровка) - [Панель](#панель) - [Как считается план](#как-считается-план) @@ -130,6 +131,90 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 секреты `SIGNING_CERT_P12` (файл `.p12` в base64) и `SIGNING_CERT_PASSWORD`; без них релиз собирается ad-hoc, как раньше. +## Два аккаунта + +Claude Code различает аккаунты **конфиг-домом** — каталогом, где лежат его +настройки, транскрипты и учётные данные. Обычная установка живёт в `~/.claude`. +Второй аккаунт — это второй такой каталог, и путь к нему передаётся переменной +`CLAUDE_CONFIG_DIR`. + +Так его заводят: + +```bash +mkdir -p ~/.claude-b +CLAUDE_CONFIG_DIR=~/.claude-b claude # внутри: /login вторым аккаунтом +``` + +Проверить, что дома действительно два и вход в один не выбил другой: + +```bash +claude auth status # первый аккаунт +CLAUDE_CONFIG_DIR=~/.claude-b claude auth status # второй +``` + +Разные адреса в ответах — всё получилось. Обратите внимание: про первый дом +спрашивать нужно **без** `CLAUDE_CONFIG_DIR`. С этой переменной, выставленной +даже в тот же самый путь `~/.claude`, Claude Code отвечает «не вошли» — ячейку +учётных данных выбирает не путь, а сам факт установки переменной. Виджет это +учитывает и переменную для стандартного дома не ставит. Записей в Keychain при этом тоже +станет две: у каждого дома своя, и `/login` во втором не трогает токен первого. + +### Что делает виджет + +Каталог `~/.claude-b` появился — в заголовке панели встают **два знака +Claude**. Выбранный аккаунт горит фирменным оранжевым, второй гаснет до +приглушённого серого. Знаки одинаковые, потому что сервис один; кто из них +кто, говорит подсказка при наведении — там стоит адрес аккаунта, который +назвал `claude auth status`, а не порядковый номер. Выбор сохраняется в +конфиге и переживает перезапуск. + +У каждого аккаунта **своё всё**: свой недельный лимит, свой снимок, свой +подобранный бюджет, своя отсечка счёта и свой журнал уведомлений. Файлы +второго аккаунта лежат рядом с файлами первого под именами с приставкой +`secondary-`. Общими их сделать было нельзя: последнее обновление одного +аккаунта стирало бы снимок другого. + +Пока во второй дом не вошли, панель на нём не рисует серые заглушки — это +читалось бы как вечная загрузка. Вместо них стоит строка о том, что входа нет, +и путь, который нужно передать в `CLAUDE_CONFIG_DIR`. + +Каталога второго дома нет вовсе — нет и переключателя: программа работает +ровно так же, как с одним аккаунтом, и настраивать для этого ничего не нужно. + +### Откуда берётся токен второго аккаунта + +Токен каждого конфиг-дома лежит в своей записи Keychain, а имя записи Claude +Code собирает из пути дома: стандартному достаётся `Claude Code-credentials`, +любому другому — то же имя с приставкой из первых восьми hex-символов sha256 +пути (`~/.claude-b` → `Claude Code-credentials-034e8c6f`). Имя это нигде не +публикуется, так что ClaudeWeek повторяет формулу — и это осознанная догадка о +чужой внутренней детали: сменится схема именования в CLI, и второй аккаунт +скажет «в этот аккаунт ещё не вошли», пока формулу не поправят. Зато чужих +цифр под своим именем он не покажет: имя выводится из дома, а не из аккаунта, +поэтому промах формулы даёт «записи нет», а не чужой токен. + +Раньше запись искали иначе — перебором записей Claude Code со сверкой +`organizationUuid` внутри каждой. Способ умер вместе с полем: на 2.1.251 +запись состоит из одного `claudeAiOauth`, организации в ней нет вовсе, и +сверять стало не с чем. Кому принадлежит токен, знает теперь только +`claude auth status`. + +По той же причине аккаунт, в который не вошли, не получает «креды по +умолчанию»: иначе он показал бы цифры первого аккаунта под своим именем. + +### Путь второго дома + +Задаётся ключом `accounts` в `config.json`, по умолчанию `~/.claude-b`: + +```json +{ + "accounts": { + "primaryHome": "~/.claude", + "secondaryHome": "~/.claude-b" + } +} +``` + ## Калибровка Руками ничего подбирать не нужно. На каждом успешном официальном ответе @@ -500,7 +585,14 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 сейчас» — она делает один настоящий запрос и печатает результат: процент или причину отказа. -Сверху — **«Сейчас в ключе»**: метка аккаунта из записи Keychain, начало UUID +Сверху — раздел **«Аккаунты»**: оба конфиг-дома, путь каждого и кто в него +вошёл. Три положения различаются намеренно: «дом не заведён» — каталога нет, +второй аккаунт не заводили; «не вошли» — каталог есть, но `/login` в нём не +делали; адрес — вошли, и видно, кто именно. Советы человеку в первых двух +случаях разные, поэтому одним «нет данных» они не склеиваются. Подробности — +в [Два аккаунта](#два-аккаунта). + +Ниже — **«Сейчас в ключе»**: метка аккаунта из записи Keychain, начало UUID организации и тариф (`7f3a1b2c·max`). Токен обновляется раз в час, а метка держится — по ней и видно, тот же это аккаунт, что вчера, или вошли другим. В режиме «только локальная оценка» строка пустая: там Keychain не читается @@ -748,6 +840,9 @@ GitHub — запись в лог, и ничего поверх работы. пробуждение и смену таймзоны, перечитывание конфига после правки. - **Автозапуск** — LaunchAgent, приложение живёт только в строке меню (`LSUIElement`), без иконки в Dock. +- **Выбор аккаунта** — флаг `--account=primary|secondary` выбирает, чей лимит + показать, в том числе для `--json`. Без флага берётся тот аккаунт, что + выбран в панели. - **Доступность** — разметка для VoiceOver (вживую ещё не прослушана), уважение к «Уменьшению движения», числа рядом с каждой полосой: цвет нигде не остаётся единственным носителем смысла. @@ -757,8 +852,8 @@ GitHub — запись в лог, и ничего поверх работы. Вошли в Claude Code другим аккаунтом — рабочим вместо домашнего, — и виджет показывает две правды сразу. Недельный процент приходит от сервера и сменяется сам: сервер узнаёт аккаунт по токену. А вот разбивка по суткам считается по -транскриптам в `~/.claude/projects`, и они пишутся в одни и те же файлы, кто бы -ни был залогинен. Различить их по содержимому нечем — маркера аккаунта в +транскриптам в `projects/` того же конфиг-дома, и они пишутся в одни и те же +файлы, кто бы ни был залогинен в этот дом. Различить их по содержимому нечем — маркера аккаунта в записях нет. Без отсечки расход прежнего аккаунта считался бы в новый лимит до конца недели. diff --git a/scripts/make-app.sh b/scripts/make-app.sh index 1fd3125..b0d9d9e 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -24,12 +24,14 @@ cd "$ROOT" # `$(...)` увёл бы туда же вывод самой сборки, а `exit 1` при ненайденном # бинаре погасил бы только подоболочку, и скрипт поехал бы дальше. SLICE="" +BIN_DIR="" build_slice() { local arch="$1" echo "==> swift build -c $CONFIG --arch $arch" swift build -c "$CONFIG" --arch "$arch" --product ClaudeWeekApp - SLICE="$(swift build -c "$CONFIG" --arch "$arch" --product ClaudeWeekApp \ - --show-bin-path)/ClaudeWeekApp" + BIN_DIR="$(swift build -c "$CONFIG" --arch "$arch" --product ClaudeWeekApp \ + --show-bin-path)" + SLICE="$BIN_DIR/ClaudeWeekApp" [ -x "$SLICE" ] || { echo "не нашёл бинарь $arch: $SLICE" >&2; exit 1; } } @@ -45,7 +47,8 @@ elif [ -n "$ARCH" ]; then else echo "==> swift build -c $CONFIG" swift build -c "$CONFIG" --product ClaudeWeekApp - SLICE="$(swift build -c "$CONFIG" --product ClaudeWeekApp --show-bin-path)/ClaudeWeekApp" + BIN_DIR="$(swift build -c "$CONFIG" --product ClaudeWeekApp --show-bin-path)" + SLICE="$BIN_DIR/ClaudeWeekApp" [ -x "$SLICE" ] || { echo "не нашёл бинарь: $SLICE" >&2; exit 1; } SLICES+=("$SLICE") fi @@ -64,6 +67,21 @@ fi cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist" printf 'APPL????' > "$APP/Contents/PkgInfo" +# Ресурсный бандл SwiftPM со знаком Claude для переключателя аккаунтов. Без +# него `Bundle.module` в приложении обрывается fatalError'ом ещё до отрисовки +# панели, поэтому его отсутствие — ошибка сборки, а не пропущенное украшение. +# У универсального бандла срезы кладут одинаковые ресурсы, хватает любого из +# проходов. +RES_BUNDLE="$BIN_DIR/ClaudeWeek_ClaudeWeekApp.bundle" +if [ -d "$RES_BUNDLE" ]; then + rm -rf "$APP/Contents/Resources/$(basename "$RES_BUNDLE")" + cp -R "$RES_BUNDLE" "$APP/Contents/Resources/" + echo "==> ресурсы: $(basename "$RES_BUNDLE")" +else + echo "не нашёл ресурсный бандл: $RES_BUNDLE" >&2 + exit 1 +fi + # Версия у программы одна — та, что в Version.swift; в plist она попадает # отсюда, а не переписывается руками во втором месте. VERSION="$(sed -n 's/.*static let version = "\(.*\)".*/\1/p' \