From 7136cffd7452d9821ebcf2826df27b11a8f58f76 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 30 Aug 2026 00:21:44 +0300 Subject: [PATCH 1/5] codex init --- AGENTS.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2472fc9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Структура проекта и модули + +Проект — Swift Package для macOS 14+ с тремя таргетами. `Sources/ClaudeWeekCore/` содержит расчёты, конфигурацию, провайдеры и обновление; ядро не должно импортировать AppKit или SwiftUI. `Sources/ClaudeWeekApp/` отвечает за меню-бар, панель, настройки и CLI. Проверки находятся в `Sources/ClaudeWeekTests/` и запускаются собственным исполняемым раннером. Ресурсы бандла лежат в `Resources/`, документация — в `docs/`, а сценарии сборки и релиза — в `scripts/`. `.build/` и `dist/` — генерируемые каталоги, их не коммитят. + +## Сборка, тесты и локальный запуск + +- `swift build` — собирает пакет в отладочной конфигурации. +- `swift run ClaudeWeekApp` — запускает приложение из исходников; рядом с установленной копией появится вторая иконка. +- `swift run ClaudeWeekTests` — выполняет все проверки без сети и UI. +- `swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration` — повторяет строгую сборку CI. +- `./scripts/make-app.sh` — создаёт `dist/ClaudeWeek.app` без установки. +- `./scripts/install.sh` — собирает, устанавливает и запускает локальную версию. + +Перед PR выполните строгую сборку, тестовый раннер и `make-app.sh`. + +## Стиль кода и именование + +Используйте Swift 6 со строгой изоляцией акторов и четырёхпробельными отступами. Следуйте Swift-конвенциям: типы — `UpperCamelCase`, функции и свойства — `lowerCamelCase`, имена файлов совпадают с основным типом. Сохраняйте разделение Core/UI и стиль соседнего кода. Комментарии пишите по-русски и объясняйте причины решений, а не очевидные действия. Отдельного форматтера нет; предупреждения компилятора считаются ошибками. + +## Правила тестирования + +Добавляйте проверки изменений расчётов в `Sources/ClaudeWeekTests/`. Файлы называйте `<Область>Tests.swift`, точку входа — `run<Область>Tests(_:)`, затем регистрируйте её в `main.swift`. Формального порога покрытия нет, но все исправления ошибок и новое поведение ядра должны иметь регрессионный сценарий. + +## Инструкции для агентов + +При поиске кода сначала используйте настроенный `codebase-memory`: `search_graph` для символов, `trace_path` для связей и `get_code_snippet` для исходника найденного типа или функции. К `rg` переходите для строк, конфигурации, документации и сценариев либо когда граф не дал достаточного результата. Перед изменением архитектуры прочитайте `docs/ARCHITECTURE.md`, а для панели — также `docs/SPACES.md`. + +## Коммиты и Pull Request + +Коммит — одна русская строка до 72 символов, с глаголом прошедшего времени: `Добавил уведомление о перерасходе`. Не используйте `feat:`, `fix:` и тела коммитов. Сначала согласуйте изменение в issue, создайте ветку от `main` и держите один PR в рамках одной идеи. В описании укажите причину, проверенные команды и связанную issue; для UI приложите снимки. Пользовательские изменения внесите в раздел `Не выпущено` файла `CHANGELOG.md`, а соответствующую документацию обновите тем же PR. From e3235cc8407b23eddab316d589e573e956efea9b Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 30 Aug 2026 09:12:15 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20Claude/Codex=20=D0=B8=20=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В заголовке панели две кнопки переключают сервис. Для Codex приложение запускает codex app-server, берёт короткое и недельное окна, моменты сброса и дневную токенную форму; длину короткого окна показывает такой, какой её сообщил сервер. Выбранный сервис переживает перезапуск. Снимок каждого аккаунта живёт отдельно: в памяти ключом служит UsageAccount, на диске у Codex свои codex-cache.json и codex-alerts.json. Запоздалый ответ старого провайдера отсекает поколение обновления в StatusItemController. Флаг --account=claude|codex выбирает сервис и для --json. Обновил README, docs/API.md, docs/ARCHITECTURE.md, docs/USAGE.md и журнал. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Cnw6tgByHoBX8xPWN3dawq --- CHANGELOG.md | 8 + README.md | 31 +- Sources/ClaudeWeekApp/AccountPicker.swift | 74 ++ Sources/ClaudeWeekApp/CLI.swift | 89 ++- .../NotificationController.swift | 47 +- Sources/ClaudeWeekApp/PopoverView.swift | 20 +- Sources/ClaudeWeekApp/SessionRow.swift | 7 +- .../ClaudeWeekApp/StatusItemController.swift | 161 ++++- Sources/ClaudeWeekApp/main.swift | 7 + Sources/ClaudeWeekCore/Cache.swift | 4 + Sources/ClaudeWeekCore/CodexProvider.swift | 648 ++++++++++++++++++ Sources/ClaudeWeekCore/Config.swift | 22 + Sources/ClaudeWeekCore/Formatting.swift | 28 + Sources/ClaudeWeekCore/Snapshot.swift | 22 +- .../ClaudeWeekTests/CodexProviderTests.swift | 305 +++++++++ Sources/ClaudeWeekTests/main.swift | 1 + docs/API.md | 79 +++ docs/ARCHITECTURE.md | 42 +- docs/USAGE.md | 38 +- 19 files changed, 1536 insertions(+), 97 deletions(-) create mode 100644 Sources/ClaudeWeekApp/AccountPicker.swift create mode 100644 Sources/ClaudeWeekCore/CodexProvider.swift create mode 100644 Sources/ClaudeWeekTests/CodexProviderTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d9249f2..4e123eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ ## [Не выпущено] +### Добавлено + +- **Переключатель Claude / Codex в заголовке панели.** Для Codex приложение + получает короткий и недельный лимиты, моменты сброса и дневную токенную форму + через официальный `codex app-server`; длина короткого окна показывается такой, + какой её сообщил сервер. Выбранный сервис переживает перезапуск, а кеши и + история уведомлений Claude и Codex хранятся раздельно. + ## [0.2.0] — 2026-08-23 ### Добавлено diff --git a/README.md b/README.md index 5914f03..c5d2ca0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ [![Swift 6](https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white)](Package.swift) [![Лицензия MIT](https://img.shields.io/badge/лицензия-MIT-blue)](LICENSE) -Меню-бар приложение для macOS: недельный лимит Claude Code одним взглядом. +Меню-бар приложение для macOS: лимиты Claude Code и Codex одним взглядом. +Две кнопки в заголовке панели переключают сервисы; у каждого сохраняются свой +снимок, короткое окно, неделя и история уведомлений. На каждой полосе два цвета — **план** (сколько допустимо потратить к этому моменту) и **факт** (сколько реально потрачено). Видно не только «сколько @@ -45,8 +47,9 @@ ## Установка -**Требования:** macOS 14+ и установленный, авторизованный Claude Code. Ни Apple -ID, ни платной подписки разработчика не нужно. +**Требования:** macOS 14+ и установленный, авторизованный клиент того сервиса, +который вы хотите смотреть: Claude Code для Claude, Codex CLI или Codex app для +Codex. Ни Apple ID, ни платной подписки разработчика не нужно. ### Готовая сборка @@ -104,8 +107,10 @@ cd ClaudeWeek ./scripts/install.sh --ref my-branch # конкретную ветку, тег или коммит ``` -Ничего вводить не нужно: приложение читает OAuth-токен уже авторизованного -Claude Code из Keychain — только читает, наружу не отдаёт, на диск не пишет. +Ничего вводить не нужно: для Claude приложение читает OAuth-токен уже +авторизованного Claude Code из Keychain — только читает, наружу не отдаёт, на +диск не пишет. Для Codex оно запускает официальный `codex app-server`, который +сам использует вход Codex; его токены ClaudeWeek тоже не читает и не хранит. Разбор — [docs/USAGE.md](docs/USAGE.md#авторизация-и-доступ). Один раз стоит сделать вот что: @@ -131,6 +136,9 @@ Code сбрасывает каждым обновлением токена, и ## Что показывает +- **Claude и Codex по отдельности** — две кнопки в заголовке мгновенно меняют + аккаунт панели. Выбор переживает перезапуск, кеши и уведомления сервисов друг + друга не затирают. - **Неделя с планом** — семь суточных полос: факт, плановая зона, перерасход. Включается в настройках галкой «Дневной план» (по умолчанию панель короче — без разбора по дням, только сессия и итог недели). 100 % лимита @@ -138,8 +146,8 @@ Code сбрасывает каждым обновлением токена, и астрономическим: сон не должен весить столько же, сколько работа. Ряд идёт с понедельника, а сутки, прошедшие сразу после сброса, стоят под чертой в конце; в настройках его можно развернуть от дня сброса. -- **Пятичасовую сессию** — отдельной строкой над сутками, с часом сброса. Тот - лимит, в который упираются чаще недельного. +- **Короткое окно** — отдельной строкой над сутками, с часом сброса. У Claude + это пятичасовая сессия; у Codex длина приходит от сервера и может отличаться. - **Прогноз** — «при таком темпе кончится ПТ 15:57», тоже по рабочим часам. - **Чем потрачено** — клик по цифрам процента заменяет дни разбивкой по моделям: доля Opus, Sonnet и Haiku теми же полосами; клик ещё раз возвращает @@ -147,7 +155,7 @@ Code сбрасывает каждым обновлением токена, и - **Откуда цифры** — кружок у полосы сессии: зелёный залитый — живой ответ сервера, жёлтый — кеш, красный контурный — локальная оценка. Форма дублирует цвет, чтобы читалось при дальтонизме. -- **Честный офлайн** — без сети расход считается по вашим же транскриптам +- **Честный офлайн для Claude** — без сети расход считается по вашим же транскриптам `~/.claude/projects` и помечается знаком `≈`. Калибруется сам, по последнему официальному ответу. - **Пороги цвета** — жёлтый после 81 %, красный после 93 % у недели и 95 % у @@ -203,7 +211,8 @@ Code сбрасывает каждым обновлением токена, и ставится обновление на вкладке «О программе». Тумблер у него свой: погасив разговоры о расходе, вы не обязаны молчать и про релизы. -Сказанное запоминается в `~/.config/claude-week/alerts.json` и переживает +Сказанное запоминается в `~/.config/claude-week/alerts.json` для Claude и +`codex-alerts.json` для Codex и переживает перезапуск: после перезагрузки посреди недели про пройденные пороги программа молчит, а про уже объявленную версию не напоминает. Выключить можно всё разом или по лимиту в отдельности; разрешение macOS спрашивается один раз, при первом запуске. Как баннер выглядит именно у вас, @@ -218,6 +227,10 @@ Code сбрасывает каждым обновлением токена, и ## Смена аккаунта +Кнопки Claude и Codex в заголовке переключают **сервисы**, а не перелогинивают +их. ClaudeWeek показывает тот аккаунт, в который уже вошёл соответствующий +клиент. Последний выбранный сервис записывается в `config.json`. + Вошли другим аккаунтом — рабочим вместо домашнего — и счёт начинается заново. Недельный процент сменяется сам: сервер узнаёт аккаунт по токену. А вот разбивка по суткам считается по транскриптам в `~/.claude/projects`, а те diff --git a/Sources/ClaudeWeekApp/AccountPicker.swift b/Sources/ClaudeWeekApp/AccountPicker.swift new file mode 100644 index 0000000..a4a7819 --- /dev/null +++ b/Sources/ClaudeWeekApp/AccountPicker.swift @@ -0,0 +1,74 @@ +import SwiftUI +import ClaudeWeekCore + +/// Две компактные кнопки в заголовке панели. Выбор хранится в конфиге, но +/// сам вид получает только значение и действие — отдельного локального +/// состояния, способного разойтись с реально загруженным аккаунтом, нет. +struct AccountPicker: View { + let selection: UsageAccount + let onSelect: (UsageAccount) -> Void + + @Environment(\.strings) private var s + + var body: some View { + HStack(spacing: 4) { + ForEach(UsageAccount.allCases, id: \.self) { account in + AccountButton( + account: account, + isSelected: account == selection, + onSelect: onSelect + ) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(s.pick("Аккаунт", "Account")) + } +} + +private struct AccountButton: View { + let account: UsageAccount + let isSelected: Bool + let onSelect: (UsageAccount) -> Void + + @Environment(\.palette) private var palette + @Environment(\.strings) private var s + + var body: some View { + Button { + onSelect(account) + } label: { + Image(systemName: symbol) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(isSelected ? palette.primaryText.color : palette.secondaryText.color) + .frame(width: 18, height: 18) + .background { + Circle().fill(isSelected ? palette.track.color : .clear) + } + .overlay { + Circle().strokeBorder( + isSelected ? palette.plan.color : palette.separator.color, + lineWidth: isSelected ? 1.5 : 1 + ) + } + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(help) + .accessibilityLabel(help) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var symbol: String { + switch account { + case .claude: "sparkle" + case .codex: "chevron.left.forwardslash.chevron.right" + } + } + + private var help: String { + let title = account.title(s.lang) + return isSelected + ? s.pick("Показан \(title)", "Showing \(title)") + : s.pick("Показать \(title)", "Show \(title)") + } +} diff --git a/Sources/ClaudeWeekApp/CLI.swift b/Sources/ClaudeWeekApp/CLI.swift index 4813b20..d3699d1 100644 --- a/Sources/ClaudeWeekApp/CLI.swift +++ b/Sources/ClaudeWeekApp/CLI.swift @@ -6,11 +6,12 @@ import ClaudeWeekCore /// даже когда калибровки ещё нет. enum CLI { static let usage = """ - ClaudeWeek \(ClaudeWeek.version) — недельный лимит Claude Code в строке меню. + ClaudeWeek \(ClaudeWeek.version) — лимиты Claude Code и Codex в строке меню. Использование: ClaudeWeek запустить приложение в строке меню ClaudeWeek --json напечатать состояние недели в JSON и выйти + ClaudeWeek --account=X сервис: claude или codex ClaudeWeek --provider=X источник данных: official, local или auto ClaudeWeek --config=ПУТЬ свой файл конфигурации ClaudeWeek --calibrate=N подогнать локальную оценку под официальные N % @@ -26,7 +27,7 @@ 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=", "--account=", "--provider=", "--calibrate=", "--lang="] static func isKnown(_ argument: String) -> Bool { flags.contains(argument) || flagPrefixes.contains { argument.hasPrefix($0) } @@ -51,6 +52,7 @@ enum CLI { } struct Output: Encodable { + let account: String let source: String let isEstimate: Bool let generatedAt: Date @@ -59,9 +61,9 @@ enum CLI { /// Пятичасовой лимит; ключа нет, когда его не сообщили или окно уже /// истекло — как и в панели, просроченный процент не показываем. let session: Session? - let cost: Cost + let cost: Cost? let days: [Day] - let scan: Scan + let scan: Scan? let note: String? struct Window: Encodable { @@ -86,6 +88,7 @@ enum CLI { struct Session: Encodable { let usedPercent: Double + let windowDurationMinutes: Int let resetsAt: Date let timeLeft: String let exhausted: Bool @@ -249,6 +252,9 @@ enum CLI { if snapshot?.isEstimate == true { return "локальная оценка: официальный источник недоступен" } + if snapshot?.source == .codex, snapshot?.shapeIsEstimate == true { + return "итог Codex официальный; разбивка по суткам восстановлена по дневным токенам" + } return snapshot?.shapeIsEstimate == true ? "итог официальный; разбивка по суткам восстановлена по транскриптам" : nil @@ -258,13 +264,18 @@ enum CLI { let now = Date() let started = Date() - // Снимок берём тем же путём, что и приложение: официальный источник - // с падением на локальную оценку. Иначе `--json` показывал бы не то, - // что видно в строке меню. + // Снимок берём тем же путём, что и приложение. Для Claude это + // официальный источник с падением на локальную оценку, для Codex — + // app-server. Иначе `--json` показывал бы не то, что видно в панели. var snapshot: UsageSnapshot? var failure: Error? do { - snapshot = try await ResolvingProvider(config: config).fetch() + switch config.activeAccount { + case .claude: + snapshot = try await ResolvingProvider(config: config).fetch() + case .codex: + snapshot = try await CodexProvider(config: config).fetch() + } } catch { failure = error } @@ -272,21 +283,29 @@ enum CLI { // Окно официального источника, а без него — рассчитанное по конфигу. let window = snapshot?.window ?? WeekWindow(containing: now, config: config) - let local = LocalProvider(config: config) - - // Локальный скан нужен и при официальном источнике: он даёт стоимость - // и диагностику обхода, которых в ответе API нет. - let usage: LocalUsage - do { - usage = try await local.scan(window: window, now: now) - } catch { - FileHandle.standardError.write(Data("не смог прочитать транскрипты: \(error)\n".utf8)) - return 1 + let usage: LocalUsage? + let budget: Double? + if config.activeAccount == .claude { + let local = LocalProvider(config: config) + do { + let scanned = try await local.scan(window: window, now: now) + usage = scanned + // Бюджет мог быть подобран автоматически по официальному + // проценту — он лежит в кеше, а не в конфиге. + budget = try? await local.budget( + for: scanned, + override: Store.loadCache()?.weeklyBudget + ) + } catch { + FileHandle.standardError.write(Data("не смог прочитать транскрипты: \(error)\n".utf8)) + return 1 + } + } else { + // Транскрипты Claude ничего не говорят о расходе Codex. Нули здесь + // выглядели бы измерением, поэтому стоимость и скан не выводятся. + usage = nil + budget = nil } - - // Бюджет мог быть подобран автоматически по официальному проценту — - // он лежит в кеше, а не в конфиге. - let budget = try? await local.budget(for: usage, override: Store.loadCache()?.weeklyBudget) var percent: Output.Percent? if let snapshot { @@ -308,6 +327,7 @@ enum CLI { .map { Output.Session( usedPercent: $0.usedPercent, + windowDurationMinutes: $0.windowDurationMinutes, resetsAt: $0.resetsAt, timeLeft: Formatting.duration($0.timeLeft(from: now)), exhausted: $0.isExhausted @@ -335,13 +355,16 @@ enum CLI { start: row.start, planPercent: row.plan, usedPercent: row.used, - cost: row.index < usage.costByDay.count ? usage.costByDay[row.index] : nil, + cost: usage.flatMap { + row.index < $0.costByDay.count ? $0.costByDay[row.index] : nil + }, partial: row.partial ) } let output = Output( - source: (snapshot?.source ?? .local).rawValue, + account: config.activeAccount.rawValue, + source: (snapshot?.source ?? (config.activeAccount == .codex ? .codex : .local)).rawValue, isEstimate: snapshot?.isEstimate ?? true, generatedAt: now, window: Output.Window( @@ -353,14 +376,18 @@ enum CLI { ), percent: percent, session: session, - cost: Output.Cost(total: usage.totalCost, weeklyBudget: budget, currency: "USD"), + cost: usage.map { + Output.Cost(total: $0.totalCost, weeklyBudget: budget, currency: "USD") + }, days: days, - scan: Output.Scan( - filesRead: usage.filesRead, - records: usage.recordCount, - duplicatesSkipped: usage.duplicatesSkipped, - elapsedSeconds: elapsed - ), + scan: usage.map { + Output.Scan( + filesRead: $0.filesRead, + records: $0.recordCount, + duplicatesSkipped: $0.duplicatesSkipped, + elapsedSeconds: elapsed + ) + }, note: note(percent: percent, snapshot: snapshot, failure: failure) ) diff --git a/Sources/ClaudeWeekApp/NotificationController.swift b/Sources/ClaudeWeekApp/NotificationController.swift index b2e426c..ea8586e 100644 --- a/Sources/ClaudeWeekApp/NotificationController.swift +++ b/Sources/ClaudeWeekApp/NotificationController.swift @@ -8,8 +8,8 @@ import ClaudeWeekCore /// с системой — разрешение, отправка, память о сказанном на диске. /// /// Всё, что связано с уведомлениями, идёт через один экземпляр: и панель, и -/// окно настроек смотрят на его состояние, а лог сказанного существует в -/// единственном числе — две копии разошлись бы и объявили один порог дважды. +/// окно настроек смотрят на его состояние. Логи сервисов раздельны, но владеет +/// ими один контроллер — две копии разошлись бы и объявили порог дважды. @MainActor @Observable final class NotificationController { @@ -29,17 +29,25 @@ final class NotificationController { private let center: UNUserNotificationCenter? private let delegate = BannerDelegate() private let logURL: URL + private let codexLogURL: URL private var log: AlertLog + private var codexLog: AlertLog /// Каким контроллер показывает себя настройкам. Совпадает с `isAvailable` /// везде, кроме отрисовки картинок для документации: `--screenshot` идёт /// из отладочного бинаря, и вкладка на снимке иначе объясняла бы, почему /// уведомления недоступны, — при том что у собранного приложения они есть. private let bundled: Bool - init(logURL: URL = Store.alertsURL, bundled: Bool = NotificationController.isAvailable) { + init( + logURL: URL = Store.alertsURL, + codexLogURL: URL = Store.codexAlertsURL, + bundled: Bool = NotificationController.isAvailable + ) { self.logURL = logURL + self.codexLogURL = codexLogURL self.bundled = bundled log = Store.loadAlerts(from: logURL) + codexLog = Store.loadAlerts(from: codexLogURL) // Центр спрашиваем только у настоящего бандла, чем бы ни было `bundled`: // без Info.plist обращение к нему роняет процесс. center = NotificationController.isAvailable ? .current() : nil @@ -89,16 +97,23 @@ final class NotificationController { func consider(_ snapshot: UsageSnapshot, config: Config, now: Date = Date()) { guard center != nil else { return } - var updated = log + let account = config.activeAccount + let old = account == .codex ? codexLog : log + var updated = old let alerts = AlertPlanner.alerts( for: snapshot, config: config.notifications, log: &updated, now: now ) // Лог меняется и без единого баннера — на смене окна лимита, — и // записать это надо тоже: иначе после перезапуска программа объявит // прошлонедельные пороги как сегодняшние. - if updated != log { - log = updated - save() + if updated != old { + if account == .codex { + codexLog = updated + save(codexLog, to: codexLogURL) + } else { + log = updated + save(log, to: logURL) + } } for alert in alerts { @@ -179,13 +194,14 @@ final class NotificationController { private func send(_ alert: LimitAlert, config: Config, now: Date) { guard let center else { return } - let text = alert.message(now: now) + let text = alert.message(now: now, lang: config.strings.lang) let content = UNMutableNotificationContent() content.title = text.title + content.subtitle = config.activeAccount.title(config.strings.lang) content.body = text.body // Своя нить на каждый лимит: неделя и сессия копятся в Центре // уведомлений двумя стопками, а не вперемешку. - content.threadIdentifier = alert.kind.rawValue + content.threadIdentifier = "\(config.activeAccount.rawValue)-\(alert.kind.rawValue)" if config.notifications.sound { content.sound = .default } if let picture = AlertArtwork.png( @@ -202,7 +218,12 @@ final class NotificationController { // Идентификатор собран из повода: пришедший дважды один и тот же // порог заменит прежний баннер, а не ляжет вторым. По-настоящему это // случается только с примером из настроек — остальное отсекают правила. - let id = "\(alert.kind.rawValue)-\(Int(alert.threshold))-\(Int(alert.resetsAt.timeIntervalSince1970))" + let id = [ + config.activeAccount.rawValue, + alert.kind.rawValue, + String(Int(alert.threshold)), + String(Int(alert.resetsAt.timeIntervalSince1970)), + ].joined(separator: "-") let request = UNNotificationRequest(identifier: id, content: content, trigger: nil) Task { @@ -230,8 +251,12 @@ final class NotificationController { } private func save() { + save(log, to: logURL) + } + + private func save(_ value: AlertLog, to url: URL) { do { - try Store.saveAlerts(log, to: logURL) + try Store.saveAlerts(value, to: url) } catch { Log.warn("не сохранил историю уведомлений: \(error)") } diff --git a/Sources/ClaudeWeekApp/PopoverView.swift b/Sources/ClaudeWeekApp/PopoverView.swift index 0c2b895..022a4cf 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 = {} @@ -144,6 +145,11 @@ struct PopoverView: View { : palette.primaryText.color ) + AccountPicker( + selection: model.config.activeAccount, + onSelect: onAccountChange + ) + Spacer(minLength: 4) if !showsSessionRow { @@ -191,7 +197,7 @@ struct PopoverView: View { // Пометки об источнике под заголовком больше нет — её место занял // кружок. VoiceOver цвет с заливкой не читает, поэтому источник он // получает подсказкой, и только когда кружок стоит здесь. - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) .accessibilityHint(showsSessionRow ? "" : model.sourceState.spokenName(s)) } @@ -248,8 +254,7 @@ struct PopoverView: View { private func models(_ snapshot: UsageSnapshot) -> some View { VStack(spacing: Theme.rowSpacing) { if snapshot.byModel.isEmpty { - Text(s.pick("разбивки нет: транскриптов за это окно не нашлось", - "no breakdown: no transcripts found for this window")) + Text(emptyBreakdownText) .font(Theme.captionFont) .foregroundStyle(palette.secondaryText.color) .fixedSize(horizontal: false, vertical: true) @@ -282,6 +287,15 @@ struct PopoverView: View { } } + private var emptyBreakdownText: String { + if model.config.activeAccount == .codex { + return s.pick("разбивки по моделям нет в данных Codex", + "Codex data has no per-model breakdown") + } + return s.pick("разбивки нет: транскриптов за это окно не нашлось", + "no breakdown: no transcripts found for this window") + } + private func toggleModels() { model.showsModels.toggle() } diff --git a/Sources/ClaudeWeekApp/SessionRow.swift b/Sources/ClaudeWeekApp/SessionRow.swift index 44eebe8..ee4d70f 100644 --- a/Sources/ClaudeWeekApp/SessionRow.swift +++ b/Sources/ClaudeWeekApp/SessionRow.swift @@ -89,7 +89,7 @@ struct SessionRow: View { var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 8) { - Text(s.pick("5 Ч", "5 H")) + Text(Formatting.limitWindow(session.windowDurationMinutes, lang: s.lang)) .font(Theme.dayFont) .foregroundStyle(palette.secondaryText.color) .frame(width: Theme.dayLabelWidth, alignment: .leading) @@ -183,11 +183,12 @@ struct SessionRow: View { private var voiceOverLabel: String { let percent = Formatting.percent(session.usedPercent, withSign: false) let verdict = session.isExhausted ? s.pick(", лимит исчерпан", ", limit spent") : "" + let window = Formatting.limitWindowSpoken(session.windowDurationMinutes, lang: s.lang) return s.pick(""" - Пятичасовая сессия, потрачено \(percent) процентов\(verdict), \ + \(window), потрачено \(percent) процентов\(verdict), \ сброс \(reset), \(source.spokenName(s)) """, """ - Five-hour session, \(percent) per cent spent\(verdict), \ + \(window), \(percent) per cent spent\(verdict), \ resets \(reset), \(source.spokenName(s)) """) } diff --git a/Sources/ClaudeWeekApp/StatusItemController.swift b/Sources/ClaudeWeekApp/StatusItemController.swift index e7eb033..bc68347 100644 --- a/Sources/ClaudeWeekApp/StatusItemController.swift +++ b/Sources/ClaudeWeekApp/StatusItemController.swift @@ -12,6 +12,12 @@ final class StatusItemController: NSObject { /// у AppKit-частей нет окружения SwiftUI, но конфиг у них общий. private var s: L10n { model.strings } private var provider: any UsageProvider + /// Снимок каждого аккаунта живёт отдельно: переключение не должно на + /// мгновение подписывать цифры Claude именем Codex и наоборот. + private var snapshots: [UsageAccount: UsageSnapshot] = [:] + /// Ответ старого провайдера может прийти уже после переключения аккаунта. + /// Поколение делает такой ответ безвредным, не отменяя сетевой запрос. + private var refreshGeneration = 0 private let update = UpdateController() private let notifications = NotificationController() @@ -38,7 +44,7 @@ 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 = Self.makeProvider(config: config) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) super.init() @@ -67,8 +73,15 @@ final class StatusItemController: NSObject { /// секунду-другую, и всё это время строка меню иначе висела бы пустой. /// Возраст подписывает сама модель — правило свежести одно на всех. private func restoreFromCache() { - guard let cache = Store.loadCache(), cache.isFresh(at: Date()) else { return } - let snapshot = cache.snapshot(config: model.config) + let now = Date() + if let cache = Store.loadCache(), cache.isFresh(at: now), cache.source != .codex { + snapshots[.claude] = cache.snapshot(config: model.config) + } + if let cache = Store.loadCache(from: Store.codexCacheURL), + cache.isFresh(at: now), cache.source == .codex { + snapshots[.codex] = cache.snapshot(config: model.config) + } + guard let snapshot = snapshots[model.config.activeAccount] else { return } model.apply(snapshot) // Кеш — такой же повод сказать про лимит, как свежий ответ: машину // перезагрузили посреди недели, и до первого ответа сервера человек @@ -102,6 +115,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) } ) @@ -121,8 +135,9 @@ final class StatusItemController: NSObject { button.image = menuBarImage(palette: model.config.appearance.theme.palette) // Без текстового заголовка кнопку нечего озвучивать — даём подпись сами. - button.setAccessibilityLabel(s.pick("ClaudeWeek — потрачено \(model.menuBarTitle)", - "ClaudeWeek — \(model.menuBarTitle) spent")) + let account = model.config.activeAccount.title(s.lang) + button.setAccessibilityLabel(s.pick("ClaudeWeek, \(account) — потрачено \(model.menuBarTitle)", + "ClaudeWeek, \(account) — \(model.menuBarTitle) spent")) button.toolTip = tooltip } @@ -174,7 +189,9 @@ final class StatusItemController: NSObject { var lines = [s.pick("Неделя — \(used) из лимита", "Week — \(used) of the limit")] if let session = model.session { let spent = Formatting.percent(session.usedPercent) - lines.append(s.pick("Сессия 5 ч — \(spent) из лимита", "5-hour session — \(spent) of the limit")) + let window = Formatting.limitWindowSpoken(session.windowDurationMinutes, lang: s.lang) + lines.append(s.pick("\(window) — \(spent) из лимита", + "\(window) — \(spent) of the limit")) } let plan = Formatting.percent(metrics.planNowPercent) lines.append(s.pick("План на сейчас — \(plan)", "Plan for now — \(plan)")) @@ -215,6 +232,44 @@ final class StatusItemController: NSObject { model.showsModels = false } + /// Меняет источник панели целиком. Предыдущий снимок запоминаем, чтобы + /// обратный щелчок был мгновенным, а отсутствующие данные не подменяем + /// цифрами другого аккаунта даже на время запуска нового запроса. + 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 + + var config = model.config + config.activeAccount = account + model.config = config + provider = Self.makeProvider(config: config) + + 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() + } + /// Три группы: что сделать сейчас, как программа заведена, справка и /// выход. Автозапуск сюда не вынесен: он такая же настройка, как остальные, /// и живёт строкой в окне настроек — искать её начинают там. Обновление — @@ -281,7 +336,11 @@ 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 @@ -301,25 +360,32 @@ final class StatusItemController: NSObject { render() if notificationsEnabled { notifications.apply(config.notifications) } - // Ползунок шлёт по десятку изменений в секунду — на диск ходим - // с задержкой, применяя к панели каждое сразу. - saveTask?.cancel() - pendingConfigWrite = config - saveTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(400)) - guard !Task.isCancelled else { return } - self?.flushConfigWrite() - } + queueConfigWrite(config) guard providerChanged else { if intervalChanged { startTimers() } return } - provider = ResolvingProvider(config: config) + refreshGeneration += 1 + model.isRefreshing = false + provider = Self.makeProvider(config: config) startTimers() refresh() } + /// Ползунок шлёт по десятку изменений в секунду — на диск ходим с + /// задержкой, применяя к панели каждое сразу. Тем же путём сохраняется и + /// выбранный аккаунт, поэтому конкурирующих записей конфига нет. + private func queueConfigWrite(_ config: Config) { + saveTask?.cancel() + pendingConfigWrite = config + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + self?.flushConfigWrite() + } + } + /// Пишет отложенную правку немедленно, если она ещё не легла на диск. /// Зовётся и таймером дебаунса, и `applicationWillTerminate`: выйти из /// приложения можно в любой момент этих 400 мс, и без досрочного сброса @@ -351,6 +417,8 @@ final class StatusItemController: NSObject { Log.warn("не смог начать счёт заново: \(error)") return } + snapshots[.claude] = nil + guard model.config.activeAccount == .claude else { return } model.snapshot = nil render() refresh() @@ -378,7 +446,7 @@ final class StatusItemController: NSObject { let alert = NSAlert() alert.messageText = "ClaudeWeek \(ClaudeWeek.version)" alert.informativeText = """ - Недельный лимит Claude Code одним взглядом. + Лимиты Claude Code и Codex одним взглядом. Конфигурация: \(configURL.path) """ alert.runModal() @@ -512,13 +580,31 @@ 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() + 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) + refreshGeneration += 1 + model.isRefreshing = false + provider = Self.makeProvider(config: config) startTimers() + render() + scheduleResetRefresh() refresh() } @@ -528,22 +614,45 @@ 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) } - self?.model.isRefreshing = false - self?.render() + model.isRefreshing = false + render() // Окно снимка могло смениться — переставляем будильник под него. // Отсюда же он ставится и в первый раз: `init` заканчивается // обновлением, а до его ответа окна может не быть вовсе. - self?.scheduleResetRefresh() + scheduleResetRefresh() + } + } + + private static func makeProvider(config: Config) -> any UsageProvider { + switch config.activeAccount { + case .claude: + return ResolvingProvider(config: config) + case .codex: + return CodexProvider(config: config) } } } diff --git a/Sources/ClaudeWeekApp/main.swift b/Sources/ClaudeWeekApp/main.swift index 0bb55b3..a0b7745 100644 --- a/Sources/ClaudeWeekApp/main.swift +++ b/Sources/ClaudeWeekApp/main.swift @@ -30,6 +30,13 @@ 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)»: бывают claude и codex")) + } + config.activeAccount = choice +} // Язык из аргумента перекрывает конфиг и на один запуск, и на снимки: так // английские картинки для документации снимаются, ничего не переключая руками. diff --git a/Sources/ClaudeWeekCore/Cache.swift b/Sources/ClaudeWeekCore/Cache.swift index 1183016..0499a33 100644 --- a/Sources/ClaudeWeekCore/Cache.swift +++ b/Sources/ClaudeWeekCore/Cache.swift @@ -182,10 +182,14 @@ public enum Store { public static var directory: URL { ConfigStore.directory } public static var indexURL: URL { directory.appendingPathComponent("index.json") } public static var cacheURL: URL { directory.appendingPathComponent("cache.json") } + /// Codex хранится отдельно: общий файл позволил бы последнему обновлению + /// одного аккаунта стереть мгновенный снимок другого. + public static var codexCacheURL: URL { directory.appendingPathComponent("codex-cache.json") } /// Что уже сказано уведомлениями. Отдельно от кеша: тот перезаписывается /// каждым обновлением и целиком описывает расход, а это — память о /// разговоре с человеком, и терять её вместе с протухшим снимком нельзя. public static var alertsURL: URL { directory.appendingPathComponent("alerts.json") } + public static var codexAlertsURL: URL { directory.appendingPathComponent("codex-alerts.json") } /// Отсечка счёта и аккаунт, на котором он ведётся. public static var stateURL: URL { directory.appendingPathComponent("state.json") } diff --git a/Sources/ClaudeWeekCore/CodexProvider.swift b/Sources/ClaudeWeekCore/CodexProvider.swift new file mode 100644 index 0000000..125efa0 --- /dev/null +++ b/Sources/ClaudeWeekCore/CodexProvider.swift @@ -0,0 +1,648 @@ +import Darwin +import Foundation + +// MARK: - Ответ app-server + +/// Одно окно лимита Codex. Поля повторяют стабильный JSON-RPC контракт +/// `account/rateLimits/read`; дата уже переведена из Unix timestamp. +public struct CodexRateLimitWindow: Decodable, Sendable, Equatable { + public let usedPercent: Double + public let windowDurationMinutes: Int? + public let resetsAt: Date? + + enum CodingKeys: String, CodingKey { + case usedPercent + case windowDurationMinutes = "windowDurationMins" + case resetsAt + } + + public init(usedPercent: Double, windowDurationMinutes: Int?, resetsAt: Date?) { + self.usedPercent = usedPercent + self.windowDurationMinutes = windowDurationMinutes + self.resetsAt = resetsAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + usedPercent: try c.decode(Double.self, forKey: .usedPercent), + windowDurationMinutes: try c.decodeIfPresent(Int.self, forKey: .windowDurationMinutes), + resetsAt: try c.decodeIfPresent(TimeInterval.self, forKey: .resetsAt).map { + Date(timeIntervalSince1970: $0) + } + ) + } +} + +/// Пара лимитов одного metered bucket: короткий `primary` и длинный +/// `secondary`. На некоторых тарифах одного из них может не быть. +public struct CodexRateLimitBucket: Decodable, Sendable, Equatable { + public let limitId: String? + public let limitName: String? + public let primary: CodexRateLimitWindow? + public let secondary: CodexRateLimitWindow? + + public init( + limitId: String? = nil, + limitName: String? = nil, + primary: CodexRateLimitWindow? = nil, + secondary: CodexRateLimitWindow? = nil + ) { + self.limitId = limitId + self.limitName = limitName + self.primary = primary + self.secondary = secondary + } +} + +/// Ответ метода лимитов. `rateLimits` оставлен сервером для совместимости, +/// словарь — новый многобакетный вид; провайдер понимает оба. +public struct CodexRateLimits: Decodable, Sendable, Equatable { + public let rateLimits: CodexRateLimitBucket? + public let rateLimitsByLimitId: [String: CodexRateLimitBucket]? + + public init( + rateLimits: CodexRateLimitBucket?, + rateLimitsByLimitId: [String: CodexRateLimitBucket]? = nil + ) { + self.rateLimits = rateLimits + self.rateLimitsByLimitId = rateLimitsByLimitId + } +} + +public struct CodexDailyUsage: Decodable, Sendable, Equatable { + public let startDate: String + public let tokens: Int64 + + public init(startDate: String, tokens: Int64) { + self.startDate = startDate + self.tokens = tokens + } +} + +/// Токенная статистика — отдельный официальный метод. Для панели сейчас +/// важны дневные бакеты: по ним восстанавливается форма недельного расхода. +public struct CodexTokenUsage: Decodable, Sendable, Equatable { + public let dailyUsageBuckets: [CodexDailyUsage]? + + public init(dailyUsageBuckets: [CodexDailyUsage]?) { + self.dailyUsageBuckets = dailyUsageBuckets + } +} + +public struct CodexAccountUsage: Sendable, Equatable { + public let limits: CodexRateLimits + public let tokens: CodexTokenUsage? + + public init(limits: CodexRateLimits, tokens: CodexTokenUsage? = nil) { + self.limits = limits + self.tokens = tokens + } +} + +public protocol CodexUsageTransport: Sendable { + func fetch() async throws -> CodexAccountUsage +} + +// MARK: - Транспорт app-server + +/// Запускает официальный `codex app-server` на один короткий обмен. Сам +/// app-server читает и обновляет авторизацию Codex; ClaudeWeek токены не +/// открывает, не копирует и не сохраняет. +public struct CodexAppServerTransport: CodexUsageTransport { + private let executableURL: URL? + private let timeout: TimeInterval + + public init(executableURL: URL? = nil, timeout: TimeInterval = 20) { + self.executableURL = executableURL + self.timeout = timeout + } + + public func fetch() async throws -> CodexAccountUsage { + let executable = try executableURL ?? Self.findExecutable() + return try AppServerCall(executable: executable, timeout: timeout).run() + } + + /// LaunchAgent получает короткий системный PATH, поэтому одного `which` + /// недостаточно. Сначала уважаем явный путь, затем PATH и обычные места + /// установки CLI на macOS. + private static func findExecutable() throws -> URL { + let environment = ProcessInfo.processInfo.environment + let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + var candidates: [URL] = [] + + if let explicit = environment["CODEX_EXECUTABLE"], !explicit.isEmpty { + candidates.append(URL(fileURLWithPath: explicit)) + } + if let path = environment["PATH"] { + candidates.append(contentsOf: path.split(separator: ":").map { + URL(fileURLWithPath: String($0), isDirectory: true).appendingPathComponent("codex") + }) + } + candidates.append(contentsOf: [ + home.appendingPathComponent(".local/bin/codex"), + home.appendingPathComponent(".npm-global/bin/codex"), + home.appendingPathComponent("Library/pnpm/codex"), + URL(fileURLWithPath: "/opt/homebrew/bin/codex"), + URL(fileURLWithPath: "/usr/local/bin/codex"), + URL(fileURLWithPath: "/Applications/Codex.app/Contents/Resources/codex"), + home.appendingPathComponent("Applications/Codex.app/Contents/Resources/codex"), + ]) + + if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) { + return found.resolvingSymlinksInPath() + } + throw UsageError.unavailable(Bilingual( + "не найден Codex CLI; установите Codex и войдите в аккаунт", + "Codex CLI was not found; install Codex and sign in" + )) + } +} + +private struct RPCError: Decodable { + let code: Int + let message: String +} + +private struct RPCEnvelope: Decodable { + let result: Result? + let error: RPCError? +} + +/// Синхронный обмен намеренно живёт внутри фонового актора провайдера. `poll` +/// даёт настоящий таймаут на чтение stdout, не оставляя зависший дочерний +/// процесс и не блокируя главный актор приложения. +private struct AppServerCall { + let executable: URL + let timeout: TimeInterval + + func run() throws -> CodexAccountUsage { + let process = Process() + let input = Pipe() + let output = Pipe() + process.executableURL = executable + process.arguments = ["app-server", "--stdio"] + process.standardInput = input + process.standardOutput = output + // В stderr app-server пишет диагностику окружения. Она не является + // протоколом и не должна ни попасть в лог с чужими путями, ни забить + // непрочитанный pipe. + process.standardError = FileHandle.nullDevice + + do { + try process.run() + Log.debug("Codex app-server запущен, pid=\(process.processIdentifier)") + } catch { + throw UsageError.unavailable(Bilingual( + "не запустил Codex CLI: \(error.localizedDescription)", + "could not start Codex CLI: \(error.localizedDescription)" + )) + } + + defer { + Log.debug("закрываю Codex app-server, pid=\(process.processIdentifier)") + try? input.fileHandleForWriting.close() + stop(process) + Log.debug("обмен с Codex app-server завершён") + } + + let deadline = Date().addingTimeInterval(timeout) + var reader = JSONLineReader(handle: output.fileHandleForReading) + + try write([ + "method": "initialize", + "id": 0, + "params": [ + "clientInfo": [ + "name": "claude_week", + "title": "ClaudeWeek", + "version": ClaudeWeek.version, + ], + ], + ], to: input.fileHandleForWriting) + Log.debug("Codex app-server: жду initialize") + + // Инициализацию надо подтвердить только после ответа: при закрытии + // stdin раньше этого app-server штатно завершится, не начав запросы. + let initialization: RPCEnvelope = try response( + id: 0, from: &reader, deadline: deadline + ) + if let error = initialization.error { throw rpcUsageError(error) } + guard initialization.result != nil else { + throw UsageError.decoding(Bilingual( + "Codex app-server не подтвердил initialize", + "Codex app-server did not acknowledge initialize" + )) + } + Log.debug("Codex app-server: initialize подтверждён") + + try write(["method": "initialized", "params": [:]], to: input.fileHandleForWriting) + try write(["method": "account/rateLimits/read", "id": 1], to: input.fileHandleForWriting) + try write(["method": "account/usage/read", "id": 2], to: input.fileHandleForWriting) + Log.debug("Codex app-server: запросил лимиты и дневную статистику") + + var limits: CodexRateLimits? + var tokens: CodexTokenUsage? + var receivedUsageReply = false + + do { + while limits == nil || !receivedUsageReply { + guard Date() < deadline else { throw JSONLineReader.Failure.timedOut } + let line = try reader.nextLine(deadline: deadline) + guard let id = responseID(in: line) else { continue } + switch id { + case 1: + let envelope = try JSONDecoder().decode(RPCEnvelope.self, from: line) + if let error = envelope.error { throw rpcUsageError(error) } + guard let value = envelope.result else { + throw UsageError.decoding(Bilingual( + "Codex не вернул rateLimits", + "Codex did not return rateLimits" + )) + } + limits = value + case 2: + let envelope = try JSONDecoder().decode(RPCEnvelope.self, from: line) + // Старый CLI может ещё не знать этот вспомогательный + // метод. Лимиты от этого не пропадают — только дневная + // форма останется пустой. + tokens = envelope.error == nil ? envelope.result : nil + receivedUsageReply = true + default: + continue + } + } + } catch JSONLineReader.Failure.timedOut where limits != nil { + // Токенная статистика необязательна. Не прячем уже полученные + // точные лимиты из-за того, что второй метод задержался. + } + + guard let limits else { + throw UsageError.decoding(Bilingual( + "Codex не вернул лимиты", + "Codex did not return usage limits" + )) + } + return CodexAccountUsage(limits: limits, tokens: tokens) + } + + private func response( + id: Int, + from reader: inout JSONLineReader, + deadline: Date + ) throws -> RPCEnvelope { + while true { + guard Date() < deadline else { throw JSONLineReader.Failure.timedOut } + let line = try reader.nextLine(deadline: deadline) + guard responseID(in: line) == id else { continue } + return try JSONDecoder().decode(RPCEnvelope.self, from: line) + } + } + + private func responseID(in data: Data) -> Int? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let number = object["id"] as? NSNumber + else { return nil } + return number.intValue + } + + private func write(_ object: [String: Any], to handle: FileHandle) throws { + var data = try JSONSerialization.data(withJSONObject: object) + data.append(0x0A) + try handle.write(contentsOf: data) + } + + /// Закрытый stdin обычно завершает app-server сам. Если он застрял в + /// старте или сетевом запросе, SIGTERM на некоторых версиях CLI тоже + /// остаётся без ответа; бесконечный `waitUntilExit` тогда вешал и CLI, и + /// обновление панели. Даём штатному выходу секунду и только затем убиваем + /// именно запущенный нами дочерний процесс. + private func stop(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let deadline = Date().addingTimeInterval(1) + while process.isRunning, Date() < deadline { + usleep(10_000) + } + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + } + // `Process` сам пожинает дочерний процесс своим source. Здесь ждать + // синхронно нельзя: именно `waitUntilExit` зависал на app-server, + // застрявшем в старте, даже после SIGKILL в ограниченном окружении. + } + + private func rpcUsageError(_ error: RPCError) -> UsageError { + let lower = error.message.lowercased() + if lower.contains("authentication") || lower.contains("sign in") || lower.contains("login") { + return .unavailable(Bilingual( + "нужно войти в аккаунт через Codex CLI", + "sign in through Codex CLI first" + )) + } + return .unavailable(Bilingual( + "Codex app-server: \(error.message)", + "Codex app-server: \(error.message)" + )) + } +} + +private struct InitializationResult: Decodable { + let userAgent: String +} + +private struct JSONLineReader { + enum Failure: Error { + case timedOut + case closed + case tooLarge + case system(Int32) + } + + let handle: FileHandle + private var buffer = Data() + + init(handle: FileHandle) { + self.handle = handle + let descriptor = handle.fileDescriptor + let flags = fcntl(descriptor, F_GETFL) + if flags >= 0 { + _ = fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) + } + } + + mutating func nextLine(deadline: Date) throws -> Data { + while true { + if let newline = buffer.firstIndex(of: 0x0A) { + var line = Data(buffer[.. 0 else { throw Failure.timedOut } + var descriptor = pollfd( + fd: handle.fileDescriptor, + events: Int16(POLLIN | POLLHUP), + revents: 0 + ) + let milliseconds = Int32(min(max(remaining * 1_000, 1), Double(Int32.max))) + let result = Darwin.poll(&descriptor, 1, milliseconds) + if result == 0 { throw Failure.timedOut } + if result < 0 { + if errno == EINTR { continue } + throw Failure.system(errno) + } + + var bytes = [UInt8](repeating: 0, count: 64 * 1_024) + let count = bytes.withUnsafeMutableBytes { raw in + Darwin.read(handle.fileDescriptor, raw.baseAddress, raw.count) + } + if count < 0 { + if errno == EINTR || errno == EAGAIN { continue } + throw Failure.system(errno) + } + guard count > 0 else { + throw Failure.closed + } + buffer.append(contentsOf: bytes.prefix(count)) + guard buffer.count <= 4 * 1_024 * 1_024 else { throw Failure.tooLarge } + } + } +} + +// MARK: - Провайдер + +public actor CodexProvider: UsageProvider { + nonisolated public let kind: SourceKind = .codex + + public static let minimumInterval: TimeInterval = 60 + public static let staleLimit: TimeInterval = 15 * 60 + private static let backoffSteps: [TimeInterval] = [60, 120, 240, 480, 900] + + private let config: Config + private let transport: CodexUsageTransport + private let cacheURL: URL? + private let clock: @Sendable () -> Date + + private var lastUsage: CodexAccountUsage? + private var lastFetch: Date? + private var retryAfter: Date? + private var failures = 0 + + public init( + config: Config, + transport: CodexUsageTransport = CodexAppServerTransport(), + cacheURL: URL? = Store.codexCacheURL, + clock: @escaping @Sendable () -> Date = { Date() } + ) { + self.config = config + self.transport = transport + self.cacheURL = cacheURL + self.clock = clock + } + + public func fetch() async throws -> UsageSnapshot { + let now = clock() + let account = try await usage(at: now) + let selected = try selectLimits(account.limits, at: now) + let window = WeekWindow(endingAt: selected.week.resetsAt, config: config) + let observedAt = lastFetch ?? now + + let snapshot = UsageSnapshot.make( + usedPercent: selected.week.usedPercent, + cumulativeByDay: cumulativeByDay( + account.tokens?.dailyUsageBuckets, + totalPercent: selected.week.usedPercent, + window: window, + now: now + ), + window: window, + source: .codex, + fetchedAt: observedAt, + isEstimate: false, + shapeIsEstimate: true, + session: selected.session.map { + SessionUsage( + usedPercent: $0.usedPercent, + resetsAt: $0.resetsAt, + windowDurationMinutes: $0.windowDurationMinutes ?? 5 * 60 + ) + } + ) + save(snapshot) + return snapshot + } + + public func usage(at now: Date) async throws -> CodexAccountUsage { + let remembered = lastUsage.flatMap { usage in + (try? selectLimits(usage.limits, at: now)) != nil ? usage : nil + } + if let remembered, let lastFetch, + now.timeIntervalSince(lastFetch) < CodexProvider.minimumInterval { + return remembered + } + if let retryAfter, now < retryAfter { + if let remembered, let lastFetch, + now.timeIntervalSince(lastFetch) <= CodexProvider.staleLimit { + return remembered + } + throw UsageError.unavailable(Bilingual( + "жду \(Formatting.duration(retryAfter.timeIntervalSince(now))) после ошибки Codex", + "waiting \(Formatting.duration(retryAfter.timeIntervalSince(now), lang: .en)) after a Codex error" + )) + } + + do { + let usage = try await transport.fetch() + _ = try selectLimits(usage.limits, at: now) + lastUsage = usage + lastFetch = now + retryAfter = nil + failures = 0 + return usage + } catch { + failures += 1 + let step = Self.backoffSteps[min(failures - 1, Self.backoffSteps.count - 1)] + retryAfter = now.addingTimeInterval(step) + Log.warn("Codex недоступен (\(error)), следующая попытка через \(Formatting.duration(step))") + throw error + } + } + + private struct ActiveWindow { + let usedPercent: Double + let windowDurationMinutes: Int? + let resetsAt: Date + } + + private struct SelectedLimits { + let week: ActiveWindow + let session: ActiveWindow? + } + + /// В многобакетном ответе только ключ `codex` описывает общий тариф. + /// Модельные bucket рядом с ним нельзя выдавать за лимит аккаунта. Старый + /// одиночный вид используем лишь как совместимый запасной вариант. + private func selectLimits(_ result: CodexRateLimits, at now: Date) throws -> SelectedLimits { + let preferred = result.rateLimitsByLimitId?["codex"] + ?? result.rateLimitsByLimitId?.values.first { $0.limitId == "codex" } + ?? result.rateLimits + let weekLength = 6 * 24 * 60 + let secondary = active(preferred?.secondary, at: now) + let primary = active(preferred?.primary, at: now) + let weeklyPrimary = primary.flatMap { + ($0.windowDurationMinutes ?? 0) >= weekLength ? $0 : nil + } + let weekly = [secondary, weeklyPrimary] + .compactMap { $0 } + .max { + ($0.windowDurationMinutes ?? 0) < ($1.windowDurationMinutes ?? 0) + } + guard let week = weekly else { + throw UsageError.decoding(Bilingual( + "Codex не сообщил действующий недельный лимит", + "Codex did not report an active weekly limit" + )) + } + + let session: CodexRateLimitWindow? + if let primary, + (primary.windowDurationMinutes ?? 0) < weekLength, + primary.resetsAt != week.resetsAt { + session = preferred?.primary + } else { + session = nil + } + return SelectedLimits(week: normalized(week), session: active(session, at: now).map(normalized)) + } + + private func active(_ window: CodexRateLimitWindow?, at now: Date) -> ActiveWindow? { + guard let window, let resetsAt = window.resetsAt, resetsAt > now else { return nil } + return ActiveWindow( + usedPercent: window.usedPercent, + windowDurationMinutes: window.windowDurationMinutes, + resetsAt: resetsAt + ) + } + + private func normalized(_ window: ActiveWindow) -> ActiveWindow { + ActiveWindow( + usedPercent: min(max(window.usedPercent, 0), 100), + windowDurationMinutes: window.windowDurationMinutes, + resetsAt: OfficialUsage.roundToMinute(window.resetsAt) + ) + } + + /// Дневные токены задают только форму: точный недельный процент остаётся + /// знаменателем сервера. Первый неполный день приблизительно режется по + /// доле суток, потому что API отдаёт бакет целиком, включая часы до сброса. + private func cumulativeByDay( + _ buckets: [CodexDailyUsage]?, + totalPercent: Double, + window: WeekWindow, + now: Date + ) -> [Double?] { + guard let buckets, !buckets.isEmpty else { + return Array(repeating: nil, count: window.slotCount) + } + + let calendar = window.calendar + var byDate: [Date: Double] = [:] + for bucket in buckets { + guard let date = calendarDate(bucket.startDate, calendar: calendar) else { continue } + byDate[calendar.startOfDay(for: date), default: 0] += Double(max(bucket.tokens, 0)) + } + + let amounts: [Double?] = window.days.map { day in + guard day.start < now else { return nil } + let dayStart = calendar.startOfDay(for: day.start) + var value = byDate[dayStart, default: 0] + if day.start > dayStart, + let next = calendar.date(byAdding: .day, value: 1, to: dayStart) { + let full = next.timeIntervalSince(dayStart) + if full > 0 { value *= day.end.timeIntervalSince(day.start) / full } + } + return value + } + let total = amounts.compactMap { $0 }.reduce(0, +) + // Нули токенов не задают форму расхода: рисовать по ним семь точных + // нулей рядом с ненулевым итогом означало бы выдумать противоречие. + guard total > 0 else { return Array(repeating: nil, count: window.slotCount) } + + var running = 0.0 + return amounts.map { value in + guard let value else { return nil } + running += value + return running / total * totalPercent + } + } + + private func calendarDate(_ text: String, calendar: Calendar) -> Date? { + let parts = text.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) + else { return nil } + return calendar.date(from: DateComponents(year: year, month: month, day: day)) + } + + private func save(_ snapshot: UsageSnapshot) { + guard let cacheURL else { return } + let cache = CachedUsage( + usedPercent: snapshot.usedPercent, + byDay: snapshot.byDay.map(\.usedPercent), + windowStart: snapshot.window.start, + windowEnd: snapshot.window.end, + source: .codex, + fetchedAt: snapshot.fetchedAt, + officialWindowEnd: snapshot.window.end, + session: snapshot.session + ) + do { + try Store.saveCache(cache, to: cacheURL) + } catch { + Log.warn("не сохранил кеш Codex: \(error)") + } + } +} diff --git a/Sources/ClaudeWeekCore/Config.swift b/Sources/ClaudeWeekCore/Config.swift index bbfc7a1..8da20ca 100644 --- a/Sources/ClaudeWeekCore/Config.swift +++ b/Sources/ClaudeWeekCore/Config.swift @@ -6,6 +6,22 @@ public enum ProviderPreference: String, Codable, Sendable, CaseIterable { case auto } +/// Какой сервис сейчас показан в панели. Это не выбор источника внутри Claude: +/// `provider` по-прежнему решает, брать ли его процент с сервера или считать +/// локально. Здесь переключаются два независимых аккаунта и два набора +/// лимитов — Claude Code и Codex. +public enum UsageAccount: String, Codable, Sendable, CaseIterable, Hashable { + case claude + case codex + + public func title(_ lang: Lang) -> String { + switch self { + case .claude: "Claude" + case .codex: "Codex" + } + } +} + public enum MenuBarStyle: String, Codable, Sendable, CaseIterable { case percent case compact @@ -318,6 +334,8 @@ public struct Config: Codable, Sendable, Equatable { public var timeZone: String /// Секунды между опросами, не меньше `minimumRefreshInterval`. public var refreshInterval: TimeInterval + /// Последний аккаунт, выбранный кнопками в заголовке панели. + public var activeAccount: UsageAccount public var provider: ProviderPreference /// Часы, между которыми растёт план. Вне их он стоит: недельный лимит /// раскладывается по рабочему времени, а не по астрономическому. @@ -360,6 +378,7 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: 0, timeZone: "", refreshInterval: 300, + activeAccount: .claude, provider: .auto, // Не `WorkHours.default` — та (11–24) остаётся запасным значением для // сломанных чисел в `WorkHours.validated()` и опорной точкой во всех @@ -383,6 +402,7 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: Int, timeZone: String, refreshInterval: TimeInterval, + activeAccount: UsageAccount = .claude, provider: ProviderPreference, workHours: WorkHours = WorkHours.default, menuBarStyle: MenuBarStyle, @@ -400,6 +420,7 @@ public struct Config: Codable, Sendable, Equatable { self.resetMinute = resetMinute self.timeZone = timeZone self.refreshInterval = refreshInterval + self.activeAccount = activeAccount self.provider = provider self.workHours = workHours self.menuBarStyle = menuBarStyle @@ -424,6 +445,7 @@ 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, + activeAccount: try c.decodeIfPresent(UsageAccount.self, forKey: .activeAccount) ?? d.activeAccount, 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/Formatting.swift b/Sources/ClaudeWeekCore/Formatting.swift index 72f1662..1cbd1f2 100644 --- a/Sources/ClaudeWeekCore/Formatting.swift +++ b/Sources/ClaudeWeekCore/Formatting.swift @@ -6,6 +6,34 @@ import Foundation /// Язык приходит параметром и по умолчанию русский — тот, на котором программа /// говорила до появления второго. Панель и настройки передают выбранный явно. public enum Formatting { + /// Короткая метка окна для левой колонки панели: «5 Ч», «15 МИН». + public static func limitWindow(_ minutes: Int, lang: Lang = .ru) -> String { + let safe = max(minutes, 1) + if safe.isMultiple(of: 24 * 60) { + let days = safe / (24 * 60) + return L10n(lang).pick("\(days) Д", "\(days) D") + } + if safe.isMultiple(of: 60) { + let hours = safe / 60 + return L10n(lang).pick("\(hours) Ч", "\(hours) H") + } + return L10n(lang).pick("\(safe) МИН", "\(safe) MIN") + } + + /// Полное название того же окна для VoiceOver. + public static func limitWindowSpoken(_ minutes: Int, lang: Lang = .ru) -> String { + let safe = max(minutes, 1) + if safe.isMultiple(of: 24 * 60) { + let days = safe / (24 * 60) + return L10n(lang).pick("окно \(days) дн.", "\(days)-day window") + } + if safe.isMultiple(of: 60) { + let hours = safe / 60 + return L10n(lang).pick("окно \(hours) ч.", "\(hours)-hour window") + } + return L10n(lang).pick("окно \(safe) мин.", "\(safe)-minute window") + } + static let shortWeekdays = ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"] static let fullWeekdays = [ "Воскресенье", "Понедельник", "Вторник", "Среда", diff --git a/Sources/ClaudeWeekCore/Snapshot.swift b/Sources/ClaudeWeekCore/Snapshot.swift index d7623e2..7e9c959 100644 --- a/Sources/ClaudeWeekCore/Snapshot.swift +++ b/Sources/ClaudeWeekCore/Snapshot.swift @@ -3,6 +3,7 @@ import Foundation public enum SourceKind: String, Codable, Sendable { case official case local + case codex } /// Одни сутки окна: накопительный план к их концу и такой же накопительный @@ -83,10 +84,29 @@ public struct SessionUsage: Codable, Sendable, Equatable { /// Расход лимита сессии, 0…100. public let usedPercent: Double public let resetsAt: Date + /// Длина короткого окна. У Claude это пять часов; Codex сообщает её + /// вместе с лимитом, и полагаться там на жёстко прошитые пять нельзя. + public let windowDurationMinutes: Int - public init(usedPercent: Double, resetsAt: Date) { + public init(usedPercent: Double, resetsAt: Date, windowDurationMinutes: Int = 5 * 60) { self.usedPercent = usedPercent self.resetsAt = resetsAt + self.windowDurationMinutes = max(windowDurationMinutes, 1) + } + + private enum CodingKeys: String, CodingKey { + case usedPercent, resetsAt, windowDurationMinutes + } + + /// Старый кеш не знает длины окна — это всегда была пятичасовая сессия + /// Claude, поэтому отсутствие нового ключа однозначно восстанавливается. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + usedPercent: try c.decode(Double.self, forKey: .usedPercent), + resetsAt: try c.decode(Date.self, forKey: .resetsAt), + windowDurationMinutes: try c.decodeIfPresent(Int.self, forKey: .windowDurationMinutes) ?? 5 * 60 + ) } /// Окно сессии ещё не истекло. Проверять обязательно: сессия короче суток, diff --git a/Sources/ClaudeWeekTests/CodexProviderTests.swift b/Sources/ClaudeWeekTests/CodexProviderTests.swift new file mode 100644 index 0000000..6f1ea5b --- /dev/null +++ b/Sources/ClaudeWeekTests/CodexProviderTests.swift @@ -0,0 +1,305 @@ +import Foundation +import ClaudeWeekCore + +/// Транспорт без процесса и настоящего аккаунта: провайдер получает ровно +/// тот официальный ответ, который проверяет сценарий, и тесты остаются +/// полностью офлайн. +private actor FakeCodexTransport: CodexUsageTransport { + private let answer: CodexAccountUsage + private(set) var calls = 0 + + init(_ answer: CodexAccountUsage) { + self.answer = answer + } + + func fetch() async throws -> CodexAccountUsage { + calls += 1 + return answer + } + + func callCount() -> Int { calls } +} + +private let codexNow = at(2026, 8, 4, 12, 0) +private let codexWeekReset = at(2026, 8, 7, 16, 0) + +private func executableScript(_ body: String) throws -> (file: URL, directory: URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-week-app-server-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent("fake-codex") + try ("#!/bin/sh\n" + body).write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) + return (file, directory) +} + +private func codexUsage( + weekPercent: Double = 18, + sessionPercent: Double = 40, + daily: [CodexDailyUsage]? = [ + CodexDailyUsage(startDate: "2026-08-01", tokens: 100), + CodexDailyUsage(startDate: "2026-08-02", tokens: 200), + CodexDailyUsage(startDate: "2026-08-03", tokens: 300), + CodexDailyUsage(startDate: "2026-08-04", tokens: 400), + ] +) -> CodexAccountUsage { + let bucket = CodexRateLimitBucket( + limitId: "codex", + primary: CodexRateLimitWindow( + usedPercent: sessionPercent, + windowDurationMinutes: 300, + resetsAt: at(2026, 8, 4, 14, 0) + ), + secondary: CodexRateLimitWindow( + usedPercent: weekPercent, + windowDurationMinutes: 7 * 24 * 60, + resetsAt: codexWeekReset + ) + ) + return CodexAccountUsage( + limits: CodexRateLimits(rateLimits: bucket), + tokens: CodexTokenUsage(dailyUsageBuckets: daily) + ) +} + +func runCodexProviderTests(_ t: Harness) async { + await t.suite("Codex: настоящий обмен JSONL") { + let script = try executableScript(#""" + contains() { + case "$1" in + *"$2"*) ;; + *) exit 40 ;; + esac + } + [ "$1" = "app-server" ] || exit 41 + [ "$2" = "--stdio" ] || exit 42 + IFS= read -r initialize || exit 43 + contains "$initialize" '"method":"initialize"' + contains "$initialize" '"id":0' + contains "$initialize" '"clientInfo"' + printf '%s\n' '{"id":0,"result":{"userAgent":"fake-codex"}}' + IFS= read -r initialized || exit 44 + contains "$initialized" '"method":"initialized"' + IFS= read -r limits_request || exit 45 + case "$limits_request" in + *'"method":"account/rateLimits/read"'*|*'"method":"account\/rateLimits\/read"'*) ;; + *) exit 47 ;; + esac + contains "$limits_request" '"id":1' + IFS= read -r usage_request || exit 46 + case "$usage_request" in + *'"method":"account/usage/read"'*|*'"method":"account\/usage\/read"'*) ;; + *) exit 48 ;; + esac + contains "$usage_request" '"id":2' + printf '%s\n' '{"id":1,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300,"resetsAt":null},"secondary":{"usedPercent":18,"windowDurationMins":10080,"resetsAt":1786107600}}}}' + printf '%s\n' '{"id":2,"result":{"dailyUsageBuckets":[{"startDate":"2026-08-04","tokens":12345}]}}' + while IFS= read -r rest; do :; done + """#) + defer { try? FileManager.default.removeItem(at: script.directory) } + + let usage = try await CodexAppServerTransport( + executableURL: script.file, + timeout: 2 + ).fetch() + t.close(usage.limits.rateLimits?.primary?.usedPercent ?? -1, 25, + "запрос лимитов прошёл после initialize") + t.equal(usage.limits.rateLimits?.limitId, nil, + "совместимый ответ не обязан содержать limitId") + t.equal(usage.limits.rateLimits?.primary?.resetsAt, nil, + "null-время сброса не ломает весь ответ") + t.close(usage.limits.rateLimits?.secondary?.usedPercent ?? -1, 18, + "недельное окно доехало по stdout") + t.equal(usage.tokens?.dailyUsageBuckets?.first?.tokens, 12_345, + "второй ответ прочитан из того же процесса") + } + + t.suite("Codex: документированный JSON лимитов") { + let json = #""" + { + "rateLimits": { + "limitName": null, + "primary": {"usedPercent": 25, "windowDurationMins": 15, "resetsAt": null}, + "secondary": {"usedPercent": 18, "windowDurationMins": 10080, "resetsAt": 1786107600}, + "rateLimitReachedType": null + }, + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "limitName": null, + "primary": {"usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1785844800}, + "secondary": {"usedPercent": 18, "windowDurationMins": 10080, "resetsAt": 1786107600} + } + } + } + """# + let decoded = try JSONDecoder().decode(CodexRateLimits.self, from: Data(json.utf8)) + t.equal(decoded.rateLimits?.limitId, nil, "отсутствующий limitId допустим") + t.close(decoded.rateLimits?.primary?.usedPercent ?? -1, 25, + "короткое окно прочиталось") + t.equal(decoded.rateLimits?.primary?.windowDurationMinutes, 15, + "длина окна взята из windowDurationMins") + t.equal(decoded.rateLimits?.primary?.resetsAt, nil, + "отсутствующий сброс изолирован в своём окне") + t.close(decoded.rateLimits?.secondary?.usedPercent ?? -1, 18, + "длинное окно прочиталось") + } + + await t.suite("Codex: снимок панели") { + let transport = FakeCodexTransport(codexUsage()) + let cacheURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-week-codex-\(UUID().uuidString)/cache.json") + defer { try? FileManager.default.removeItem(at: cacheURL.deletingLastPathComponent()) } + let provider = CodexProvider( + config: config(), + transport: transport, + cacheURL: cacheURL, + clock: { codexNow } + ) + let snapshot = try await provider.fetch() + + t.equal(snapshot.source, .codex, "источник помечен как Codex") + t.close(snapshot.usedPercent, 18, "недельный процент взят из secondary") + t.equal(snapshot.window.end, codexWeekReset, "сброс недели взят у сервера") + t.check(!snapshot.isEstimate, "итоговый процент точный") + t.check(snapshot.shapeIsEstimate, "дневная форма честно помечена оценкой") + t.close(snapshot.byDay.compactMap(\.usedPercent).last ?? -1, 18, + "накопительная дневная форма приходит ровно к итогу") + + guard let session = snapshot.session else { + return t.fail("короткое окно Codex не попало в строку сессии") + } + t.close(session.usedPercent, 40, "процент короткого окна взят из primary") + t.equal(session.windowDurationMinutes, 300, "его настоящая длина сохранена") + t.equal(session.resetsAt, at(2026, 8, 4, 14, 0), "момент сброса сохранён") + + let cached = Store.loadCache(from: cacheURL) + t.equal(cached?.source, .codex, "снимок Codex записан в отдельный кеш") + t.close(cached?.usedPercent ?? -1, 18, "процент пережил запись кеша") + } + + await t.suite("Codex: многобакетный ответ") { + let codexBucket = codexUsage().limits.rateLimits! + let modelBucket = CodexRateLimitBucket( + limitId: "gpt-model", + secondary: CodexRateLimitWindow( + usedPercent: 91, + windowDurationMinutes: 14 * 24 * 60, + resetsAt: codexWeekReset.addingTimeInterval(7 * 24 * 60 * 60) + ) + ) + let answer = CodexAccountUsage( + limits: CodexRateLimits( + rateLimits: modelBucket, + rateLimitsByLimitId: ["gpt-model": modelBucket, "codex": codexBucket] + ) + ) + let provider = CodexProvider( + config: config(), + transport: FakeCodexTransport(answer), + cacheURL: nil, + clock: { codexNow } + ) + let snapshot = try await provider.fetch() + t.close(snapshot.usedPercent, 18, + "агрегатный bucket codex важнее длинного модельного и legacy-вида") + t.check(snapshot.byDay.allSatisfy { $0.usedPercent == nil }, + "без дневных бакетов форма не выдумывается") + } + + await t.suite("Codex: необязательный сброс одного окна") { + let bucket = CodexRateLimitBucket( + primary: CodexRateLimitWindow( + usedPercent: 25, + windowDurationMinutes: 300, + resetsAt: nil + ), + secondary: CodexRateLimitWindow( + usedPercent: 18, + windowDurationMinutes: 7 * 24 * 60, + resetsAt: codexWeekReset + ) + ) + let provider = CodexProvider( + config: config(), + transport: FakeCodexTransport(CodexAccountUsage( + limits: CodexRateLimits(rateLimits: bucket) + )), + cacheURL: nil, + clock: { codexNow } + ) + let snapshot = try await provider.fetch() + t.close(snapshot.usedPercent, 18, "действующее недельное окно сохранилось") + t.equal(snapshot.session, nil, "окно без сброса не показывается действующим") + } + + await t.suite("Codex: короткое окно не выдаётся за неделю") { + let primaryOnly = CodexRateLimitBucket( + limitId: "codex", + primary: CodexRateLimitWindow( + usedPercent: 25, + windowDurationMinutes: 15, + resetsAt: codexNow.addingTimeInterval(900) + ) + ) + let answer = CodexAccountUsage( + limits: CodexRateLimits(rateLimits: primaryOnly) + ) + let provider = CodexProvider( + config: config(), + transport: FakeCodexTransport(answer), + cacheURL: nil, + clock: { codexNow } + ) + do { + _ = try await provider.fetch() + t.fail("ждали ошибку: недельного окна в ответе нет") + } catch UsageError.decoding { + t.check(true, "15 минут не подписываются недельным лимитом") + } + } + + await t.suite("Codex: троттлинг") { + let transport = FakeCodexTransport(codexUsage()) + let clock = MutableClock(codexNow) + let provider = CodexProvider( + config: config(), + transport: transport, + cacheURL: nil, + clock: { clock.now } + ) + _ = try await provider.fetch() + clock.advance(30) + _ = try await provider.fetch() + t.equal(await transport.callCount(), 1, "повтор через 30 секунд взят из памяти") + + clock.advance(31) + _ = try await provider.fetch() + t.equal(await transport.callCount(), 2, "через 61 секунду app-server опрашивается снова") + } + + t.suite("Codex: совместимость конфига и кеша") { + let oldConfig = try JSONDecoder().decode(Config.self, from: Data(#"{"resetHour":9}"#.utf8)) + t.equal(oldConfig.activeAccount, .claude, "старый конфиг остаётся на Claude") + + var selected = Config.default + selected.activeAccount = .codex + let roundTrip = try JSONDecoder().decode(Config.self, from: JSONEncoder().encode(selected)) + t.equal(roundTrip.activeAccount, .codex, "выбор Codex переживает запись") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let oldSession = try decoder.decode( + SessionUsage.self, + from: Data(#"{"usedPercent":41,"resetsAt":"2026-08-04T20:20:00Z"}"#.utf8) + ) + t.equal(oldSession.windowDurationMinutes, 300, + "старый кеш без длины восстанавливает пять часов Claude") + } + + t.suite("Codex: подписи окон") { + t.equal(Formatting.limitWindow(15, lang: .ru), "15 МИН", "минутное окно") + t.equal(Formatting.limitWindow(300, lang: .ru), "5 Ч", "часовое окно") + t.equal(Formatting.limitWindow(1_440, lang: .en), "1 D", "суточное окно") + } +} diff --git a/Sources/ClaudeWeekTests/main.swift b/Sources/ClaudeWeekTests/main.swift index 3f549b8..546e278 100644 --- a/Sources/ClaudeWeekTests/main.swift +++ b/Sources/ClaudeWeekTests/main.swift @@ -10,6 +10,7 @@ runFormattingTests(t) runAlertTests(t) await runLocalProviderTests(t) await runOfficialProviderTests(t) +await runCodexProviderTests(t) await runUpdaterTests(t) t.report() diff --git a/docs/API.md b/docs/API.md index 4cfff0c..900753c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -224,3 +224,82 @@ swift scripts/probe-usage.swift Зонд читает токен из Keychain, делает один запрос и печатает ответ целиком. Токен не выводит — только префикс и длину. + +--- + +# Официальный источник Codex: `codex app-server` + +**Проверено:** 2026-08-30, Codex CLI 0.151.0. В отличие от эндпоинта Claude, +это [публично документированный интерфейс](https://learn.chatgpt.com/docs/app-server). +ClaudeWeek запускает установленный `codex app-server --stdio`; авторизацией +занимается сам Codex, его токены приложение не читает. + +## Обмен + +Протокол — JSONL поверх stdin/stdout. Сначала отправляется `initialize`, после +его ответа — уведомление `initialized`. Только затем можно спрашивать данные: + +```json +{"method":"account/rateLimits/read","id":1} +{"method":"account/usage/read","id":2} +``` + +Каждое обновление — отдельный короткоживущий процесс с таймаутом 20 секунд. +Между удачными запросами действует минутный троттлинг; после ошибок — та же +пауза 1 → 2 → 4 → 8 → 15 минут и предел устаревания 15 минут, что у Claude. + +CLI ищется в `CODEX_EXECUTABLE`, `PATH`, обычных каталогах npm/Homebrew и +внутри `/Applications/Codex.app`. Если он не найден или аккаунт не +авторизован, наружу идёт честная `UsageError`, а не нулевой процент. + +## Лимиты + +`account/rateLimits/read` возвращает совместимый одиночный `rateLimits` и, +если лимитов несколько, `rateLimitsByLimitId`: + +```json +{ + "rateLimits": { + "limitId": "codex", + "primary": { + "usedPercent": 25, + "windowDurationMins": 300, + "resetsAt": 1785844800 + }, + "secondary": { + "usedPercent": 18, + "windowDurationMins": 10080, + "resetsAt": 1786107600 + } + } +} +``` + +Если сервер вернул `rateLimitsByLimitId`, сначала выбирается bucket с ключом +`codex` (либо с внутренним `limitId == "codex"`). Модельные bucket рядом с ним +не считаются общим лимитом аккаунта; одиночный `rateLimits` служит только +запасным вариантом для старых версий app-server. + +`usedPercent` — расход 0…100, `windowDurationMins` — настоящая длина окна, +`resetsAt` — Unix timestamp в секундах. Позиция сама по себе не определяет +назначение окна: недельным считается `secondary` либо `primary` длительностью +не меньше шести суток; короткий действующий `primary` попадает в отдельную +строку панели. Окно без времени сброса нельзя показать как действующее, но оно +не мешает прочитать остальные лимиты ответа. + +## Дневная форма + +`account/usage/read` отдельно отдаёт необязательные дневные токенные бакеты: + +```json +{"dailyUsageBuckets":[{"startDate":"2026-06-18","tokens":12345}]} +``` + +Они задают относительную форму расхода по дням. Накопительная форма +масштабируется так, чтобы последнее известное значение совпало с точным +`usedPercent` недельного окна. Поэтому итог недели точный +(`isEstimate == false`), а дневные полосы помечены в модели как оценка +(`shapeIsEstimate == true`). Разбивки по моделям этот интерфейс не сообщает. + +Снимок хранится отдельно в `~/.config/claude-week/codex-cache.json`; кеш Claude +он не перезаписывает. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8fe804..629fb33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,8 +23,15 @@ LocalProvider ────┘ │ │ ▼ ~/.claude/projects/*.jsonl cache.json (кеш + подобранный бюджет + сессия) state.json (отсечка счёта + метка аккаунта) - ▲ -config.json ────────────────────┴──► StatusItemController ◄──► SettingsView (окно настроек) + +авторизация Codex ─► codex app-server ─► CodexProvider ─► UsageSnapshot + │ + ▼ + codex-cache.json + +config.json ─────────────────────► StatusItemController ◄──► SettingsView (окно настроек) + │ + └── AccountPicker выбирает ветку ``` Правило разделения: **`ClaudeWeekCore` не импортирует ни AppKit, ни SwiftUI.** @@ -46,15 +53,16 @@ SwiftUI` — значит, расчёт просочился в UI или нао | Файл | Отвечает за | Ключевые типы | |---|---|---| -| `Config.swift` | все настройки, чтение и запись `config.json`, починка недопустимых значений | `Config`, `AppearanceConfig`, `ThemeKind`, `Thresholds`, `NotificationsConfig`, `ConfigStore` | +| `Config.swift` | все настройки, выбранный сервис, чтение и запись `config.json`, починка недопустимых значений | `Config`, `UsageAccount`, `AppearanceConfig`, `ThemeKind`, `Thresholds`, `NotificationsConfig`, `ConfigStore` | | `WorkHours.swift` | рабочий день: сколько рабочего времени в отрезке | `WorkHours` | | `WeekWindow.swift` | границы недельного окна, нарезка на календарные сутки, план на момент и на строку | `WeekWindow`, `WeekDaySlot` | | `Snapshot.swift` | что показываем: итог, дни, сессия, производные метрики | `UsageSnapshot`, `DayUsage`, `SessionUsage`, `UsageMetrics`, `LimitState` | | `UsageProvider.swift` | протокол источника и типы ошибок | `UsageProvider`, `UsageError` | | `OfficialProvider.swift` | запрос к `/api/oauth/usage`, разбор ответа, паузы после отказов | `OfficialProvider`, `OfficialUsage`, `UsageTransport` | +| `CodexProvider.swift` | JSON-RPC с официальным `codex app-server`, короткое и недельное окна, дневная токенная форма, паузы после отказов | `CodexProvider`, `CodexAppServerTransport`, `CodexRateLimits` | | `LocalProvider.swift` | расчёт по транскриптам, цены моделей, инкрементальное чтение файлов | `LocalProvider`, `LocalUsage`, `ModelPricing` | | `ResolvingProvider.swift` | выбор источника, падение на запасной, калибровка, запись кеша, сверка аккаунта | `ResolvingProvider` | -| `Cache.swift` | `cache.json`, индекс прочитанных транскриптов, лог сказанного уведомлениями и состояние счёта (`state.json`) | `CachedUsage`, `UsageIndex`, `CountingState`, `Store` | +| `Cache.swift` | раздельные кеши Claude/Codex, индекс прочитанных транскриптов, логи уведомлений и состояние счёта (`state.json`) | `CachedUsage`, `UsageIndex`, `CountingState`, `Store` | | `Alerts.swift` | уведомления: пороги, правила «когда сказать» и слова баннера | `NotificationsConfig`, `LimitNotifications`, `LimitAlert`, `AlertLog`, `AlertPlanner` | | `Keychain.swift` | чтение OAuth-кредов Claude Code и метка аккаунта, по которой видно смену | `KeychainCredentials`, `OAuthCredentials`, `CredentialsSource` | | `Formatting.swift` | «3 дн 6 ч», проценты, дни недели, часы | `Formatting` | @@ -68,6 +76,8 @@ SwiftUI` — значит, расчёт просочился в UI или нао - **«Откуда берётся 64 %»** → `OfficialProvider.usage(at:)`, поле `seven_day.utilization`; форма недели — `shapeByDay`. +- **«Откуда берутся проценты Codex»** → `CodexProvider.selectLimits`, методы + app-server `account/rateLimits/read` и `account/usage/read`. - **«Почему полосы такие»** → `WeekWindow.planPercent(forDay:)` и `UsageSnapshot.make(...)`. - **«Почему суток восемь, а строк семь»** → `WeekWindow.bounds(...)`: сутки @@ -162,6 +172,14 @@ SwiftUI` — значит, расчёт просочился в UI или нао WorkHours(start: 10, end: 18)`), а не через `WorkHours.default`: поменяй там — и разъедутся ожидания полусотни тестов плана, которые вообще не про заводской день. +17. **Аккаунты не делят снимок и побочные файлы.** В памяти ключом служит + `UsageAccount`, на диске у Codex свои `codex-cache.json` и + `codex-alerts.json`. Запоздалый ответ старого провайдера отсекает поколение + обновления в `StatusItemController`. +18. **Codex `primary` не называют неделей только потому, что оно единственное.** + Недельным считается `secondary` либо окно длительностью не меньше шести + суток. Если сервер сообщил лишь 15 минут, панель показывает ошибку, а не + ложный «недельный лимит». --- @@ -174,6 +192,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `PanelModel.swift` | состояние для SwiftUI: снимок, статус, текущий момент, производные подписи | | `DropdownPanel.swift` | окно панели: форма как у системного меню, материал, обводка, позиция под строкой меню своего монитора, закрытие по клику мимо, закрепление на время настроек | | `PopoverView.swift` | вёрстка панели: заголовок, сессия, ряд дней (семь строк или одни текущие сутки — `appearance.panelLayout`) либо, при выключенном `appearance.showsPlan`, одна полоса недели вместо ряда, футер | +| `AccountPicker.swift` | две кнопки Claude/Codex в заголовке; выбор поступает из `Config.activeAccount`, собственного расходящегося состояния нет | | `DayBar.swift` | одна двухцветная полоса и строка дня; в компактном виде строка ещё и раскрывает неделю кликом (`DayRowTap`), а цифры справа открывают разбивку по моделям (`valueTap`) | | `SessionRow.swift` | полоса пятичасовой сессии (`LimitBar` — общая с недельной полосой ниже); её процент нажимается так же, как цифры дня | | `WeekRow.swift` | недельный итог одной `LimitBar` без плановой зоны — на месте `DayBar`-ряда, когда `appearance.showsPlan == false` | @@ -191,7 +210,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `AlertArtwork.swift` | картинка баннера: дуга для сессии, красное число для недели | | `Screenshot.swift` | `--screenshot`: панель во всех темах, она же в разбивке по моделям, вкладка уведомлений, иконка в PNG | | `AppIcon.swift` | `--icon`: кольцо недели по порогам, `.iconset` для сборки бандла | -| `CLI.swift` | `--json`, `--calibrate` и `--update` | +| `CLI.swift` | `--json` для выбранного `--account`, `--calibrate` и `--update` | ### Потоки данных @@ -202,6 +221,12 @@ SwiftUI` — значит, расчёт просочился в UI или нао куда её пишет сам `refresh()`; панель называет только состояние — «данные online», «данные offline», «данные недоступны», «данные загружаются». +**Переключение сервиса.** `AccountPicker` → `selectAccount(_:)`: текущий +снимок кладётся в словарь по `UsageAccount`, провайдер меняется между +`ResolvingProvider` и `CodexProvider`, а из кеша другого сервиса ничего не +подставляется. `refreshGeneration` не даёт ответу прежней ветки примениться +после щелчка. + **Свежесть — одно правило на всех.** Решает `PanelModel.apply(_:at:)`: снимок старше `freshFor` (2 минуты) переводит статус в `.stale`. Так подписывается и восстановленный из кеша снимок при старте, и официальное число, отданное во @@ -237,7 +262,9 @@ online», «данные offline», «данные недоступны», «д с порогами, помнит через `AlertLog`, о чём уже говорили, и отдаёт список поводов — обычно пустой. Контроллеру остаётся системная часть: разрешение macOS, сборка `UNNotificationRequest` с картинкой от `AlertArtwork` и запись -лога в `alerts.json`. +лога в `alerts.json` или `codex-alerts.json`. Подзаголовок баннера называет +сервис, а идентификатор содержит аккаунт, поэтому два одинаковых порога не +заменяют друг друга в Центре уведомлений. Правила живут в ядре именно затем, чтобы их гоняли тесты: «один порог — один раз за окно», «только на ухудшении», остывание. Уведомление, показанное дважды @@ -590,7 +617,9 @@ Intel — собирает у себя, `install.sh` соберёт нативн |---|---| | `~/.config/claude-week/config.json` | настройки (пишет и окно настроек, и вы сами) | | `~/.config/claude-week/cache.json` | последний снимок, подобранный бюджет, момент сброса, сессия | +| `~/.config/claude-week/codex-cache.json` | последний снимок Codex: проценты, границы окон и дневная форма | | `~/.config/claude-week/alerts.json` | о каких порогах уведомления уже говорили: окна лимитов, последние объявленные проценты, момент последнего баннера. Удаление безопасно — вернётся одно повторное уведомление | +| `~/.config/claude-week/codex-alerts.json` | то же для Codex; хранится отдельно, чтобы переключение не повторяло и не подавляло пороги Claude | | `~/.config/claude-week/state.json` | с какого момента считается локальный расход и на каком аккаунте: `countFrom` и метка организации. Отдельно от кеша намеренно — тот перезаписывается каждым обновлением, а отсечка обязана его пережить. Удаление вернёт в счёт расход прежнего аккаунта | | `~/.config/claude-week/index.json` | индекс прочитанных транскриптов (инкрементальное чтение); схема 2 — записи хранят семейство модели и токены, из них считается разбивка. Индекс прошлой схемы не переносится, а отстраивается заново: версия проверяется до разбора записей | | `~/.claude/projects/**/*.jsonl` | транскрипты Claude Code — вход локального источника | @@ -598,3 +627,4 @@ Intel — собирает у себя, `install.sh` соберёт нативн | `~/Library/LaunchAgents/com.greem4.claudeweek.plist` | автозапуск | | `~/Applications/ClaudeWeek.app` | установленная копия | | Keychain `Claude Code-credentials` | токен Claude Code (только читаем) | +| авторизация Codex CLI / app | читается самим `codex app-server`; ClaudeWeek токен не открывает и не сохраняет | diff --git a/docs/USAGE.md b/docs/USAGE.md index f39ca2d..9a41780 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -23,7 +23,13 @@ ## Авторизация и доступ -Ничего вводить не нужно: приложение берёт OAuth-токен уже авторизованного +Ничего вводить не нужно. Для **Codex** приложение запускает установленный +`codex app-server`: официальный процесс сам использует вход Codex CLI или +Codex app и возвращает только лимиты и токенную статистику. ClaudeWeek не +читает и не хранит токены Codex. Если команда `codex` не найдена или в клиенте +не выполнен вход, вкладка Codex честно остаётся без данных. + +Для **Claude** приложение берёт OAuth-токен уже авторизованного Claude Code из Keychain (запись `Claude Code-credentials`) и потому видит ровно тот аккаунт, что показывает `/usage`. Ни API-ключа, ни отдельного входа, ни строки в конфиге — токену не место в файле на диске. @@ -159,6 +165,12 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 или по Esc. Правый клик по иконке даёт обычное меню: «Обновить», «Настройки…», «О программе», «Выйти». +Две круглые кнопки в заголовке выбирают Claude или Codex. Они не выполняют +вход и не меняют аккаунт внутри клиентов — только показывают лимиты уже +авторизованного сервиса. Последний выбор запоминается. У каждого сервиса свой +снимок, поэтому при переключении цифры одного никогда не подписываются именем +другого даже на время обновления. + Панель приходит туда, где вы сейчас: на текущий рабочий стол и под ту строку меню, в которой стоит значок, — на втором мониторе так же, как на основном. Уходите на другой стол — панель закрывается, как это делает системное меню. @@ -652,6 +664,7 @@ GitHub — запись в лог, и ничего поверх работы. | `resetWeekday` / `resetHour` / `resetMinute` | `6` / `16` / `0` | запасной момент сброса — пятница 16:00 в `timeZone` ниже, то есть 15:00 МСК. В настройках этих полей нет: они идут в дело, только если сервер не отвечал ещё ни разу и кеш пуст | | `timeZone` | `""` | таймзона окна, границ суток и подписи сброса; пустая строка — системная | | `refreshInterval` | `300` | секунды между обновлениями. Меньше 30 чинится на 30, ползунок в настройках начинается с 60. В сеть при этом ходят не чаще раза в минуту в любом случае — короткий интервал только чаще перерисовывает панель по уже полученному числу | +| `activeAccount` | `claude` | какой сервис выбран в панели: `claude` или `codex` | | `provider` | `auto` | `official`, `local` или `auto` | | `workHours.start` / `.end` | `10` / `18` | рабочий день, по которому раскладывается лимит; `24` — полночь. `0`/`24` — считать круглосуточно, как раньше | | `menuBarStyle` | `ring` | `percent`, `compact` (только полоска) или `ring` (кольцо с процентом) | @@ -685,7 +698,11 @@ GitHub — запись в лог, и ничего поверх работы. ## Откуда берутся цифры -- **Официальный источник** — тот же процент, что в `/usage`: `GET +- **Codex** — официальный `codex app-server`: `account/rateLimits/read` + сообщает короткое и недельное окна с процентами и сбросами, а + `account/usage/read` — дневные токенные бакеты. Процент недели точный; + дневные полосы — форма, масштабированная к нему. Схема — в [API.md](API.md). +- **Официальный источник Claude** — тот же процент, что в `/usage`: `GET /api/oauth/usage` с токеном из Keychain. Момент сброса недели тоже берётся из ответа, а не из конфига. Схема и дисциплина запросов — в [API.md](API.md). - **Локальный источник** — запасной, работает офлайн: расход считается по @@ -739,8 +756,10 @@ GitHub — запись в лог, и ничего поверх работы. ### Что ещё умеет -- **Пятичасовая сессия** — отдельная полоса над сутками с часом сброса. Число - официальное; локально оно не считается, поэтому офлайн показывается последнее +- **Короткое окно** — отдельная полоса над сутками с часом сброса. У Claude + оно пятичасовое, у Codex длительность приходит в `windowDurationMins` и + поэтому подпись может быть другой. Число официальное; локально оно не + считается, поэтому для Claude офлайн показывается последнее известное — и только пока его пятичасовое окно не истекло. После сброса строка исчезает: показать вчерашние 95 % как сегодняшние хуже, чем не показать ничего. @@ -754,6 +773,10 @@ GitHub — запись в лог, и ничего поверх работы. ## Смена аккаунта +Сначала не путайте два действия: кнопки в панели переключают **Claude и +Codex**, а вход другим аккаунтом выполняется в самом Claude Code или Codex. +ClaudeWeek всегда следует за входом соответствующего клиента. + Вошли в Claude Code другим аккаунтом — рабочим вместо домашнего, — и виджет показывает две правды сразу. Недельный процент приходит от сервера и сменяется сам: сервер узнаёт аккаунт по токену. А вот разбивка по суткам считается по @@ -796,6 +819,7 @@ Keychain недоступен: без него метку не прочитат ``` ClaudeWeek запустить приложение в строке меню ClaudeWeek --json состояние в JSON (дни, сессия, стоимость, темп) +ClaudeWeek --account=X сервис для UI и --json: claude или codex ClaudeWeek --calibrate=N подогнать оценку под официальные N % ClaudeWeek --screenshot К отрисовать панель во всех темах и иконку в PNG ClaudeWeek --config=ПУТЬ свой файл конфигурации @@ -806,9 +830,9 @@ ClaudeWeek --update поставить свежий выпуск с GitHu ClaudeWeek --verbose подробный лог в stderr ``` -Незнакомый флаг, неизвестный источник у `--provider=` или не-число у -`--calibrate=` — это ошибка с кодом 2 и подсказкой, а не молчаливый запуск -иконки в строке меню. +Незнакомый флаг, неизвестный сервис у `--account=`, неизвестный источник у +`--provider=` или не-число у `--calibrate=` — это ошибка с кодом 2 и +подсказкой, а не молчаливый запуск иконки в строке меню. ## Почему так сделано From 9c6f1c8a3e86933d466de034a15c3cf033345c14 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 30 Aug 2026 09:41:54 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D0=9F=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B4=D0=BD=D0=B5=D0=B2=D0=BD=D1=83=D1=8E=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=83=20Codex=20=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B4=D0=B5=D0=BD=D1=8C=20=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex датирует дневные токенные бакеты по тихоокеанской зоне биллинга. cumulativeByDay раскладывал их по датам местного календаря окна, и в первые местные сутки недели единственный бакет выпадал за левую границу окна: total выходил нулевым, вся дневная форма становилась nil, а строка дня показывала «— / план» вместо точного недельного процента. Теперь каждый бакет — это интервал [полночь Pacific, +сутки), и токены раскладываются по суткам окна пропорционально реальному перекрытию во времени. Если перекрытия нет ни у одних прошедших суток, точный недельный процент вешается на последние из них, а не прячется за прочерками. Проверено на живом codex app-server: строка «сегодня» стала 21 / 14 % вместо — / 14 %. Обновил docs/API.md и счётчик тестов в README и ARCHITECTURE (477 → 528). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Cnw6tgByHoBX8xPWN3dawq --- CHANGELOG.md | 5 +- README.md | 2 +- Sources/ClaudeWeekCore/CodexProvider.swift | 58 +++++++++++----- .../ClaudeWeekTests/CodexProviderTests.swift | 67 +++++++++++++++++++ docs/API.md | 10 +++ docs/ARCHITECTURE.md | 2 +- 6 files changed, 124 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e123eb..7448ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,10 @@ - **Переключатель Claude / Codex в заголовке панели.** Для Codex приложение получает короткий и недельный лимиты, моменты сброса и дневную токенную форму через официальный `codex app-server`; длина короткого окна показывается такой, - какой её сообщил сервер. Выбранный сервис переживает перезапуск, а кеши и + какой её сообщил сервер. Дневная форма раскладывается по суткам окна через + реальное перекрытие во времени с тихоокеанскими сутками биллинга, в которых + Codex ведёт токенную статистику, — иначе в первый местный день недельного + окна она пропадала целиком. Выбранный сервис переживает перезапуск, а кеши и история уведомлений Claude и Codex хранятся раздельно. ## [0.2.0] — 2026-08-23 diff --git a/README.md b/README.md index c5d2ca0..5d3593d 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Keychain с той, на которой ведётся счёт, и при ра ```bash swift build # обе цели -swift run ClaudeWeekTests # 477 проверок: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 528 проверок: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekCore/CodexProvider.swift b/Sources/ClaudeWeekCore/CodexProvider.swift index 125efa0..55e2b78 100644 --- a/Sources/ClaudeWeekCore/CodexProvider.swift +++ b/Sources/ClaudeWeekCore/CodexProvider.swift @@ -575,9 +575,21 @@ public actor CodexProvider: UsageProvider { ) } + /// Зона биллинга OpenAI: сутки токенной статистики (`startDate` бакета) + /// нарезаны по её полуночи, а не по местной и не по UTC. Проверено на + /// живом ответе — обмен в 05:53 UTC попал в бакет предыдущего дня, что + /// сходится только с тихоокеанским временем. + private static let codexBillingZone = TimeZone(identifier: "America/Los_Angeles") + /// Дневные токены задают только форму: точный недельный процент остаётся - /// знаменателем сервера. Первый неполный день приблизительно режется по - /// доле суток, потому что API отдаёт бакет целиком, включая часы до сброса. + /// знаменателем сервера. + /// + /// `startDate` бакета — календарный день тихоокеанской зоны, а окно живёт + /// в зоне конфига, поэтому бакет раскладывается по суткам окна по + /// настоящему перекрытию во времени, а не сопоставлением дат. Иначе в + /// первые местные сутки недельного окна единственный бакет уезжает за его + /// левую границу, `total` выходит нулевым, и дневная форма пропадает + /// целиком — хотя точный расход недели сервер уже сообщил. private func cumulativeByDay( _ buckets: [CodexDailyUsage]?, totalPercent: Double, @@ -588,28 +600,40 @@ public actor CodexProvider: UsageProvider { return Array(repeating: nil, count: window.slotCount) } - let calendar = window.calendar - var byDate: [Date: Double] = [:] - for bucket in buckets { - guard let date = calendarDate(bucket.startDate, calendar: calendar) else { continue } - byDate[calendar.startOfDay(for: date), default: 0] += Double(max(bucket.tokens, 0)) + var billing = Calendar(identifier: .gregorian) + if let zone = CodexProvider.codexBillingZone { billing.timeZone = zone } + + // Настоящие интервалы бакетов: [полночь дня биллинга, +сутки). + // Календарный шаг, а не «+86400», — на переводе часов сутки короче + // или длиннее, и знаменатель доли должен это учитывать. + let spans: [(start: Date, end: Date, tokens: Double)] = buckets.compactMap { bucket in + guard let day = calendarDate(bucket.startDate, calendar: billing) else { return nil } + let start = billing.startOfDay(for: day) + guard let end = billing.date(byAdding: .day, value: 1, to: start), end > start else { return nil } + return (start, end, Double(max(bucket.tokens, 0))) } let amounts: [Double?] = window.days.map { day in guard day.start < now else { return nil } - let dayStart = calendar.startOfDay(for: day.start) - var value = byDate[dayStart, default: 0] - if day.start > dayStart, - let next = calendar.date(byAdding: .day, value: 1, to: dayStart) { - let full = next.timeIntervalSince(dayStart) - if full > 0 { value *= day.end.timeIntervalSince(day.start) / full } + return spans.reduce(0.0) { sum, span in + let lo = max(span.start, day.start) + let hi = min(span.end, day.end) + guard hi > lo else { return sum } + return sum + span.tokens * hi.timeIntervalSince(lo) / span.end.timeIntervalSince(span.start) } - return value } + let total = amounts.compactMap { $0 }.reduce(0, +) - // Нули токенов не задают форму расхода: рисовать по ним семь точных - // нулей рядом с ненулевым итогом означало бы выдумать противоречие. - guard total > 0 else { return Array(repeating: nil, count: window.slotCount) } + guard total > 0 else { + // Бакеты есть, но ни один не перекрыл прошедшую часть окна + // (первые сутки недели, редкий сдвиг зоны). Точный недельный + // процент известен — относим его к последним прошедшим суткам, + // а не прячем весь расход за прочерками. + guard totalPercent > 0, let last = amounts.lastIndex(where: { $0 != nil }) else { + return Array(repeating: nil, count: window.slotCount) + } + return amounts.indices.map { $0 == last ? totalPercent : nil } + } var running = 0.0 return amounts.map { value in diff --git a/Sources/ClaudeWeekTests/CodexProviderTests.swift b/Sources/ClaudeWeekTests/CodexProviderTests.swift index 6f1ea5b..54ae7b3 100644 --- a/Sources/ClaudeWeekTests/CodexProviderTests.swift +++ b/Sources/ClaudeWeekTests/CodexProviderTests.swift @@ -207,6 +207,73 @@ func runCodexProviderTests(_ t: Harness) async { "без дневных бакетов форма не выдумывается") } + await t.suite("Codex: первый день недели — дневной факт не теряется") { + // Недельное окно открылось сразу после местной полуночи, а + // единственный дневной бакет датирован по тихоокеанской зоне + // вчерашним числом. По датам он выпадает за левую границу окна — + // но по времени перекрывает его первые часы. + let now = at(2026, 8, 30, 9, 0) + let weekReset = at(2026, 9, 6, 0, 25) + func answer(bucketDate: String) -> CodexAccountUsage { + CodexAccountUsage( + limits: CodexRateLimits(rateLimits: CodexRateLimitBucket( + limitId: "codex", + primary: CodexRateLimitWindow( + usedPercent: 36, windowDurationMinutes: 300, + resetsAt: at(2026, 8, 30, 13, 0) + ), + secondary: CodexRateLimitWindow( + usedPercent: 21, windowDurationMinutes: 7 * 24 * 60, + resetsAt: weekReset + ) + )), + tokens: CodexTokenUsage(dailyUsageBuckets: [ + CodexDailyUsage(startDate: bucketDate, tokens: 36_854_107), + ]) + ) + } + + let overlapping = try await CodexProvider( + config: config(), transport: FakeCodexTransport(answer(bucketDate: "2026-08-29")), + cacheURL: nil, clock: { now } + ).fetch() + t.close(overlapping.byDay.first(where: { $0.usedPercent != nil })?.usedPercent ?? -1, 21, + "бакет за пределами дат окна разложен по перекрытию во времени") + t.close(overlapping.byDay.compactMap(\.usedPercent).last ?? -1, 21, + "накопительная форма приходит ровно к недельному итогу") + + let disjoint = try await CodexProvider( + config: config(), transport: FakeCodexTransport(answer(bucketDate: "2026-08-20")), + cacheURL: nil, clock: { now } + ).fetch() + t.check(disjoint.byDay.contains { $0.usedPercent != nil }, + "без единого перекрытия точный процент всё равно виден в строке дня") + t.close(disjoint.byDay.compactMap(\.usedPercent).last ?? -1, 21, + "запасной путь вешает недельный итог на последние прошедшие сутки") + + // Несколько суток позади, несколько бакетов — форма должна + // распределиться по перекрытию, а не осесть одним числом. + let midWeek = try await CodexProvider( + config: config(), + transport: FakeCodexTransport(CodexAccountUsage( + limits: answer(bucketDate: "x").limits, + tokens: CodexTokenUsage(dailyUsageBuckets: [ + CodexDailyUsage(startDate: "2026-08-29", tokens: 1_000), + CodexDailyUsage(startDate: "2026-08-30", tokens: 1_000), + CodexDailyUsage(startDate: "2026-08-31", tokens: 1_000), + ]) + )), + cacheURL: nil, + clock: { at(2026, 9, 2, 12, 0) } + ).fetch() + let shape = midWeek.byDay.compactMap(\.usedPercent) + t.check(shape.count >= 3 && zip(shape, shape.dropFirst()).allSatisfy { $0 <= $1 }, + "накопительная форма растёт по дням, а не скачком") + t.check(shape.first.map { $0 > 0 && $0 < 21 } ?? false, + "первые сутки получили свою долю, а не весь итог") + t.close(shape.last ?? -1, 21, "и всё равно приходит ровно к недельному проценту") + } + await t.suite("Codex: необязательный сброс одного окна") { let bucket = CodexRateLimitBucket( primary: CodexRateLimitWindow( diff --git a/docs/API.md b/docs/API.md index 900753c..719783d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -301,5 +301,15 @@ CLI ищется в `CODEX_EXECUTABLE`, `PATH`, обычных каталога (`isEstimate == false`), а дневные полосы помечены в модели как оценка (`shapeIsEstimate == true`). Разбивки по моделям этот интерфейс не сообщает. +`startDate` — календарный день **тихоокеанской зоны** (зона биллинга OpenAI), +а не UTC и не местной зоны: проверено на живом ответе — обмен в 05:53 UTC попал +в бакет предыдущего числа. Окно недели живёт в зоне конфига, поэтому бакет +раскладывается по суткам окна пропорционально настоящему перекрытию во времени +(`[местная полночь дня, +сутки)` против границ суток окна), а не сопоставлением +дат. Без этого в первые местные сутки недельного окна единственный бакет +уезжал за его левую границу, и вся дневная форма пропадала — хотя точный +процент недели сервер уже сообщил. Если перекрытия всё же нет ни у одних +прошедших суток, точный недельный процент вешается на последние из них. + Снимок хранится отдельно в `~/.config/claude-week/codex-cache.json`; кеш Claude он не перезаписывает. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 629fb33..d3b173a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -440,7 +440,7 @@ swift build # оба таргета # объявления: ключи kSecUseAuthenticationUI в Keychain.swift оставлены # намеренно, замены им нет, и группа понижена обратно до предупреждения. swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration -swift run ClaudeWeekTests # 477 проверок, без сети и без UI +swift run ClaudeWeekTests # 528 проверок, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) ./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app From 79235df11c9a00a1600fda35656df07664d1f8ca Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 30 Aug 2026 10:35:13 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=D0=9F=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=B7=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=D0=B8=20Claude=20=D0=B8=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ближайшие SF Symbols — звёздочка и «» — сервис не называли, а намекали на него. Вместо них в кнопках стоят сами знаки: PNG в ресурсах таргета приложения, шаблонные, поэтому красятся под тему и под выбранное состояние. Кнопки заодно сведены в общую рамку — выбранный сервис виден по подложке и чёрточке под знаком, а не по одиночной обводке кружка. Ресурсы у таргета появились впервые, и `make-app.sh` теперь кладёт бандл SwiftPM в `.app`. Без него `Bundle.module` обрывается fatalError'ом ещё до отрисовки панели, так что ненайденный бандл останавливает сборку, а не уезжает в релиз молча. Обновил CHANGELOG, docs/USAGE.md, docs/ARCHITECTURE.md и AGENTS.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jjfp3AoEy2b6YCKfDKoLT7 --- AGENTS.md | 2 +- CHANGELOG.md | 11 +++ Package.swift | 3 +- Sources/ClaudeWeekApp/AccountPicker.swift | 83 +++++++++++++++++----- Sources/ClaudeWeekApp/Assets/Claude.png | Bin 0 -> 711 bytes Sources/ClaudeWeekApp/Assets/Codex.png | Bin 0 -> 638 bytes docs/ARCHITECTURE.md | 2 +- docs/USAGE.md | 7 +- scripts/make-app.sh | 23 +++++- 9 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 Sources/ClaudeWeekApp/Assets/Claude.png create mode 100644 Sources/ClaudeWeekApp/Assets/Codex.png diff --git a/AGENTS.md b/AGENTS.md index 2472fc9..7c46e39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Структура проекта и модули -Проект — Swift Package для macOS 14+ с тремя таргетами. `Sources/ClaudeWeekCore/` содержит расчёты, конфигурацию, провайдеры и обновление; ядро не должно импортировать AppKit или SwiftUI. `Sources/ClaudeWeekApp/` отвечает за меню-бар, панель, настройки и CLI. Проверки находятся в `Sources/ClaudeWeekTests/` и запускаются собственным исполняемым раннером. Ресурсы бандла лежат в `Resources/`, документация — в `docs/`, а сценарии сборки и релиза — в `scripts/`. `.build/` и `dist/` — генерируемые каталоги, их не коммитят. +Проект — Swift Package для macOS 14+ с тремя таргетами. `Sources/ClaudeWeekCore/` содержит расчёты, конфигурацию, провайдеры и обновление; ядро не должно импортировать AppKit или SwiftUI. `Sources/ClaudeWeekApp/` отвечает за меню-бар, панель, настройки и CLI. Проверки находятся в `Sources/ClaudeWeekTests/` и запускаются собственным исполняемым раннером. Ресурсы бандла лежат в `Resources/`, ресурсы таргета приложения (знаки Claude и Codex) — в `Sources/ClaudeWeekApp/Assets/`, документация — в `docs/`, а сценарии сборки и релиза — в `scripts/`. `.build/` и `dist/` — генерируемые каталоги, их не коммитят. ## Сборка, тесты и локальный запуск diff --git a/CHANGELOG.md b/CHANGELOG.md index 7448ce5..a9662d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ окна она пропадала целиком. Выбранный сервис переживает перезапуск, а кеши и история уведомлений Claude и Codex хранятся раздельно. +### Изменено + +- **В переключателе Claude / Codex стоят знаки самих сервисов.** Раньше это + были ближайшие SF Symbols — звёздочка и «», — по которым сервис + угадывался, а не читался. Теперь кнопки несут узнаваемые знаки Claude и + Codex, крашенные под тему как шаблонные, и сведены в общую рамку: выбранный + сервис виден по подложке и чёрточке под знаком. Картинки лежат в ресурсном + бандле таргета приложения (`Sources/ClaudeWeekApp/Assets`), и `make-app.sh` + кладёт этот бандл в `.app` — без него `Bundle.module` оборвал бы программу + ещё до отрисовки панели, поэтому его отсутствие останавливает сборку. + ## [0.2.0] — 2026-08-23 ### Добавлено diff --git a/Package.swift b/Package.swift index be299ca..1e312f7 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,8 @@ let package = Package( .executableTarget( name: "ClaudeWeekApp", - dependencies: ["ClaudeWeekCore"] + dependencies: ["ClaudeWeekCore"], + resources: [.process("Assets")] ), // XCTest и swift-testing без Xcode недоступны, поэтому проверки — diff --git a/Sources/ClaudeWeekApp/AccountPicker.swift b/Sources/ClaudeWeekApp/AccountPicker.swift index a4a7819..5264154 100644 --- a/Sources/ClaudeWeekApp/AccountPicker.swift +++ b/Sources/ClaudeWeekApp/AccountPicker.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import ClaudeWeekCore @@ -11,7 +12,7 @@ struct AccountPicker: View { @Environment(\.strings) private var s var body: some View { - HStack(spacing: 4) { + HStack(spacing: 1) { ForEach(UsageAccount.allCases, id: \.self) { account in AccountButton( account: account, @@ -20,6 +21,15 @@ struct AccountPicker: View { ) } } + .padding(2) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.primary.opacity(0.06)) + } + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(.primary.opacity(0.12), lineWidth: 1) + } .accessibilityElement(children: .contain) .accessibilityLabel(s.pick("Аккаунт", "Account")) } @@ -37,20 +47,20 @@ private struct AccountButton: View { Button { onSelect(account) } label: { - Image(systemName: symbol) - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(isSelected ? palette.primaryText.color : palette.secondaryText.color) - .frame(width: 18, height: 18) - .background { - Circle().fill(isSelected ? palette.track.color : .clear) - } - .overlay { - Circle().strokeBorder( - isSelected ? palette.plan.color : palette.separator.color, - lineWidth: isSelected ? 1.5 : 1 - ) - } - .contentShape(Circle()) + VStack(spacing: 1) { + providerIcon + + Capsule() + .fill(palette.plan.color) + .frame(width: 12, height: 1.5) + .opacity(isSelected ? 1 : 0) + } + .frame(width: 22, height: 20) + .background { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isSelected ? palette.track.color : .clear) + } + .contentShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) } .buttonStyle(.plain) .help(help) @@ -58,10 +68,47 @@ private struct AccountButton: View { .accessibilityAddTraits(isSelected ? .isSelected : []) } - private var symbol: String { + private var assetName: String { + switch account { + case .claude: "Claude" + case .codex: "Codex" + } + } + + /// У поставщиков нет подходящих SF Symbols: загружаем их контуры как + /// обычные PNG, чтобы не подменять узнаваемый знак абстрактной звездой + /// или значком исходного кода. + @ViewBuilder + private var providerIcon: some View { + if let url = Bundle.module.url(forResource: assetName, 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: 14, height: 14) + } else { + Image(systemName: "questionmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(palette.secondaryText.color) + .frame(width: 14, height: 14) + } + } + + private func template(_ image: NSImage) -> NSImage { + image.isTemplate = true + return image + } + + private var iconColor: Color { switch account { - case .claude: "sparkle" - case .codex: "chevron.left.forwardslash.chevron.right" + case .claude: + return isSelected ? Color(red: 0.87, green: 0.36, blue: 0.10) : palette.secondaryText.color + case .codex: + return isSelected ? palette.primaryText.color : palette.secondaryText.color } } diff --git a/Sources/ClaudeWeekApp/Assets/Claude.png b/Sources/ClaudeWeekApp/Assets/Claude.png new file mode 100644 index 0000000000000000000000000000000000000000..30d3918149905010451558ab8e0aa1104137204a GIT binary patch literal 711 zcmeAS@N?(olHy`uVBq!ia0vp^G9b*s1|*Ak?@s|zjKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(-uvw#`F1}RM0`(+UW1Cybri(`lf z@7t;SjaVE7jy)IPR7sgGd~(MDYmPllZ>G2lRB${sTEAdQgw_t>6_XS~)s=)e&i^@9 z7rl4$Uh|NZ-CJ(Gp80Oxzg_!(E*5v1aWp@1(-wmzTrcDeWO%n7I{dNjlHA`P^~$bZ z<-cBQ-Y?i7S+;s31H*DOy8;j>>iBDiH%ip!@%x;0B4EIA_jkaB43K4(P>+mwcq|Wd4lW?KZ^n)Mf&R!d! zvx_xjGV?0oEa8YLPU^?vHS4#xs@lJMqzL|K8B>VVa@qSC%O* zu-r=2z=7#XQbXvJYjf`0IsNuk+!itU_j8QQgH7`0$6jZB^Ub_z9dGNZ*K7;cPFrFn z!1P0N!=tH(nbt+9w=1$wwP&Ahd;JKb!OVSIxzx5@TqsuF=rq|@q0627#I1dAeG|kD z;*ZQxsu0-a(h%qPgHJ#I@+Bn&hefkmJ;a$+LV14o78JaZJIA_2S@n6{M*SNSuUSs< z3p~DLn7f>XNfzjr5Wo>9`KW>z-kS%!80nk73QJ>C8@y1%@{tmWK_d+&I!ALh#W;HdlY z!pYT-Jp}~}CRJ89#VV~Tov|T$|Dy}VFTeb`aQgXA8>!rw=k?tZS2X{yJFb(9k1|{@ lKVNsY>6yLlW*^oci=Xr|;_>l+x^AFE_*MA#MNw literal 0 HcmV?d00001 diff --git a/Sources/ClaudeWeekApp/Assets/Codex.png b/Sources/ClaudeWeekApp/Assets/Codex.png new file mode 100644 index 0000000000000000000000000000000000000000..bcca6e130e1ee8743db329bfc075e18f0b6b4a87 GIT binary patch literal 638 zcmeAS@N?(olHy`uVBq!ia0vp^G9b*s1|*Ak?@s|zjKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(-uvw#`F1}RM0`(+VO*$Yn>#}E(R zx6=ZP4l4*a7cOV__7i)aQs(FOJ@APZi;TMb&sXOX5-xg0Ju0+bI;E(m|LFDOQ&wHr zpYZ*d|MK9dX{t}>oSyOAa-y|Ra6?>Gra)6o?DgLGr0cIsPj5Wnp)X)#;-oX{lqJi% z4{zKZeGk|B=-d(W6HnF^K2z7$ktKEUaZ{XzQOt(CT{pfRihR3o0oR-eOJz-_Q`Tb2 z4|uG_L>Lp)1LI=1hQF>jC*iX9)4l~BGZodgT${;V7{@8UYigr&)2l`59(@9e*)xOY zo+;d>eM9VboC-(hjL6F4liWL2{P;IjDxqSj-m=ofb^JUpW~u&?J&^ZfIt#<^(@!rQ z-R4EV3!I|c<3j{-mc&Ye>I z_OxRy%ffv5Yuj(HeX`5%amw$FcTFqX?i9Bb_XoBow8@HTe_iOuJilp8%FOf6izADf z6Sk~REuZ|#!|v^b`@%6IxxFj2)8;lS9GKiq{@=gk2~;_%UXK z=8YqvujhLA81wwt_vkxos77Gv(KoS<>x{D^Rxjf^`J&j7Yftx~6n^m{Lm!i*pHKg1 zI$2o!bBdS07vw5&qc&SU#^vEdnY$Nl?+TpBJ$~+cQTwY$cYm_~{mvruaGLNsP~3aE L`njxgN@xNASiA{O literal 0 HcmV?d00001 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3b173a..9a45bf1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -192,7 +192,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `PanelModel.swift` | состояние для SwiftUI: снимок, статус, текущий момент, производные подписи | | `DropdownPanel.swift` | окно панели: форма как у системного меню, материал, обводка, позиция под строкой меню своего монитора, закрытие по клику мимо, закрепление на время настроек | | `PopoverView.swift` | вёрстка панели: заголовок, сессия, ряд дней (семь строк или одни текущие сутки — `appearance.panelLayout`) либо, при выключенном `appearance.showsPlan`, одна полоса недели вместо ряда, футер | -| `AccountPicker.swift` | две кнопки Claude/Codex в заголовке; выбор поступает из `Config.activeAccount`, собственного расходящегося состояния нет | +| `AccountPicker.swift` | переключатель Claude/Codex в заголовке: знаки сервисов из ресурсного бандла (`Sources/ClaudeWeekApp/Assets`), крашенные как шаблонные под тему. Выбор поступает из `Config.activeAccount`, собственного расходящегося состояния нет | | `DayBar.swift` | одна двухцветная полоса и строка дня; в компактном виде строка ещё и раскрывает неделю кликом (`DayRowTap`), а цифры справа открывают разбивку по моделям (`valueTap`) | | `SessionRow.swift` | полоса пятичасовой сессии (`LimitBar` — общая с недельной полосой ниже); её процент нажимается так же, как цифры дня | | `WeekRow.swift` | недельный итог одной `LimitBar` без плановой зоны — на месте `DayBar`-ряда, когда `appearance.showsPlan == false` | diff --git a/docs/USAGE.md b/docs/USAGE.md index 9a41780..a42d04e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -165,9 +165,10 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 или по Esc. Правый клик по иконке даёт обычное меню: «Обновить», «Настройки…», «О программе», «Выйти». -Две круглые кнопки в заголовке выбирают Claude или Codex. Они не выполняют -вход и не меняют аккаунт внутри клиентов — только показывают лимиты уже -авторизованного сервиса. Последний выбор запоминается. У каждого сервиса свой +Переключатель в заголовке выбирает Claude или Codex: две кнопки со знаками +самих сервисов в общей рамке, выбранная подсвечена подложкой и чёрточкой под +знаком. Кнопки не выполняют вход и не меняют аккаунт внутри клиентов — только +показывают лимиты уже авторизованного сервиса. Последний выбор запоминается. У каждого сервиса свой снимок, поэтому при переключении цифры одного никогда не подписываются именем другого даже на время обновления. diff --git a/scripts/make-app.sh b/scripts/make-app.sh index 1fd3125..b2de07a 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,20 @@ fi cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist" printf 'APPL????' > "$APP/Contents/PkgInfo" +# Ресурсный бандл SwiftPM с иконками поставщиков. Без него `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' \ From e021b1ab89c0af86e54b665ee26b4aa49f12e573 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 30 Aug 2026 12:41:12 +0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(app):=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB?= =?UTF-8?q?=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=B8=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D1=8B,=20=D1=81=D1=8A=D0=B5=D0=B7=D0=B6=D0=B0=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Знаки Claude/Codex в заголовке панели были кнопками с рамкой, подложкой и чёрточкой под выбранным — тяжеловесно для двух значков рядом с надписью. Теперь это просто знаки без рамок и подложек: выбранный сервис виден по цвету (у Claude фирменный оранжевый, у Codex — цвет текста), невыбранный гаснет до серого. Вертикально они выровнены не числом-заглушкой, а центром строки текста: Theme.textCenterAboveBaseline считается по подъёму и спуску системного шрифта меню, так что знаки не поедут при другом кегле. Заодно поправил баг с полосами: при переключении на разбивку по моделям и обратно они на кадр отрисовывались выше своего места и съезжали вниз. Панель растёт от строки меню вниз, и после смены высоты содержимого кромку возвращает pinToAnchor — он висел на didResizeNotification с queue: .main, то есть срабатывал следующим проходом цикла, а не в момент самого уведомления. Между ними и проскакивал лишний кадр со старым положением. Подписка стала синхронной (queue: nil). Обновил CHANGELOG, docs/ARCHITECTURE.md и docs/USAGE.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jjfp3AoEy2b6YCKfDKoLT7 --- CHANGELOG.md | 18 +++++- Sources/ClaudeWeekApp/AccountPicker.swift | 66 ++++++++++------------ Sources/ClaudeWeekApp/Assets/Claude.png | Bin 711 -> 764 bytes Sources/ClaudeWeekApp/Assets/Codex.png | Bin 638 -> 813 bytes Sources/ClaudeWeekApp/DropdownPanel.swift | 9 ++- Sources/ClaudeWeekApp/Theme.swift | 9 +++ docs/ARCHITECTURE.md | 7 ++- docs/USAGE.md | 6 +- 8 files changed, 72 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9662d9..268eecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,12 +32,24 @@ - **В переключателе Claude / Codex стоят знаки самих сервисов.** Раньше это были ближайшие SF Symbols — звёздочка и «», — по которым сервис угадывался, а не читался. Теперь кнопки несут узнаваемые знаки Claude и - Codex, крашенные под тему как шаблонные, и сведены в общую рамку: выбранный - сервис виден по подложке и чёрточке под знаком. Картинки лежат в ресурсном - бандле таргета приложения (`Sources/ClaudeWeekApp/Assets`), и `make-app.sh` + Codex — шаблонные, силуэт держит альфа-канал. Рамок и подложек у них нет: + в заголовке это знаки, а не органы управления, и выбранный сервис виден по + цвету — у Claude фирменный оранжевый, у Codex цвет текста, — а невыбранный + уходит в приглушённый серый. Картинки лежат в ресурсном бандле таргета + приложения (`Sources/ClaudeWeekApp/Assets`), и `make-app.sh` кладёт этот бандл в `.app` — без него `Bundle.module` оборвал бы программу ещё до отрисовки панели, поэтому его отсутствие останавливает сборку. +### Исправлено + +- **Полосы больше не съезжают сверху при переключении на разбивку по + моделям.** Панель приклеена верхней кромкой к строке меню, и после смены + высоты содержимого кромку возвращает на место `pinToAnchor`. Он висел на + `didResizeNotification` с `queue: .main`, то есть срабатывал следующим + проходом цикла, — между уведомлением и правкой успевал отрисоваться кадр, + где окно уже новой высоты, а стоит по старому месту. Теперь подписка + синхронная, и промежуточного кадра нет. + ## [0.2.0] — 2026-08-23 ### Добавлено diff --git a/Sources/ClaudeWeekApp/AccountPicker.swift b/Sources/ClaudeWeekApp/AccountPicker.swift index 5264154..ad79fe7 100644 --- a/Sources/ClaudeWeekApp/AccountPicker.swift +++ b/Sources/ClaudeWeekApp/AccountPicker.swift @@ -2,8 +2,8 @@ import AppKit import SwiftUI import ClaudeWeekCore -/// Две компактные кнопки в заголовке панели. Выбор хранится в конфиге, но -/// сам вид получает только значение и действие — отдельного локального +/// Два знака в заголовке панели — Claude и Codex. Выбор хранится в конфиге, но +/// сам вид получает только значение и действие: отдельного локального /// состояния, способного разойтись с реально загруженным аккаунтом, нет. struct AccountPicker: View { let selection: UsageAccount @@ -12,7 +12,7 @@ struct AccountPicker: View { @Environment(\.strings) private var s var body: some View { - HStack(spacing: 1) { + HStack(spacing: 5) { ForEach(UsageAccount.allCases, id: \.self) { account in AccountButton( account: account, @@ -21,17 +21,15 @@ struct AccountPicker: View { ) } } - .padding(2) - .background { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.primary.opacity(0.06)) - } - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(.primary.opacity(0.12), lineWidth: 1) - } .accessibilityElement(children: .contain) .accessibilityLabel(s.pick("Аккаунт", "Account")) + // Заголовок собран по базовой линии текста, а у картинки она проходит + // по нижней кромке — знаки вставали над строкой. Выдаём за базовую + // линию собственный центр, поднятый на столько же, на сколько центр + // строки стоит над baseline: знаки встают ровно посередине надписи. + .alignmentGuide(.firstTextBaseline) { + $0[VerticalAlignment.center] + Theme.textCenterAboveBaseline + } } } @@ -47,22 +45,17 @@ private struct AccountButton: View { Button { onSelect(account) } label: { - VStack(spacing: 1) { - providerIcon - - Capsule() - .fill(palette.plan.color) - .frame(width: 12, height: 1.5) - .opacity(isSelected ? 1 : 0) - } - .frame(width: 22, height: 20) - .background { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(isSelected ? palette.track.color : .clear) - } - .contentShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + // Ни подложки, ни рамки: в заголовке это знаки, а не органы + // управления. Что выбрано, говорит цвет; поле вокруг знака + // добавлено только ради попадания мышью. + providerIcon + .padding(2) + .contentShape(Rectangle()) } .buttonStyle(.plain) + // Системное кольцо фокуса рисует вокруг знака рамку, которой здесь + // быть не должно: после клика подсвечены оба сервиса сразу. + .focusEffectDisabled() .help(help) .accessibilityLabel(help) .accessibilityAddTraits(isSelected ? .isSelected : []) @@ -75,9 +68,9 @@ private struct AccountButton: View { } } - /// У поставщиков нет подходящих SF Symbols: загружаем их контуры как - /// обычные PNG, чтобы не подменять узнаваемый знак абстрактной звездой - /// или значком исходного кода. + /// У поставщиков нет подходящих SF Symbols: знаки лежат в ресурсах + /// таргета как шаблонные PNG — силуэт держит альфа-канал, цвета в файле + /// нет. Красит их `iconColor`, поэтому знак живёт в любой теме. @ViewBuilder private var providerIcon: some View { if let url = Bundle.module.url(forResource: assetName, withExtension: "png"), @@ -89,12 +82,12 @@ private struct AccountButton: View { .resizable() .scaledToFit() .foregroundStyle(iconColor) - .frame(width: 14, height: 14) + .frame(width: 13, height: 13) } else { Image(systemName: "questionmark") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(palette.secondaryText.color) - .frame(width: 14, height: 14) + .frame(width: 13, height: 13) } } @@ -103,12 +96,15 @@ private struct AccountButton: View { return image } + /// Выбранный сервис горит своим цветом — Claude фирменным оранжевым, + /// Codex цветом текста, — невыбранный гаснет до приглушённого серого. + /// Разница цвета и есть выделение: подложка под знаком в заголовке + /// читалась бы как кнопка, которой здесь нет. private var iconColor: Color { + guard isSelected else { return palette.secondaryText.color.opacity(0.55) } switch account { - case .claude: - return isSelected ? Color(red: 0.87, green: 0.36, blue: 0.10) : palette.secondaryText.color - case .codex: - return isSelected ? palette.primaryText.color : palette.secondaryText.color + case .claude: return Color(red: 0.83, green: 0.42, blue: 0.30) + case .codex: return palette.primaryText.color } } diff --git a/Sources/ClaudeWeekApp/Assets/Claude.png b/Sources/ClaudeWeekApp/Assets/Claude.png index 30d3918149905010451558ab8e0aa1104137204a..7006ffd785c934106c5d98906f4e27c3e532be2b 100644 GIT binary patch delta 741 zcmVGpE^7k$EUI5VA5 z-`wz_XP-IyKYOizt$hN40W%!O@uE~b7iYu-kNcBFae`-i;D1+ekN7P9h|OZS>t2DV z73W26e+fJ~>yW4-1s}v3KY|m*Gx0`j5W~a}QNjEV5n-3> zS$Lz!M(iUm)_*ES_z?^eif^J_G>X+CPh4QJTVghXmfuZ?b#UKAx20m59P;*+1zw9S zgxYr5Mc%AbZi=XQ%Wpdn52fl-v-w=|V_|C(M{%l+d+>?y+vwyavinjNxzwV)`AY=4 z7w>d>4%_Vvo-r@X6D!Dnv6o;@9BGJJjV&RJ?M~uh;eS3T&XMmXUb4Yb;w5M`c9NV# z$yo%JbN$3gKFq}r#TCS@G!}`eUV_#)E5!+yXg9mWfh2-mbe%eJ2nU-`p&0qU9ELo? zn~!LskiI9ByTw%(m+(M|U(;B(nI_hX8{!?ii62(Cm&GcaeF{gnP*$h?2B?IJPVc4yfX=H44Y@Ma04ev31+$@meK}W zIi}K6?KByPHPc_M35+A3Q5>9!sVw|Q#8^(6dS0THl3)dgY>n$PB1|}si&4xKl2hv} z%a|MNKK12WVePx#L}ge>W=%~1DgXcg2mk?xX#fNO00031 z000^Q000001E2u_0{{R30RRC20H6W@1ONa40RR919H0XL1ONa40RR918~^|S0A#)N zq5uE_F-b&0R7ef&mA^6sK@i605D6q?mlMgjz&i-Mg6xz>5Px_Gy*E^;i?rJ7neLwcy1)6O7ebiDZ(*{uFr*6TA21vZw!*{le$pQO z_xNl3Dy9yo1{9VTrkOg*cy1LA}5XAuBRYfwk(gIW$?2SO_U^ISC>*QAwb!Wlwotv#t zEV>0~lLM*~Y7kau{1atp<|Jp_Ut6vH*eFZ_m@Q6rX%`Tu zIu$TL0@PxFRg~78+}z9U>Rz-KAMczpH&ZljpIz4l?0@z*g02mPs_O=zt(K%Z5CZrq zu;P`&0zU@t57cgJMoFaG- zxjcUV5aZH4Y#f?Y zQv6||=zk*-Zz-Br2jPe_COJYpUGRxgU4s;0eq2EO09YzSk@iA4m;(+_@{L0gl#4(2 zi(s>ubcQB2c9iC5JCCfSx#H!w`CE^7bT)*}sNU`k*TV{I@Ifu{(8;UgNf8h*l6iZA zT_UP=n6O*F;?Q&H^!(7v=lMJuZCvMnix|`?{3!=Pt{z`nF|Qw=Et@sWy@xjNuf|`J W=~&~({w+oT0000;BYy()Nkl7m7p#0ZMAMOvU}uZL_wO)ZOpXr(NZa>^px3tFw0um7o^Fbwm3!uj)D6!XM@Fynp`sef>d@Qb^mT3i6lOW&3Uc#9n?uY`>T_lR>;-q-UZ#AMyOcg#t-ynv#2{Vz|B$L?u zVBtD4@c{ciA`=ohkuRl>>(@;>Mn`YszVLh_%Wg% z3AM|j;3o9bNxCsviMU32EJ3(1MET4jyp<%4t#%`IYc0|5Cd?z_CX04r3Uyb-WG_90 zRxZoLTycwe?6-Jv!A+P%QdH2oVo7hyw)cdskYaC^S?IPR%o4o_W3#Y>jrXd=Q^d;Mgf_m!OKA1~z^d(tddh$Nh-zGfv1F~&65(NS zoc%aU(u`w|N(sI7%h_UO2%&YDH0G%F5~k2$a;e8OR{BLeqg0NFY|)3)V|55&6YbGf zpB=7w9~0Lp-Z-QOY0x>;UIO*DjTTqR|N4r39DmT|yhPa3!ZNWDv5(1PIEE|m}M=12x*}TU#*JUFOgn>L~o}VsJ#)#DDhr1yqGq Vp%A})irfGI002ovPDHLkV1i){hKK+F delta 614 zcmZ3>_K#(Payghjv{rHqs7xpK7KjyzYIBJ^e(>bSSJhz-^ zEfm}kSCuKyR1H$Lh5>(bL34|wPc*qAu!%sOSs^6tYMcSqmD^*%ax#QemQb%oE= zwRL1kU3}aWr(qPcA#c}>Z-*k^?pwe$C&E%$lj)STnDPT2Yq5F}#su}ixY(`XuPe?; zxa|G3Z-K{5MYS#0W^xzCamw$S+UVT$YLU7}pMYZa%%Hhv3b$$B5c?gc!qGV+vhw&O z_l^}m{!Nuis937EtTb^QKhKL6q{ekTXZL(t8Ul;l@&u?0jGV}cN;>cp=ge~h+%O}6`uzNe$%=N#ymgv#XtJa z8mbXkdh|`K<2vK4h}FxuPQEC1F3k`nNAiK|D59G?*+L^+^Ef# vk8yeUQ0DGM+q(j1a*v<;Uex~T(cPcyf4{THJe(%H4ix{Mu6{1-oD!M<{3!_i diff --git a/Sources/ClaudeWeekApp/DropdownPanel.swift b/Sources/ClaudeWeekApp/DropdownPanel.swift index c342cc8..f021ea4 100644 --- a/Sources/ClaudeWeekApp/DropdownPanel.swift +++ b/Sources/ClaudeWeekApp/DropdownPanel.swift @@ -318,9 +318,16 @@ final class DropdownPanel { panel.invalidateShadow() } + /// Наблюдатель без очереди — намеренно. С `queue: .main` блок уходит в + /// `OperationQueue` и правит кромку не в момент уведомления, а следующим + /// проходом цикла: между ними успевает отрисоваться кадр, где окно уже + /// новой высоты, а стоит по старому месту. На смене содержимого — из + /// недели в разбивку по моделям и обратно — это и видно как полосы, + /// нарисованные выше места и съезжающие вниз. Уведомление приходит на + /// главном потоке, поэтому синхронная обработка законна. private func observeResize() { resizeObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didResizeNotification, object: panel, queue: .main + forName: NSWindow.didResizeNotification, object: panel, queue: nil ) { [weak self] _ in MainActor.assumeIsolated { self?.pinToAnchor() } } 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9a45bf1..ba316e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -192,7 +192,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `PanelModel.swift` | состояние для SwiftUI: снимок, статус, текущий момент, производные подписи | | `DropdownPanel.swift` | окно панели: форма как у системного меню, материал, обводка, позиция под строкой меню своего монитора, закрытие по клику мимо, закрепление на время настроек | | `PopoverView.swift` | вёрстка панели: заголовок, сессия, ряд дней (семь строк или одни текущие сутки — `appearance.panelLayout`) либо, при выключенном `appearance.showsPlan`, одна полоса недели вместо ряда, футер | -| `AccountPicker.swift` | переключатель Claude/Codex в заголовке: знаки сервисов из ресурсного бандла (`Sources/ClaudeWeekApp/Assets`), крашенные как шаблонные под тему. Выбор поступает из `Config.activeAccount`, собственного расходящегося состояния нет | +| `AccountPicker.swift` | переключатель Claude/Codex в заголовке: знаки сервисов из ресурсного бандла (`Sources/ClaudeWeekApp/Assets`) — шаблонные PNG без рамок и подложек, выделение — цвет знака (`iconColor`). Выбор поступает из `Config.activeAccount`, собственного расходящегося состояния нет | | `DayBar.swift` | одна двухцветная полоса и строка дня; в компактном виде строка ещё и раскрывает неделю кликом (`DayRowTap`), а цифры справа открывают разбивку по моделям (`valueTap`) | | `SessionRow.swift` | полоса пятичасовой сессии (`LimitBar` — общая с недельной полосой ниже); её процент нажимается так же, как цифры дня | | `WeekRow.swift` | недельный итог одной `LimitBar` без плановой зоны — на месте `DayBar`-ряда, когда `appearance.showsPlan == false` | @@ -606,6 +606,11 @@ Intel — собирает у себя, `install.sh` соберёт нативн - **`--screenshot` рисует выдуманный момент**, а не текущий: демо-снимок подаётся через `model.apply(_:at:)` вместе со своим «сейчас». Забыть `at:` — значит получить на картинке для README подпись «данные 1 дн 15 ч назад». +- **Подписка на `didResizeNotification` — без очереди.** `queue: .main` + откладывает блок до следующего прохода цикла, и панель успевает отрисоваться + новой высоты по старому месту: полосы видно выше их места, следующим кадром + они съезжают вниз. Заметнее всего на смене содержимого — неделя ↔ разбивка + по моделям. Кромку правит `pinToAnchor`, и звать его надо синхронно. - **Версия живёт в `Version.swift`.** В `Info.plist` бандла её кладёт `make-app.sh`; править её во втором месте руками не нужно. diff --git a/docs/USAGE.md b/docs/USAGE.md index a42d04e..b5b03dd 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -165,9 +165,9 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 или по Esc. Правый клик по иконке даёт обычное меню: «Обновить», «Настройки…», «О программе», «Выйти». -Переключатель в заголовке выбирает Claude или Codex: две кнопки со знаками -самих сервисов в общей рамке, выбранная подсвечена подложкой и чёрточкой под -знаком. Кнопки не выполняют вход и не меняют аккаунт внутри клиентов — только +Переключатель в заголовке выбирает Claude или Codex: два знака самих +сервисов рядом с надписью, без рамок и подложек. Выбранный горит своим цветом +— у Claude фирменным оранжевым, — невыбранный приглушён серым. Кнопки не выполняют вход и не меняют аккаунт внутри клиентов — только показывают лимиты уже авторизованного сервиса. Последний выбор запоминается. У каждого сервиса свой снимок, поэтому при переключении цифры одного никогда не подписываются именем другого даже на время обновления.