Skip to content
Merged
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@

## [Не выпущено]

### Добавлено

- **Счёт начинается заново при входе другим аккаунтом.** Недельный процент
приходит от сервера и сменяется сам, а разбивка по суткам считается по
транскриптам в `~/.claude/projects` — они пишутся в одни и те же файлы при
любом аккаунте, и различить их по содержимому нечем. Теперь программа
замечает смену по метке организации из Keychain и ставит счёту отсечку:
расход прежнего аккаунта в новый лимит больше не идёт.
- **Кнопка «Начать счёт заново»** и строка **«Сейчас в ключе»** на вкладке
«Доступ»: видно, чей аккаунт в токене сейчас, и есть чем сбросить счёт
вручную — например, когда официальный источник молчит и смену аккаунта
заметить неоткуда.
- **Баннер о новой версии.** Раньше о вышедшем релизе говорила только строка
внизу панели — её видел лишь тот, кто панель открывал. Теперь приходит
уведомление, по одному на версию, а не каждые сутки до установки. Кнопки в
нём нет намеренно: установка перезаписывает приложение и перезапускает его,
и делать это одним щелчком посреди чужой работы неправильно — ставится
обновление по-прежнему на вкладке «О программе». Выключается отдельным
тумблером «Сообщать о новой версии»: он не гаснет вместе с разговорами о
лимитах. Сказанным версия считается только после удачной отправки — иначе
отказ системы (нет разрешения, сбой центра уведомлений) съедал бы новость
навсегда.

## [0.1.13] — 2026-08-18

### Добавлено
Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ Developer ID, — потому Gatekeeper и просит подтвержден
там работает, но регулярно её никто не проверяет.

Дальше приложение следит за версией само: раз в сутки спрашивает GitHub, а
найдя новый выпуск, показывает строку внизу панели. Обновляется по кнопке в
найдя новый выпуск, показывает строку внизу панели и присылает баннер — по
одному на версию, а не каждые сутки до установки. Обновляется по кнопке в
настройках («О программе»): покажет, что изменилось, скачает образ, сверит его
SHA256 с суммой из релиза, заменит себя и спросит про перезапуск. Настройки и
калибровка остаются на месте. Подробности — в
Expand Down Expand Up @@ -194,9 +195,15 @@ Code сбрасывает каждым обновлением токена, и
- **только на ухудшении** — откатившийся расход молчит;
- **не чаще раза в пять минут**, даже когда оба лимита пробиты разом.

Отдельно от лимитов стоит третий повод заговорить — **вышла новая версия**.
Кнопки в том баннере нет намеренно: установка перезаписывает приложение и
перезапускает его, а такому не место за одним щелчком из-под чужой работы —
ставится обновление на вкладке «О программе». Тумблер у него свой: погасив
разговоры о расходе, вы не обязаны молчать и про релизы.

Сказанное запоминается в `~/.config/claude-week/alerts.json` и переживает
перезапуск: после перезагрузки посреди недели про пройденные пороги программа
молчит. Выключить можно всё разом или по лимиту в отдельности; разрешение macOS
молчит, а про уже объявленную версию не напоминает. Выключить можно всё разом или по лимиту в отдельности; разрешение macOS
спрашивается один раз, при первом запуске. Как баннер выглядит именно у вас,
показывает кнопка «Показать пример» — своя в каждой секции:

Expand All @@ -207,11 +214,29 @@ Code сбрасывает каждым обновлением токена, и

---

## Смена аккаунта

Вошли другим аккаунтом — рабочим вместо домашнего — и счёт начинается заново.
Недельный процент сменяется сам: сервер узнаёт аккаунт по токену. А вот
разбивка по суткам считается по транскриптам в `~/.claude/projects`, а те
пишутся в одни и те же файлы при любом аккаунте, и различить их по содержимому
нечем. Поэтому у счёта есть начало: программа сверяет метку аккаунта из
Keychain с той, на которой ведётся счёт, и при расхождении ставит отсечку —
расход прежнего аккаунта в новый лимит больше не идёт.

Ту же отсечку можно поставить руками — кнопкой **«Начать счёт заново»** на
вкладке «Доступ». Она нужна, когда Keychain недоступен: без него метку не
прочитать, и смену аккаунта заметить неоткуда. Там же видно, **чей аккаунт
сейчас в ключе**. Подробности — в
[docs/USAGE.md](docs/USAGE.md#смена-аккаунта).

---

## Сборка и проверки

```bash
swift build # обе цели
swift run ClaudeWeekTests # 439 проверок: без сети, без UI, свой раннер
swift run ClaudeWeekTests # 477 проверок: без сети, без UI, свой раннер
./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая
ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg
```
Expand Down
40 changes: 40 additions & 0 deletions Sources/ClaudeWeekApp/NotificationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,46 @@ final class NotificationController {
}
}

/// Баннер о вышедшей версии. О каждой говорим один раз: проверка идёт при
/// запуске и раз в сутки, и без памяти о сказанном напоминание приходило бы
/// каждый день, пока не обновишься.
///
/// Кнопки в баннере нет намеренно: установка перезаписывает приложение и
/// перезапускает его, а такому не место за одним щелчком из-под чужой
/// работы. Баннер только сообщает, ставит человек — из настроек.
func announceUpdate(_ release: Release, config: Config) {
guard center != nil, config.notifications.update else { return }
let version = release.version.description
guard log.updateSaid != version else { return }

let s = config.strings
let content = UNMutableNotificationContent()
content.title = s.pick("Вышла версия \(version)", "Version \(version) is out")
content.body = s.pick("Обновиться — в настройках, вкладка «О программе»",
"To update, open Settings → About")
content.threadIdentifier = "update"
if config.notifications.sound { content.sound = .default }

let request = UNNotificationRequest(
identifier: "update-\(version)", content: content, trigger: nil
)
Task {
await authorize()
do {
try await center?.add(request)
// Сказанным версия считается только после удачной отправки.
// Пометь мы её раньше — отказ системы (нет разрешения, сбой
// центра) навсегда съел бы новость: второй раз о ней бы уже
// не заговорили.
log.updateSaid = version
save()
Log.info("уведомление: вышла версия \(version)")
} catch {
Log.warn("не показал уведомление об обновлении: \(error)")
}
}
}

private func send(_ alert: LimitAlert, config: Config, now: Date) {
guard let center else { return }

Expand Down
3 changes: 2 additions & 1 deletion Sources/ClaudeWeekApp/Screenshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ enum Screenshot {
update: UpdateController(bundle: nil),
notifications: NotificationController(bundled: true),
apply: { _ in },
check: { _ in ("", true) }
check: { _ in ("", true) },
reset: {}
)

// Высота больше оконной: в живом окне вкладка прокручивается, а на
Expand Down
100 changes: 100 additions & 0 deletions Sources/ClaudeWeekApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,9 @@ struct NotificationSettings: View {
Toggle(s.pick("Со звуком", "With sound"), isOn: sound)
.disabled(isOff)

Toggle(s.pick("Сообщать о новой версии", "Announce a new version"),
isOn: updates)

Text(s.pick("""
В баннере две строки: сколько израсходовано и через сколько \
сброс. Какой это лимит, говорит картинка справа — пятичасовая \
Expand All @@ -650,6 +653,22 @@ struct NotificationSettings: View {
.font(.caption)
.foregroundStyle(.secondary)

Text(s.pick("""
О вышедшей версии программа говорит один раз — не каждые сутки, \
пока не обновитесь. Кнопки в том баннере нет: установка \
перезаписывает приложение и перезапускает его, и делать это \
одним щелчком посреди чужой работы неправильно. Ставится \
обновление на вкладке «О программе».
""", """
A released version is announced once — not daily until you \
update. That banner has no button: installing replaces the app \
and restarts it, and that should not happen on a single click \
in the middle of your work. Updates are installed on the About \
tab.
"""))
.font(.caption)
.foregroundStyle(.secondary)

Text(s.pick("""
Об одном пороге программа говорит один раз за окно лимита и \
только на ухудшении: откатившийся расход молчит, а следующая \
Expand Down Expand Up @@ -733,6 +752,16 @@ struct NotificationSettings: View {
)
}

/// Не гаснет вместе с общим тумблером: тот про разговоры о расходе, а
/// новая версия — новость другого рода, и молчать о ней человек просит
/// отдельно.
private var updates: Binding<Bool> {
Binding(
get: { notifications.update },
set: { model.config.notifications.update = $0 }
)
}

private var weekEnabled: Binding<Bool> {
Binding(
get: { notifications.week.enabled },
Expand Down Expand Up @@ -795,11 +824,63 @@ struct NotificationSettings: View {

private struct AccessSettings: View {
@Bindable var model: SettingsModel
/// Спрашиваем до, а не «отменить» после: отсечку назад не отмотать —
/// прежний счёт нигде не сохранён.
@State private var confirmingReset = false

private var s: L10n { model.config.strings }

var body: some View {
Form {
Section(s.pick("Аккаунт и счёт", "Account and count")) {
LabeledContent(s.pick("Сейчас в ключе", "Currently in the key")) {
Text(model.account ?? s.pick("не читается", "cannot be read"))
.font(.caption.monospaced())
.foregroundStyle(model.account == nil ? .secondary : .primary)
.textSelection(.enabled)
}
Text(s.pick("""
Метка аккаунта из записи Keychain: начало UUID организации и тариф. \
Токен обновляется раз в час, а она держится — по ней и видно, тот \
же это аккаунт, что вчера, или вошли другим.
""", """
The account mark from the Keychain item: the start of the \
organisation UUID and the plan. The token is refreshed hourly, this \
stays — it is what tells yesterday’s account from a new login.
"""))
.font(.caption)
.foregroundStyle(.secondary)

HStack {
Button(s.pick("Начать счёт заново", "Start counting over")) {
confirmingReset = true
}
Spacer()
}
Text(model.countingNote)
.font(.caption)
.foregroundStyle(.secondary)

Text(s.pick("""
Нужно после входа другим аккаунтом. Недельный процент приходит от \
сервера и сменится сам, а вот разбивка по суткам считается по \
транскриптам в ~/.claude/projects — они пишутся в одни и те же \
файлы при любом аккаунте, и различить их по содержимому нельзя. \
Смену аккаунта программа замечает и сама, но только пока читает \
Keychain.
""", """
Needed after logging in with a different account. The weekly \
percentage comes from the server and changes by itself, but the \
daily breakdown is counted from the transcripts in \
~/.claude/projects — they are written to the same files whatever \
account is used, and nothing in them tells one from the other. The \
app notices a switch on its own too, but only while it can read the \
Keychain.
"""))
.font(.caption)
.foregroundStyle(.secondary)
}

Section(s.pick("Токен для официального источника", "Token for the official source")) {
Text(s.pick("""
Берётся из Keychain Claude Code, запись «Claude Code-credentials». \
Expand Down Expand Up @@ -905,6 +986,25 @@ private struct AccessSettings: View {
}
}
.formStyle(.grouped)
.confirmationDialog(
s.pick("Начать счёт заново?", "Start counting over?"),
isPresented: $confirmingReset
) {
Button(s.pick("Начать заново", "Start over"), role: .destructive, action: model.resetCounting)
Button(s.pick("Отмена", "Cancel"), role: .cancel) {}
} message: {
Text(s.pick("""
Расход до этой минуты в счёт больше не идёт: снимок, подобранный \
бюджет и уже сказанные предупреждения стираются, а суточные полосы \
начнутся с нуля. Настройки, транскрипты и сам недельный процент от \
сервера остаются на месте.
""", """
Spending up to this minute stops counting: the snapshot, the worked-out \
budget and the warnings already given are erased, and the daily bars \
start from zero. Settings, transcripts and the server’s own weekly \
percentage stay as they are.
"""))
}
}
}

Expand Down
Loading