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
49 changes: 46 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,41 @@ jobs:
- name: Проверки
run: swift run ClaudeWeekTests

# Постоянный сертификат подписи: без него образ подписывается ad-hoc, а
# разрешение на чтение токена Claude Code macOS привязывает к designated
# requirement — у ad-hoc это хеш бинаря, свой у каждой сборки, и человек
# получает вопрос про доступ к Keychain после каждого обновления.
# Секретов нет — шаг пропускается, и всё идёт как прежде.
#
# Ключ здесь только за стабильность requirement: сертификат
# самоподписанный, подлинности сборки он не доказывает (для этого нужен
# Developer ID и нотаризация) и Gatekeeper им не обмануть.
# Как получить пару: ./scripts/signing-cert.sh --export cert.p12,
# затем base64 -i cert.p12 → секрет SIGNING_CERT_P12, пароль →
# SIGNING_CERT_PASSWORD.
- name: Сертификат подписи
if: ${{ secrets.SIGNING_CERT_P12 != '' }}
env:
P12_BASE64: ${{ secrets.SIGNING_CERT_P12 }}
P12_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 3600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
# Связка должна попасть в список поиска, иначе codesign её не увидит;
# прежние оставляем на месте — из них раннер берёт всё остальное.
security list-keychains -d user -s "$KEYCHAIN" \
$(security list-keychains -d user | tr -d '"')
printf '%s' "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
# На раннере sudo без пароля, поэтому доверие ставим на всю машину:
# самоподписанную identity codesign иначе не берёт.
KEYCHAIN_PASSWORD="$KEYCHAIN_PASSWORD" P12_PASSWORD="$P12_PASSWORD" \
./scripts/signing-cert.sh --import "$RUNNER_TEMP/cert.p12" \
--keychain "$KEYCHAIN" --trust system
rm -f "$RUNNER_TEMP/cert.p12"

- name: Образ — Apple Silicon (arm64)
run: ARCH=arm64 ./scripts/make-dmg.sh

Expand All @@ -78,9 +113,17 @@ jobs:
echo 'xattr -dr com.apple.quarantine /Applications/ClaudeWeek.app'
echo '```'
echo
echo 'Сборка подписана ad-hoc, без Apple Developer ID, поэтому Gatekeeper'
echo 'просит подтверждения. Кому это не нравится — собирайте из исходников:'
echo '`./scripts/install.sh`.'
if codesign -d -r- dist/ClaudeWeek.app 2>&1 | grep -q 'certificate leaf'; then
echo 'Сборка подписана постоянным сертификатом проекта. Apple Developer ID'
echo 'у неё всё равно нет, поэтому Gatekeeper просит подтверждения — зато'
echo 'доступ к токену Claude Code macOS спросит один раз: requirement'
echo 'подписи от версии к версии не меняется, и разрешение переживает'
echo 'обновление.'
else
echo 'Сборка подписана ad-hoc, без Apple Developer ID, поэтому Gatekeeper'
echo 'просит подтверждения. Кому это не нравится — собирайте из исходников:'
echo '`./scripts/install.sh`.'
fi
echo
echo 'Требуется macOS 14+ на Apple Silicon (M1 и новее). Готового образа'
echo 'под Intel нет — там сборка из исходников тем же `install.sh`.'
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ cd ClaudeWeek
Claude Code из Keychain — только читает, наружу не отдаёт, на диск не пишет.
Разбор — [docs/USAGE.md](docs/USAGE.md#авторизация-и-доступ).

Один раз стоит сделать вот что:

```bash
./scripts/signing-cert.sh # постоянный сертификат подписи
```

Без него macOS спрашивает доступ к записи Keychain с токеном после каждой
пересборки: разрешение привязано к подписи, а ad-hoc подпись — это хеш бинаря,
свой у каждой сборки. С сертификатом подпись перестаёт меняться, и «Всегда
разрешать» нажимается в последний раз. Apple ID и Developer ID для этого не
нужны; Gatekeeper он не отменяет. Подробности —
[docs/USAGE.md](docs/USAGE.md#чтобы-разрешение-спросили-один-раз).

---

## Что показывает
Expand Down Expand Up @@ -126,7 +139,7 @@ Claude Code из Keychain — только читает, наружу не от

```bash
swift build # обе цели
swift run ClaudeWeekTests # 336 проверок: без сети, без UI, свой раннер
swift run ClaudeWeekTests # 371 проверка: без сети, без UI, свой раннер
./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая
ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg
```
Expand Down
14 changes: 14 additions & 0 deletions Sources/ClaudeWeekApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,20 @@ private struct AccessSettings: View {
.foregroundStyle(.secondary)
}

Section("Если macOS снова спросила доступ") {
Text("""
Разрешение привязано к подписи приложения, а не к его пути: у \
сборки без постоянного сертификата подпись меняется при каждой \
пересборке, и «Всегда разрешать» перестаёт действовать. \
Лечится один раз — ./scripts/signing-cert.sh в каталоге \
исходников, затем ./scripts/install.sh. Дальше подпись не \
меняется, и вопрос больше не возвращается: обновления \
переподписываются тем же сертификатом.
""")
.font(.caption)
.foregroundStyle(.secondary)
}

Section("Проверка") {
HStack {
Button(model.isChecking ? "Проверяю…" : "Проверить сейчас", action: model.checkNow)
Expand Down
60 changes: 60 additions & 0 deletions Sources/ClaudeWeekCore/UpdateInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ public actor UpdateInstaller {
let staged = staging.appendingPathComponent(source.lastPathComponent)
try manager.copyItem(at: source, to: staged)
try accept(staged, expecting: release.version)
resign(staged)

// Единственное необратимое движение за всю установку — и оно
// атомарное: либо на месте старый бандл, либо целиком новый.
Expand Down Expand Up @@ -225,6 +226,65 @@ public actor UpdateInstaller {
}
}

/// Имя постоянного сертификата подписи — того, что заводит
/// `scripts/signing-cert.sh`.
public static let signingIdentityName = "ClaudeWeek Signing"

/// Переподписывает новый бандл тем же сертификатом, что стоит на машине.
///
/// Разрешение читать запись Keychain «Claude Code-credentials» macOS
/// привязывает к designated requirement приложения, а образ из релиза
/// подписан ad-hoc — requirement там cdhash, свой у каждой сборки. Не
/// переподписав, мы получили бы после каждого обновления вопрос про доступ
/// к токену заново; с постоянным сертификатом requirement сходится с тем,
/// на который разрешение уже выдано.
///
/// Неудача обновление не роняет: хуже вернувшегося диалога только
/// не поставленная версия. В лог она попадает — иначе вопрос «почему опять
/// спрашивает» разбирать будет нечем.
private func resign(_ app: URL) {
guard let identity = Self.signingIdentity() else {
Log.debug("постоянного сертификата нет — оставляю подпись образа")
return
}
let result = Self.run("/usr/bin/codesign", [
"--force", "--sign", identity, app.path,
])
guard result.code == 0 else {
Log.warn("""
не переподписал обновление (\(result.output)) — macOS спросит доступ \
к записи Keychain заново
""")
return
}
Log.info("переподписал обновление сертификатом \(identity)")
}

/// Хеш identity, которой подписываемся, или nil — сертификата в связке нет.
private static func signingIdentity() -> String? {
let listing = Self.run("/usr/bin/security", ["find-identity", "-v", "-p", "codesigning"])
guard listing.code == 0 else { return nil }
return identity(named: signingIdentityName, in: listing.output)
}

/// Разбирает вывод `security find-identity`: строки вида
/// ` 1) 8F19…0724 "ClaudeWeek Signing"`. Берём хеш, а не имя: тёзок в
/// связке может оказаться два (старый сертификат забыли удалить), и тогда
/// подпись по имени падает на неоднозначности.
public static func identity(named name: String, in listing: String) -> String? {
for line in listing.split(separator: "\n") {
guard line.contains("\"\(name)\"") else { continue }
let fields = line.split(separator: " ", omittingEmptySubsequences: true)
guard fields.count >= 2 else { continue }
let hash = String(fields[1])
// SHA-1 в hex — ровно 40 знаков; всё прочее значит, что формат
// вывода изменился, и угадывать по позиции больше нельзя.
guard hash.count == 40, hash.allSatisfy(\.isHexDigit) else { continue }
return hash
}
return nil
}

/// Запускает утилиту и отдаёт код возврата с выводом. Вывод нужен целиком:
/// он идёт человеку в текст ошибки, и «не получилось» без причины
/// разбирать потом будет нечем.
Expand Down
2 changes: 1 addition & 1 deletion Sources/ClaudeWeekCore/Version.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

public enum ClaudeWeek {
public static let version = "0.1.3"
public static let version = "0.1.4"
public static let bundleIdentifier = "com.greem4.claudeweek"
/// Откуда приходят обновления: `владелец/репозиторий` на GitHub. Тот же
/// репозиторий выпускает образы, поэтому имя одно на проверку версии и на
Expand Down
27 changes: 27 additions & 0 deletions Sources/ClaudeWeekTests/UpdaterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,33 @@ func runUpdaterTests(_ t: Harness) async {
}
}

t.suite("подпись: разбор find-identity") {
// Вывод `security find-identity -v -p codesigning` в том виде, в каком
// он приходит: отступы, нумерация, кавычки вокруг имени.
let listing = """
1) 8F19A7106BFAC7B25F11654C2E3994901C3A0724 "ClaudeWeek Signing"
2) 1AF3C2D4E5B6978012345678901234567890ABCD "Apple Development: кто-то (TEAMID)"
2 valid identities found
"""
t.equal(
UpdateInstaller.identity(named: "ClaudeWeek Signing", in: listing),
"8F19A7106BFAC7B25F11654C2E3994901C3A0724",
"хеш взят у нужного сертификата, а не у первой строки"
)
t.equal(
UpdateInstaller.identity(named: "ClaudeWeek Signing", in: " 0 valid identities found"),
nil,
"пустая связка — nil, а не пустая строка"
)
// Формат вывода — не контракт: сместится колонка, и подписывать надо
// не тем, что оказалось вторым словом, а ничем.
t.equal(
UpdateInstaller.identity(named: "ClaudeWeek Signing", in: " 1) короткий \"ClaudeWeek Signing\""),
nil,
"на месте хеша не хеш — не подписываем"
)
}

await t.suite("проверка: сети нет") {
let updater = Updater(
transport: FakeGitHub(failure: URLError(.notConnectedToInternet)),
Expand Down
19 changes: 17 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,9 @@ swift scripts/probe-panel.swift --behaviors # прежде чем менять
```bash
swift build # оба таргета
swift build -Xswiftc -warnings-as-errors # так же, как в CI: предупреждение = падение
swift run ClaudeWeekTests # 336 проверок, без сети и без UI
swift run ClaudeWeekTests # 371 проверка, без сети и без UI
swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!)
./scripts/signing-cert.sh # один раз: постоянный сертификат подписи
./scripts/make-app.sh # собрать dist/ClaudeWeek.app
./scripts/make-dmg.sh # упаковать бандл в dist/ClaudeWeek-<версия>[-арх].dmg
./scripts/install.sh # пересобрать, снести старую версию, поставить и запустить
Expand All @@ -334,6 +335,16 @@ swift run ClaudeWeekApp # запустить из исходников (
./scripts/uninstall.sh # снять агент и удалить приложение
```

`signing-cert.sh` заводит самоподписанный сертификат «ClaudeWeek Signing» в
связке ключей и делает его доверенным для подписи кода (без доверия `codesign`
identity не берёт вовсе — «no identity found» при живом ключе). Дальше
`make-app.sh` подписывает бандл им, а не ad-hoc, и designated requirement
перестаёт меняться от сборки к сборке — вместе с ним перестаёт слетать
разрешение на чтение токена Claude Code. Скрипт идемпотентен: с готовым
сертификатом он ничего не меняет, только печатает его. `--export` снимает копию
ключа (единственный момент, когда её можно снять), `--import` ставит её на
другую машину или в CI, `--show` печатает, что есть сейчас.

`install.sh` печатает, что именно собирает: каталог, ветку, коммит и дату, — и
предупреждает, если в репозитории есть ветка свежее собираемой. Без этого легко
поставить не то: каталогов с исходниками у репозитория несколько (рабочая копия
Expand Down Expand Up @@ -380,7 +391,11 @@ Intel — собирает у себя, `install.sh` соберёт нативн
### Грабли

- **Keychain спрашивает доступ после каждой пересборки** — разрешение
привязано к подписи, а она ad-hoc и меняется. Это не баг.
привязано к designated requirement, а у ad-hoc подписи это хеш бинаря.
Лечится постоянным сертификатом: `./scripts/signing-cert.sh` один раз, дальше
`make-app.sh` подписывает им, а `UpdateInstaller.resign` переподписывает
бандл из релиза, чтобы обновление не сбрасывало выданное разрешение. Пока
сертификата нет, сборка честно печатает свой requirement и что делать.
- **`swift run ClaudeWeekApp` даёт вторую иконку** рядом с установленной
копией. Для проверки живьём лучше `./scripts/install.sh`.
- **«На каком я столе» окно отвечает, только если живёт на одном столе.**
Expand Down
6 changes: 6 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ XCTest без Xcode недоступен, поэтому проверки нап
нотаризации: образ подписан ad-hoc, и первый запуск скачанного приложения
упирается в Gatekeeper, пока не снимешь карантин руками.

Постоянная подпись при этом уже есть, но другая и для другого:
`scripts/signing-cert.sh` заводит самоподписанный сертификат, чтобы designated
requirement не менялся и разрешение Keychain на токен выдавалось один раз
([USAGE](USAGE.md#чтобы-разрешение-спросили-один-раз)). Gatekeeper она не
касается: тот верит только Developer ID с нотаризацией.

*Что делать:* ничего, пока нет Apple Developer Program — из РФ её штатно не
оплатить (та же причина, что у виджета Центра уведомлений). Появится
сертификат — `codesign --sign "Developer ID Application"` и `notarytool submit
Expand Down
Loading