diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f34c0..0fe8046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,29 @@ ## [Не выпущено] +### Добавлено + +- **Счёт начинается заново при входе другим аккаунтом.** Недельный процент + приходит от сервера и сменяется сам, а разбивка по суткам считается по + транскриптам в `~/.claude/projects` — они пишутся в одни и те же файлы при + любом аккаунте, и различить их по содержимому нечем. Теперь программа + замечает смену по метке организации из Keychain и ставит счёту отсечку: + расход прежнего аккаунта в новый лимит больше не идёт. +- **Кнопка «Начать счёт заново»** и строка **«Сейчас в ключе»** на вкладке + «Доступ»: видно, чей аккаунт в токене сейчас, и есть чем сбросить счёт + вручную — например, когда официальный источник молчит и смену аккаунта + заметить неоткуда. +- **Баннер о новой версии.** Раньше о вышедшем релизе говорила только строка + внизу панели — её видел лишь тот, кто панель открывал. Теперь приходит + уведомление, по одному на версию, а не каждые сутки до установки. Кнопки в + нём нет намеренно: установка перезаписывает приложение и перезапускает его, + и делать это одним щелчком посреди чужой работы неправильно — ставится + обновление по-прежнему на вкладке «О программе». Выключается отдельным + тумблером «Сообщать о новой версии»: он не гаснет вместе с разговорами о + лимитах. Сказанным версия считается только после удачной отправки — иначе + отказ системы (нет разрешения, сбой центра уведомлений) съедал бы новость + навсегда. + ## [0.1.13] — 2026-08-18 ### Добавлено diff --git a/README.md b/README.md index acf66cb..83f1c61 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Developer ID, — потому Gatekeeper и просит подтвержден там работает, но регулярно её никто не проверяет. Дальше приложение следит за версией само: раз в сутки спрашивает GitHub, а -найдя новый выпуск, показывает строку внизу панели. Обновляется по кнопке в +найдя новый выпуск, показывает строку внизу панели и присылает баннер — по +одному на версию, а не каждые сутки до установки. Обновляется по кнопке в настройках («О программе»): покажет, что изменилось, скачает образ, сверит его SHA256 с суммой из релиза, заменит себя и спросит про перезапуск. Настройки и калибровка остаются на месте. Подробности — в @@ -194,9 +195,15 @@ Code сбрасывает каждым обновлением токена, и - **только на ухудшении** — откатившийся расход молчит; - **не чаще раза в пять минут**, даже когда оба лимита пробиты разом. +Отдельно от лимитов стоит третий повод заговорить — **вышла новая версия**. +Кнопки в том баннере нет намеренно: установка перезаписывает приложение и +перезапускает его, а такому не место за одним щелчком из-под чужой работы — +ставится обновление на вкладке «О программе». Тумблер у него свой: погасив +разговоры о расходе, вы не обязаны молчать и про релизы. + Сказанное запоминается в `~/.config/claude-week/alerts.json` и переживает перезапуск: после перезагрузки посреди недели про пройденные пороги программа -молчит. Выключить можно всё разом или по лимиту в отдельности; разрешение macOS +молчит, а про уже объявленную версию не напоминает. Выключить можно всё разом или по лимиту в отдельности; разрешение macOS спрашивается один раз, при первом запуске. Как баннер выглядит именно у вас, показывает кнопка «Показать пример» — своя в каждой секции: @@ -207,11 +214,29 @@ Code сбрасывает каждым обновлением токена, и --- +## Смена аккаунта + +Вошли другим аккаунтом — рабочим вместо домашнего — и счёт начинается заново. +Недельный процент сменяется сам: сервер узнаёт аккаунт по токену. А вот +разбивка по суткам считается по транскриптам в `~/.claude/projects`, а те +пишутся в одни и те же файлы при любом аккаунте, и различить их по содержимому +нечем. Поэтому у счёта есть начало: программа сверяет метку аккаунта из +Keychain с той, на которой ведётся счёт, и при расхождении ставит отсечку — +расход прежнего аккаунта в новый лимит больше не идёт. + +Ту же отсечку можно поставить руками — кнопкой **«Начать счёт заново»** на +вкладке «Доступ». Она нужна, когда Keychain недоступен: без него метку не +прочитать, и смену аккаунта заметить неоткуда. Там же видно, **чей аккаунт +сейчас в ключе**. Подробности — в +[docs/USAGE.md](docs/USAGE.md#смена-аккаунта). + +--- + ## Сборка и проверки ```bash swift build # обе цели -swift run ClaudeWeekTests # 439 проверок: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 477 проверок: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekApp/NotificationController.swift b/Sources/ClaudeWeekApp/NotificationController.swift index fade082..b2e426c 100644 --- a/Sources/ClaudeWeekApp/NotificationController.swift +++ b/Sources/ClaudeWeekApp/NotificationController.swift @@ -136,6 +136,46 @@ final class NotificationController { } } + /// Баннер о вышедшей версии. О каждой говорим один раз: проверка идёт при + /// запуске и раз в сутки, и без памяти о сказанном напоминание приходило бы + /// каждый день, пока не обновишься. + /// + /// Кнопки в баннере нет намеренно: установка перезаписывает приложение и + /// перезапускает его, а такому не место за одним щелчком из-под чужой + /// работы. Баннер только сообщает, ставит человек — из настроек. + func announceUpdate(_ release: Release, config: Config) { + guard center != nil, config.notifications.update else { return } + let version = release.version.description + guard log.updateSaid != version else { return } + + let s = config.strings + let content = UNMutableNotificationContent() + content.title = s.pick("Вышла версия \(version)", "Version \(version) is out") + content.body = s.pick("Обновиться — в настройках, вкладка «О программе»", + "To update, open Settings → About") + content.threadIdentifier = "update" + if config.notifications.sound { content.sound = .default } + + let request = UNNotificationRequest( + identifier: "update-\(version)", content: content, trigger: nil + ) + Task { + await authorize() + do { + try await center?.add(request) + // Сказанным версия считается только после удачной отправки. + // Пометь мы её раньше — отказ системы (нет разрешения, сбой + // центра) навсегда съел бы новость: второй раз о ней бы уже + // не заговорили. + log.updateSaid = version + save() + Log.info("уведомление: вышла версия \(version)") + } catch { + Log.warn("не показал уведомление об обновлении: \(error)") + } + } + } + private func send(_ alert: LimitAlert, config: Config, now: Date) { guard let center else { return } diff --git a/Sources/ClaudeWeekApp/Screenshot.swift b/Sources/ClaudeWeekApp/Screenshot.swift index 3b28ae8..8d1a7f9 100644 --- a/Sources/ClaudeWeekApp/Screenshot.swift +++ b/Sources/ClaudeWeekApp/Screenshot.swift @@ -250,7 +250,8 @@ enum Screenshot { update: UpdateController(bundle: nil), notifications: NotificationController(bundled: true), apply: { _ in }, - check: { _ in ("", true) } + check: { _ in ("", true) }, + reset: {} ) // Высота больше оконной: в живом окне вкладка прокручивается, а на diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index e221a22..6c2d1ac 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -637,6 +637,9 @@ struct NotificationSettings: View { Toggle(s.pick("Со звуком", "With sound"), isOn: sound) .disabled(isOff) + Toggle(s.pick("Сообщать о новой версии", "Announce a new version"), + isOn: updates) + Text(s.pick(""" В баннере две строки: сколько израсходовано и через сколько \ сброс. Какой это лимит, говорит картинка справа — пятичасовая \ @@ -650,6 +653,22 @@ struct NotificationSettings: View { .font(.caption) .foregroundStyle(.secondary) + Text(s.pick(""" + О вышедшей версии программа говорит один раз — не каждые сутки, \ + пока не обновитесь. Кнопки в том баннере нет: установка \ + перезаписывает приложение и перезапускает его, и делать это \ + одним щелчком посреди чужой работы неправильно. Ставится \ + обновление на вкладке «О программе». + """, """ + A released version is announced once — not daily until you \ + update. That banner has no button: installing replaces the app \ + and restarts it, and that should not happen on a single click \ + in the middle of your work. Updates are installed on the About \ + tab. + """)) + .font(.caption) + .foregroundStyle(.secondary) + Text(s.pick(""" Об одном пороге программа говорит один раз за окно лимита и \ только на ухудшении: откатившийся расход молчит, а следующая \ @@ -733,6 +752,16 @@ struct NotificationSettings: View { ) } + /// Не гаснет вместе с общим тумблером: тот про разговоры о расходе, а + /// новая версия — новость другого рода, и молчать о ней человек просит + /// отдельно. + private var updates: Binding { + Binding( + get: { notifications.update }, + set: { model.config.notifications.update = $0 } + ) + } + private var weekEnabled: Binding { Binding( get: { notifications.week.enabled }, @@ -795,11 +824,63 @@ struct NotificationSettings: View { private struct AccessSettings: View { @Bindable var model: SettingsModel + /// Спрашиваем до, а не «отменить» после: отсечку назад не отмотать — + /// прежний счёт нигде не сохранён. + @State private var confirmingReset = false private var s: L10n { model.config.strings } var body: some View { Form { + Section(s.pick("Аккаунт и счёт", "Account and count")) { + LabeledContent(s.pick("Сейчас в ключе", "Currently in the key")) { + Text(model.account ?? s.pick("не читается", "cannot be read")) + .font(.caption.monospaced()) + .foregroundStyle(model.account == nil ? .secondary : .primary) + .textSelection(.enabled) + } + Text(s.pick(""" + Метка аккаунта из записи Keychain: начало UUID организации и тариф. \ + Токен обновляется раз в час, а она держится — по ней и видно, тот \ + же это аккаунт, что вчера, или вошли другим. + """, """ + The account mark from the Keychain item: the start of the \ + organisation UUID and the plan. The token is refreshed hourly, this \ + stays — it is what tells yesterday’s account from a new login. + """)) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Button(s.pick("Начать счёт заново", "Start counting over")) { + confirmingReset = true + } + Spacer() + } + Text(model.countingNote) + .font(.caption) + .foregroundStyle(.secondary) + + Text(s.pick(""" + Нужно после входа другим аккаунтом. Недельный процент приходит от \ + сервера и сменится сам, а вот разбивка по суткам считается по \ + транскриптам в ~/.claude/projects — они пишутся в одни и те же \ + файлы при любом аккаунте, и различить их по содержимому нельзя. \ + Смену аккаунта программа замечает и сама, но только пока читает \ + Keychain. + """, """ + Needed after logging in with a different account. The weekly \ + percentage comes from the server and changes by itself, but the \ + daily breakdown is counted from the transcripts in \ + ~/.claude/projects — they are written to the same files whatever \ + account is used, and nothing in them tells one from the other. The \ + app notices a switch on its own too, but only while it can read the \ + Keychain. + """)) + .font(.caption) + .foregroundStyle(.secondary) + } + Section(s.pick("Токен для официального источника", "Token for the official source")) { Text(s.pick(""" Берётся из Keychain Claude Code, запись «Claude Code-credentials». \ @@ -905,6 +986,25 @@ private struct AccessSettings: View { } } .formStyle(.grouped) + .confirmationDialog( + s.pick("Начать счёт заново?", "Start counting over?"), + isPresented: $confirmingReset + ) { + Button(s.pick("Начать заново", "Start over"), role: .destructive, action: model.resetCounting) + Button(s.pick("Отмена", "Cancel"), role: .cancel) {} + } message: { + Text(s.pick(""" + Расход до этой минуты в счёт больше не идёт: снимок, подобранный \ + бюджет и уже сказанные предупреждения стираются, а суточные полосы \ + начнутся с нуля. Настройки, транскрипты и сам недельный процент от \ + сервера остаются на месте. + """, """ + Spending up to this minute stops counting: the snapshot, the worked-out \ + budget and the warnings already given are erased, and the daily bars \ + start from zero. Settings, transcripts and the server’s own weekly \ + percentage stay as they are. + """)) + } } } diff --git a/Sources/ClaudeWeekApp/SettingsWindow.swift b/Sources/ClaudeWeekApp/SettingsWindow.swift index eec6e76..8045cbf 100644 --- a/Sources/ClaudeWeekApp/SettingsWindow.swift +++ b/Sources/ClaudeWeekApp/SettingsWindow.swift @@ -35,6 +35,13 @@ final class SettingsModel { /// Автозапуск тоже мимо конфига: он и есть launchd-агент, и правда о нём /// одна — лежит плист или нет. private(set) var launchAtLogin = LoginItem.isEnabled + /// Аккаунт, чей токен сейчас в Keychain, — короткой меткой вида + /// `7f3a1b2c·max`. Показываем именно её: спрошенный «а по какому ключу оно + /// сейчас ходит» иначе не проверить ничем, кроме кнопки «Проверить», а та + /// отвечает процентом, в котором аккаунт не виден. + private(set) var account: String? + /// С какого момента ведётся локальный счёт; nil — с начала окна. + private(set) var countingSince: Date? /// Обновление идёт мимо конфига, и состояние у него общее с панелью и /// меню — сюда приходит тот же контроллер, а не его копия. @@ -46,19 +53,22 @@ final class SettingsModel { private let apply: (Config) -> Void private let check: (Config) async -> (String, Bool) + private let reset: () -> Void init( config: Config, update: UpdateController, notifications: NotificationController, apply: @escaping (Config) -> Void, - check: @escaping (Config) async -> (String, Bool) + check: @escaping (Config) async -> (String, Bool), + reset: @escaping () -> Void ) { self.config = config self.update = update self.notifications = notifications self.apply = apply self.check = check + self.reset = reset refreshDiagnostics() } @@ -73,6 +83,13 @@ final class SettingsModel { // целыми неделями даёт тот, который наступит следующим. officialReset = cache?.projectedWindow(at: Date(), config: config)?.end launchAtLogin = LoginItem.isEnabled + countingSince = Store.loadState().countFrom + // В режиме «только локальная оценка» Keychain не читаем и здесь: + // окно настроек — не повод нарушить обещание, данное на этой же + // вкладке. Строка про аккаунт тогда просто не показывается. + account = config.provider == .local + ? nil + : (try? KeychainCredentials().load())?.accountMark // Разрешение на уведомления снимают там же, где выдали, — в системных // настройках, мимо этого окна. Спрашиваем систему на каждый показ. notifications.refresh() @@ -150,6 +167,30 @@ final class SettingsModel { } } + /// Начать счёт заново — после входа другим аккаунтом. Стирает снимок и + /// журнал уведомлений, а локальному счёту ставит отсечку по нынешнему + /// моменту: транскрипты прежнего аккаунта лежат в тех же файлах, и без + /// отсечки его расход считался бы в чужой лимит до конца недели. + /// + /// Настройки при этом не трогаются вовсе — это разные кнопки и разные + /// сожаления: тему вернуть легко, а накопленный счёт не восстановить. + func resetCounting() { + reset() + refreshDiagnostics() + } + + /// Подпись под кнопкой сброса: с какого момента идёт счёт. + var countingNote: String { + let s = config.strings + guard let countingSince else { + return s.pick("Считается вся неделя целиком.", "The whole week is counted.") + } + let day = Formatting.weekdayShort(countingSince, calendar: config.calendar, lang: s.lang) + let time = Formatting.clock(countingSince, calendar: config.calendar) + return s.pick("Локальный счёт идёт с \(day) \(time) — раньше этого момента расход не считается.", + "The local count runs from \(day) \(time) — spending before that is not counted.") + } + /// Вернуть всё к заводскому — кроме калибровки и бюджета: их подбирала /// программа по живым данным, и терять их из-за «сбросить внешний вид» /// было бы обидно. diff --git a/Sources/ClaudeWeekApp/StatusItemController.swift b/Sources/ClaudeWeekApp/StatusItemController.swift index 805e5ce..e7eb033 100644 --- a/Sources/ClaudeWeekApp/StatusItemController.swift +++ b/Sources/ClaudeWeekApp/StatusItemController.swift @@ -54,6 +54,10 @@ final class StatusItemController: NSObject { // после установки, читается как выходка, а на первом запуске он // ожидаем. Выключенные уведомления не спрашивают ничего. notifications.apply(config.notifications) + update.onFound = { [weak self] release in + guard let self else { return } + notifications.announceUpdate(release, config: model.config) + } restoreFromCache() render() refresh() @@ -256,7 +260,8 @@ final class StatusItemController: NSObject { update: update, notifications: notifications, apply: { [weak self] config in self?.applyFromSettings(config) }, - check: { config in await Self.check(config: config) } + check: { config in await Self.check(config: config) }, + reset: { [weak self] in self?.resetCounting() } ) let controller = SettingsWindowController(model: model) controller.onPresent = { [weak self] in self?.pinPanel() } @@ -334,6 +339,23 @@ final class StatusItemController: NSObject { } } + /// Кнопка «Начать счёт заново» из настроек: ставим отсечку по нынешнему + /// моменту и тут же идём за свежими цифрами. Провайдер не пересоздаём — + /// отсечку он перечитывает на каждом обходе транскриптов, а вот показать + /// панели старый снимок после сброса было бы прямым враньём. + private func resetCounting() { + let account = (try? KeychainCredentials().load())?.accountMark + do { + try Store.resetCounting(at: Date(), account: account) + } catch { + Log.warn("не смог начать счёт заново: \(error)") + return + } + model.snapshot = nil + render() + refresh() + } + /// Одна честная попытка сходить в официальный источник — чтобы кнопка /// «Проверить» в настройках отвечала не «сохранено», а «работает». private static func check(config: Config) async -> (String, Bool) { diff --git a/Sources/ClaudeWeekApp/UpdateController.swift b/Sources/ClaudeWeekApp/UpdateController.swift index 2cc8dcc..d30cf7d 100644 --- a/Sources/ClaudeWeekApp/UpdateController.swift +++ b/Sources/ClaudeWeekApp/UpdateController.swift @@ -14,6 +14,11 @@ final class UpdateController { /// конфиг. var strings = L10n(Lang.ru) + /// Нашлась новая версия при самостоятельной проверке. Владелец превращает + /// это в баннер: сам контроллер обновлений с уведомлениями не знаком — + /// он про GitHub и установку, а не про разговоры с человеком. + var onFound: ((Release) -> Void)? + enum State: Equatable { /// Ни новостей, ни повода что-то показывать. case idle @@ -123,7 +128,10 @@ final class UpdateController { case .available(let release): Log.info("вышла версия \(release.version), у нас \(ClaudeWeek.version)") state = .available(release) - if manually { offer(release) } + // Нажатая кнопка отвечает окном, самостоятельная проверка — + // баннером: строку внизу панели видит только тот, кто её + // открыл, а обновление ждёт как раз тех, кто не открывает. + if manually { offer(release) } else { onFound?(release) } } } catch { let text = (error as? UpdateError)?.message(strings.lang) ?? error.localizedDescription diff --git a/Sources/ClaudeWeekCore/Alerts.swift b/Sources/ClaudeWeekCore/Alerts.swift index 84c4d73..5bab5d0 100644 --- a/Sources/ClaudeWeekCore/Alerts.swift +++ b/Sources/ClaudeWeekCore/Alerts.swift @@ -82,6 +82,10 @@ public struct NotificationsConfig: Codable, Sendable, Equatable { public var sound: Bool public var week: LimitNotifications public var session: LimitNotifications + /// Баннер о вышедшей версии. Отдельно от общего выключателя по смыслу: + /// это разговор не про расход, и человек, погасивший разговоры о лимитах, + /// не обязательно просил молчать про обновления. + public var update: Bool /// Недельные пороги выше сессионных: неделя не сбросится до конца недели, /// и предупреждать о ней надо раньше, чем о пятичасовом окне, которое @@ -90,12 +94,14 @@ public struct NotificationsConfig: Codable, Sendable, Equatable { enabled: Bool = true, sound: Bool = true, week: LimitNotifications = LimitNotifications(first: 80, second: 95), - session: LimitNotifications = LimitNotifications(first: 75, second: 95) + session: LimitNotifications = LimitNotifications(first: 75, second: 95), + update: Bool = true ) { self.enabled = enabled self.sound = sound self.week = week self.session = session + self.update = update } // Как и во всём конфиге: каждый ключ необязателен, недостающее берётся @@ -107,7 +113,8 @@ public struct NotificationsConfig: Codable, Sendable, Equatable { enabled: try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? d.enabled, sound: try c.decodeIfPresent(Bool.self, forKey: .sound) ?? d.sound, week: try c.decodeIfPresent(LimitNotifications.self, forKey: .week) ?? d.week, - session: try c.decodeIfPresent(LimitNotifications.self, forKey: .session) ?? d.session + session: try c.decodeIfPresent(LimitNotifications.self, forKey: .session) ?? d.session, + update: try c.decodeIfPresent(Bool.self, forKey: .update) ?? d.update ) } @@ -199,19 +206,25 @@ public struct AlertLog: Codable, Sendable, Equatable { public var sessionSaid: Double? /// Когда показали последний баннер — на нём держится остывание. public var lastSentAt: Date? + /// Версия, о выходе которой уже сказали. Про одну и ту же новость + /// напоминают ровно раз: проверка обновлений идёт при каждом запуске и раз + /// в сутки, и без этой памяти баннер приходил бы каждый день до установки. + public var updateSaid: String? public init( weekEnd: Date? = nil, weekSaid: Double? = nil, sessionEnd: Date? = nil, sessionSaid: Double? = nil, - lastSentAt: Date? = nil + lastSentAt: Date? = nil, + updateSaid: String? = nil ) { self.weekEnd = weekEnd self.weekSaid = weekSaid self.sessionEnd = sessionEnd self.sessionSaid = sessionSaid self.lastSentAt = lastSentAt + self.updateSaid = updateSaid } } diff --git a/Sources/ClaudeWeekCore/Cache.swift b/Sources/ClaudeWeekCore/Cache.swift index 0346d5c..1183016 100644 --- a/Sources/ClaudeWeekCore/Cache.swift +++ b/Sources/ClaudeWeekCore/Cache.swift @@ -152,6 +152,30 @@ public struct CachedUsage: Codable, Sendable, Equatable { } } +/// Чем живёт сам счёт, а не последний снимок. Отдельно от кеша по той же +/// причине, что и журнал уведомлений: кеш перезаписывается каждым обновлением, +/// а эти два поля обязаны его переживать — иначе отсечка стёрлась бы через +/// минуту после того, как её поставили. +public struct CountingState: Codable, Sendable, Equatable { + /// Локальный расход считаем только с этого момента. nil — считаем всё + /// окно, как раньше. + /// + /// Нужна при смене аккаунта: транскрипты в `~/.claude/projects` пишутся + /// в одни и те же файлы, кто бы ни был залогинен, и различить их по + /// содержимому нельзя — маркера аккаунта в записях нет. Единственное, чем + /// отделяется чужой расход от своего, — момент, с которого считаем. + public var countFrom: Date? + /// Метка аккаунта, на котором накоплен нынешний счёт (`OAuthCredentials.accountMark`). + /// Разошлась с тем, что в Keychain, — вошли другим аккаунтом, и всё + /// накопленное относится к чужому лимиту. + public var account: String? + + public init(countFrom: Date? = nil, account: String? = nil) { + self.countFrom = countFrom + self.account = account + } +} + /// Файлы состояния в `~/.config/claude-week/`. Битый файл — не повод падать: /// индекс отстроится заново, кеш просто окажется пустым. public enum Store { @@ -162,6 +186,8 @@ public enum Store { /// каждым обновлением и целиком описывает расход, а это — память о /// разговоре с человеком, и терять её вместе с протухшим снимком нельзя. public static var alertsURL: URL { directory.appendingPathComponent("alerts.json") } + /// Отсечка счёта и аккаунт, на котором он ведётся. + public static var stateURL: URL { directory.appendingPathComponent("state.json") } private static func decoder() -> JSONDecoder { let d = JSONDecoder() @@ -238,6 +264,50 @@ public enum Store { try write(encoder().encode(log), to: url) } + /// Битый файл состояния — то же самое, что его отсутствие: счёт пойдёт + /// с начала окна, как до появления отсечки. Ронять из-за него панель или + /// молча выдумывать отсечку нельзя — первое лишает цифры вовсе, второе + /// прячет чужой расход без ведома человека. + public static func loadState(from url: URL = Store.stateURL) -> CountingState { + guard let data = try? Data(contentsOf: url) else { return CountingState() } + do { + return try decoder().decode(CountingState.self, from: data) + } catch { + Log.warn("не разобрал \(url.path): \(error). Считаю с начала окна") + return CountingState() + } + } + + public static func saveState(_ state: CountingState, to url: URL = Store.stateURL) throws { + try write(encoder().encode(state), to: url) + } + + /// Начать счёт заново: всё накопленное относится к прежнему аккаунту, и + /// показывать его дальше — врать про нынешний. + /// + /// Стирается снимок (в нём процент, бюджет и сессия прошлого аккаунта) и + /// журнал уведомлений (о его порогах человеку уже сказали, и говорить о + /// них снова, считая с нуля, — шум). Индекс транскриптов остаётся: он + /// всего лишь разобранные файлы, и старые записи из него отсечёт + /// `countFrom`, а выброшенный он стоил бы полного перечитывания + /// `~/.claude/projects` на ровном месте. + public static func resetCounting( + at now: Date, + account: String?, + stateURL: URL = Store.stateURL, + cacheURL: URL? = Store.cacheURL, + alertsURL: URL = Store.alertsURL + ) throws { + // Отсечку записываем первой: не сумей мы её сохранить — снимок и + // журнал останутся на месте, и счёт продолжится по-старому. Обратный + // порядок оставил бы стёртый кеш без отсечки, то есть счёт с нуля по + // чужим транскриптам — худший из трёх исходов. + try saveState(CountingState(countFrom: now, account: account), to: stateURL) + if let cacheURL { try? FileManager.default.removeItem(at: cacheURL) } + try? FileManager.default.removeItem(at: alertsURL) + Log.info("счёт начат заново с \(now)") + } + private static func write(_ data: Data, to url: URL) throws { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), diff --git a/Sources/ClaudeWeekCore/Keychain.swift b/Sources/ClaudeWeekCore/Keychain.swift index 34d499d..e1cf29f 100644 --- a/Sources/ClaudeWeekCore/Keychain.swift +++ b/Sources/ClaudeWeekCore/Keychain.swift @@ -17,17 +17,39 @@ public struct OAuthCredentials: Sendable, Equatable { /// Момент истечения; nil — поля не было. public let expiresAt: Date? public let subscriptionType: String? + /// Организация, которой принадлежит аккаунт. Единственное в записи, что + /// переживает обновление токена: сам токен меняется раз в час, и отличить + /// по нему рабочий вход от домашнего нельзя, а этот UUID держится, пока + /// не вошли другим аккаунтом. + public let organizationUuid: String? - public init(accessToken: String, expiresAt: Date?, subscriptionType: String?) { + public init( + accessToken: String, + expiresAt: Date?, + subscriptionType: String?, + organizationUuid: String? = nil + ) { self.accessToken = accessToken self.expiresAt = expiresAt self.subscriptionType = subscriptionType + self.organizationUuid = organizationUuid } public func isExpired(at date: Date) -> Bool { guard let expiresAt else { return false } return expiresAt <= date } + + /// Чем аккаунт отличают от прошлого — короткая метка организации и тариф. + /// В файл кеша идёт она, а не сам UUID: сравнивать хватает и её, а + /// писать на диск идентификатор аккаунта без нужды незачем. + /// + /// nil — в записи не было `organizationUuid`: сравнивать не с чем, и смену + /// аккаунта тогда замечает только человек кнопкой в настройках. + public var accountMark: String? { + guard let organizationUuid, !organizationUuid.isEmpty else { return nil } + return "\(organizationUuid.prefix(8))·\(subscriptionType ?? "—")" + } } public protocol CredentialsSource: Sendable { @@ -70,7 +92,13 @@ public struct KeychainCredentials: CredentialsSource { return OAuthCredentials( accessToken: token, expiresAt: KeychainCredentials.date(from: oauth?["expiresAt"]), - subscriptionType: oauth?["subscriptionType"] as? String + subscriptionType: oauth?["subscriptionType"] as? String, + // Лежит рядом с `claudeAiOauth`, а не внутри него — но искать + // будем в обоих местах: форма записи не публичный контракт, и + // переезд поля на уровень ниже не должен ронять определение + // смены аккаунта молча. + organizationUuid: (root["organizationUuid"] as? String) + ?? (oauth?["organizationUuid"] as? String) ) } diff --git a/Sources/ClaudeWeekCore/LocalProvider.swift b/Sources/ClaudeWeekCore/LocalProvider.swift index 8d21f14..91696b6 100644 --- a/Sources/ClaudeWeekCore/LocalProvider.swift +++ b/Sources/ClaudeWeekCore/LocalProvider.swift @@ -246,17 +246,20 @@ public actor LocalProvider: UsageProvider { private let config: Config private let root: URL private let indexURL: URL + private let stateURL: URL? private let clock: @Sendable () -> Date public init( config: Config, root: URL = LocalProvider.defaultRoot, indexURL: URL = Store.indexURL, + stateURL: URL? = Store.stateURL, clock: @escaping @Sendable () -> Date = { Date() } ) { self.config = config self.root = root self.indexURL = indexURL + self.stateURL = stateURL self.clock = clock } @@ -372,14 +375,27 @@ public actor LocalProvider: UsageProvider { Log.warn("не сохранил индекс: \(error)") } - return aggregate(index: index, window: window, now: now, filesRead: filesRead) + // Отсечку перечитываем на каждом обходе, а не запоминаем в поле: + // сброс приходит из окна настроек, и провайдер к этому моменту уже + // создан — с запомненной отсечкой он считал бы по-старому до + // перезапуска. + return aggregate( + index: index, + window: window, + now: now, + filesRead: filesRead, + // nil-путь — это «состояния не ведём» (так делают тесты), а не + // повод залезть в файл пользователя. + countFrom: stateURL.flatMap { Store.loadState(from: $0).countFrom } + ) } private func aggregate( index: UsageIndex, window: WeekWindow, now: Date, - filesRead: Int + filesRead: Int, + countFrom: Date? ) -> LocalUsage { var seen: Set = [] var duplicates = 0 @@ -397,6 +413,10 @@ public actor LocalProvider: UsageProvider { // Записи позже `now` бывают при калибровке: она смотрит // на прошлый момент, а индекс уже знает про более свежие. guard record.timestamp <= now, + // Всё, что раньше отсечки, накоплено прежним аккаунтом: + // в лимит нынешнего оно не идёт, хотя лежит в тех же + // файлах транскриптов. + countFrom.map({ record.timestamp >= $0 }) ?? true, let day = window.dayIndex(for: record.timestamp) else { continue } perDay[day] += record.cost diff --git a/Sources/ClaudeWeekCore/ResolvingProvider.swift b/Sources/ClaudeWeekCore/ResolvingProvider.swift index 1c68f3b..335a143 100644 --- a/Sources/ClaudeWeekCore/ResolvingProvider.swift +++ b/Sources/ClaudeWeekCore/ResolvingProvider.swift @@ -19,6 +19,15 @@ public actor ResolvingProvider: UsageProvider { private let official: OfficialProvider? private let local: LocalProvider private let cacheURL: URL? + private let stateURL: URL? + /// Журнал уведомлений: его тоже стирает сброс счёта. Путь держим полем, а + /// не берём у `Store` на месте — иначе провайдер, которому дали файлы + /// песочницы, всё равно лез бы в настоящий журнал пользователя. + private let alertsURL: URL + /// Те же креды, что уходят в официальный источник. Держим их и здесь — + /// чтобы спросить, чей аккаунт, не дожидаясь удачного запроса: смена + /// аккаунта важна как раз тогда, когда сеть молчит. + private let credentials: CredentialsSource? private let clock: @Sendable () -> Date /// Креды по умолчанию — из Keychain Claude Code: другого источника у @@ -30,14 +39,30 @@ public actor ResolvingProvider: UsageProvider { cacheURL: URL? = Store.cacheURL, localRoot: URL = LocalProvider.defaultRoot, indexURL: URL = Store.indexURL, + stateURL: URL? = Store.stateURL, + alertsURL: URL = Store.alertsURL, clock: @escaping @Sendable () -> Date = { Date() } ) { - let local = LocalProvider(config: config, root: localRoot, indexURL: indexURL, clock: clock) + let local = LocalProvider( + config: config, + root: localRoot, + indexURL: indexURL, + stateURL: stateURL, + clock: clock + ) self.config = config self.clock = clock self.preference = config.provider self.local = local self.cacheURL = cacheURL + self.stateURL = stateURL + self.alertsURL = alertsURL + // В режиме «только локальная оценка» Keychain не трогаем вовсе — это + // обещание вкладки «Доступ», и ради определения аккаунта нарушать его + // нельзя. Цена: смену аккаунта там замечает только кнопка в настройках. + self.credentials = config.provider == .local + ? nil + : (credentials ?? KeychainCredentials()) self.official = config.provider == .local ? nil : OfficialProvider( @@ -51,6 +76,8 @@ public actor ResolvingProvider: UsageProvider { } public func fetch() async throws -> UsageSnapshot { + syncAccount(at: clock()) + if let official { do { let snapshot = withStoredSession(try await official.fetch()) @@ -83,6 +110,45 @@ public actor ResolvingProvider: UsageProvider { return snapshot } + /// Сверяет аккаунт в Keychain с тем, на котором ведётся счёт, и при + /// расхождении начинает счёт заново. + /// + /// Без этого вход другим аккаунтом виден не сразу и не весь: официальный + /// процент сменится сам с первым же ответом, а локальная оценка — нет. + /// Она считает по транскриптам в `~/.claude/projects`, а те пишутся в одни + /// и те же файлы при любом аккаунте, и без отсечки виджет показывал бы + /// прежнему расходу чужой лимит до конца недели. + /// + /// Первое знакомство сбросом не считается: метки в состоянии ещё нет, + /// накопленное относится к тому же аккаунту, и обнулять тут нечего — + /// просто запоминаем, с кем имеем дело. + private func syncAccount(at now: Date) { + guard let credentials, let stateURL else { return } + guard let mark = (try? credentials.load())?.accountMark else { return } + + var state = Store.loadState(from: stateURL) + guard state.account != mark else { return } + + guard let previous = state.account else { + state.account = mark + try? Store.saveState(state, to: stateURL) + return + } + + Log.info("аккаунт сменился (\(previous) → \(mark)), начинаю счёт заново") + do { + try Store.resetCounting( + at: now, + account: mark, + stateURL: stateURL, + cacheURL: cacheURL, + alertsURL: alertsURL + ) + } catch { + Log.warn("не смог начать счёт заново после смены аккаунта: \(error)") + } + } + /// Подставляет в снимок последнюю официальную сессию, пока её пятичасовое /// окно не истекло. Без этого строка сессии пропадала бы из панели при /// первом же обрыве сети, хотя сам лимит никуда не делся; истёкшую же diff --git a/Sources/ClaudeWeekTests/AlertTests.swift b/Sources/ClaudeWeekTests/AlertTests.swift index c15c4b2..6849d98 100644 --- a/Sources/ClaudeWeekTests/AlertTests.swift +++ b/Sources/ClaudeWeekTests/AlertTests.swift @@ -255,4 +255,27 @@ func runAlertTests(_ t: Harness) { t.equal(Store.loadAlerts(from: URL(fileURLWithPath: "/nope/нет-такого.json")), AlertLog(), "отсутствующий файл — пустой лог, а не отказ от уведомлений") } + + t.suite("уведомления: новая версия") { + t.check(NotificationsConfig().update, "по умолчанию о вышедшей версии говорим") + + // Конфиг, написанный до появления настройки, читается как обычно — + // недостающий ключ берётся из дефолтов. + let old = Data(""" + {"enabled":true,"sound":false} + """.utf8) + let decoded = try JSONDecoder().decode(NotificationsConfig.self, from: old) + t.check(decoded.update, "старый конфиг не гасит баннер об обновлении") + t.check(!decoded.sound, "а свои ключи из него читаются") + + // Память о сказанном — на диске, рядом с лимитными порогами: иначе + // после перезапуска о той же версии напомнили бы снова. + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-week-alerts-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try Store.saveAlerts(AlertLog(updateSaid: "0.1.14"), to: url) + t.equal(Store.loadAlerts(from: url).updateSaid, "0.1.14", + "версия, о которой сказали, переживает перезапуск") + t.check(AlertLog().updateSaid == nil, "в новом логе о версиях ещё не говорили") + } } diff --git a/Sources/ClaudeWeekTests/LocalProviderTests.swift b/Sources/ClaudeWeekTests/LocalProviderTests.swift index e293142..4ea52a1 100644 --- a/Sources/ClaudeWeekTests/LocalProviderTests.swift +++ b/Sources/ClaudeWeekTests/LocalProviderTests.swift @@ -5,12 +5,14 @@ import ClaudeWeekCore private struct Sandbox { let root: URL let indexURL: URL + let stateURL: URL init() { let base = FileManager.default.temporaryDirectory .appendingPathComponent("claude-week-fixtures-\(UUID().uuidString)") root = base.appendingPathComponent("projects") indexURL = base.appendingPathComponent("index.json") + stateURL = base.appendingPathComponent("state.json") try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } @@ -67,7 +69,13 @@ private func line( private let testNow = at(2026, 8, 4, 12, 0) private func provider(_ sandbox: Sandbox, config c: Config = config()) -> LocalProvider { - LocalProvider(config: c, root: sandbox.root, indexURL: sandbox.indexURL, clock: { testNow }) + LocalProvider( + config: c, + root: sandbox.root, + indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + clock: { testNow } + ) } func runLocalProviderTests(_ t: Harness) async { @@ -361,4 +369,30 @@ func runLocalProviderTests(_ t: Harness) async { t.equal(index.version, UsageIndex.currentVersion, "индекс прошлой схемы заменён пустым") t.equal(index.files.count, 0, "старые записи не подхватываются") } + + await t.suite("отсечка счёта: расход прежнего аккаунта не считается") { + let sandbox = Sandbox() + defer { sandbox.cleanup() } + // Два сообщения одной цены по разные стороны отсечки, оба в сутках + // `testNow`: разложи их по разным дням — и проверка спорила бы ещё и + // с границей недельного окна, а речь тут не о ней. + sandbox.write("сессия.jsonl", lines: [ + line(uuid: "старое", at: "2026-08-04T05:00:00.000Z", input: 1_000_000), + line(uuid: "новое", at: "2026-08-04T07:00:00.000Z", input: 1_000_000), + ]) + let window = WeekWindow(containing: testNow, config: config()) + + let whole = try await provider(sandbox).scan(window: window, now: testNow) + t.equal(whole.recordCount, 2, "без отсечки считаются оба сообщения") + + try Store.saveState( + CountingState(countFrom: ISO8601.parse("2026-08-04T06:00:00.000Z"), account: "org·max"), + to: sandbox.stateURL + ) + let after = try await provider(sandbox).scan(window: window, now: testNow) + t.equal(after.recordCount, 1, "с отсечкой остаётся только запись после неё") + t.close(after.totalCost, 5, "и стоимость считается по ней одной") + t.close(after.costByDay.compactMap { $0 }.reduce(0, +), 5, + "суточные полосы тоже без прежнего расхода") + } } diff --git a/Sources/ClaudeWeekTests/OfficialProviderTests.swift b/Sources/ClaudeWeekTests/OfficialProviderTests.swift index d775c62..8c27f3a 100644 --- a/Sources/ClaudeWeekTests/OfficialProviderTests.swift +++ b/Sources/ClaudeWeekTests/OfficialProviderTests.swift @@ -27,10 +27,17 @@ private actor FakeTransport: UsageTransport { private struct FakeCredentials: CredentialsSource { var token = "sk-ant-oat01-тест" var error: Error? + /// Организация: по ней провайдер и отличает вход другим аккаунтом. + var organization: String? func load() throws -> OAuthCredentials { if let error { throw error } - return OAuthCredentials(accessToken: token, expiresAt: nil, subscriptionType: "max") + return OAuthCredentials( + accessToken: token, + expiresAt: nil, + subscriptionType: "max", + organizationUuid: organization + ) } } @@ -63,6 +70,8 @@ private struct TranscriptSandbox { let root: URL let indexURL: URL let cacheURL: URL + let stateURL: URL + let alertsURL: URL init() { base = FileManager.default.temporaryDirectory @@ -70,6 +79,8 @@ private struct TranscriptSandbox { root = base.appendingPathComponent("projects") indexURL = base.appendingPathComponent("index.json") cacheURL = base.appendingPathComponent("cache.json") + stateURL = base.appendingPathComponent("state.json") + alertsURL = base.appendingPathComponent("alerts.json") try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } @@ -169,6 +180,8 @@ func runOfficialProviderTests(_ t: Harness) async { cacheURL: sandbox.cacheURL, localRoot: sandbox.root, indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, clock: { now } ) } @@ -358,6 +371,8 @@ func runOfficialProviderTests(_ t: Harness) async { cacheURL: sandbox.cacheURL, localRoot: sandbox.root, indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, clock: { testNow } ) } @@ -408,6 +423,7 @@ func runOfficialProviderTests(_ t: Harness) async { credentials: FakeCredentials(error: UsageError.unauthorized), transport: FakeTransport(answers: [(200, realResponse)]), cacheURL: nil, + stateURL: nil, clock: { testNow } ) do { @@ -429,6 +445,7 @@ func runOfficialProviderTests(_ t: Harness) async { credentials: FakeCredentials(), transport: transport, cacheURL: nil, + stateURL: nil, clock: { testNow } ) _ = try? await resolving.fetch() @@ -460,6 +477,8 @@ func runOfficialProviderTests(_ t: Harness) async { cacheURL: cacheURL, localRoot: sandbox.root, indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, clock: { testNow } ) @@ -477,6 +496,8 @@ func runOfficialProviderTests(_ t: Harness) async { cacheURL: cacheURL, localRoot: sandbox.root, indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, clock: { testNow } ) let fallback = try await offline.fetch() @@ -550,6 +571,107 @@ func runOfficialProviderTests(_ t: Harness) async { .isExpired(at: testNow), "без срока считаем годным") } + await t.suite("смена аккаунта: счёт начинается заново") { + let sandbox = TranscriptSandbox() + defer { sandbox.cleanup() } + sandbox.write(cost: 10, at: "2026-08-01T10:00:00.000Z") + // Счёт вёлся на прежнем аккаунте, и от него остался снимок. + try Store.saveState(CountingState(account: "старая01·max"), to: sandbox.stateURL) + try Store.saveCache( + CachedUsage(usedPercent: 50, byDay: [], windowStart: testNow, windowEnd: testNow, + source: .official, fetchedAt: testNow), + to: sandbox.cacheURL + ) + + let resolving = ResolvingProvider( + config: config(), + credentials: FakeCredentials(organization: "новая0123-4567"), + transport: FakeTransport(answers: [(200, realResponse)]), + cacheURL: sandbox.cacheURL, + localRoot: sandbox.root, + indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, + clock: { testNow } + ) + _ = try? await resolving.fetch() + + let state = Store.loadState(from: sandbox.stateURL) + t.equal(state.account, "новая012·max", "запомнен аккаунт, который в ключе сейчас") + t.equal(state.countFrom, testNow, "отсечка поставлена моментом, когда заметили смену") + // Кеш при этом не остаётся пустым: тот же `fetch` тут же кладёт в него + // снимок нового аккаунта — стирается прежний, а не сам файл навсегда. + t.equal(Store.loadCache(from: sandbox.cacheURL)?.usedPercent, 50, + "в кеше уже снимок нынешнего аккаунта") + } + + t.suite("сброс счёта стирает только те файлы, которые ему дали") { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-week-reset-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let stateURL = base.appendingPathComponent("state.json") + let cacheURL = base.appendingPathComponent("cache.json") + let alertsURL = base.appendingPathComponent("alerts.json") + try Store.saveCache( + CachedUsage(usedPercent: 50, byDay: [], windowStart: testNow, windowEnd: testNow, + source: .official, fetchedAt: testNow), + to: cacheURL + ) + try Store.saveAlerts(AlertLog(weekSaid: 80), to: alertsURL) + + // Пути настоящего приложения не должны пострадать — иначе прогон + // тестов сносил бы журнал и кеш живого виджета. + let liveAlerts = Store.loadAlerts() + let liveState = Store.loadState() + + try Store.resetCounting( + at: testNow, account: "метка·max", + stateURL: stateURL, cacheURL: cacheURL, alertsURL: alertsURL + ) + + t.check(!FileManager.default.fileExists(atPath: cacheURL.path), "снимок стёрт") + t.check(!FileManager.default.fileExists(atPath: alertsURL.path), "журнал стёрт") + t.equal(Store.loadState(from: stateURL), CountingState(countFrom: testNow, account: "метка·max"), + "отсечка и аккаунт записаны") + t.equal(Store.loadAlerts(), liveAlerts, "настоящий журнал пользователя не тронут") + t.equal(Store.loadState(), liveState, "и его состояние счёта тоже") + } + + await t.suite("первое знакомство сбросом не считается") { + let sandbox = TranscriptSandbox() + defer { sandbox.cleanup() } + sandbox.write(cost: 10, at: "2026-08-01T10:00:00.000Z") + + let resolving = ResolvingProvider( + config: config(), + credentials: FakeCredentials(organization: "первая01-2345"), + transport: FakeTransport(answers: [(200, realResponse)]), + cacheURL: sandbox.cacheURL, + localRoot: sandbox.root, + indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, + clock: { testNow } + ) + _ = try? await resolving.fetch() + + let state = Store.loadState(from: sandbox.stateURL) + t.equal(state.account, "первая01·max", "аккаунт запомнен") + t.check(state.countFrom == nil, "но накопленное не выброшено: сменить было не с чего") + } + + t.suite("метка аккаунта") { + let creds = OAuthCredentials( + accessToken: "x", expiresAt: nil, subscriptionType: "max", + organizationUuid: "0123abcd-89ef-4321-0000-000000000000" + ) + t.equal(creds.accountMark, "0123abcd·max", "метка — начало UUID организации и тариф") + t.check(OAuthCredentials(accessToken: "x", expiresAt: nil, subscriptionType: "max") + .accountMark == nil, "без организации сравнивать не с чем") + } + t.suite("разбор меток времени") { same(t, ISO8601.parse("2026-08-07T12:00:00.357993+00:00"), at(2026, 8, 7, 16, 0), "микросекунды и смещение вместо Z") diff --git a/docs/API.md b/docs/API.md index 3ed5857..ed1bb31 100644 --- a/docs/API.md +++ b/docs/API.md @@ -70,6 +70,15 @@ found or is malformed`; заголовки `anthropic-beta: oauth-2025-04-20` и `expiresAt` — миллисекунды (значения меньше `1e11` трактуем как секунды). Токен живёт около часа. +`organizationUuid` читается тоже — из него и `subscriptionType` складывается +**метка аккаунта** (`OAuthCredentials.accountMark`, вида `7f3a1b2c·max`). Токен +меняется раз в час, а она держится, пока не вошли другим аккаунтом, — и это +единственное, чем смена аккаунта замечается вообще: в транскриптах +`~/.claude/projects` маркера аккаунта нет, а сервер отвечает по токену и о +смене не сообщает. По расхождению метки счёт начинается заново +([USAGE.md](USAGE.md#смена-аккаунта)). Метка в `state.json` хранится целиком, +сам UUID — нет: сравнивать хватает её. + **Обновлением токена ClaudeWeek не занимается.** Refresh-цикл — дело Claude Code; два процесса, наперегонки меняющие одну запись Keychain, теряют токен. Мы перечитываем запись перед каждым запросом и на `401` уходим на локальную diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 06c73b3..53cd1b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,6 +22,7 @@ LocalProvider ────┘ │ ▲ │ │ ▼ ~/.claude/projects/*.jsonl cache.json (кеш + подобранный бюджет + сессия) + state.json (отсечка счёта + метка аккаунта) ▲ config.json ────────────────────┴──► StatusItemController ◄──► SettingsView (окно настроек) ``` @@ -52,10 +53,10 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `UsageProvider.swift` | протокол источника и типы ошибок | `UsageProvider`, `UsageError` | | `OfficialProvider.swift` | запрос к `/api/oauth/usage`, разбор ответа, паузы после отказов | `OfficialProvider`, `OfficialUsage`, `UsageTransport` | | `LocalProvider.swift` | расчёт по транскриптам, цены моделей, инкрементальное чтение файлов | `LocalProvider`, `LocalUsage`, `ModelPricing` | -| `ResolvingProvider.swift` | выбор источника, падение на запасной, калибровка, запись кеша | `ResolvingProvider` | -| `Cache.swift` | `cache.json`, индекс прочитанных транскриптов и лог сказанного уведомлениями | `CachedUsage`, `UsageIndex`, `Store` | +| `ResolvingProvider.swift` | выбор источника, падение на запасной, калибровка, запись кеша, сверка аккаунта | `ResolvingProvider` | +| `Cache.swift` | `cache.json`, индекс прочитанных транскриптов, лог сказанного уведомлениями и состояние счёта (`state.json`) | `CachedUsage`, `UsageIndex`, `CountingState`, `Store` | | `Alerts.swift` | уведомления: пороги, правила «когда сказать» и слова баннера | `NotificationsConfig`, `LimitNotifications`, `LimitAlert`, `AlertLog`, `AlertPlanner` | -| `Keychain.swift` | чтение OAuth-кредов Claude Code | `KeychainCredentials`, `OAuthCredentials`, `CredentialsSource` | +| `Keychain.swift` | чтение OAuth-кредов Claude Code и метка аккаунта, по которой видно смену | `KeychainCredentials`, `OAuthCredentials`, `CredentialsSource` | | `Formatting.swift` | «3 дн 6 ч», проценты, дни недели, часы | `Formatting` | | `ISO8601.swift` | разбор меток времени обоих видов (с долями секунды и без) | `ISO8601` | | `Log.swift` | уровни лога в stderr | `Log` | @@ -101,17 +102,23 @@ SwiftUI` — значит, расчёт просочился в UI или нао 6. **Калибровка идёт от `minimumCalibrationPercent`** (5 %, `ResolvingProvider`). Официальный процент целый, и на 2 % ошибка округления — четверть значения: бюджет из неё вышел бы кривым вдвое. -7. **`LocalProvider.scan` всегда идёт по диску.** Кешировать его итог нельзя, +7. **Локальный расход считается только с `countFrom`** (`CountingState`, + `state.json`). Транскрипты пишутся в одни и те же файлы при любом + аккаунте, и маркера аккаунта в записях нет — отсечка по времени + единственное, чем расход прежнего аккаунта отделяется от нынешнего. + Перечитывается она на каждом обходе, а не запоминается в поле: сброс + приходит из окна настроек, когда провайдер давно создан. +8. **`LocalProvider.scan` всегда идёт по диску.** Кешировать его итог нельзя, даже когда за цикл его зовут дважды: дописанная строка транскрипта потерялась бы до следующего запроса. -8. **Прибавление недели — календарное** (`byAdding: .day, value: 7`), не +9. **Прибавление недели — календарное** (`byAdding: .day, value: 7`), не `+604800`: в неделю с переводом часов сутки бывают 23 или 25 часов. -9. **Сутки окна — календарные сутки местной зоны** (`WeekWindow.dayBounds`), +10. **Сутки окна — календарные сутки местной зоны** (`WeekWindow.dayBounds`), а не интервалы «от сброса до сброса». Их семь только когда сброс попадает в полночь, иначе восемь: день сброса разрезан границами окна. Всё, что делит расход по суткам, спрашивает длину у `window.slotCount`, а не у `WeekWindow.daysInWeek` — та осталась длиной окна в сутках. -10. **Строк на панели всегда семь** (`WeekWindow.rowOrder(at:)`, +11. **Строк на панели всегда семь** (`WeekWindow.rowOrder(at:)`, `UsageSnapshot.rows(at:)`): день сброса занимает одну — двух строк с одинаковым «ПТ» и несопоставимыми процентами здесь быть не должно. Порядок задаёт `Config.weekStart`, и только порядок: границы окна и все @@ -135,18 +142,18 @@ SwiftUI` — значит, расчёт просочился в UI или нао подтягивается факт. Текущую строку ищет `WeekWindow.highlightedRow(at:)` — в день сброса его половина может не попасть в ряд, и «сегодня» подсвечивается по дню недели. -11. **План суток считается временем, а не их номером** +12. **План суток считается временем, а не их номером** (`planPercent(forDay:)` зовёт `planPercent(at:)`). Иначе обрезанные сутки получили бы долю полных, а ряд не сошёлся бы к 100 % в момент сброса. -12. **Время в плане — рабочее, а не астрономическое** (`WorkHours`, +13. **Время в плане — рабочее, а не астрономическое** (`WorkHours`, `WeekWindow.progress(at:)`). Ночью план стоит. Всё, что переводит проценты в моменты (прогноз исчерпания), идёт через `WeekWindow.date(atProgress:)`: делением тут больше ничего не находится. Границы рабочего дня — часы на циферблате (`Calendar.date(bySettingHour:…)`), а не «полночь плюс столько-то часов»: во втором случае в сутки перевода часов день съезжает на час. -13. **Кривой конфиг чинится, а не отвергается** (`Config.validated()`): +14. **Кривой конфиг чинится, а не отвергается** (`Config.validated()`): потерять строку меню из-за опечатки в JSON нельзя. -14. **Токен не логируется, не пишется в конфиг и не выводится в UI.** +15. **Токен не логируется, не пишется в конфиг и не выводится в UI.** --- @@ -236,6 +243,14 @@ macOS, сборка `UNNotificationRequest` с картинкой от `AlertArt своих отметках, и баннер о пробитом пороге приходил бы со спокойным зелёным числом — просто потому, что до окраски значка не хватило процента. +Третий повод заговорить — вышедшая версия. Он идёт мимо `AlertPlanner`: +правил у него нет, кроме «о каждой версии один раз», и живёт эта память тем же +`AlertLog` (`updateSaid`). `UpdateController` о уведомлениях не знает — он про +GitHub и установку; найдя релиз при самостоятельной проверке, он зовёт +`onFound`, а превращает это в баннер владелец обоих контроллеров +(`StatusItemController`). Нажатая кнопка «Проверить обновления» баннера не +шлёт: она отвечает окном, и второе сообщение о том же было бы шумом. + Разрешение спрашивается на старте (если уведомления включены) и в момент, когда их включают в настройках, — но не тогда, когда порог уже пробит: системный диалог посреди работы читается как выходка. У бинаря без бандла @@ -382,7 +397,7 @@ swift build # оба таргета # объявления: ключи kSecUseAuthenticationUI в Keychain.swift оставлены # намеренно, замены им нет, и группа понижена обратно до предупреждения. swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration -swift run ClaudeWeekTests # 439 проверок, без сети и без UI +swift run ClaudeWeekTests # 477 проверок, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) ./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app @@ -554,6 +569,7 @@ Intel — собирает у себя, `install.sh` соберёт нативн | `~/.config/claude-week/config.json` | настройки (пишет и окно настроек, и вы сами) | | `~/.config/claude-week/cache.json` | последний снимок, подобранный бюджет, момент сброса, сессия | | `~/.config/claude-week/alerts.json` | о каких порогах уведомления уже говорили: окна лимитов, последние объявленные проценты, момент последнего баннера. Удаление безопасно — вернётся одно повторное уведомление | +| `~/.config/claude-week/state.json` | с какого момента считается локальный расход и на каком аккаунте: `countFrom` и метка организации. Отдельно от кеша намеренно — тот перезаписывается каждым обновлением, а отсечка обязана его пережить. Удаление вернёт в счёт расход прежнего аккаунта | | `~/.config/claude-week/index.json` | индекс прочитанных транскриптов (инкрементальное чтение); схема 2 — записи хранят семейство модели и токены, из них считается разбивка. Индекс прошлой схемы не переносится, а отстраивается заново: версия проверяется до разбора записей | | `~/.claude/projects/**/*.jsonl` | транскрипты Claude Code — вход локального источника | | `~/Library/Logs/ClaudeWeek.log` | лог запущенного через LaunchAgent приложения | diff --git a/docs/L10N.md b/docs/L10N.md index cb1d4de..31a5f19 100644 --- a/docs/L10N.md +++ b/docs/L10N.md @@ -165,10 +165,11 @@ SwiftUI. чем ресурсный механизм, ради которого придётся чинить сборку бандла и который всё равно не покрывает главное требование, явный выбор языка. -В [ROADMAP](ROADMAP.md#11-локализация-только-русская) записан путь А -(«`String(localized:)` и `.strings`. День работы») — эта оценка сделана до -того, как выяснилось, что `make-app.sh` не переносит ресурсы SwiftPM в бандл. -Пункт поправлен ссылкой сюда. +В ROADMAP этот пункт стоял одиннадцатым и предлагал путь А +(«`String(localized:)` и `.strings`. День работы») — оценка сделана до того, +как выяснилось, что `make-app.sh` не переносит ресурсы SwiftPM в бандл. После +выпуска 0.1.11 пункт закрыт и переехал в [таблицу сделанного](ROADMAP.md), +уже со ссылкой сюда. ## Множественное число diff --git a/docs/PLAN.md b/docs/PLAN.md index 77cc26d..e7c352c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -277,7 +277,7 @@ ClaudeWeek/ │ │ ├── WorkHours.swift # рабочий день: сколько рабочего времени в отрезке │ │ ├── Snapshot.swift # модель того, что рисуем, и метрики футера │ │ ├── Config.swift # чтение/дефолты config.json -│ │ ├── Cache.swift # index.json + cache.json +│ │ ├── Cache.swift # index.json + cache.json + state.json │ │ ├── UsageProvider.swift # protocol UsageProvider, UsageError │ │ ├── LocalProvider.swift # разбор jsonl, веса, инкрементальный индекс │ │ ├── OfficialProvider.swift # /api/oauth/usage, декодер, троттлинг, backoff @@ -653,7 +653,7 @@ assert-хелперов, запускаемый `swift run ClaudeWeekTests`; н = падение. Когда/если появится Xcode, таргет переносится на swift-testing без изменения самих проверок. -Что покрыто (439 проверок, `swift run ClaudeWeekTests`): +Что покрыто (477 проверок, `swift run ClaudeWeekTests`): 1. **Окно недели.** Момент ровно на сбросе; за минуту до и после; полсекунды до; воскресный полдень; смена таймзоны между вызовами; другой день сброса. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8f6bc26..ecf8688 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -168,11 +168,20 @@ XCTest без Xcode недоступен, поэтому проверки нап ### 10. Только один аккаунт за раз -Токен один — либо из Claude Code, либо свой. Показать два аккаунта рядом -(рабочий и личный) нельзя. - -*Что делать:* список источников вместо одного и переключатель в панели. -Работы на день, но польза сомнительна, пока никто не попросил. +Токен один и всегда из Claude Code: поля «свой токен» в приложении нет — +`/api/oauth/usage` принимает только токен сеанса, остальные отвергает с `401` +(разбор в [API.md](API.md#токен)). Виджет показывает тот аккаунт, под которым +сейчас работает Claude Code, и показать два рядом — рабочий и личный — нельзя. + +Смена аккаунта при этом уже не врёт: программа замечает её по метке +организации из Keychain и начинает счёт заново, а вручную то же делает кнопка +«Начать счёт заново» ([USAGE.md](USAGE.md#смена-аккаунта)). Чего нет — истории +по каждому аккаунту: после переключения прежние цифры не сохраняются, а +отбрасываются. + +*Что делать:* хранить состояние по метке аккаунта, а не одним файлом, и +переключатель в панели. Работы на день, но польза сомнительна, пока никто не +попросил. ### 11. Сборки не нотаризованы diff --git a/docs/USAGE.md b/docs/USAGE.md index f9a4641..9bd964a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -441,6 +441,7 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 | Показать пример | своя кнопка в каждой секции лимита: шлёт такой же баннер, как настоящий, с первым порогом этого лимита вместо живого расхода. На историю сказанного не влияет | | Недельный лимит | уведомлять ли о неделе и после скольких процентов — два порога, по умолчанию 80 и 95. В баннере приходит красным числом | | Пятичасовая сессия | то же для сессии, по умолчанию 75 и 95. В баннере приходит дугой | +| Сообщать о новой версии | баннер, когда на GitHub вышел релиз новее вашего. Не гаснет вместе с общим выключателем: тот про разговоры о расходе, а это новость другого рода | Пороги здесь свои, отдельные от цветовых со вкладки «Строка меню», и это не дублирование: цвет замечают, только посмотрев на часы, а баннер приходит @@ -454,9 +455,17 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 ### Доступ -Настроек здесь нет — только объяснение, откуда берётся токен, почему вставить -свой нельзя и как обойтись без него вовсе, плюс кнопка «Проверить сейчас»: она -делает один настоящий запрос и печатает результат — процент или причину отказа. +Объяснение, откуда берётся токен, почему вставить свой нельзя и как обойтись +без него вовсе, плюс кнопка «Проверить сейчас»: она делает один настоящий +запрос и печатает результат — процент или причину отказа. + +Сверху — **«Сейчас в ключе»**: метка аккаунта из записи Keychain, начало UUID +организации и тариф (`7f3a1b2c·max`). Токен обновляется раз в час, а метка +держится — по ней и видно, тот же это аккаунт, что вчера, или вошли другим. +В режиме «только локальная оценка» строка пустая: там Keychain не читается +вовсе, и ради одной подписи нарушать это обещание незачем. + +Рядом — **«Начать счёт заново»** (см. [Смена аккаунта](#смена-аккаунта)). ### О программе @@ -563,9 +572,15 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 До перезапуска в строке меню живёт прежняя версия. Конфиг, кеш, калибровка и launchd-агент не трогаются. -Окнами разговаривает только нажатая кнопка. Самостоятельная проверка молчит: -нашла — строка в панели, не дозвонилась до GitHub — запись в лог, и ничего -поверх работы. +Окнами разговаривает только нажатая кнопка. Самостоятельная проверка окон не +открывает: нашла — строка внизу панели и баннер macOS, не дозвонилась до +GitHub — запись в лог, и ничего поверх работы. + +О версии баннер говорит **один раз**, а не каждые сутки до установки: сказанное +запоминается в `alerts.json` и переживает перезапуск. Кнопки в нём нет +намеренно — установка перезаписывает приложение и перезапускает его, а такому +не место за одним щелчком из-под чужой работы. Выключается на вкладке +«Уведомления», отдельно от разговоров о лимитах. Ограничения: @@ -588,15 +603,17 @@ launchd-агент не трогаются. | Ключ | По умолчанию | Смысл | |---|---|---| +| `language` | `system` | язык интерфейса: `system`, `ru` или `en`. Тот же выбор, что на вкладке «Общие» | | `weekStart` | `monday` | с какого дня ряд суток на панели: `monday` или `reset` (день сброса). На цифры не влияет — только порядок строк | | `resetWeekday` / `resetHour` / `resetMinute` | `6` / `16` / `0` | запасной момент сброса — пятница 16:00 в `timeZone` ниже, то есть 15:00 МСК. В настройках этих полей нет: они идут в дело, только если сервер не отвечал ещё ни разу и кеш пуст | | `timeZone` | `""` | таймзона окна, границ суток и подписи сброса; пустая строка — системная | -| `refreshInterval` | `300` | секунды между обновлениями, минимум 30 | +| `refreshInterval` | `300` | секунды между обновлениями. Меньше 30 чинится на 30, ползунок в настройках начинается с 60. В сеть при этом ходят не чаще раза в минуту в любом случае — короткий интервал только чаще перерисовывает панель по уже полученному числу | | `provider` | `auto` | `official`, `local` или `auto` | | `workHours.start` / `.end` | `11` / `24` | рабочий день, по которому раскладывается лимит; `24` — полночь. `0`/`24` — считать круглосуточно, как раньше | | `menuBarStyle` | `ring` | `percent`, `compact` (только полоска) или `ring` (кольцо с процентом) | | `ringArc` | `session` | что заполняет дугу кольца: `session` или `week`; второй лимит уходит на цифру внутри | | `weeklyBudget` | `0` | условная стоимость недели для офлайн-оценки; ставится калибровкой | +| `calibration.observedPercent` / `.at` | — | ручная калибровка: официальный процент и момент, когда он наблюдался. Программа подбирает бюджет и сама, складывая его в `cache.json`, — заданное здесь важнее | | `thresholds.weekWarn` / `.weekCritical` | `81` / `93` | проценты недельного лимита, после которых цифра желтеет и краснеет | | `thresholds.sessionWarn` / `.sessionCritical` | `81` / `95` | то же для пятичасовой сессии — кольцо в строке меню и полоса в панели | | `thresholds.colorizeMenuBar` | `true` | красить ли значок в строке меню по порогам | @@ -606,6 +623,7 @@ launchd-агент не трогаются. | `notifications.week.first` / `.second` | `80` / `95` | проценты недели, на которых приходит баннер | | `notifications.session.enabled` | `true` | уведомлять ли о пятичасовой сессии | | `notifications.session.first` / `.second` | `75` / `95` | то же для сессии | +| `notifications.update` | `true` | баннер о вышедшей версии | | `appearance.theme` | `contrast` | `system`, `midnight`, `graphite`, `paper`, `contrast` | | `appearance.transparentPanel` | `true` | материал с размытием или сплошной фон | | `appearance.panelTintOpacity` | `0.6` | плотность вуали поверх материала, 0…1 | @@ -689,6 +707,45 @@ launchd-агент не трогаются. уважение к «Уменьшению движения», числа рядом с каждой полосой: цвет нигде не остаётся единственным носителем смысла. +## Смена аккаунта + +Вошли в Claude Code другим аккаунтом — рабочим вместо домашнего, — и виджет +показывает две правды сразу. Недельный процент приходит от сервера и сменяется +сам: сервер узнаёт аккаунт по токену. А вот разбивка по суткам считается по +транскриптам в `~/.claude/projects`, и они пишутся в одни и те же файлы, кто бы +ни был залогинен. Различить их по содержимому нечем — маркера аккаунта в +записях нет. Без отсечки расход прежнего аккаунта считался бы в новый лимит до +конца недели. + +Поэтому у счёта есть **начало**. Программа замечает смену сама: сверяет метку +аккаунта из Keychain (начало UUID организации и тариф) с той, на которой ведётся +счёт, и при расхождении начинает заново. Метка живёт в +`~/.config/claude-week/state.json` вместе с моментом отсечки. + +Первый запуск сбросом не считается: метки в состоянии ещё нет, сравнивать не с +чем, и программа просто запоминает, с кем имеет дело. По той же причине не +сработает автосброс сразу после обновления с версии, которая метку не писала, — +там счёт сбрасывается кнопкой. + +Кнопка эта — **«Начать счёт заново»** на вкладке «Доступ». Нужна и тогда, когда +Keychain недоступен: без него метку не прочитать, и смену аккаунта заметить +неоткуда. Что делает: + +- ставит отсечку по текущей минуте — расход до неё в счёт больше не идёт; +- стирает `cache.json`: в нём процент, подобранный бюджет и сессия прежнего + аккаунта; +- стирает `alerts.json`: о порогах прежнего аккаунта уже сказали, и повторять + это, считая с нуля, — шум. + +Настройки, транскрипты и недельный процент от сервера остаются на месте. Индекс +транскриптов тоже не трогается: старые записи из него отсекает та же отсечка, а +выброшенный он стоил бы полного перечитывания `~/.claude/projects` на ровном +месте. + +Пока официальный источник отвечает, разница видна только в суточных полосах: +итог недели сервер и так считает по нынешнему аккаунту. Офлайн отсечка важнее — +локальная оценка целиком построена на транскриптах. + ## Командная строка ``` diff --git a/docs/images/settings-notifications-dark.png b/docs/images/settings-notifications-dark.png index 4755762..9cf8014 100644 Binary files a/docs/images/settings-notifications-dark.png and b/docs/images/settings-notifications-dark.png differ diff --git a/docs/images/settings-notifications-light.png b/docs/images/settings-notifications-light.png index 120c206..495a7ce 100644 Binary files a/docs/images/settings-notifications-light.png and b/docs/images/settings-notifications-light.png differ