Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@

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

### Исправлено

- **Обновление больше не оставляет приложение мёртвым.** 0.2.1 из релиза
падала при первой же отрисовке панели: знак Claude в переключателе аккаунтов
читался через `Bundle.module`, а генерируемый SwiftPM аксессор ищет ресурсный
бандл в корне `.app` (куда `make-app.sh` его не кладёт — в бандле macOS всё
живёт под `Contents/`) и по абсолютному пути в `.build` машины сборщика. У
собиравшего этот путь есть, поэтому локальная сборка работала; в образе из
релиза он вёл в `/Users/runner/work/…`, и приложение обрывалось
`fatalError`'ом через секунду после запуска. Обновление при этом вставало
успешно — в строке меню просто оставалась прежняя версия, и назавтра всё
повторялось. Теперь ресурсы лежат в `Contents/Resources` и читаются через
`Bundle.main`, а пропавшая картинка заменяется вопросительным знаком, как и
задумано, вместо падения. `make-app.sh` сверяет, что каждый ресурс лёг туда,
откуда его возьмёт `Bundle.main`, и не собирает бандл, если `Bundle.module`
вернётся в код приложения: прежняя проверка убеждалась только в том, что
ресурсный бандл есть в `.build`, — он был на месте всё то время, пока
приложение падало.

## [0.2.1] — 2026-08-31

### Добавлено
Expand Down
25 changes: 24 additions & 1 deletion Sources/ClaudeWeekApp/AccountPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ private struct AccountButton: View {
/// его `iconColor`, поэтому знак живёт в любой теме.
@ViewBuilder
private var providerIcon: some View {
if let url = Bundle.module.url(forResource: "Claude", withExtension: "png"),
if let url = Self.claudeMark,
let image = NSImage(contentsOf: url) {
// Шаблонным знак делает сама картинка: SwiftUI на macOS красит
// `Image(nsImage:)` по `foregroundStyle` только тогда, когда
Expand All @@ -90,6 +90,29 @@ private struct AccountButton: View {
}
}

/// Где лежит знак. Ищем руками, а не через `Bundle.module`: генерируемый
/// SwiftPM аксессор смотрит ровно в два места — корень `.app`, куда
/// `make-app.sh` ресурсы не кладёт (в бандле macOS всё живёт под
/// `Contents/`), и абсолютный путь в `.build` той машины, где собирали.
/// На машине сборщика второй путь есть, и подмены не видно; в образе из
/// релиза его нет ни у кого, и `Bundle.module` роняет приложение
/// `fatalError`'ом при первой отрисовке панели — ровно так умирала 0.2.1
/// сразу после установки, оставляя человека на прежней версии.
///
/// Порядок: собранный бандл — `Contents/Resources`; отладочный
/// `swift run` — ресурсный бандл SwiftPM рядом с бинарём. Не нашлось ни
/// там, ни там — `nil`, и знак заменит вопросительный знак: из-за
/// пропавшей картинки приложение падать не должно.
private static let claudeMark: URL? = {
if let url = Bundle.main.url(forResource: "Claude", withExtension: "png") {
return url
}
let beside = Bundle.main.bundleURL
.appendingPathComponent("ClaudeWeek_ClaudeWeekApp.bundle", isDirectory: true)
.appendingPathComponent("Claude.png")
return FileManager.default.fileExists(atPath: beside.path) ? beside : nil
}()

private func template(_ image: NSImage) -> NSImage {
image.isTemplate = true
return image
Expand Down
24 changes: 24 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,21 @@ identity не берёт вовсе — «no identity found» при живом
ключа (единственный момент, когда её можно снять), `--import` ставит её на
другую машину или в CI, `--show` печатает, что есть сейчас.

`make-app.sh` кладёт ресурсы SwiftPM (пока это один знак Claude для
переключателя аккаунтов) поштучно в `Contents/Resources`, а сам ресурсный бандл
`ClaudeWeek_ClaudeWeekApp.bundle` в `.app` не переносит, и код читает их через
`Bundle.main`. `Bundle.module` в приложении запрещён, и запрет проверяет сама
сборка: генерируемый SwiftPM аксессор ищет ресурсный бандл ровно в двух местах
— в корне `.app`, куда в бандле macOS ничего не кладут (всё живёт под
`Contents/`), и по абсолютному пути в `.build` той машины, где собирали. На
машине сборщика второй путь есть, и подмены не видно; в образе из релиза он
ведёт в `/Users/runner/work/…`, которого нет ни у кого. Так умирала 0.2.1:
обновление вставало, приложение падало `fatalError`'ом при первой отрисовке
панели, и человек оставался на прежней версии. Скрипт теперь сверяет, что
каждый ресурс лёг туда, откуда его возьмёт `Bundle.main`, — прежняя проверка
убеждалась лишь в том, что бандл есть в `.build`, а он был на месте всё то
время, пока приложение падало.

`install.sh` печатает, что именно собирает: каталог, ветку, коммит и дату, — и
предупреждает, если в репозитории есть ветка свежее собираемой. Без этого легко
поставить не то: каталогов с исходниками у репозитория несколько (рабочая копия
Expand Down Expand Up @@ -541,6 +556,15 @@ Intel — собирает у себя, `install.sh` соберёт нативн
панели раз в минуту оборачивалось бы модальным окном посреди чужой работы.
Смотреть обе проверки: `security dump-keychain -a ~/Library/Keychains/login.keychain-db`
(печатает правила доступа, не секреты).
- **`Bundle.module` работает только у того, кто собирал.** Генерируемый
SwiftPM аксессор ищет ресурсный бандл в корне `.app` и по абсолютному пути в
`.build` машины сборки. У собиравшего второй путь есть, поэтому локально
ресурс находится и подмены не видно; в образе из релиза он ведёт в
`/Users/runner/work/…`, и `Bundle.module` роняет приложение `fatalError`'ом.
Так умерла 0.2.1: обновление вставало, приложение падало через секунду после
запуска, и человек оставался на прежней версии — а назавтра всё повторялось.
Ресурсы читаются через `Bundle.main` из `Contents/Resources`, и `make-app.sh`
на `Bundle.module` в коде приложения сборку не отдаёт.
- **`swift run ClaudeWeekApp` даёт вторую иконку** рядом с установленной
копией. Для проверки живьём лучше `./scripts/install.sh`.
- **«На каком я столе» окно отвечает, только если живёт на одном столе.**
Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,4 +325,5 @@ requirement не менялся и приложение держалось в с
| Было | Стало |
|---|---|
| виджет показывал ровно один аккаунт — тот, под которым сейчас работает Claude Code, — и поставить рядом рабочий с личным было нельзя. Прежние цифры при переключении не сохранялись, а отбрасывались (бывший пункт 10) | два аккаунта в панели. Claude Code различает их конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит свой токен, не выбивая первый. Появился каталог — в заголовке встают два знака Claude; выбранный горит оранжевым, второй гаснет до серого, а кто из них кто, говорит подсказка с живым адресом из `claude auth status`. Состояние хранится по аккаунту, а не одним файлом: у второго свои `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json` и `secondary-index.json`, поэтому обратное переключение мгновенно и ничего не теряет. Имя записи Keychain второго дома Claude Code не публикует, но собирает его из пути дома (`Claude Code-credentials-<первые 8 hex sha256 пути>`) — ClaudeWeek повторяет ту же формулу, и сторожит её тест с живым вектором. Искать запись по `organizationUuid` внутри неё, как было задумано сперва, больше нельзя: 2.1.251 убрал это поле из записи, и принадлежность токена теперь называет только `claude auth status`. Каталога нет — нет и переключателя. Осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно |
| приложение из релиза 0.2.1 падало через секунду после запуска, и обновление выглядело как «клад падает и не обновляется»: образ вставал успешно, но новая версия умирала, а в строке меню оставалась прежняя — и назавтра всё повторялось | знак Claude читается через `Bundle.main` из `Contents/Resources`, а не через `Bundle.module`: генерируемый SwiftPM аксессор ищет ресурсный бандл в корне `.app` и по абсолютному пути в `.build` машины сборки, поэтому у собиравшего работал, а у всех остальных — `fatalError`. `make-app.sh` теперь проверяет, что каждый ресурс лёг туда, откуда его возьмёт `Bundle.main`, и не собирает бандл, если `Bundle.module` вернётся в код приложения: прежняя проверка убеждалась лишь в том, что ресурсный бандл есть в `.build`, — он был на месте всё то время, пока приложение падало. Пропавшая картинка теперь заменяется вопросительным знаком, как и задумано, а не роняет процесс |
| `claude auth status` спрашивали всегда с `CLAUDE_CONFIG_DIR`, и вкладка «Доступ» показывала «не вошли» у аккаунта, в котором человек работает прямо сейчас | переменная ставится только для нестандартного дома. Выставленная даже в тот же самый путь `~/.claude`, она заставляет Claude Code отвечать `loggedIn: false` — ячейку учётных данных выбирает не путь, а факт установки переменной (2.1.251). Унаследованная из окружения снимается: приложение могли запустить из оболочки под вторым аккаунтом. Грабли записаны в [ARCHITECTURE.md](ARCHITECTURE.md#грабли) |
63 changes: 55 additions & 8 deletions scripts/make-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,68 @@ fi
cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist"
printf 'APPL????' > "$APP/Contents/PkgInfo"

# Ресурсный бандл SwiftPM со знаком Claude для переключателя аккаунтов. Без
# него `Bundle.module` в приложении обрывается fatalError'ом ещё до отрисовки
# панели, поэтому его отсутствие — ошибка сборки, а не пропущенное украшение.
# Ресурсы SwiftPM (знак Claude для переключателя аккаунтов) кладём поштучно в
# Contents/Resources, а сам ресурсный бандл в .app не переносим. Причина —
# 0.2.1: код читал знак через `Bundle.module`, а генерируемый SwiftPM аксессор
# ищет бандл ровно в двух местах — в корне .app, куда мы ничего не кладём (в
# бандле macOS всё живёт под Contents/), и по абсолютному пути в .build той
# машины, где собирали. На машине сборщика второй путь есть, и подмена не
# видна; в образе из релиза он вёл в /Users/runner/work/… — приложение
# падало fatalError'ом сразу после обновления. Теперь ресурс лежит там, где
# его берёт `Bundle.main`, и путь не зависит от того, кто собирал.
# У универсального бандла срезы кладут одинаковые ресурсы, хватает любого из
# проходов.
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
if [ ! -d "$RES_BUNDLE" ]; then
echo "не нашёл ресурсный бандл: $RES_BUNDLE" >&2
exit 1
fi

# Info.plist ресурсного бандла — служебный, приложению он не нужен и затёр бы
# Info.plist самого .app, окажись он рядом.
ASSETS=()
while IFS= read -r asset; do
name="$(basename "$asset")"
# `[ … ] && continue` здесь нельзя: при несовпадении список вернёт 1, и
# `set -e` погасит скрипт на первом же ресурсе, который не Info.plist.
if [ "$name" = "Info.plist" ]; then
continue
fi
cp "$asset" "$APP/Contents/Resources/"
ASSETS+=("$name")
done < <(find "$RES_BUNDLE" -type f)

# Проверяет машина, а не человек на слово: до этой проверки сборка убеждалась
# только в том, что ресурсный бандл есть в .build, — он был на месте всё то
# время, пока приложение падало. Годность — ресурс читается там, откуда его
# возьмёт `Bundle.main` у собранного бандла.
if [ "${#ASSETS[@]}" -eq 0 ]; then
echo "в ресурсном бандле нет ни одного ресурса: $RES_BUNDLE" >&2
exit 1
fi
for asset in "${ASSETS[@]}"; do
if [ ! -f "$APP/Contents/Resources/$asset" ]; then
echo "ресурс не лёг в бандл: Contents/Resources/$asset" >&2
exit 1
fi
done
echo "==> ресурсы в Contents/Resources: ${ASSETS[*]}"

# `Bundle.module` в приложении запрещён по той же причине: он снова уведёт
# сборку на путь машины сборщика, а заметит это не тот, кто собирал, — а тот,
# кто обновился. Ресурсы читаются через `Bundle.main` (см. AccountPicker).
# Строки-комментарии пропускаем: разбор этих самых грабель написан в коде
# рядом с заменой, и запрещать себе объяснять причину — перебор.
MODULE_USES="$(grep -rn "Bundle\.module" "$ROOT/Sources/ClaudeWeekApp" --include="*.swift" \
| grep -vE ':[[:space:]]*//' || true)"
if [ -n "$MODULE_USES" ]; then
echo "$MODULE_USES" >&2
echo "выше — Bundle.module в приложении: он ищет ресурсы по абсолютному" >&2
echo "пути .build машины сборщика и падает у всех остальных." >&2
echo "Читайте ресурс через Bundle.main из Contents/Resources." >&2
exit 1
fi

# Версия у программы одна — та, что в Version.swift; в plist она попадает
# отсюда, а не переписывается руками во втором месте.
VERSION="$(sed -n 's/.*static let version = "\(.*\)".*/\1/p' \
Expand Down