diff --git a/CHANGELOG.md b/CHANGELOG.md index e31f8be..4cfcd1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Добавлено diff --git a/Sources/ClaudeWeekApp/AccountPicker.swift b/Sources/ClaudeWeekApp/AccountPicker.swift index e6ad47d..6de2e66 100644 --- a/Sources/ClaudeWeekApp/AccountPicker.swift +++ b/Sources/ClaudeWeekApp/AccountPicker.swift @@ -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` только тогда, когда @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af71eb3..6af1b89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` печатает, что именно собирает: каталог, ветку, коммит и дату, — и предупреждает, если в репозитории есть ветка свежее собираемой. Без этого легко поставить не то: каталогов с исходниками у репозитория несколько (рабочая копия @@ -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`. - **«На каком я столе» окно отвечает, только если живёт на одном столе.** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 364bcf3..eb7abd7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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#грабли) | diff --git a/scripts/make-app.sh b/scripts/make-app.sh index b0d9d9e..94f044f 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -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' \