From 0266f8aa1123aeae81cfd96b2c0ae10019e67b12 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:17:06 +0000 Subject: [PATCH 01/11] Switch auto-updating to Sparkle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the download-only GitHub-API update check with Sparkle 2.10.0 (app target only; the engine stays dependency-free). - App: UpdaterModel wraps SPUStandardUpdaterController; the app menu, menu-bar item and a new Settings → Advanced → Updates section (check button, auto-check and auto-install toggles) all drive it. Only the shipping bundle id starts the updater, so Blurt Dev never replaces itself with the release. - project.yml: Sparkle package pinned exactly, SUFeedURL pointing at releases/latest/download/appcast.xml, SUPublicEDKey from the new SPARKLE_PUBLIC_ED_KEY setting (placeholder until the key pair exists). - Engine: remove UpdateChecker, GitHubRelease, UpdateAlertContent, AutomaticUpdateCheck, LastUpdateCheckStore, SemanticVersion, HostIdentity.releaseURL and the lastUpdateCheck defaults key. - Release: sign Sparkle's nested helpers, EdDSA-sign the DMG with sign_update, verify the signature against the built app's key, write appcast.xml, and attach + verify it on publish. New SPARKLE_ED_PRIVATE_KEY secret on release-build. - Guards/docs: check.sh allowlists the Sparkle URL as the one remote app package; retire the "download-only updates" invariant rule and rewrite the settled-decision row, guardrails skill, AGENTS.md, RELEASE.md, SECURITY.md and READMEs. project.pbxproj still needs `xcodegen generate` on a Mac. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FQUiqVMpAMeReVN9Ej2yBu --- .claude/skills/project-guardrails/SKILL.md | 17 +- .github/workflows/release.yml | 6 + AGENTS.md | 105 ++++----- .../xcshareddata/swiftpm/Package.resolved | 14 ++ App/Blurt/Blurt/App.swift | 2 +- App/Blurt/Blurt/AppDelegate.swift | 26 +-- App/Blurt/Blurt/MenuBar/MenuBarScene.swift | 5 +- App/Blurt/Blurt/UITestSupport.swift | 36 --- .../Blurt/Update/CheckForUpdatesButton.swift | 24 ++ App/Blurt/Blurt/Update/UpdateCheckModel.swift | 207 ------------------ App/Blurt/Blurt/Update/UpdaterModel.swift | 106 +++++++++ App/Blurt/Blurt/Wizard/BlurtCommands.swift | 10 +- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 88 ++++---- .../Blurt/Wizard/SettingsWindowRoot.swift | 7 +- App/Blurt/BlurtUITests/SettingsUITests.swift | 21 +- App/Blurt/Shared/UITestIdentifiers.swift | 1 + App/Blurt/project.yml | 26 +++ README.md | 12 +- RELEASE.md | 91 ++++++-- SECURITY.md | 6 +- Sources/BlurtEngine/Config/DefaultsKey.swift | 5 +- Sources/BlurtEngine/Config/InstallReset.swift | 5 +- Sources/BlurtEngine/HostIdentity.swift | 25 +-- Sources/BlurtEngine/README.md | 32 +-- .../Update/AutomaticUpdateCheck.swift | 51 ----- .../BlurtEngine/Update/GitHubRelease.swift | 34 --- .../Update/LastUpdateCheckStore.swift | 38 ---- .../BlurtEngine/Update/SemanticVersion.swift | 54 ----- .../Update/UpdateAlertContent.swift | 133 ----------- .../BlurtEngine/Update/UpdateChecker.swift | 55 ----- .../AutomaticUpdateCheckTests.swift | 73 ------ Tests/BlurtEngineTests/DefaultsKeyTests.swift | 3 +- .../BlurtEngineTests/HostIdentityTests.swift | 4 +- .../LastUpdateCheckStoreTests.swift | 54 ----- .../PersistedSettingsTests.swift | 10 +- .../SemanticVersionTests.swift | 97 -------- .../UpdateAlertContentTests.swift | 111 ---------- .../BlurtEngineTests/UpdateCheckerTests.swift | 114 ---------- scripts/check-invariants.sh | 10 +- scripts/check.sh | 29 ++- scripts/mutate.sh | 3 - scripts/release-build.sh | 80 ++++++- scripts/release-lib.sh | 53 +++++ scripts/release-publish.sh | 18 +- scripts/release.test.sh | 20 ++ scripts/verify-sparkle-signature.swift | 37 ++++ 46 files changed, 632 insertions(+), 1326 deletions(-) create mode 100644 App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 App/Blurt/Blurt/Update/CheckForUpdatesButton.swift delete mode 100644 App/Blurt/Blurt/Update/UpdateCheckModel.swift create mode 100644 App/Blurt/Blurt/Update/UpdaterModel.swift delete mode 100644 Sources/BlurtEngine/Update/AutomaticUpdateCheck.swift delete mode 100644 Sources/BlurtEngine/Update/GitHubRelease.swift delete mode 100644 Sources/BlurtEngine/Update/LastUpdateCheckStore.swift delete mode 100644 Sources/BlurtEngine/Update/SemanticVersion.swift delete mode 100644 Sources/BlurtEngine/Update/UpdateAlertContent.swift delete mode 100644 Sources/BlurtEngine/Update/UpdateChecker.swift delete mode 100644 Tests/BlurtEngineTests/AutomaticUpdateCheckTests.swift delete mode 100644 Tests/BlurtEngineTests/LastUpdateCheckStoreTests.swift delete mode 100644 Tests/BlurtEngineTests/SemanticVersionTests.swift delete mode 100644 Tests/BlurtEngineTests/UpdateAlertContentTests.swift delete mode 100644 Tests/BlurtEngineTests/UpdateCheckerTests.swift create mode 100644 scripts/verify-sparkle-signature.swift diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index 56c3c9b0..601ded4e 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -141,14 +141,12 @@ only one of stt_prompt or prompt; they are the same field`, before the audio is - The dictation trigger is a **single lone modifier** (right ⌘ default), home- grown via `CGEventTap` + `DictationKeyGate`. No `KeyboardShortcuts` package, no key+modifier chord. -- **Updates are download-only** — check → open the DMG in the browser → the user - installs it. The `mxcl/AppUpdater` dependency and its in-place self-updater - were removed; don't reintroduce a self-replacing install path, a timer-driven - poll, or anything that installs on the user's behalf. Checking automatically - once at launch on a configured app (`AutomaticUpdateCheck`, ≤ once a day, - silent unless a newer release exists) is the one automatic part, and it still - ends in the same Download/Later alert. Extend `UpdateChecker` / - `UpdateCheckModel`. +- **Updates go through Sparkle** (`UpdaterModel`, app-side only) — an + EdDSA-signed `appcast.xml` published with each release, Sparkle's own UI, + daily checks. Don't hand-roll a second updater or a GitHub-API checker beside + it, don't put update code back in the engine, and don't start the updater in + anything but the shipping bundle id (a dev build must never replace itself + with the release). Silent install stays opt-in. ## Build / tests @@ -156,7 +154,8 @@ only one of stt_prompt or prompt; they are the same field`, before the audio is generated from `project.yml`; edit that and run `xcodegen generate`. check.sh fails on pbxproj drift (a PreToolUse hook also blocks edits to it). - The engine has **no external SPM dependencies** (Foundation/Security/ - AVFoundation/CoreAudio only). Don't add one to `Sources/BlurtEngine/`. + AVFoundation/CoreAudio only). Don't add one to `Sources/BlurtEngine/`. The app + carries exactly one remote package, Sparkle; check.sh rejects any other. - Unit tests use **Swift Testing**, not XCTest (the `BlurtUITests` XCUITest bundle is the one exception — XCUIAutomation requires XCTest). **Never touch the real Keychain in tests** — `APIKeyStore` is the production item; use an diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eac57ac7..ef25dacf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -196,9 +196,11 @@ jobs: P12_PASSWORD: ${{ secrets.SIGNING_P12_PASSWORD }} NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8_BASE64 }} NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} + SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | set -euo pipefail missing="" + [ -n "$SPARKLE_KEY" ] || missing="$missing SPARKLE_ED_PRIVATE_KEY" [ -n "$P12" ] || missing="$missing SIGNING_P12_BASE64" [ -n "$P12_PASSWORD" ] || missing="$missing SIGNING_P12_PASSWORD" [ -n "$NOTARY_KEY_P8" ] || [ -n "$NOTARY_APPLE_ID" ] \ @@ -242,6 +244,9 @@ jobs: BLURT_NOTARY_KEY_P8_BASE64: ${{ secrets.NOTARY_KEY_P8_BASE64 }} BLURT_NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} BLURT_NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} + # Sparkle's EdDSA private key (base64, as `generate_keys -x` exports + # it). Signs the DMG for the appcast; handed to sign_update on stdin. + BLURT_SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} # Both are dispatch-only escape hatches, so both are empty (and the # full checks run) on the merged-bump path. That is the right default: # nobody is standing there to judge whether skipping was safe. @@ -264,6 +269,7 @@ jobs: blurt/build/release/Blurt-*.dmg blurt/build/release/Blurt-*.app.dSYM.zip blurt/build/release/SHA256SUMS + blurt/build/release/appcast.xml blurt/build/release/build-info.txt blurt/build/release/notary-*-log.json if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 3b0b7ff3..cfabd460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,19 +27,20 @@ Four reflexes before you touch anything: likely way to make things worse. 3. **Never hand-edit `App/Blurt/Blurt.xcodeproj/project.pbxproj`** — it is generated from `App/Blurt/project.yml`. Edit the yml, run `xcodegen generate` on a Mac, commit the result. -4. **No external SPM dependencies in the engine** (rule, guarded by `check.sh`); the app carries only - the local `BlurtEngine` package. +4. **No external SPM dependencies in the engine** (rule, guarded by `check.sh`); the app carries the + local `BlurtEngine` package plus exactly one remote package, Sparkle (the updater), and `check.sh` + rejects any other. ### The two layers | Layer | What it is | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Sources/BlurtEngine/` | Swift package (`swift-tools-version:6.2`, `platforms: [.macOS(.v15)]`) owning the pipeline. Pure logic behind protocol seams, no AppKit-shell deps, **no external SPM deps** — Foundation/Security/AVFoundation/CoreAudio plus toolchain modules like Synchronization, with AppKit types only at the seams. | -| `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | +| `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its packages are the local `BlurtEngine` and Sparkle (both declared in `App/Blurt/project.yml`). | -The engine's dependency-free rule is a **rule**; the app merely happens to carry none today. The -former `mxcl/AppUpdater` dependency and its in-place self-updater were removed (see [Updates](#updates)), -and the rebindable dictation trigger is home-grown (`CGEventTap` over a pure state machine), not a +The engine's dependency-free rule is a **rule**. The app carries one remote package — Sparkle, the +in-place updater (see [Updates](#updates)) — allowlisted by URL in `check.sh`'s dependency guard, and +the rebindable dictation trigger is home-grown (`CGEventTap` over a pure state machine), not a package. For embedding or extending the engine's public API — seam contracts, error table, quick start — @@ -59,7 +60,7 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) SoundPack/Catalog/Store (the voice *descriptors*; the voices themselves are app-side) HostIdentity.swift the host's Keychain service, log subsystem, defaults prefix, log - directory, product name and release feed — one overridable value + directory and product name — one overridable value Config/ Keychain-backed API key, key terms, developer mode, style profiles, DefaultsKey + PersistedSettings (every defaults key, and the reset sweep over them) @@ -70,11 +71,10 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) Pipeline/ DictationSession (actor) + phases, UI projections, geometry, log STT/ AssemblyAITranscriber, Conversation/TranscriptionContext, KeytermsBoost, CleanupInstruction, SyncSTTLimits - Update/ UpdateChecker (download-only) + the launch-check policy App/Blurt/ project.yml XcodeGen source of truth — Blurt.xcodeproj is GENERATED Blurt/ the shell: App.swift scenes, AppCoordinator, Wizard/, Overlay/, - MenuBar/, Hotkey/DictationKeyTap, Update/, CueSoundPlayer, + MenuBar/, Hotkey/DictationKeyTap, Update/ (Sparkle), CueSoundPlayer, SoundPackCatalog.swift (GENERATED — the cue voices, beside the Resources/Sounds/ audio they name) Shared/ UITestIdentifiers.swift — compiled into BOTH app and UI-test targets @@ -330,7 +330,7 @@ Each was tried the other way and reverted. If a task seems to require one, stop entries fail `check.sh` rather than relying on a reviewer's memory: `AVAudioEngine`/`installTap` capture, a streaming or on-device path, a client-side cleanup pass (`StylerProtocol`, an LLM Gateway/LeMUR client), `config.language_code(s)`, `config.conversation_context`, `config.prompt`, a keystroke-typing injector, -`LSUIElement`, a `KeyboardShortcuts` import, a self-replacing updater, the production Keychain +`LSUIElement`, a `KeyboardShortcuts` import, the production Keychain under `Tests/`, and `@available(*, deprecated)` shims. The SPM-dependency row has its own guard in `check.sh` (it parses `project.yml`'s `packages:` block), and the `.pbxproj` row is covered by the xcodegen drift check plus a Claude `PreToolUse` hook. The rest — the filler-word clause, the @@ -361,7 +361,7 @@ rule along with the row. | Add a keystroke-typing paste path or a length threshold | Injection is **always** clipboard paste (save → write → ⌘V → settle → restore), with the copied-to-clipboard degradation when the target is lost. | | Add `LSUIElement` or a menu-bar-**only** mode | Blurt is a Dock app first. The `MenuBarExtra` status item is convenience layered on the Dock icon; the notch can hide a status item, so nothing may depend on it. A menu-bar-only variant was reverted twice. | | Add a `KeyboardShortcuts` package or a key+modifier chord | The trigger is a single lone modifier, home-grown (`CGEventTap` + `DictationKeyGate`), and swallows nothing. | -| Add a self-replacing install or background auto-updater | Updates are download-only; `mxcl/AppUpdater` and its in-place updater were removed. The once-a-day launch _check_ (`AutomaticUpdateCheck`) is fine; installing for the user, or polling, is not. Extend `UpdateCheckModel`. | +| Hand-roll an updater beside Sparkle, or start it in a dev build | Updates go through Sparkle (`UpdaterModel`, app-side; EdDSA-signed appcast published per release). The download-only GitHub-API checker it replaced and the earlier `mxcl/AppUpdater` are gone — don't add a second update path, and keep update code out of the engine. Only the shipping bundle id starts the updater: a dev build must never replace itself with the release. | | Hand-edit `Blurt.xcodeproj/project.pbxproj` | Generated from `project.yml`; `check.sh`'s drift check fails on any manual edit (a Claude PreToolUse hook also blocks it). | | Redirect the post-build install away from `/Applications` | TCC won't register apps in DerivedData/`/tmp`, so permission toggles never appear. | | Touch the real Keychain in tests | `APIKeyStore` is the production item — a test that writes it triggers Keychain prompts and corrupts the real item's ACL. Use an isolated service (see `KeychainStoreTests`) or `InMemoryAPIKeyStore`. | @@ -398,7 +398,7 @@ framework, or notarization rejects the build; roll-forward-only for a bad releas engine that only tests exercise. - **Pure projections over shell logic**: phase→UI mapping (`OverlayUIState`, `MenuBarStatus`), chime edges (`RecordingCueGate`), geometry (`OverlayPlacement`, `MeterBarGeometry`), history - (`RecentDictations`), alert wording (`UpdateAlertContent`, `APIKeySubmission.FailureReport`), + (`RecentDictations`), alert wording (`APIKeySubmission.FailureReport`, `InstallReset.AlertContent`), credential display (`APIKeyDisplay`) and setup policy (`SetupReadiness`) live in the engine as value types so they're unit-testable; the AppKit side just renders whatever they resolve to. Keep new logic on that side of the line — the shell has no test target, so wording and classification assembled at @@ -962,8 +962,7 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: **`MicDeviceStore`** (`BlurtMicDeviceUID`, the input device dictation is pinned to, as its CoreAudio UID — empty/unset follows the system default; re-read at every press, and a pinned device that isn't connected falls back to the system default without unpinning), - **`OverlayOriginStore`** (the pill's dragged origin, x/y), **`LastUpdateCheckStore`** - (`BlurtLastUpdateCheck`, the stamp throttling the automatic launch update check), + **`OverlayOriginStore`** (the pill's dragged origin, x/y), **`TextShortcutStore`** (`BlurtTextShortcuts`, the JSON list of spoken phrase → saved text pairs; re-read per transcript via the session's `textShortcutsProvider` and applied locally by `TextShortcutExpander` between the response and the paste — never on the wire. Like @@ -976,7 +975,7 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: launch reset calls. So adding a store and adding it to every "reset to a clean state" sweep aren't merely the same edit, they're the same line: there is no roster to keep in sync. (It used to be a hand-maintained array, and the forgotten half of that edit happened twice — the overlay origin and - the update-check stamp.) A case's raw value is the _unprefixed_ half of the key: the key actually + a since-removed update-check stamp.) A case's raw value is the _unprefixed_ half of the key: the key actually written is `HostIdentity.current.defaultsPrefix` + the raw value, which under Blurt's identity is the `Blurt…` names above and under a third-party host is that host's own namespace. Both halves are the on-disk contract: changing either abandons every existing user's setting, so rename cases freely @@ -1101,51 +1100,37 @@ restating them. ## Updates -Updates are **download-only** — Blurt opens the release DMG in the browser and the user installs it. -Nothing installs itself and nothing polls on a timer. Checks happen when the user asks (the Settings -button, the app menu, the menu-bar item) **and** once shortly after launch on an app that's already -configured, at most once a day. Five pieces: - -- **`UpdateChecker`** (`Sources/BlurtEngine/Update/UpdateChecker.swift`) — a `Sendable` struct that - `GET`s `https://api.github.com/repos/AssemblyAI/blurt/releases/latest`, decodes `GitHubRelease` - (internal), parses the tag as a `SemanticVersion`, and returns `.upToDate` or - `.available(version:, dmgURL:)`. It throws on network failure, malformed JSON, an unparseable tag, - or a newer release with no `.dmg` asset. The call goes through the injected `HTTPTransport`, so - tests run against fixture JSON (`UpdateCheckerTests`); no AppKit here. -- **`UpdateAlertContent`** (`Sources/BlurtEngine/Update/UpdateAlertContent.swift`) — what each result - _says_: title, body, button titles (the first is the default), the `downloadURL` that default button - opens, and the alert style. A pure projection of a result into wording, owned in the engine for the - same reason as `OverlayUIState.accessibilityLabel`. `appVersionLabel(_:)` is the shared "Blurt - 0.1.31" form, so the alerts and the Settings Updates row can't name the version two ways. The - memberwise init is private: an alert comes from a named result (`upToDate` / `available` / - `checkFailed`), never assembled ad hoc in the shell. -- **`AutomaticUpdateCheck`** (`Sources/BlurtEngine/Update/AutomaticUpdateCheck.swift`) — the policy - for the launch check: `shouldRun(isConfigured:lastCheck:now:)` (setup finished, and ≥ 24 h since the - last completed check — a stamp in the future counts as due, so clock skew can't wedge the check off) - plus `launchDelay`, the short wait before fetching. Engine-side for the `SetupReadiness` reason: it's - a rule, and the shell that applies it has no test target. -- **`LastUpdateCheckStore`** (`Sources/BlurtEngine/Update/LastUpdateCheckStore.swift`) — the - `UserDefaults` timestamp that throttle reads (`BlurtLastUpdateCheck`, in - `PersistedSettings.allDefaultsKeys`). Only a check that _completed_ stamps it — a failed one hasn't - answered the question, so the next launch asks again instead of coasting for a day on a fetch that - never landed. -- **`UpdateCheckModel`** (`App/Blurt/Blurt/Update/UpdateCheckModel.swift`, `@MainActor`) — runs both - kinds of check and reports them in a Sparkle-style alert: _up to date_, _available_ (**Download** - opens the release DMG in the browser, **Later** dismisses), or a recoverable _couldn't check_ (every - throw, plus an unparseable bundle version, collapses to that one content). It holds only the AppKit - half: build an `NSAlert` from an `UpdateAlertContent`, then open `downloadURL` if the default button - came back. It presents via `beginSheetModal` on the host window (a nested `runModal()` loop was - reported as an app hang, so `runModal()` is the fallback only when no window can host a sheet), and - an `isChecking` guard stops any two entry points from stacking two alerts. One shared instance - (owned by `AppDelegate`) backs all of them. - - `checkForUpdates()` is the user-initiated path — it always reports a result, including "you're up - to date", because the user asked and the check has to visibly conclude. - - `checkForUpdatesAtLaunch(isConfigured:)` is the automatic one, called once from - `applicationDidFinishLaunching` with the wizard's `isReady`. It alerts **only** on an available - update: up-to-date and couldn't-check answer a question nobody asked, so they only log (a laptop - opened offline must not be greeted by an error). Gated on readiness so a first run never gets a - modal over its setup screen, and read once at launch rather than observed — someone who just - finished onboarding installed the newest build minutes ago. +Blurt updates itself with [Sparkle](https://sparkle-project.org) 2 — the app's one remote package, +pinned exactly in `project.yml` and `Package.resolved`. Sparkle owns the whole flow: the scheduled +background check (daily), the "up to date" / "new version available" UI, downloading the DMG, +verifying its EdDSA signature, and replacing the app in place. Nothing about updates lives in the +engine any more; the download-only GitHub-API checker (`UpdateChecker`, `UpdateAlertContent`, +`AutomaticUpdateCheck`, `LastUpdateCheckStore`, `SemanticVersion`) was removed with the switch. + +- **`UpdaterModel`** (`App/Blurt/Blurt/Update/UpdaterModel.swift`, `@MainActor @Observable`) wraps + one `SPUStandardUpdaterController`. `AppDelegate` owns the single instance; the app-menu "Check for + Updates…" command, the menu-bar item, and the Settings → Advanced → Updates section all drive it. + It mirrors `canCheckForUpdates` (KVO) so every entry point disables itself while a check runs, and + exposes Sparkle's two preferences — check automatically (on by default) and download-and-install + automatically (off by default) — as Settings toggles. +- **Only the shipping bundle id starts the updater** (`dev.alex.blurt`, i.e. `HostIdentity.blurt.subsystem`, + decided the same way `BlurtApp.init` picks the host identity). "Blurt Dev" and the UI-test build are + separate apps, and the appcast describes the release, so a running updater there would offer to + overwrite a dev build with a shipped one. Unstarted, `canCheckForUpdates` stays false: the controls + render disabled and the Updates footer says development builds don't update. +- **Configuration is `Info.plist`, from `project.yml`**: `SUFeedURL` is + `https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml` (a stable release-asset + URL, so no Pages site or extra hosting); `SUPublicEDKey` comes from the `SPARKLE_PUBLIC_ED_KEY` + build setting; `SUEnableAutomaticChecks` is set, which also skips Sparkle's second-launch "check + automatically?" prompt. Sparkle never checks on an app's very first launch, so a first run is not + greeted by an update dialog over its setup screen. +- **The release pipeline produces the feed.** `release-build.sh` signs the notarized DMG with the + EdDSA private key (`SPARKLE_ED_PRIVATE_KEY`, a `release-build` secret) using Sparkle's + `sign_update`, and writes a one-item `appcast.xml` whose enclosure is the versioned DMG on the + release. `release-publish.sh` attaches it to the release, verifies the re-download, and only then + flips the release live — which is the moment `/latest/download/appcast.xml` starts pointing at it. + Sparkle compares `CFBundleVersion` (`sparkle:version`), which `release-bump.sh` increments on every + bump. See [`RELEASE.md`](./RELEASE.md#sparkle-updates). ## Tests diff --git a/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..89a223c6 --- /dev/null +++ b/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100", + "version" : "2.10.0" + } + } + ], + "version" : 2 +} diff --git a/App/Blurt/Blurt/App.swift b/App/Blurt/Blurt/App.swift index 85efbf14..d46e0fd2 100644 --- a/App/Blurt/Blurt/App.swift +++ b/App/Blurt/Blurt/App.swift @@ -8,7 +8,7 @@ struct BlurtApp: App { /// Tell the engine which app it is running inside — the Keychain item, the /// logging subsystem, the `UserDefaults` prefix, the log directory, the product - /// name in update alerts, and the release feed the update check reads. + /// name in user-facing copy. /// /// This is the composition root: the identity belongs to the host, and a fork /// or a second embedder replaces exactly these lines rather than hunting diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 942f4363..9834ed33 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -19,13 +19,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private(set) var coordinator: AppCoordinator? private(set) var wizardController: WizardController? - /// Backs the app-menu "Check for Updates…" command, the menu-bar item, the - /// Settings button, and the automatic launch check, so a check from any of - /// them runs through the same controller and can't stack two result alerts. - /// `lazy` so the UI-test substitution below can replace it before it's ever - /// read — `applicationDidFinishLaunching` assigns `.uiTest()` ahead of the - /// launch check, which is the first read on a normal launch. - @ObservationIgnored lazy var updateCheckModel = UpdateCheckModel() + /// Backs the app-menu "Check for Updates…" command, the menu-bar item, and the + /// Settings Updates row, so all of them drive the one Sparkle updater. Built + /// with the delegate, so Sparkle starts its scheduled checks at launch. + @ObservationIgnored let updaterModel = UpdaterModel() /// One-shot deep-link into Settings, consumed by `SettingsWindowRoot`: while /// true, the Settings window switches to (or opens on) the Styles pane. @@ -124,10 +121,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { apiKey: APIKeyModel( keyStore: InMemoryAPIKeyStore(), validateKey: { UITestKeyValidation.result(for: $0) })) - // Offline update check so the Settings "Check for Updates" button shows a - // stable "up to date" result without reaching GitHub. Assigned before the - // `lazy` default is ever read (first check), so it replaces it cleanly. - updateCheckModel = .uiTest() } else { coord = AppCoordinator(onSetupBlocked: onSetupBlocked) } @@ -157,17 +150,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let wizard = makeWizardController(coord: coord) self.wizardController = wizard - // A configured app checks for updates on its own shortly after launch — - // at most once a day, and silent unless a newer release exists (see - // `UpdateCheckModel.checkForUpdatesAtLaunch`). Still download-only: the - // alert offers the DMG, nothing installs itself. - // - // Gated on the wizard's readiness so a first run never gets an update modal - // thrown over its setup screen. Read once, here, rather than observed: this - // is the launch check, not a watcher that fires the moment the user finishes - // onboarding — someone who just installed Blurt has the newest build. - updateCheckModel.checkForUpdatesAtLaunch(isConfigured: wizard.isReady) - #if UITEST_HOOKS // Build the overlay pill up front under UI testing so the suite can observe // it during dictation. Normally `WizardController` reveals it only on the diff --git a/App/Blurt/Blurt/MenuBar/MenuBarScene.swift b/App/Blurt/Blurt/MenuBar/MenuBarScene.swift index fc3b2d02..b2761d83 100644 --- a/App/Blurt/Blurt/MenuBar/MenuBarScene.swift +++ b/App/Blurt/Blurt/MenuBar/MenuBarScene.swift @@ -49,10 +49,7 @@ struct MenuBarContent: View { NSApp.activate() openSettings() } - Button("Check for Updates…") { - NSApp.activate() - appDelegate.updateCheckModel.checkForUpdates() - } + CheckForUpdatesButton(model: appDelegate.updaterModel, activatesApp: true) Divider() diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index f47114f2..72525050 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -136,42 +136,6 @@ } } - /// Offline update check for UI testing: a transport that always returns a - /// release older than any real version, so `UpdateChecker` reports - /// "up to date" without reaching GitHub. `UpdateChecker` ignores the response, - /// so a bare `URLResponse` (no force-unwrapped `HTTPURLResponse`) is enough. - nonisolated struct UITestUpdateTransport: HTTPTransport { - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - let json = Data(#"{"tag_name":"0.0.0","assets":[]}"#.utf8) - // UpdateChecker ignores the response, so any non-nil URL will do; the - // request always carries one (UpdateChecker builds URLRequest(url:)). - let url = request.url ?? URL(fileURLWithPath: "/") - let response = URLResponse( - url: url, mimeType: "application/json", - expectedContentLength: json.count, textEncodingName: nil) - return (json, response) - } - - // The update check only ever GETs; upload is never called. - func upload( - for request: URLRequest, streaming body: AsyncThrowingStream, - delegate: (any URLSessionTaskDelegate)? - ) async throws -> (Data, URLResponse) { - throw URLError(.unsupportedURL) - } - } - - extension UpdateCheckModel { - /// The update controller used under UI testing: a fixed running version and a - /// transport that always reports "up to date", so clicking "Check for Updates" - /// deterministically shows the up-to-date result alert with no network. - static func uiTest() -> UpdateCheckModel { - UpdateCheckModel( - checker: UpdateChecker(transport: UITestUpdateTransport()), - currentVersion: SemanticVersion("1.0.0")) - } - } - extension DictationComponents { /// The all-stub pipeline used under UI testing: no mic, no network, no /// Accessibility paste. `UITestMic` inherits `MicCaptureProtocol`'s default diff --git a/App/Blurt/Blurt/Update/CheckForUpdatesButton.swift b/App/Blurt/Blurt/Update/CheckForUpdatesButton.swift new file mode 100644 index 00000000..13ee7b90 --- /dev/null +++ b/App/Blurt/Blurt/Update/CheckForUpdatesButton.swift @@ -0,0 +1,24 @@ +import AppKit +import SwiftUI + +/// The "Check for Updates…" menu item, shared by the app menu (`BlurtCommands`) +/// and the menu-bar item. A `View` rather than a bare `Button` inside the +/// `Commands` body — the shape Sparkle's own SwiftUI guidance uses — because a +/// view is what reliably re-renders when the observed `canCheckForUpdates` +/// flips, which is what disables the item while a check runs (and in builds +/// that never start the updater). +struct CheckForUpdatesButton: View { + let model: UpdaterModel + /// Pull Blurt frontmost first, so Sparkle's window isn't opened behind the app + /// the user was in. The menu-bar item needs it (it can be clicked while + /// another app is active); the app menu is only reachable with Blurt active. + var activatesApp = false + + var body: some View { + Button("Check for Updates…") { + if activatesApp { NSApp.activate() } + model.checkForUpdates() + } + .disabled(!model.canCheckForUpdates) + } +} diff --git a/App/Blurt/Blurt/Update/UpdateCheckModel.swift b/App/Blurt/Blurt/Update/UpdateCheckModel.swift deleted file mode 100644 index 2e6f34cd..00000000 --- a/App/Blurt/Blurt/Update/UpdateCheckModel.swift +++ /dev/null @@ -1,207 +0,0 @@ -import AppKit -import BlurtEngine -import OSLog -import Observation - -/// Runs an update check and reports the result in a modal alert (Sparkle-style). -/// One shared instance (owned by `AppDelegate`) backs every entry point, so no -/// two checks can stack two result alerts. Two kinds of check: -/// -/// - **User-initiated** — `checkForUpdates()`, from the Settings "Check for -/// Updates" button or the "Check for Updates…" app-menu command. Always -/// reports something, including "you're up to date" and "couldn't check": the -/// user asked, so the check has to visibly conclude. -/// - **Automatic at launch** — `checkForUpdatesAtLaunch(isConfigured:)`, once per -/// launch on a configured app and at most once a day, speaking *only* when a -/// newer release exists. Nobody launches Blurt to be told it's current, and a -/// laptop opened offline mustn't be greeted by an error it never asked for. -/// -/// Neither installs anything. On an available update the alert offers -/// **Download** (opens the release DMG in the browser) or **Later**. -/// -/// What each result *says* — titles, bodies, button titles, and which button -/// downloads — is the engine's `UpdateAlertContent`, where `swift test` covers -/// it. What's left here is the AppKit half: turning a content value into an -/// `NSAlert`, hosting it as a sheet, and opening the URL the default button -/// carries. -@MainActor -@Observable -final class UpdateCheckModel { - private let checker: UpdateChecker - private let currentVersion: SemanticVersion? - private let openURL: (URL) -> Void - private let presentingWindow: () -> NSWindow? - /// When a check last completed, the input to the launch check's daily - /// throttle. Written by every completed check — manual ones included, so a - /// check the user just ran isn't repeated by the next launch. - private let lastCheckStore: LastUpdateCheckStore - /// The clock, injected alongside the store so the throttle is drivable in a - /// test without waiting a day. - private let now: () -> Date - private let log = HostIdentity.current.logger("update") - - /// Guards against a second check while one is in flight (double-click, or the - /// button and menu both fired), so we never stack two result alerts. Observable - /// so the Settings "Check for Updates" button can show a spinner and disable - /// itself while a check runs — the feedback a slow connection otherwise lacks. - private(set) var isChecking = false - - /// Title of the Settings "Updates" row, e.g. "Blurt 0.1.31" — or just "Blurt" - /// when the bundle version can't be parsed (the button still works; the check - /// reads the version itself). The wording is the engine's, shared with the - /// alerts, so the row and the "you have …" line can't name the version two - /// different ways. - var versionLabel: String { UpdateAlertContent.appVersionLabel(currentVersion) } - - /// Every collaborator is injected (with sensible production defaults) so this - /// stays exercisable without a real bundle, a live browser, an on-screen - /// window, the user's defaults, or the wall clock. - init( - checker: UpdateChecker = UpdateChecker(), - currentVersion: SemanticVersion? = UpdateCheckModel.bundleVersion(), - openURL: @escaping (URL) -> Void = { _ = NSWorkspace.shared.open($0) }, - presentingWindow: @escaping () -> NSWindow? = { NSApp.keyWindow }, - lastCheckStore: LastUpdateCheckStore = LastUpdateCheckStore(), - now: @escaping () -> Date = { Date() } - ) { - self.checker = checker - self.currentVersion = currentVersion - self.openURL = openURL - self.presentingWindow = presentingWindow - self.lastCheckStore = lastCheckStore - self.now = now - } - - /// Checks GitHub and reports the result in a modal alert. Safe to call from - /// the app menu and the menu-bar item; a check already in flight is ignored. - func checkForUpdates() { - guard !isChecking else { return } - guard let currentVersion else { - log.error("no parseable CFBundleShortVersionString; can't check for updates") - // Indistinguishable from any other failed check as far as the user is - // concerned — which is why it shares the one `.checkFailed` content - // rather than a second hand-rolled alert. - Task { await present(.checkFailed) } - return - } - isChecking = true - Task { - defer { isChecking = false } - await present(await resolveContent(current: currentVersion)) - } - } - - /// The automatic check, run once from `applicationDidFinishLaunching` on a - /// configured app. Whether it runs at all — setup finished, and a day since the - /// last completed check — is the engine's `AutomaticUpdateCheck.shouldRun`. - /// - /// Deliberately quieter than the manual path: it alerts only on an available - /// update. "Up to date" and "couldn't check" answer a question the user didn't - /// ask, and a modal at launch saying either one is a nuisance (offline, on a - /// plane, GitHub down). They still log, and the menu command still reports - /// them on demand. - func checkForUpdatesAtLaunch(isConfigured: Bool) { - // Cheap early-out so a launch that clearly isn't due spawns nothing. The - // decision is made again after the wait, where it's authoritative. - guard isLaunchCheckDue(isConfigured: isConfigured) else { return } - // No parseable bundle version means no check to run. The manual path turns - // that into an alert because someone is waiting on an answer; here nobody is. - guard currentVersion != nil else { - log.error("no parseable CFBundleShortVersionString; skipping the launch update check") - return - } - Task { - // Let the launch settle before fetching, and give the main window time to - // exist so a result has a sheet host — see `AutomaticUpdateCheck.launchDelay`. - try? await Task.sleep(for: AutomaticUpdateCheck.launchDelay) - // Everything is re-read after the wait, because the user can act during it: - // `isChecking` catches a manual check still in flight (two would stack two - // alerts), and the throttle catches one that *completed* here — it stamped - // the store, so this launch has its answer already and fetching again would - // be a redundant request and possibly a second alert. - guard !isChecking, isLaunchCheckDue(isConfigured: isConfigured), let currentVersion else { return } - isChecking = true - defer { isChecking = false } - guard let result = await runCheck(current: currentVersion) else { return } - // Which results are worth interrupting an unprompted launch for is the - // engine's `forUnpromptedCheck` (nil = log only), so this path can't disagree - // with the wording — or quietly drop a result added later. - guard - let content = UpdateAlertContent.forUnpromptedCheck( - result: result, current: currentVersion) - else { return } - await present(content) - } - } - - /// The launch gate, read fresh each time: the stored stamp moves whenever any - /// check completes, so this can flip between the call and the post-delay - /// re-check. `isConfigured` is the caller's (the wizard's readiness at launch), - /// which is why it's passed through rather than captured here. - private func isLaunchCheckDue(isConfigured: Bool) -> Bool { - AutomaticUpdateCheck.shouldRun(isConfigured: isConfigured, lastCheck: lastCheckStore.lastCheck, now: now()) - } - - /// Runs the check and resolves what to show. Every throw — offline, GitHub - /// unreachable, malformed JSON, an unparseable tag — is the same recoverable - /// "couldn't check" to the user, so they collapse to one content value here. - private func resolveContent(current: SemanticVersion) async -> UpdateAlertContent { - guard let result = await runCheck(current: current) else { return .checkFailed } - return UpdateAlertContent(result: result, current: current) - } - - /// Performs the check and stamps the throttle, returning `nil` when it threw - /// (the callers differ only in whether they say so out loud). - /// - /// Only a *completed* check is stamped: a failed one never answered the - /// question, so the next launch should ask again rather than coast for a day - /// on a fetch that never landed. A successful manual check is stamped too — - /// the launch check has nothing to add the morning after the user looked. - private func runCheck(current: SemanticVersion) async -> UpdateCheckResult? { - do { - let result = try await checker.check(current: current) - lastCheckStore.lastCheck = now() - return result - } catch { - log.error("update check failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } - - /// Presents `content` as an alert and performs its default action. The only - /// judgement left here is AppKit-shaped: the *first* button is the default, so - /// that response — and only when the content carries a URL — is the download. - private func present(_ content: UpdateAlertContent) async { - let alert = NSAlert() - alert.messageText = content.title - alert.informativeText = content.message - // `NSAlert` already defaults to `.warning`, and on current macOS `.warning` - // and `.informational` render identically (only `.critical` adds the caution - // badge), so only the caution case is worth stating — same presentation the - // three hand-built alerts had. - if content.style == .warning { alert.alertStyle = .warning } - for title in content.buttons { - alert.addButton(withTitle: title) // first added is the default button - } - if await runAlert(alert) == .alertFirstButtonReturn, let dmgURL = content.downloadURL { - openURL(dmgURL) - } - } - - /// Presents `alert` as a sheet on the host window and awaits the choice. A - /// sheet keeps the main thread on its run loop, unlike `runModal()`'s nested - /// modal loop (reported as an app hang). Falls back to `runModal()` only when - /// no window can host a sheet (e.g. the menu command fired with every window - /// closed) — a rare spurious hang beats dropping the result. - private func runAlert(_ alert: NSAlert) async -> NSApplication.ModalResponse { - guard let window = presentingWindow() else { return alert.runModal() } - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { continuation.resume(returning: $0) } - } - } - - private static func bundleVersion() -> SemanticVersion? { - let raw = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - return raw.flatMap(SemanticVersion.init) - } -} diff --git a/App/Blurt/Blurt/Update/UpdaterModel.swift b/App/Blurt/Blurt/Update/UpdaterModel.swift new file mode 100644 index 00000000..ef935312 --- /dev/null +++ b/App/Blurt/Blurt/Update/UpdaterModel.swift @@ -0,0 +1,106 @@ +import BlurtEngine +import Foundation +import Observation +import Sparkle + +/// Blurt's updater: a thin `@Observable` face on Sparkle's standard controller. +/// One shared instance (owned by `AppDelegate`) backs every entry point — the +/// "Check for Updates…" app-menu command, the menu-bar item, and the Settings +/// Updates row — so they all drive the one `SPUUpdater`. +/// +/// Sparkle owns everything past the button: the scheduled background check +/// (daily, per `SUScheduledCheckInterval`), the "you're up to date" / +/// "a new version is available" UI, downloading the DMG from the appcast, +/// verifying its EdDSA signature against `SUPublicEDKey`, and replacing the app +/// in place. The feed and key live in `Info.plist` (see `project.yml`); the +/// release pipeline publishes the signed `appcast.xml` next to the DMG (see +/// RELEASE.md). +/// +/// **Only the shipping build starts the updater.** "Blurt Dev" is a separate app +/// (`dev.alex.blurt.dev`) installed at a separate path, and the appcast +/// describes the *release* — letting Sparkle run would offer to overwrite a dev +/// build with a shipped one. With the updater unstarted, `canCheckForUpdates` +/// stays false, so every entry point is disabled rather than silently inert. +/// Decided from the running bundle id, not `#if DEBUG`, for the reason +/// `BlurtApp.init` picks the host identity that way: the id is what makes the two +/// apps different to macOS, so a configuration added later can't start updating +/// itself because someone forgot a compilation condition. +@MainActor +@Observable +final class UpdaterModel { + @ObservationIgnored private let controller: SPUStandardUpdaterController + @ObservationIgnored private var canCheckObservation: NSKeyValueObservation? + + /// Whether a user-initiated check can start right now. False while one is + /// already in flight (Sparkle is showing its own progress UI) and always false + /// when the updater was never started (debug builds). Mirrored from Sparkle's + /// KVO-observable property so SwiftUI can disable the buttons. + private(set) var canCheckForUpdates = false + + /// Whether this build runs the updater at all — false in debug builds, where + /// the Settings row says so instead of offering controls that do nothing. + let isEnabled: Bool + + /// Title of the Settings "Updates" row, e.g. "Blurt 0.1.31". + let versionLabel: String + + init() { + isEnabled = Self.startsUpdater + controller = SPUStandardUpdaterController( + startingUpdater: isEnabled, updaterDelegate: nil, userDriverDelegate: nil) + versionLabel = Self.bundleVersionLabel() + // Sparkle publishes `canCheckForUpdates` on the main thread. + let updater = controller.updater + canCheckObservation = updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] observed, _ in + MainActor.assumeIsolated { self?.canCheckForUpdates = observed.canCheckForUpdates } + } + } + + /// Runs a user-initiated check; Sparkle presents the result either way. + func checkForUpdates() { + controller.checkForUpdates(nil) + } + + /// The "check automatically" preference. Sparkle persists it in the app's + /// defaults; it starts on because `SUEnableAutomaticChecks` is set in + /// `Info.plist`, which also suppresses Sparkle's second-launch permission + /// prompt. + var automaticallyChecksForUpdates: Bool { + get { + access(keyPath: \.automaticallyChecksForUpdates) + return controller.updater.automaticallyChecksForUpdates + } + set { + withMutation(keyPath: \.automaticallyChecksForUpdates) { + controller.updater.automaticallyChecksForUpdates = newValue + } + } + } + + /// The "download and install automatically" preference. Off by default: a + /// found update is offered, and installed only when the user says so. + var automaticallyDownloadsUpdates: Bool { + get { + access(keyPath: \.automaticallyDownloadsUpdates) + return controller.updater.automaticallyDownloadsUpdates + } + set { + withMutation(keyPath: \.automaticallyDownloadsUpdates) { + controller.updater.automaticallyDownloadsUpdates = newValue + } + } + } + + /// The shipping build updates itself; debug builds (Blurt Dev, the UI-test + /// build) never do — see the type comment. + private static var startsUpdater: Bool { + Bundle.main.bundleIdentifier == HostIdentity.blurt.subsystem + } + + private static func bundleVersionLabel() -> String { + let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Blurt" + guard let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + else { return name } + return "\(name) \(version)" + } +} diff --git a/App/Blurt/Blurt/Wizard/BlurtCommands.swift b/App/Blurt/Blurt/Wizard/BlurtCommands.swift index d7135904..8e950fdf 100644 --- a/App/Blurt/Blurt/Wizard/BlurtCommands.swift +++ b/App/Blurt/Blurt/Wizard/BlurtCommands.swift @@ -8,12 +8,12 @@ struct BlurtCommands: Commands { var body: some Commands { // "Check for Updates…" sits just below "About Blurt" in the app menu — the // conventional macOS spot, and the placement Sparkle's own SwiftUI guidance - // uses (`CommandGroup(after: .appInfo)`). It runs the same check as the - // Settings button and reports the result in a modal (see `UpdateCheckModel`). - // The ellipsis marks that it goes off and does work (and may present a - // dialog). + // uses (`CommandGroup(after: .appInfo)`). It drives the same Sparkle updater + // as the Settings button (see `UpdaterModel`), and is disabled while a check + // is already running or in builds that don't update. The ellipsis marks that + // it goes off and does work (and may present a dialog). CommandGroup(after: .appInfo) { - Button("Check for Updates…") { appDelegate.updateCheckModel.checkForUpdates() } + CheckForUpdatesButton(model: appDelegate.updaterModel) } // Blurt ships no help book, so SwiftUI's default "Blurt Help" item would // open nothing; it's replaced with the two outbound links that do belong diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index f8aaeef6..d25c21fc 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -3,16 +3,52 @@ import BlurtEngine import OSLog import SwiftUI -// The Advanced pane's two sections: the maintenance group (version, update -// check, developer mode) and the start-over button. Both are Settings-only -// (neither gates setup, so neither is a wizard step), and they live here rather -// than in `SettingsWindowRoot` because that file is at the repo's file-length -// limit. +// The Advanced pane's three sections: updates (version, check, Sparkle's two +// preferences), developer mode, and the start-over button. All are +// Settings-only (none gates setup, so none is a wizard step), and they live here +// rather than in `SettingsWindowRoot` because that file is at the repo's +// file-length limit. -/// The Advanced pane's first group: the running version with its update check, -/// and the developer-mode switch. One headerless section rather than an -/// "Updates" and a "Developer" section of one row each, whose headers only -/// repeated the row beneath them. +/// The running version, a "Check for Updates" button, and Sparkle's two +/// preferences. Every control drives the one `UpdaterModel` owned by +/// `AppDelegate` — the same updater the app-menu command and the menu-bar item +/// use — and Sparkle presents the check's progress and result itself. +/// +/// In a debug build the updater never starts (see `UpdaterModel`), so the +/// controls are disabled and the footer says why rather than leaving a button +/// that silently does nothing. +struct UpdatesSection: View { + @Bindable var model: UpdaterModel + + var body: some View { + Section { + SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { + Button("Check for Updates") { model.checkForUpdates() } + .disabled(!model.canCheckForUpdates) + .accessibilityIdentifier(UITestIdentifiers.updateCheck) + } + Toggle(isOn: $model.automaticallyChecksForUpdates) { + SettingLabel(title: "Check for updates automatically", systemImage: "clock.arrow.circlepath") + } + .disabled(!model.isEnabled) + .accessibilityIdentifier(UITestIdentifiers.updateAutoCheck) + // Installing on its own only means anything while Blurt is looking on its + // own, so the second switch follows the first. + Toggle(isOn: $model.automaticallyDownloadsUpdates) { + SettingLabel(title: "Download and install automatically", systemImage: "arrow.down.circle") + } + .disabled(!model.isEnabled || !model.automaticallyChecksForUpdates) + } header: { + Text("Updates") + } footer: { + if !model.isEnabled { + Text("Development builds don’t update themselves.") + } + } + } +} + +/// The Advanced pane's developer-mode switch. /// /// Developer mode is an opt-in: while on, every completed dictation is appended /// to the local JSONL log and every failed one to a sibling error log (see @@ -20,13 +56,10 @@ import SwiftUI /// the section grows a "Show Logs in Finder" row and a footer naming where the /// logs live. struct MaintenanceSection: View { - let updateModel: UpdateCheckModel - @AppStorage(DeveloperModeStore.defaultsKey) private var developerMode = false var body: some View { Section { - UpdateRow(model: updateModel) Toggle(isOn: $developerMode) { SettingLabel(title: "Developer mode", systemImage: "hammer") } @@ -66,35 +99,6 @@ struct MaintenanceSection: View { } } -/// The running version and a "Check for Updates" button that runs the check and -/// reports the result in a modal (see `UpdateCheckModel`). The same check is -/// reachable from the "Check for Updates…" app-menu command and the menu-bar -/// item; all three share the one `UpdateCheckModel` owned by `AppDelegate`, so a -/// check from any place runs through the same controller. -private struct UpdateRow: View { - let model: UpdateCheckModel - - var body: some View { - // "Blurt 0.1.31" — the label is the engine's (shared with the result - // alerts, so the two can't name the version differently). - SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { - HStack(spacing: 8) { - // A user-initiated check that can stall on a slow connection needs - // visible progress, or the button reads as dead until the result alert - // lands. Show a spinner and disable the button while in flight (the - // model already ignores a second check) — the native equivalent of - // Sparkle's "Checking for updates…". - if model.isChecking { - ProgressView().controlSize(.small) - } - Button("Check for Updates") { model.checkForUpdates() } - .disabled(model.isChecking) - .accessibilityIdentifier(UITestIdentifiers.updateCheck) - } - } - } -} - /// The Reset section of the Settings window: one destructive button that hands /// the install back to the state a fresh download starts from — no API key, no /// settings, no dictation logs, and none of the TCC grants — for the user whose @@ -157,7 +161,7 @@ struct ResetSection: View { // opens something rather than completing the action. SettingRow(title: "Reset Blurt", systemImage: "arrow.counterclockwise") { // Red text, because `role: .destructive` alone draws a bordered - // button no differently from "Check for Updates" in the section + // button no differently from "Check for Updates" in the sections // above — and this one deletes the key, every setting and the logs. Button(role: .destructive) { prompt = .confirm diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index beb47f33..439b6fd1 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -36,7 +36,7 @@ struct SettingsWindowRoot: View { Label(UITestIdentifiers.vocabularySettingsTab, systemImage: "character.book.closed") } .tag(Tab.vocabulary) - AdvancedSettingsTab(coordinator: coordinator, updateModel: appDelegate.updateCheckModel) + AdvancedSettingsTab(coordinator: coordinator, updaterModel: appDelegate.updaterModel) .tabItem { Label(UITestIdentifiers.advancedSettingsTab, systemImage: "gearshape.2") } .tag(Tab.advanced) } @@ -106,11 +106,12 @@ private struct StylesSettingsTab: View { /// and the start-over button. Kept out of General so the common pane stays short. private struct AdvancedSettingsTab: View { let coordinator: AppCoordinator - let updateModel: UpdateCheckModel + let updaterModel: UpdaterModel var body: some View { SettingsPane { - MaintenanceSection(updateModel: updateModel) + UpdatesSection(model: updaterModel) + MaintenanceSection() ResetSection(coordinator: coordinator) } } diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 87540254..9f5915fe 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -122,24 +122,21 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertEqual("\(toggle.value ?? "")", "1", "Clicking should switch developer mode on") } - /// The Advanced pane's "Check for Updates" button runs the check and reports - /// the result in a modal. Under UI testing the check is stubbed offline to - /// always report up-to-date, so clicking it surfaces the "You’re up to date" - /// result sheet deterministically (no network). - func testCheckForUpdatesShowsResultAlert() { + /// UI-test builds are debug builds, which never start the Sparkle updater (a + /// dev build must not overwrite itself with the release the appcast + /// describes). The Updates section still renders, with its controls disabled + /// rather than offering a check that could never run. + func testUpdateControlsAreDisabledInDebugBuilds() { let settings = openSettingsWindow() let advanced = selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) let button = advanced.anyDescendant(identified: UITestIdentifiers.updateCheck) XCTAssertTrue(button.waitForExistence(timeout: 10), "Check for Updates button not found") - button.click() + XCTAssertFalse(button.isEnabled, "A debug build's updater never starts, so the check is disabled") - let alert = app.sheets.firstMatch - XCTAssertTrue(alert.waitForExistence(timeout: 10), "The check should present a result sheet") - XCTAssertTrue( - alert.staticTexts["You’re up to date"].exists, - "The stubbed check should report up to date") - alert.buttons["OK"].click() + let autoCheck = advanced.anyDescendant(identified: UITestIdentifiers.updateAutoCheck) + XCTAssertTrue(autoCheck.waitForExistence(timeout: 10), "Automatic-check toggle not found") + XCTAssertFalse(autoCheck.isEnabled, "A debug build's update preferences are disabled") } /// The Advanced pane's reset button asks first, and dismissing the diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index d371e70e..2a66a056 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -70,6 +70,7 @@ enum UITestIdentifiers { static let developerShowLogs = "settings.developer.showLogs" static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" static let updateCheck = "settings.update.check" + static let updateAutoCheck = "settings.update.autoCheck" /// The Advanced pane's "Reset…" button (`SettingsWindowRoot`'s reset section). /// Only the row button is identified: the confirmation it opens is an alert, /// whose buttons the suite addresses by title. diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index ee4a9ff2..d4bbafa6 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -37,6 +37,11 @@ settings: # to the user's system accent when it's unset). See `BlurtBrand`. ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor DEAD_CODE_STRIPPING: YES + # Sparkle's public EdDSA key (base64), baked into Info.plist as + # SUPublicEDKey. Generated once with Sparkle's `generate_keys`; the private + # half never leaves the maintainer's keychain and the release-build secret. + # release-build.sh refuses to ship while this is still the placeholder. + SPARKLE_PUBLIC_ED_KEY: "REPLACE_WITH_SPARKLE_PUBLIC_ED_KEY" configs: Release: VALIDATE_PRODUCT: YES @@ -52,6 +57,13 @@ settings: packages: BlurtEngine: path: ../.. + # The app's one remote package: the in-place updater (see AGENTS.md → Updates). + # Pinned to an exact version so an update to the updater is a reviewed diff + # here and in Package.resolved, never a silent float. The engine stays + # dependency-free; check.sh allows this URL and nothing else. + Sparkle: + url: https://github.com/sparkle-project/Sparkle + exactVersion: 2.10.0 targets: Blurt: type: application @@ -134,6 +146,18 @@ targets: LSMinimumSystemVersion: "15.0" LSApplicationCategoryType: public.app-category.productivity NSMicrophoneUsageDescription: Blurt uses your microphone to transcribe your dictation with AssemblyAI. + # Sparkle. The feed is a stable release-asset URL: release-publish.sh + # attaches appcast.xml to every release, so /latest/download/ always + # resolves to the newest one. The public EdDSA key verifies each update + # before it is installed — its private half is the SPARKLE_ED_PRIVATE_KEY + # secret release-build.sh signs with (see RELEASE.md). Checks are on by + # default (which also skips Sparkle's "check automatically?" prompt) and + # daily; installing without asking stays opt-in. Only the shipping bundle + # id starts the updater at all (see UpdaterModel). + SUFeedURL: https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml + SUPublicEDKey: $(SPARKLE_PUBLIC_ED_KEY) + SUEnableAutomaticChecks: true + SUScheduledCheckInterval: 86400 entitlements: path: Blurt/Blurt.entitlements properties: @@ -143,6 +167,8 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + - package: Sparkle + product: Sparkle postBuildScripts: - name: Install to /Applications script: | diff --git a/README.md b/README.md index 2bbbfae5..e7f8ea99 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,11 @@ utterance. key, the settings and the permission grants, then restarts Blurt into first-run setup — so an install whose permissions have got stuck can start clean. -- **No surprises** — installing an update is always yours to do: Blurt looks for - a newer release once a day (and whenever you ask) and, if there is one, offers - to open the DMG — it never replaces itself. There's no telemetry of any kind. +- **Updates in place** — Blurt checks for a newer release once a day (and + whenever you ask) with [Sparkle](https://sparkle-project.org), and installs it + when you say so. Every update is signed and verified before it's installed; + Settings → Advanced can turn the daily check off, or let updates install on + their own. There's no telemetry of any kind. ## Requirements @@ -231,13 +233,13 @@ Sources/BlurtEngine/ Swift 6 package owning the pipeline — no external dep Injection/ KeyInjector: save clipboard → paste via synthesized ⌘V → restore FocusCapture/ Accessibility reads of the focused app/window/field, kept local: paste spacing and the developer-mode log - Config/, Update/ Keychain API-key store, key terms, styles, text shortcuts, - download-only release check + Config/ Keychain API-key store, key terms, styles, text shortcuts App/Blurt/ AppKit/SwiftUI shell (Xcode project generated by XcodeGen) AppCoordinator.swift the one place the engine is composed for the real app Hotkey/ DictationKeyTap: the CGEventTap feeding the engine's key gate Overlay/, MenuBar/ floating status pill, menu bar dictation indicator + Update/ UpdaterModel: the Sparkle updater behind every "Check for Updates" Wizard/ setup wizard + settings window ``` diff --git a/RELEASE.md b/RELEASE.md index d65dfb1e..28502a03 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,8 +11,8 @@ decisions that aren't obvious from the scripts and the workflows. | Stage | Where | Gate | | ----------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Bump `CFBundleShortVersionString` + `CFBundleVersion` | `release-bump` workflow on `macos-26` (`scripts/release-bump.sh`) | Lands on `main` via PR: normal review + the `check` workflow — merging it starts the release | -| Build → sign → notarize → staple → DMG | `release` workflow, `build` job (`scripts/release-build.sh`) | Signer-pin, Gatekeeper assessment, mount-and-verify (all in-script) | -| Tag, push, publish the GitHub Release | `release` workflow, `publish` job (`scripts/release-publish.sh`) | **Required reviewer on the `release-publish` environment** | +| Build → sign → notarize → staple → DMG → appcast | `release` workflow, `build` job (`scripts/release-build.sh`) | Signer-pin, Gatekeeper assessment, mount-and-verify, EdDSA verify (all in-script) | +| Tag, push, publish the GitHub Release + appcast | `release` workflow, `publish` job (`scripts/release-publish.sh`) | **Required reviewer on the `release-publish` environment** | Start to finish: @@ -144,15 +144,16 @@ branches to `main` (optionally plus a `release-dry-run*` pattern — see [Dry-running the signing path](#dry-running-the-signing-path)) so a fork or a stray branch can never reach the Developer ID key. -| Secret | What | -| ---------------------- | ------------------------------------------------------------------------------------- | -| `SIGNING_P12_BASE64` | Developer ID Application cert **and** private key, exported as `.p12`, base64-encoded | -| `SIGNING_P12_PASSWORD` | The export password for that `.p12` | -| `NOTARY_KEY_P8_BASE64` | App Store Connect API key (`.p8`), base64-encoded — the preferred notary credential | -| `NOTARY_KEY_ID` | That key's Key ID | -| `NOTARY_ISSUER_ID` | That key's Issuer ID | -| `NOTARY_APPLE_ID` | _Fallback only_ — Apple ID for notarization, if no API key is available | -| `NOTARY_PASSWORD` | _Fallback only_ — app-specific password for that Apple ID | +| Secret | What | +| ------------------------ | ------------------------------------------------------------------------------------------------------- | +| `SIGNING_P12_BASE64` | Developer ID Application cert **and** private key, exported as `.p12`, base64-encoded | +| `SIGNING_P12_PASSWORD` | The export password for that `.p12` | +| `NOTARY_KEY_P8_BASE64` | App Store Connect API key (`.p8`), base64-encoded — the preferred notary credential | +| `NOTARY_KEY_ID` | That key's Key ID | +| `NOTARY_ISSUER_ID` | That key's Issuer ID | +| `NOTARY_APPLE_ID` | _Fallback only_ — Apple ID for notarization, if no API key is available | +| `NOTARY_PASSWORD` | _Fallback only_ — app-specific password for that Apple ID | +| `SPARKLE_ED_PRIVATE_KEY` | Sparkle's EdDSA private key, as `generate_keys -x` exports it — see [Sparkle updates](#sparkle-updates) | Prefer the API key. It is revocable on its own (an app-specific password is tied to the Apple ID that owns it), it needs no keychain, and it never appears in a @@ -259,13 +260,71 @@ revoke the old password at appleid.apple.com and mint a new one. For CI, replace B2VQF7Q2QY --password `. The profile is submit-only; it cannot sign. +## Sparkle updates + +Installed copies update themselves with [Sparkle](https://sparkle-project.org). +Each release carries an `appcast.xml` asset; every installed copy polls +`https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml` +(`SUFeedURL` in `project.yml`), so the feed moves to a release at the moment +`release-publish.sh` flips it live and repoints `/latest` — never before its +assets are verified. The appcast's one item names the versioned +`Blurt-X.Y.Z.dmg` on that release, with the DMG's EdDSA signature and length. + +`release-build.sh` produces it: after the DMG is notarized and stapled, Sparkle's +`sign_update` (from the SPM artifact in DerivedData) signs it, and +`scripts/verify-sparkle-signature.swift` checks that signature against the +`SUPublicEDKey` read out of the **built app** — the key every installed copy +will check — before the appcast is written. A private key that doesn't match +fails the build, not users' updates. The build also refuses to start while +`SPARKLE_PUBLIC_ED_KEY` is still the placeholder. + +Sparkle compares `CFBundleVersion` (`sparkle:version`), not the marketing +version, so the build number must rise with every release — `release-bump.sh` +already increments it on each bump. + +### One-time setup: the key pair + +The EdDSA key pair is the updater's root of trust, separate from the Developer +ID key: an installed copy only installs an archive signed by the private half of +the public key it shipped with. On the maintainer's Mac, with Sparkle's tools +(in the Sparkle release tarball, or `…/SourcePackages/artifacts/sparkle/Sparkle/bin/` +after any build): + +```sh +./bin/generate_keys # creates the pair in the login keychain; prints the public key +./bin/generate_keys -x sparkle.key # exports the private key for the CI secret +``` + +1. Put the printed public key in `App/Blurt/project.yml` as + `SPARKLE_PUBLIC_ED_KEY`, replacing the placeholder, and land it through the + normal PR flow. +2. Paste the contents of `sparkle.key` into the `SPARKLE_ED_PRIVATE_KEY` secret + on the `release-build` environment, then delete the file. +3. Back up the keychain item (it's the "Private key for signing Sparkle + updates" entry) the same way as the Developer ID `.p12`: offline, never + synced, never committed. + +### Key custody and rotation + +**Losing the private key strands every installed copy** — nothing else can sign +an update they will accept, so they'd have to download a new DMG by hand. Keep +the offline backup. + +A **leaked** private key lets whoever holds it sign an update installed copies +will accept — though Sparkle also requires the update to carry the same +Developer ID signature as the running app, so the attacker would need both keys. +To rotate: generate a new pair, ship one bridge release that is signed with the +**old** private key but carries the **new** `SPARKLE_PUBLIC_ED_KEY` (run that one +build with `BLURT_SPARKLE_VERIFY_PUBLIC_KEY` set to the old public key, since the +build otherwise verifies against the key the app carries), then switch the +secret to the new private key for every release after it. + ## A bad release: roll forward, never roll back -Blurt does **not** yank published releases. The update check only ever offers -users a strictly higher version (`UpdateChecker` compares `SemanticVersion` and -reports `.available` only when the latest tag is greater), so the fix for any bad -build is to **ship a new patch**: dispatch `release-bump`, merge, dispatch -`release`. +Blurt does **not** yank published releases. Sparkle only ever offers users a +strictly higher `CFBundleVersion` than the one they're running, so the fix for +any bad build is to **ship a new patch**: dispatch `release-bump`, merge, +dispatch `release`. The one exception is a fault caught **before announcing**, while the same version is still safe to overwrite (e.g. a corrupted upload flagged by the post-publish diff --git a/SECURITY.md b/SECURITY.md index 019b9b9f..e986cefb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,11 @@ permissions it uses are in scope here. Blurt sends no telemetry — no crash reporting, no analytics, and no usage tracking. The only network traffic the app produces is the dictation audio it -sends to AssemblyAI and the GitHub Releases check for self-updates. +sends to AssemblyAI and the update check: Sparkle fetches `appcast.xml` from the +latest GitHub Release and, when you install an update, downloads the DMG it +names. Sparkle verifies each update's EdDSA signature against the public key +built into the app before installing it, on top of the Developer ID signature +and notarization. ## Supported versions diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 7850d08a..a4dd3ee6 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -9,8 +9,8 @@ /// `allCases` rather than a hand-maintained list. That's the point: adding a store /// means adding a case, and a case is in the sweep the moment it exists. The list /// and the sweep can no longer disagree, which is what a copied-and-edited store -/// used to get wrong (the overlay origin and the update-check stamp were both -/// missed once, and a UI-test run inherited them). +/// used to get wrong (the overlay origin and a since-removed update-check stamp +/// were both missed once, and a UI-test run inherited them). /// /// A key that is *not* a user setting stays out of this enum on purpose — see /// `SigningIdentityMigration.lastSigningIdentityDefaultsKey`, which records what the @@ -42,7 +42,6 @@ enum DefaultsKey: String, CaseIterable { /// `OverlayOriginStore` persists a point, so it owns two keys rather than one. case overlayCustomOriginX = "OverlayCustomOriginX" case overlayCustomOriginY = "OverlayCustomOriginY" - case lastUpdateCheck = "LastUpdateCheck" /// The CoreAudio UID of the input device dictation is pinned to, or empty for /// "same as system" (`MicDeviceStore`). case micDeviceUID = "MicDeviceUID" diff --git a/Sources/BlurtEngine/Config/InstallReset.swift b/Sources/BlurtEngine/Config/InstallReset.swift index 2d6bdf17..97f72c3a 100644 --- a/Sources/BlurtEngine/Config/InstallReset.swift +++ b/Sources/BlurtEngine/Config/InstallReset.swift @@ -40,9 +40,8 @@ public struct InstallReset { let logsCleared: Bool /// What to tell the user when part of the reset didn't land, or `nil` when - /// it all did. Owned here rather than at the `NSAlert` call site for the - /// reason `UpdateAlertContent` is: it's a pure projection of a result into - /// wording, and the shell that draws it has no test target — so a step + /// it all did. Owned here rather than at the `NSAlert` call site because + /// it's a pure projection of a result into wording, and the shell that draws it has no test target — so a step /// added later can't ship with an alert that forgets to name it. var failureAlert: AlertContent? { let survivors = [ diff --git a/Sources/BlurtEngine/HostIdentity.swift b/Sources/BlurtEngine/HostIdentity.swift index 448f8dcf..ef3be51e 100644 --- a/Sources/BlurtEngine/HostIdentity.swift +++ b/Sources/BlurtEngine/HostIdentity.swift @@ -24,8 +24,8 @@ import os /// lifetime of a process. public struct HostIdentity: Sendable, Equatable { /// The product name as it appears in user-facing copy ("Blurt") — distinct - /// from `subsystem`, which is the reverse-DNS identity. `UpdateAlertContent` - /// is its only reader today. + /// from `subsystem`, which is the reverse-DNS identity. `UserAgent` is its + /// only reader today. public let productName: String /// Reverse-DNS identity ("dev.alex.blurt"): the `os_log` subsystem for every @@ -76,26 +76,18 @@ public struct HostIdentity: Sendable, Equatable { /// logs are written to (`~/Library/Logs/Blurt/{dictations,errors}.jsonl`). public let logDirectoryName: String - /// The "latest release" endpoint `UpdateChecker` reads, and therefore the repo - /// whose DMG the update alert offers. Still an initializer parameter on - /// `UpdateChecker` (tests substitute a local URL); this is the default a host - /// gets without passing one. - public let releaseURL: URL - public init( productName: String, subsystem: String, keychainService: String, defaultsPrefix: String, - logDirectoryName: String, - releaseURL: URL + logDirectoryName: String ) { self.productName = productName self.subsystem = subsystem self.keychainService = keychainService self.defaultsPrefix = defaultsPrefix self.logDirectoryName = logDirectoryName - self.releaseURL = releaseURL } /// Blurt's own identity, and the value an unconfigured host inherits — so the @@ -108,9 +100,7 @@ public struct HostIdentity: Sendable, Equatable { subsystem: "dev.alex.blurt", keychainService: "blurt", defaultsPrefix: "Blurt", - logDirectoryName: "Blurt", - releaseURL: URL( - staticString: "https://api.github.com/repos/AssemblyAI/blurt/releases/latest")) + logDirectoryName: "Blurt") /// The identity a **debug build** of Blurt runs under — "Blurt Dev", which /// macOS already treats as a separate app (its own bundle id, TCC rows and @@ -137,8 +127,7 @@ public struct HostIdentity: Sendable, Equatable { subsystem: subsystem, keychainService: service, defaultsPrefix: defaultsPrefix, - logDirectoryName: logDirectoryName, - releaseURL: releaseURL) + logDirectoryName: logDirectoryName) } /// The identity every engine component reads. `.blurt` until a host calls @@ -186,8 +175,8 @@ public struct HostIdentity: Sendable, Equatable { /// A `Logger` on this identity's subsystem. Spelled once here so the engine's /// loggers don't each restate `subsystem:` — and public because the host's own /// components want the same subsystem, so their lines show up under the - /// documented `log show` predicates too (Blurt's `DictationKeyTap` and - /// `UpdateCheckModel` are the in-repo examples). + /// documented `log show` predicates too (Blurt's `DictationKeyTap` is the + /// in-repo example). public func logger(_ category: String) -> Logger { Logger(subsystem: subsystem, category: category) } diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index c5f7a83d..51745a20 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -241,26 +241,16 @@ Keys are the host identity's `defaultsPrefix` followed by the `DefaultsKey` case | `StyleProfileStore` | `BlurtActiveStyleProfile` | the first defined profile | | `StyleProfileStore` | `BlurtCustomStyle` | the pre-profiles field, read-only | | `OverlayOriginStore` | `BlurtOverlayCustomOriginX`/`…Y` | pill never dragged; default place | -| `LastUpdateCheckStore` | `BlurtLastUpdateCheck` | never checked | | `TextShortcutStore` | `BlurtTextShortcuts` | no text shortcuts | -Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only key-terms field reads back as "no terms" — and a setter here would fight the text field. `StyleProfileStore` is the counter-case: its value is JSON, so the encoding is the store's to own and the views write through it (`profiles`, `activate(_:)`, `activateDefault()` — the last stores a sentinel meaning "base styling, no profile appended", distinct from unset so a migrated legacy profile stays active) while binding `@AppStorage` only to observe. It also still **reads** `BlurtCustomStyle`, the single field that predates profiles, and never writes it — an install with text there and no list reads back as one active profile named "Custom". The six a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `StyleProfileStore`, `TextShortcutStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) +Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only key-terms field reads back as "no terms" — and a setter here would fight the text field. `StyleProfileStore` is the counter-case: its value is JSON, so the encoding is the store's to own and the views write through it (`profiles`, `activate(_:)`, `activateDefault()` — the last stores a sentinel meaning "base styling, no profile appended", distinct from unset so a migrated legacy profile stays active) while binding `@AppStorage` only to observe. It also still **reads** `BlurtCustomStyle`, the single field that predates profiles, and never writes it — an install with text there and no list reads back as one active profile named "Custom". The five a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `StyleProfileStore`, `TextShortcutStore`, `OverlayOriginStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are half the on-disk contract and the identity's `defaultsPrefix` is the other — rename a case freely, never its raw value or your prefix, or every existing user's setting is silently abandoned. -**`InstallReset`** is the whole-install sweep behind Blurt's Settings → Advanced → Reset button: `PersistedSettings.resetAll()`, the API key deleted through the host's own `APIKeyGateway` (so a UI-test run clears its in-memory store, not the real Keychain item), `PermissionsReset.resetAll(bundleID:)` over the three TCC services an install holds grants under (`Accessibility`, `Microphone`, and Input Monitoring's internal `ListenEvent`), and `DictationLog.removeStoredLogs()`. Pass the **running** bundle id — `Bundle.main.bundleIdentifier`, never `HostIdentity.current.subsystem`, or a debug build clears the shipping app's grants. No step short-circuits the ones after it: `run()` returns `nil` when the install came out clean (Blurt's shell restarts the app on that — TCC prompts once per process, so only a process started after the sweep is asked again) or an `AlertContent` naming what survived, wording owned here for the same reason `UpdateAlertContent` is. It is the in-app half of `scripts/reset-install.sh`; the only thing the script does that this can't is unregister _other_ copies of the app from LaunchServices. +**`InstallReset`** is the whole-install sweep behind Blurt's Settings → Advanced → Reset button: `PersistedSettings.resetAll()`, the API key deleted through the host's own `APIKeyGateway` (so a UI-test run clears its in-memory store, not the real Keychain item), `PermissionsReset.resetAll(bundleID:)` over the three TCC services an install holds grants under (`Accessibility`, `Microphone`, and Input Monitoring's internal `ListenEvent`), and `DictationLog.removeStoredLogs()`. Pass the **running** bundle id — `Bundle.main.bundleIdentifier`, never `HostIdentity.current.subsystem`, or a debug build clears the shipping app's grants. No step short-circuits the ones after it: `run()` returns `nil` when the install came out clean (Blurt's shell restarts the app on that — TCC prompts once per process, so only a process started after the sweep is asked again) or an `AlertContent` naming what survived, wording owned here because the shell that draws it has no test target. It is the in-app half of `scripts/reset-install.sh`; the only thing the script does that this can't is unregister _other_ copies of the app from LaunchServices. Two string helpers carry the "usable text" rule shared by focus capture, the context/prompt, and the stores: `trimmedNonEmpty()` (on both `String` and `String?` — trims surrounding whitespace, treats blank as absent) and `prefix(maxUTF8Bytes:)`, which drops whole `Character`s from the end so a multi-scalar emoji is removed intact rather than sliced into an invalid fragment. The latter is the single truncation rule behind the custom-style budget, shared by the Settings field's counter and `CleanupInstruction.sendable(appending:)` — the two disagreed once, and that shipped. -## Update checking - -The engine carries the _decision_ half of Blurt's self-update, for the same reason `SetupReadiness` and `UpdateAlertContent` live here: these are rules and wording, and the AppKit shell that applies them has no test target. Nothing here installs anything — it reports whether a newer DMG exists and where to download it, and the user still installs it themselves. - -- **`UpdateChecker.check(current:)`** GETs the latest GitHub release of the identity's `releaseURL` repo through the shared `HTTPTransport` seam (so tests substitute `FakeHTTPTransport` instead of the live API) and returns `.upToDate` or `.available(version:dmgURL:)`. It throws on network failure, malformed JSON, an unparseable tag, or a newer release with no DMG asset — the app maps every throw to one "couldn't check" caption. -- **`SemanticVersion`** parses a dotted numeric version with an optional leading `v` (GitHub tags are `v0.1.39`; `CFBundleShortVersionString` is the bare form), comparing component-wise with missing trailing components as zero, so `1.2 == 1.2.0`. A malformed tag reads as "can't determine" rather than crashing. -- **`AutomaticUpdateCheck.shouldRun(isConfigured:lastCheck:now:)`** gates the launch check: never over an unfinished wizard, at most once per `minimumInterval` (24 h, because the unauthenticated GitHub API is rate-limited per IP and five relaunches in an afternoon must not be five fetches), and treating a timestamp in the future — clock correction, a restored backup — as due rather than trusting it. `launchDelay` (3 s) keeps the fetch off the launch path and out of the window before the main `NSWindow` exists to host the result's sheet. -- **`UpdateAlertContent`** owns the wording: title, body, buttons in presentation order, and the URL the _default_ button opens (`nil` when it only dismisses, so "which button downloads" is never re-derived by matching a button title). Its initializer is private, so every alert comes from one of the named results rather than being assembled ad hoc in the shell. `appVersionLabel(_:)` is the one phrasing of the running-version label the alerts and the Settings row share. - ## Hotkey building blocks The engine ships the _decision logic_ for a lone-modifier trigger; the host supplies the event source (in Blurt, a `CGEventTap` — see `App/Blurt/Blurt/Hotkey/DictationKeyTap.swift` for the reference wiring). @@ -292,7 +282,7 @@ Run `swift test` for the engine suites (`--filter DictationSessionTests` for one ## Embedding outside Blurt -The dictation pipeline itself is clean — the three seams, `DictationSession`, the phase projections, and the transcriber carry nothing Blurt-specific. Everything that _was_ Blurt-specific now has a seam: the identity below, and the cue catalog, which the engine no longer ships at all. One thing still doesn't, and it isn't a bug in the engine so much as the shape of a package that has only ever had one host. +The dictation pipeline itself is clean — the three seams, `DictationSession`, the phase projections, and the transcriber carry nothing Blurt-specific. Everything that _was_ Blurt-specific now has a seam: the identity below, and the cue catalog, which the engine no longer ships at all. Blurt's self-update used to be the one exception; it moved to the app (Sparkle, in `App/Blurt/Blurt/Update/`), so the engine carries no updater. ### Tell the engine which app it is: `HostIdentity` @@ -301,12 +291,11 @@ Everything the engine writes somewhere addressable is namespaced by one value. S ```swift HostIdentity.configure( HostIdentity( - productName: "Acme Voice", // update alerts say this + productName: "Acme Voice", // the User-Agent header's product token subsystem: "com.acme.voice", // os_log subsystem + queue labels keychainService: "acme-voice", // the API key's Keychain item defaultsPrefix: "AcmeVoice", // AcmeVoiceTriggerKeyCode, AcmeVoiceSoundPack, … - logDirectoryName: "Acme Voice", // ~/Library/Logs/Acme Voice/{dictations,errors}.jsonl - releaseURL: acmeLatestReleaseURL)) // UpdateChecker's default feed + logDirectoryName: "Acme Voice")) // ~/Library/Logs/Acme Voice/{dictations,errors}.jsonl ``` | Field | What it namespaces | @@ -315,18 +304,11 @@ HostIdentity.configure( | `subsystem` | every `os_log` category, `OSSignposter`, and the engine's dispatch-queue labels | | `defaultsPrefix` | every `DefaultsKey` — the prefix, then the case's raw value | | `logDirectoryName` | `DictationLog.defaultURL` / `defaultErrorURL` under `~/Library/Logs` | -| `productName` | `UpdateAlertContent`'s wording, including `appVersionLabel(_:)` | -| `releaseURL` | `UpdateChecker`'s default release feed (still overridable per instance) | +| `productName` | the product token in every request's `User-Agent` header | Doing nothing inherits `HostIdentity.blurt`, which is exactly the set of constants these used to be. The app in this repo configures explicitly in `BlurtApp.init` because the identity is the host's to state — and because it has two: a debug build runs as a separate app (`dev.alex.blurt.dev`) and takes `HostIdentity.blurtDev`, which differs from `.blurt` in the Keychain service alone. That is the field worth thinking about if you ship more than one build of your own app: `UserDefaults` is already per-app and `~/Library/Logs` is per directory name, but Keychain items are per _login keychain_, so two builds naming the same service share one item — including the ability to overwrite and delete it. `HostIdentity.current.logger(_:)` is public too, so your own components can log under the same subsystem. -Three things to know. The value is **process-wide**, not injected: its readers are lazily-initialized `static let` loggers, an enum of defaults keys and a Keychain facade, none of which a caller constructs — which is also why the "before you construct any engine type" ordering is a real requirement rather than politeness (a reader that already resolved keeps the identity it resolved with). `defaultsPrefix` is an **on-disk contract** with your shipped users, exactly as a `DefaultsKey` raw value is: changing it later abandons their settings. And `GitHubRelease.dmgAsset` takes the first asset whose name ends in `.dmg`, so it needs nothing from you — but `Update/` is still Blurt's self-update feature living in a dictation engine, which is its own gap below. - -### `Update/` isn't part of a dictation engine - -`UpdateChecker`, `AutomaticUpdateCheck`, `GitHubRelease`, `UpdateAlertContent`, and `LastUpdateCheckStore` are Blurt's own self-update feature, and they live in the engine for a concrete reason: the AppKit shell has **no unit-test target** (`App/Blurt` builds the app and an XCUITest bundle, nothing else), so a launch gate or an alert's wording written inline there would be covered by nothing. That reasoning is sound for this repo and wrong for a published package — as shipped, the dictation library contains a self-updater for a specific app. - -Two ways out, neither taken yet: drop it from the public product (its own target, so it keeps its Swift Testing coverage without riding inside `BlurtEngine`), or finish generalizing it — the repo slug and the product name already come from `HostIdentity`, and `GitHubRelease.dmgAsset` matches any `.dmg`, so what remains is that a dictation library contains a self-updater at all. Note the second is not free — `LastUpdateCheckStore` reads its key from the internal `DefaultsKey` enum, and that enum is deliberately the single roster `PersistedSettings.resetAll` sweeps, so a target split has to keep that invariant intact rather than stranding the update stamp outside the reset (which is exactly the bug the roster was created to prevent, twice). +Two things to know. The value is **process-wide**, not injected: its readers are lazily-initialized `static let` loggers, an enum of defaults keys and a Keychain facade, none of which a caller constructs — which is also why the "before you construct any engine type" ordering is a real requirement rather than politeness (a reader that already resolved keeps the identity it resolved with). `defaultsPrefix` is an **on-disk contract** with your shipped users, exactly as a `DefaultsKey` raw value is: changing it later abandons their settings. ## Invariants — don't break these diff --git a/Sources/BlurtEngine/Update/AutomaticUpdateCheck.swift b/Sources/BlurtEngine/Update/AutomaticUpdateCheck.swift deleted file mode 100644 index fe6d47b6..00000000 --- a/Sources/BlurtEngine/Update/AutomaticUpdateCheck.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -/// The policy behind the *automatic* update check: whether Blurt may check on -/// its own at launch, and how long it waits before doing so. -/// -/// Owned in the engine for the same reason as `SetupReadiness` — it is a rule, -/// not plumbing, and `UpdateCheckModel` (which applies it) has no test target, -/// so a launch-gate written inline there would be covered by nothing. -/// -/// "Automatic" here means the *check*, never the install. A launch check that -/// finds a newer release shows the same Download/Later alert the menu command -/// does, and the user still downloads and installs the DMG themselves — the -/// manual, download-only policy documented on `UpdateChecker` is unchanged, and -/// nothing here runs in the background beyond that one launch fetch. -public enum AutomaticUpdateCheck { - /// The floor between automatic checks. The launch check exists to notice a - /// release within a day or so, not to poll GitHub: quitting and relaunching - /// five times in an afternoon must not fetch five times (the unauthenticated - /// API is rate-limited per IP), and someone who never quits Blurt is served - /// by the menu command. Internal — callers ask `shouldRun` rather than - /// redoing the arithmetic. - static let minimumInterval: TimeInterval = 24 * 60 * 60 - - /// How long after launch the automatic check waits before fetching. - /// - /// Two reasons it isn't zero. The launch path shouldn't share its first - /// moments with a network request; and at `applicationDidFinishLaunching` the - /// main window's `NSWindow` doesn't exist yet, so a result arriving that early - /// would have no window to host its sheet (`UpdateCheckModel` then falls back - /// to a nested `runModal()` loop, which has been reported as an app hang). - public static let launchDelay: Duration = .seconds(3) - - /// Whether a launch check should run, given how far setup has got and when a - /// check last completed. - /// - /// `isConfigured` is the wizard's readiness (`SetupReadiness.isReady`): an app - /// still in setup doesn't get an update alert thrown over its wizard, and - /// someone who hasn't finished onboarding hasn't got a working Blurt to - /// update yet. - public static func shouldRun(isConfigured: Bool, lastCheck: Date?, now: Date) -> Bool { - guard isConfigured else { return false } - // Never checked — a fresh install, or a user who has only ever used the - // menu command's predecessor. Check. - guard let lastCheck else { return true } - // A stamp in the future (clock correction, a restored backup, a machine that - // travelled back across a timezone) would otherwise wedge the check off for - // as long as the skew lasts. Treat it as "due" rather than trusting it. - guard lastCheck <= now else { return true } - return now.timeIntervalSince(lastCheck) >= minimumInterval - } -} diff --git a/Sources/BlurtEngine/Update/GitHubRelease.swift b/Sources/BlurtEngine/Update/GitHubRelease.swift deleted file mode 100644 index 40b13465..00000000 --- a/Sources/BlurtEngine/Update/GitHubRelease.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/// The subset of GitHub's "get the latest release" response Blurt reads. -/// https://docs.github.com/en/rest/releases/releases#get-the-latest-release -/// -/// Internal: only `UpdateChecker` (same module) decodes and inspects this — -/// the app consumes the digested `UpdateCheckResult`, never the raw release. -struct GitHubRelease: Decodable, Sendable { - struct Asset: Decodable, Sendable { - let name: String - let browserDownloadURL: URL - - enum CodingKeys: String, CodingKey { - case name - case browserDownloadURL = "browser_download_url" - } - } - - let tagName: String - let assets: [Asset] - - enum CodingKeys: String, CodingKey { - case tagName = "tag_name" - case assets - } - - /// The first asset whose name ends in `.dmg`. The release pipeline publishes - /// the one notarized image under two names, `Blurt.dmg` and - /// `Blurt-.dmg` (see `scripts/release-publish.sh`), so any match is - /// the right download. Nil when the release carries no DMG. - var dmgAsset: Asset? { - assets.first { $0.name.lowercased().hasSuffix(".dmg") } - } -} diff --git a/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift b/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift deleted file mode 100644 index 8e931acf..00000000 --- a/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -/// Persists when an update check last completed, so the automatic launch check -/// can be throttled to `AutomaticUpdateCheck.minimumInterval` instead of -/// fetching GitHub on every launch. Same shape as `TriggerKeyStore` / -/// `OverlayOriginStore`; its key is a `DefaultsKey` case, so every "reset to a clean -/// state" sweep clears it too. -public struct LastUpdateCheckStore { - /// Public so the reset sweep can name it. - public static var defaultsKey: String { DefaultsKey.lastUpdateCheck.key } - - private let defaults: UserDefaults - - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// When a check last produced a result, or `nil` if none ever has. - /// - /// Probed with `object(forKey:)` first because `double(forKey:)` reports 0 for - /// a missing key — i.e. the reference date, a check made two decades ago. - /// That happens to lead to the same decision as "never checked", but only by - /// luck, and only while the interval is shorter than the value's age; keeping - /// "never" its own answer means the gate can't quietly depend on that. - public var lastCheck: Date? { - get { - guard defaults.object(forKey: Self.defaultsKey) != nil else { return nil } - return Date(timeIntervalSinceReferenceDate: defaults.double(forKey: Self.defaultsKey)) - } - nonmutating set { - guard let newValue else { - defaults.removeObject(forKey: Self.defaultsKey) - return - } - defaults.set(newValue.timeIntervalSinceReferenceDate, forKey: Self.defaultsKey) - } - } -} diff --git a/Sources/BlurtEngine/Update/SemanticVersion.swift b/Sources/BlurtEngine/Update/SemanticVersion.swift deleted file mode 100644 index 904c944c..00000000 --- a/Sources/BlurtEngine/Update/SemanticVersion.swift +++ /dev/null @@ -1,54 +0,0 @@ -/// A dotted numeric version (`major.minor.patch…`) parsed for comparison — -/// used to decide whether a GitHub release is newer than the running app. -/// Accepts an optional leading `v` (GitHub tags are `v0.1.30`; the bundle's -/// `CFBundleShortVersionString` is the bare `0.1.30`). Comparison is -/// component-wise with missing trailing components treated as zero, so -/// `1.2 == 1.2.0`. -public struct SemanticVersion: Sendable, Equatable, Comparable, CustomStringConvertible { - /// The `v`-stripped source string, kept for display in the UI. - public let description: String - private let components: [Int] - - /// Parses `"0.1.30"` / `"v0.1.30"`. Returns nil when the string is empty or - /// any dot-separated component isn't a non-negative integer, so a malformed - /// tag reads as "can't determine" rather than crashing. - public init?(_ string: String) { - var text = string - if text.hasPrefix("v") || text.hasPrefix("V") { text.removeFirst() } - let parts = text.split(separator: ".", omittingEmptySubsequences: false) - guard !parts.isEmpty else { return nil } - var parsed: [Int] = [] - for part in parts { - guard let value = Int(part), value >= 0 else { return nil } - parsed.append(value) - } - description = text - components = parsed - } - - public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { - let (left, right) = alignedComponents(lhs, rhs) - return left.lexicographicallyPrecedes(right) - } - - public static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { - let (left, right) = alignedComponents(lhs, rhs) - return left == right - } - - /// Both versions' components zero-extended to a common length, so `1.2` and - /// `1.2.0` compare as the identical `[1, 2, 0]`. The single basis both `<` and - /// `==` order on: element-wise array comparison, rather than a hand-rolled - /// three-way `Int` sentinel the two operators then had to interpret. - private static func alignedComponents( - _ lhs: SemanticVersion, _ rhs: SemanticVersion - ) -> (left: [Int], right: [Int]) { - let width = max(lhs.components.count, rhs.components.count) - return (lhs.padded(to: width), rhs.padded(to: width)) - } - - /// This version's components zero-extended to `width`. - private func padded(to width: Int) -> [Int] { - components + Array(repeating: 0, count: max(0, width - components.count)) - } -} diff --git a/Sources/BlurtEngine/Update/UpdateAlertContent.swift b/Sources/BlurtEngine/Update/UpdateAlertContent.swift deleted file mode 100644 index 3dd45370..00000000 --- a/Sources/BlurtEngine/Update/UpdateAlertContent.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation - -/// The user-facing content of the alert an update check ends in: title, body, -/// button titles, and — for an available update — the URL the default button -/// downloads. -/// -/// Owned here rather than in the AppKit shell for the same reason as -/// `OverlayUIState.accessibilityLabel` and `MenuBarStatus.symbolName`: it is a -/// pure projection of an engine result into wording, and the shell that draws it -/// has no test target, so wording assembled at the `NSAlert` call site was -/// covered by nothing. `UpdateCheckModel` keeps only the AppKit plumbing — -/// resolve one of these, build the alert from it, and open `downloadURL` when -/// the default button comes back. -public struct UpdateAlertContent: Equatable, Sendable { - /// How prominently the shell presents the alert. Only "couldn't check" is a - /// caution: a result the user asked for isn't a warning. - public enum Style: Equatable, Sendable { - case informational - case warning - } - - /// Alert title (`NSAlert.messageText`). - public let title: String - /// Explanatory body (`NSAlert.informativeText`). - public let message: String - /// Button titles in presentation order; the first is the default action. - /// Never empty — an alert with no button can't be dismissed. - public let buttons: [String] - /// What the *default* button downloads, or `nil` when it only dismisses. - /// - /// Carrying the URL here is what keeps "which button downloads" from being - /// re-derived at the call site by matching a button title: the shell opens - /// this if and only if the first button was chosen. - public let downloadURL: URL? - public let style: Style - - /// Private so every value comes from one of the named results below — a new - /// alert can't be assembled ad hoc in the shell, which is the whole point of - /// owning the wording here. - private init( - title: String, message: String, buttons: [String], downloadURL: URL? = nil, - style: Style = .informational - ) { - self.title = title - self.message = message - self.buttons = buttons - self.downloadURL = downloadURL - self.style = style - } - - /// "Blurt 0.1.31" — the running-version label, also shown as the title of the - /// Settings window's Updates row. Takes an optional so the unparseable-bundle - /// -version case has one answer too (the bare product name; the check still - /// works, it just can't name a version). Published here so the alerts and the - /// Settings row can't word the same fact two ways. - public static func appVersionLabel(_ version: SemanticVersion?) -> String { - guard let version else { return productName } - return "\(productName) \(version)" - } - - /// The product name as it appears in user-facing copy — the host identity's, - /// so an embedding app's alerts name *it* rather than Blurt. Distinct from - /// `HostIdentity.subsystem`, which is the reverse-DNS identity. - private static var productName: String { HostIdentity.current.productName } - - /// The reassuring result a user-initiated check always shows, so pressing - /// "Check for Updates" visibly confirms it ran. - public static func upToDate(current: SemanticVersion) -> UpdateAlertContent { - UpdateAlertContent( - title: "You’re up to date", - message: "\(appVersionLabel(current)) is the latest version.", - buttons: ["OK"]) - } - - /// A newer release exists. **Download** (the default) opens the release DMG in - /// the browser; **Later** dismisses. There is no in-place install, so these are - /// the only two choices — see the manual-update policy in `UpdateChecker`. - public static func available( - current: SemanticVersion, latest: SemanticVersion, dmgURL: URL - ) -> UpdateAlertContent { - UpdateAlertContent( - title: "A new version of \(productName) is available", - message: "\(appVersionLabel(latest)) is available—you have \(current). Download it now?", - buttons: ["Download", "Later"], - downloadURL: dmgURL) - } - - /// A recoverable "couldn't check" — offline, GitHub unreachable, a malformed - /// response, or a bundle version that wouldn't parse. Every one of those is the - /// same thing to the user (try again later), which is why `UpdateChecker` - /// throws rather than enumerating them. - public static let checkFailed = UpdateAlertContent( - title: "Couldn’t check for updates", - message: "Check your internet connection and try again.", - buttons: ["OK"], - style: .warning) - - /// The alert an **unprompted** check should show, or `nil` when the result isn't - /// worth interrupting for. Today only an available update speaks: "you're up to - /// date" answers a question nobody asked, and a modal saying it at launch is a - /// nuisance (offline, on a plane, GitHub down). - /// - /// Owned here, beside the wording and next to `AutomaticUpdateCheck.shouldRun`, - /// for the reason the rest of this type is: *whether to speak* is the same class - /// of rule as *whether to check*, and the shell that applies it has no test - /// target. It's also exhaustive over `UpdateCheckResult`, so a result added later - /// has to decide here — the shell previously did this with - /// `guard case .available = result else { return }`, where a new case would have - /// silently made the launch check go quiet. - /// - /// A check that *failed* never reaches this: it throws, and the caller returns - /// without a result rather than mapping to `checkFailed`. - public static func forUnpromptedCheck( - result: UpdateCheckResult, current: SemanticVersion - ) -> UpdateAlertContent? { - switch result { - case .upToDate: nil - case .available(let version, let dmgURL): - .available(current: current, latest: version, dmgURL: dmgURL) - } - } - - /// The alert for a completed check. The throwing paths map to `checkFailed` - /// at the call site, where the error is caught. - public init(result: UpdateCheckResult, current: SemanticVersion) { - switch result { - case .upToDate: - self = .upToDate(current: current) - case .available(let version, let dmgURL): - self = .available(current: current, latest: version, dmgURL: dmgURL) - } - } -} diff --git a/Sources/BlurtEngine/Update/UpdateChecker.swift b/Sources/BlurtEngine/Update/UpdateChecker.swift deleted file mode 100644 index 4800ad9c..00000000 --- a/Sources/BlurtEngine/Update/UpdateChecker.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -/// The outcome of a successful update check. -public enum UpdateCheckResult: Sendable, Equatable { - /// The running app is the latest published release (or newer). - case upToDate - /// A newer release exists; `dmgURL` downloads its DMG in the browser. - case available(version: SemanticVersion, dmgURL: URL) -} - -/// Checks GitHub for a newer Blurt release. No AppKit and no in-place install — -/// it only reports whether a newer `.dmg` exists and where to download it. The -/// network call goes through the engine's shared `HTTPTransport` seam (same as -/// `AssemblyAITranscriber` / `APIKeyValidator`), so tests substitute a -/// `FakeHTTPTransport` instead of hitting the live API. -public struct UpdateChecker: Sendable { - private let releaseURL: URL - private let transport: any HTTPTransport - - /// `releaseURL` defaults to the configured host identity's, so a host that - /// called `HostIdentity.configure(_:)` checks *its* repo without passing one; - /// tests still substitute a local URL here. - public init( - releaseURL: URL = HostIdentity.current.releaseURL, - transport: any HTTPTransport = URLSession.shared - ) { - self.releaseURL = releaseURL - self.transport = transport - } - - /// Fetches the latest release and compares it to `current`. Throws on network - /// failure, malformed JSON, an unparseable tag, or a newer release with no - /// DMG asset — the app maps any throw to a single "couldn't check" caption. - public func check(current: SemanticVersion) async throws -> UpdateCheckResult { - let (data, _) = try await transport.data(for: URLRequest(url: releaseURL)) - let release = try JSONDecoder().decode(GitHubRelease.self, from: data) - guard let latest = SemanticVersion(release.tagName) else { - throw UpdateCheckError.malformedResponse - } - guard current < latest else { - return .upToDate - } - guard let asset = release.dmgAsset else { - throw UpdateCheckError.malformedResponse - } - return .available(version: latest, dmgURL: asset.browserDownloadURL) - } -} - -/// The check couldn't produce a result from a well-formed HTTP response (an -/// unparseable tag, or a newer release with no DMG). Internal: the app catches -/// generically and shows one caption, so this never needs to be `public`. -enum UpdateCheckError: Error { - case malformedResponse -} diff --git a/Tests/BlurtEngineTests/AutomaticUpdateCheckTests.swift b/Tests/BlurtEngineTests/AutomaticUpdateCheckTests.swift deleted file mode 100644 index 6fb6c0bc..00000000 --- a/Tests/BlurtEngineTests/AutomaticUpdateCheckTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -/// The gate on the launch-time update check. It lives in the engine precisely so -/// these cases are covered: `UpdateCheckModel`, which applies it, has no test -/// target, so a launch gate written inline there would be checked by nobody — -/// and its failure modes are quiet ones (a check that never runs, or one that -/// runs on every relaunch). -@Suite("AutomaticUpdateCheck") -struct AutomaticUpdateCheckTests { - /// A fixed "now" so the arithmetic is deterministic; the absolute value is - /// irrelevant, only the distances from it. - private let now = Date(timeIntervalSinceReferenceDate: 800_000_000) - - private func ago(_ seconds: TimeInterval) -> Date { now.addingTimeInterval(-seconds) } - - @Test("a configured app that has never checked checks") - func firstEverCheck() { - #expect(AutomaticUpdateCheck.shouldRun(isConfigured: true, lastCheck: nil, now: now)) - } - - @Test("setup still unfinished never checks") - func unconfiguredNeverChecks() { - // The point of the gate: no update modal thrown over the setup wizard, and - // nothing to update to before the app even works. True regardless of when a - // check last ran. - #expect(!AutomaticUpdateCheck.shouldRun(isConfigured: false, lastCheck: nil, now: now)) - #expect( - !AutomaticUpdateCheck.shouldRun( - isConfigured: false, lastCheck: ago(AutomaticUpdateCheck.minimumInterval * 10), now: now)) - } - - @Test("a check within the interval is skipped") - func throttledWithinInterval() { - // Quit-and-relaunch a few times in an afternoon and GitHub is fetched once. - #expect(!AutomaticUpdateCheck.shouldRun(isConfigured: true, lastCheck: now, now: now)) - #expect(!AutomaticUpdateCheck.shouldRun(isConfigured: true, lastCheck: ago(60), now: now)) - #expect( - !AutomaticUpdateCheck.shouldRun( - isConfigured: true, lastCheck: ago(AutomaticUpdateCheck.minimumInterval - 1), now: now)) - } - - @Test("a check older than the interval runs again") - func dueAfterInterval() { - #expect( - AutomaticUpdateCheck.shouldRun( - isConfigured: true, lastCheck: ago(AutomaticUpdateCheck.minimumInterval), now: now)) - #expect( - AutomaticUpdateCheck.shouldRun( - isConfigured: true, lastCheck: ago(AutomaticUpdateCheck.minimumInterval * 3), now: now)) - } - - @Test("a timestamp in the future doesn't wedge the check off") - func futureStampIsDue() { - // Clock correction, a restored backup, or a machine that travelled back a - // timezone can leave a stamp ahead of now. Naive subtraction would suppress - // the check until real time caught up with the skew. - #expect( - AutomaticUpdateCheck.shouldRun( - isConfigured: true, lastCheck: now.addingTimeInterval(60 * 60 * 24 * 365), now: now)) - } - - @Test("the throttle is a day and the launch wait is short") - func policyConstants() { - // Both numbers are judgement calls, but their scale is the policy: check - // about daily, and don't fetch in the launch's first moments. - #expect(AutomaticUpdateCheck.minimumInterval == 24 * 60 * 60) - #expect(AutomaticUpdateCheck.launchDelay > .zero) - #expect(AutomaticUpdateCheck.launchDelay < .seconds(30)) - } -} diff --git a/Tests/BlurtEngineTests/DefaultsKeyTests.swift b/Tests/BlurtEngineTests/DefaultsKeyTests.swift index abb73c11..35306391 100644 --- a/Tests/BlurtEngineTests/DefaultsKeyTests.swift +++ b/Tests/BlurtEngineTests/DefaultsKeyTests.swift @@ -44,8 +44,7 @@ struct DefaultsKeyTests { // the unprefixed half, so nothing here says "Blurt". let acme = HostIdentity( productName: "Acme Voice", subsystem: "com.acme.voice", keychainService: "acme-voice", - defaultsPrefix: "AcmeVoice", logDirectoryName: "Acme Voice", - releaseURL: HostIdentity.blurt.releaseURL) + defaultsPrefix: "AcmeVoice", logDirectoryName: "Acme Voice") #expect(DefaultsKey.soundPack.key(in: acme) == "AcmeVoiceSoundPack") #expect(DefaultsKey.soundPack.key(in: .blurt) == "BlurtSoundPack") } diff --git a/Tests/BlurtEngineTests/HostIdentityTests.swift b/Tests/BlurtEngineTests/HostIdentityTests.swift index 0b74d5a1..ee74d62a 100644 --- a/Tests/BlurtEngineTests/HostIdentityTests.swift +++ b/Tests/BlurtEngineTests/HostIdentityTests.swift @@ -14,8 +14,7 @@ struct HostIdentityTests { subsystem: "com.acme.voice", keychainService: "acme-voice", defaultsPrefix: "AcmeVoice", - logDirectoryName: "Acme Voice", - releaseURL: URL(staticString: "https://api.github.com/repos/acme/voice/releases/latest")) + logDirectoryName: "Acme Voice") @Test("the reverse-DNS identity matches the value scripts hard-code") func subsystemPinned() { @@ -56,7 +55,6 @@ struct HostIdentityTests { #expect(HostIdentity.blurtDev.defaultsPrefix == HostIdentity.blurt.defaultsPrefix) #expect(HostIdentity.blurtDev.logDirectoryName == HostIdentity.blurt.logDirectoryName) #expect(HostIdentity.blurtDev.productName == HostIdentity.blurt.productName) - #expect(HostIdentity.blurtDev.releaseURL == HostIdentity.blurt.releaseURL) } @Test("an unconfigured engine is Blurt") diff --git a/Tests/BlurtEngineTests/LastUpdateCheckStoreTests.swift b/Tests/BlurtEngineTests/LastUpdateCheckStoreTests.swift deleted file mode 100644 index e2e7a383..00000000 --- a/Tests/BlurtEngineTests/LastUpdateCheckStoreTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -/// The timestamp behind the launch check's daily throttle. Round-tripping is the -/// whole job, but "never checked" has to stay distinguishable from "checked at -/// the reference date" — `double(forKey:)` reports 0 for a missing key, so the -/// store probes `object(forKey:)` first. -@Suite("LastUpdateCheckStore") -struct LastUpdateCheckStoreTests { - @Test("an unset store reports no previous check") - func unsetIsNil() { - let store = LastUpdateCheckStore(defaults: freshDefaults()) - #expect(store.lastCheck == nil) - } - - @Test("a stamp round-trips") - func roundTrips() { - let store = LastUpdateCheckStore(defaults: freshDefaults()) - let when = Date(timeIntervalSinceReferenceDate: 800_000_000) - store.lastCheck = when - #expect(store.lastCheck == when) - } - - @Test("the reference date is a real stamp, not an unset store") - func referenceDateIsNotNil() { - // The 0 `double(forKey:)` would report for a missing key. Storing it must - // still read back as a value, or "never checked" and "checked in 2001" - // would be the same answer. - let store = LastUpdateCheckStore(defaults: freshDefaults()) - store.lastCheck = Date(timeIntervalSinceReferenceDate: 0) - #expect(store.lastCheck == Date(timeIntervalSinceReferenceDate: 0)) - } - - @Test("clearing the stamp returns the store to never-checked") - func clearing() { - let store = LastUpdateCheckStore(defaults: freshDefaults()) - store.lastCheck = Date(timeIntervalSinceReferenceDate: 800_000_000) - store.lastCheck = nil - #expect(store.lastCheck == nil) - } - - @Test("the stamp drives the launch gate end to end") - func feedsTheGate() { - // The pair as the app uses it: a fresh install checks, and a check just - // recorded suppresses the next launch's. - let store = LastUpdateCheckStore(defaults: freshDefaults()) - let now = Date(timeIntervalSinceReferenceDate: 800_000_000) - #expect(AutomaticUpdateCheck.shouldRun(isConfigured: true, lastCheck: store.lastCheck, now: now)) - store.lastCheck = now - #expect(!AutomaticUpdateCheck.shouldRun(isConfigured: true, lastCheck: store.lastCheck, now: now)) - } -} diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 19d1f822..cd9be83b 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -32,9 +32,6 @@ struct PersistedSettingsTests { // during a UI-test run survived into later runs. #expect(PersistedSettings.allDefaultsKeys.contains(OverlayOriginStore.xDefaultsKey)) #expect(PersistedSettings.allDefaultsKeys.contains(OverlayOriginStore.yDefaultsKey)) - // The launch update check's throttle: left out of the sweep, a UI-test run - // (or a "clean install") would inherit yesterday's stamp and skip the check. - #expect(PersistedSettings.allDefaultsKeys.contains(LastUpdateCheckStore.defaultsKey)) // The pinned microphone: left out, a reset "clean install" would keep // recording from a previously pinned device. #expect(PersistedSettings.allDefaultsKeys.contains(MicDeviceStore.defaultsKey)) @@ -45,10 +42,10 @@ struct PersistedSettingsTests { @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the eleven known stores' keys (OverlayOriginStore contributes two, + // Exactly the ten known stores' keys (OverlayOriginStore contributes two, // StyleProfileStore three): a removed store must leave the roster in the same // change, and a key listed twice would hint at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 14) + #expect(PersistedSettings.allDefaultsKeys.count == 13) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -72,14 +69,13 @@ struct PersistedSettingsTests { StyleProfileStore.legacyDefaultsKey, OverlayOriginStore.xDefaultsKey, OverlayOriginStore.yDefaultsKey, - LastUpdateCheckStore.defaultsKey, MicDeviceStore.defaultsKey, TextShortcutStore.defaultsKey, ] #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 14) + #expect(storeKeys.count == 13) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/Tests/BlurtEngineTests/SemanticVersionTests.swift b/Tests/BlurtEngineTests/SemanticVersionTests.swift deleted file mode 100644 index 2180c82d..00000000 --- a/Tests/BlurtEngineTests/SemanticVersionTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Testing - -@testable import BlurtEngine - -@Suite("SemanticVersion") -struct SemanticVersionTests { - @Test("parses a bare dotted version") - func parsesBare() { - let version = SemanticVersion("0.1.30") - #expect(version != nil) - #expect(version?.description == "0.1.30") - } - - @Test("parses a v-prefixed tag") - func parsesVPrefixed() { - #expect(SemanticVersion("v0.1.30")?.description == "0.1.30") - #expect(SemanticVersion("V2.0.0")?.description == "2.0.0") - } - - @Test("orders by numeric components, not lexically") - func ordersNumerically() throws { - let v1 = try #require(SemanticVersion("0.1.9")) - let v2 = try #require(SemanticVersion("0.1.10")) - #expect(v1 < v2) - let v3 = try #require(SemanticVersion("0.1.30")) - let v4 = try #require(SemanticVersion("0.2.0")) - #expect(v3 < v4) - let v5 = try #require(SemanticVersion("1.0.0")) - let v6 = try #require(SemanticVersion("2.0.0")) - #expect(v5 < v6) - } - - @Test("treats missing trailing components as zero") - func padsMissingComponents() throws { - let a = try #require(SemanticVersion("1.2")) - let b = try #require(SemanticVersion("1.2.0")) - #expect(a == b) - #expect(!(a < b)) - } - - @Test("a shorter version sorts below a longer one whose extra component is non-zero") - func extraComponentIsNotIgnored() throws { - // `1.2 == 1.2.0` above is satisfied even by an ordering that only walks the - // *shorter* version's components — and that shortcut would also call 1.2 and - // 1.2.1 equal. `UpdateChecker` gates on `current < latest`, so it would then - // report "up to date" for a real published release and the update would never - // be offered. Pin both directions and the inequality. - let short = try #require(SemanticVersion("1.2")) - let longer = try #require(SemanticVersion("1.2.1")) - #expect(short < longer) - #expect(!(longer < short)) - #expect(short != longer) - } - - @Test("comparison keeps going past the third component") - func comparesBeyondThreeComponents() throws { - // A tag can carry a fourth component (a re-cut of a published version). It - // must order after the three-component release it extends, not equal to it. - let release = try #require(SemanticVersion("1.2.3")) - let recut = try #require(SemanticVersion("1.2.3.4")) - let laterRecut = try #require(SemanticVersion("1.2.3.5")) - #expect(release < recut) - #expect(recut < laterRecut) - #expect(!(laterRecut < recut)) - } - - @Test("equal versions are not less than each other") - func equalNotLess() throws { - let v = try #require(SemanticVersion("0.1.30")) - #expect(!(v < v)) - } - - @Test("rejects malformed strings") - func rejectsMalformed() { - #expect(SemanticVersion("") == nil) - #expect(SemanticVersion("1.x.3") == nil) - #expect(SemanticVersion("-1.0") == nil) - #expect(SemanticVersion("abc") == nil) - } - - @Test("a doubled or dangling separator is malformed, not silently collapsed") - func rejectsEmptyComponents() { - // The parse splits *keeping* empty subsequences, so an empty component is a - // component that isn't a number — malformed — rather than one that quietly - // disappears. Omitting them instead would read `v1.2.` as 1.2 and `v1..2` as - // 1.2, and `UpdateChecker` gates the entire update decision on the parsed - // tag: it would then compare the running build against a version the release - // never actually stated. A tag nobody can parse has one correct outcome, the - // `malformedResponse` throw that surfaces as "couldn't check for updates". - #expect(SemanticVersion("1..2") == nil) - #expect(SemanticVersion("1.2.") == nil) - #expect(SemanticVersion(".1.2") == nil) - #expect(SemanticVersion(".") == nil) - // A tag that is nothing but the `v` prefix leaves an empty string behind. - #expect(SemanticVersion("v") == nil) - } -} diff --git a/Tests/BlurtEngineTests/UpdateAlertContentTests.swift b/Tests/BlurtEngineTests/UpdateAlertContentTests.swift deleted file mode 100644 index 8b6af1b9..00000000 --- a/Tests/BlurtEngineTests/UpdateAlertContentTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -/// The wording an update check ends in. This used to be assembled inline at the -/// `NSAlert` call site in the app shell, which has no test target — so the -/// version arithmetic in the copy, the button order, and "which button -/// downloads" were all uncovered. -@Suite("UpdateAlertContent") -struct UpdateAlertContentTests { - private func version(_ string: String) throws -> SemanticVersion { - try #require(SemanticVersion(string)) - } - - private let dmg = URL(staticString: "https://example.com/Blurt.dmg") - - @Test("up to date names the running version and only dismisses") - func upToDate() throws { - let content = UpdateAlertContent.upToDate(current: try version("0.1.31")) - #expect(content.title == "You’re up to date") - #expect(content.message == "Blurt 0.1.31 is the latest version.") - #expect(content.buttons == ["OK"]) - // Nothing to download, so the shell must not open anything. - #expect(content.downloadURL == nil) - #expect(content.style == .informational) - } - - @Test("an available update names both versions and carries the DMG") - func available() throws { - let content = UpdateAlertContent.available( - current: try version("0.1.31"), latest: try version("0.2.0"), dmgURL: dmg) - #expect(content.title == "A new version of Blurt is available") - #expect(content.message == "Blurt 0.2.0 is available—you have 0.1.31. Download it now?") - #expect(content.downloadURL == dmg) - } - - @Test("Download is the default action and Later dismisses") - func availableButtonOrder() throws { - let content = UpdateAlertContent.available( - current: try version("1.0"), latest: try version("1.1"), dmgURL: dmg) - // The shell opens `downloadURL` when the *first* button comes back, so the - // order here is load-bearing: flipping it would make "Later" download. - #expect(content.buttons == ["Download", "Later"]) - #expect(content.buttons.first == "Download") - } - - @Test("couldn't check is a warning with no download") - func checkFailed() { - let content = UpdateAlertContent.checkFailed - #expect(content.title == "Couldn’t check for updates") - #expect(content.message == "Check your internet connection and try again.") - #expect(content.buttons == ["OK"]) - #expect(content.downloadURL == nil) - // The one caution: a result the user asked for isn't a warning, a failure is. - #expect(content.style == .warning) - } - - @Test("every alert offers at least one way out") - func everyAlertIsDismissible() throws { - let all = [ - UpdateAlertContent.upToDate(current: try version("1.0")), - UpdateAlertContent.available(current: try version("1.0"), latest: try version("2.0"), dmgURL: dmg), - .checkFailed, - ] - for content in all { - #expect(!content.buttons.isEmpty) - } - } - - @Test("the result initializer maps both check outcomes") - func resultMapping() throws { - let current = try version("1.0.0") - let latest = try version("1.1.0") - #expect( - UpdateAlertContent(result: .upToDate, current: current) - == UpdateAlertContent.upToDate(current: current)) - #expect( - UpdateAlertContent(result: .available(version: latest, dmgURL: dmg), current: current) - == UpdateAlertContent.available(current: current, latest: latest, dmgURL: dmg)) - } - - @Test("the version label is shared by the alerts and the Settings row") - func versionLabel() throws { - #expect(UpdateAlertContent.appVersionLabel(try version("0.1.31")) == "Blurt 0.1.31") - // A `v`-prefixed GitHub tag is stripped by SemanticVersion, so the label - // never reads "Blurt v0.1.31". - #expect(UpdateAlertContent.appVersionLabel(try version("v0.1.31")) == "Blurt 0.1.31") - } - - @Test("an unparseable bundle version still yields a usable label") - func versionLabelWithoutVersion() { - // The Settings row's title falls back to the bare product name; the check - // itself still runs, it just can't name what's running. - #expect(UpdateAlertContent.appVersionLabel(nil) == "Blurt") - } - - /// The launch check is deliberately quieter than the manual one: it interrupts - /// only for an available update. Pinned here because the app shell that applies - /// it has no test target. - @Test("an unprompted check speaks only for an available update") - func unpromptedCheckIsQuietUnlessAnUpdateExists() throws { - let current = try version("1.0.0") - let latest = try version("1.1.0") - #expect(UpdateAlertContent.forUnpromptedCheck(result: .upToDate, current: current) == nil) - #expect( - UpdateAlertContent.forUnpromptedCheck( - result: .available(version: latest, dmgURL: dmg), current: current) - == UpdateAlertContent.available(current: current, latest: latest, dmgURL: dmg)) - } -} diff --git a/Tests/BlurtEngineTests/UpdateCheckerTests.swift b/Tests/BlurtEngineTests/UpdateCheckerTests.swift deleted file mode 100644 index 4eb6e028..00000000 --- a/Tests/BlurtEngineTests/UpdateCheckerTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -@Suite("UpdateChecker") -struct UpdateCheckerTests { - /// A latest-release payload shaped like GitHub's. A non-`.dmg` asset (the - /// dSYM zip) comes first so the tests pin that `dmgAsset` skips it and finds - /// the DMG. The pipeline publishes the image under both `Blurt.dmg` and - /// `Blurt-.dmg`; the fixture uses one representative `.dmg`. - private func releaseJSON(tag: String, assetName: String = "Blurt.dmg") -> Data { - Data( - """ - { - "tag_name": "\(tag)", - "assets": [ - { "name": "Blurt.app.dSYM.zip", "browser_download_url": "https://example.com/Blurt.app.dSYM.zip" }, - { "name": "\(assetName)", "browser_download_url": "https://example.com/\(assetName)" } - ] - } - """.utf8) - } - - private func checker(returning data: Data) -> UpdateChecker { - UpdateChecker(transport: FakeHTTPTransport { _ in (200, data) }) - } - - /// The installed version every case's fixture tag is chosen relative to - /// (`v0.2.0` is newer, `v0.1.0` older). Factored out like the two fixtures above: - /// spelled at each case, bumping it is seven edits and a missed one inverts that - /// case's meaning while still passing. - private func currentVersion() throws -> SemanticVersion { - try #require(SemanticVersion("0.1.30")) - } - - @Test("reports .available with the DMG URL when the release is newer") - func availableWhenNewer() async throws { - let checker = checker(returning: releaseJSON(tag: "v0.2.0")) - let current = try currentVersion() - let result = try await checker.check(current: current) - let expectedVersion = try #require(SemanticVersion("0.2.0")) - let expectedURL = try #require(URL(string: "https://example.com/Blurt.dmg")) - #expect(result == .available(version: expectedVersion, dmgURL: expectedURL)) - } - - @Test("reports .upToDate when the release matches the current version") - func upToDateWhenSame() async throws { - let checker = checker(returning: releaseJSON(tag: "v0.1.30")) - let current = try currentVersion() - let result = try await checker.check(current: current) - #expect(result == .upToDate) - } - - @Test("reports .upToDate when the release is older than the current build") - func upToDateWhenOlder() async throws { - let checker = checker(returning: releaseJSON(tag: "v0.1.0")) - let current = try currentVersion() - let result = try await checker.check(current: current) - #expect(result == .upToDate) - } - - @Test("the DMG asset is matched case-insensitively") - func dmgAssetIgnoresAssetNameCase() async throws { - // `dmgAsset` lowercases the name before matching the suffix. Nothing the release - // pipeline publishes capitalizes the extension today, which is why the - // lowercasing was uncovered — but matching the literal `.dmg` only would turn a - // release asset renamed `Blurt.DMG` into a thrown `malformedResponse`, i.e. - // "Couldn't check for updates" for every user, from a release that is fine. The - // check has no way to report "I found the release but not its download". - let checker = checker(returning: releaseJSON(tag: "v0.2.0", assetName: "Blurt.DMG")) - let current = try currentVersion() - let result = try await checker.check(current: current) - let expectedVersion = try #require(SemanticVersion("0.2.0")) - let expectedURL = try #require(URL(string: "https://example.com/Blurt.DMG")) - #expect(result == .available(version: expectedVersion, dmgURL: expectedURL)) - } - - @Test("throws when a newer release has no .dmg asset") - func throwsWithoutDMG() async throws { - let checker = checker(returning: releaseJSON(tag: "v0.2.0", assetName: "notes.txt")) - let current = try currentVersion() - await #expect(throws: (any Error).self) { - try await checker.check(current: current) - } - } - - @Test("throws on an unparseable tag") - func throwsOnBadTag() async throws { - let checker = checker(returning: releaseJSON(tag: "nightly")) - let current = try currentVersion() - await #expect(throws: (any Error).self) { - try await checker.check(current: current) - } - } - - @Test("throws on malformed JSON") - func throwsOnMalformedJSON() async throws { - let checker = checker(returning: Data("not json".utf8)) - let current = try currentVersion() - await #expect(throws: (any Error).self) { - try await checker.check(current: current) - } - } - - @Test("propagates a network failure from the transport") - func propagatesFetchError() async throws { - let checker = UpdateChecker(transport: FakeHTTPTransport.failing(with: URLError(.notConnectedToInternet))) - let current = try currentVersion() - await #expect(throws: (any Error).self) { - try await checker.check(current: current) - } - } -} diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index fb1614d8..30b0d6b5 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -93,7 +93,6 @@ PATTERNS=( "SetUnicodeString" "LSUIElement" "import KeyboardShortcuts" - "AppUpdater|Sparkle|SPUUpdater" "KeychainStore\\(service: *(HostIdentity\\.current\\.keychainService|\"blurt\")" "@available\\(\\*, *deprecated" ) @@ -108,7 +107,6 @@ SCOPES=( "$ENGINE $APP" "$APP" "$ENGINE $APP" - "$ENGINE $APP" "$TESTS" "$ENGINE $APP" ) @@ -123,7 +121,6 @@ ADVICE=( "injection is always clipboard paste (save → write → ⌘V → settle → restore)" "Blurt is a Dock app first; the MenuBarExtra item is layered on, never depended on" "the trigger is a home-grown lone modifier (CGEventTap + DictationKeyGate)" - "updates are download-only; extend UpdateCheckModel, don't install for the user" "use an isolated service (see KeychainStoreTests) or InMemoryAPIKeyStore" "deleted types stay deleted — no deprecated re-exports" ) @@ -143,7 +140,6 @@ PROBES=( "CGEventKeyboardSetUnicodeString(event, count, chars)" " LSUIElement: true" "import KeyboardShortcuts" - "let updater = AppUpdater(owner: \"assemblyai\", repo: \"blurt\")" "let store = KeychainStore(service: HostIdentity.current.keychainService, account: \"AssemblyAIAPIKey\")" "@available(*, deprecated, renamed: \"NewName\")" ) @@ -165,7 +161,6 @@ TABLE_ANCHORS=( "Add a keystroke-typing paste path or a length threshold" "Add \`LSUIElement\` or a menu-bar-**only** mode" "Add a \`KeyboardShortcuts\` package or a key+modifier chord" - "Add a self-replacing install or background auto-updater" "Touch the real Keychain in tests" "Add backwards-compat shims for removed types" ) @@ -186,7 +181,6 @@ SKILL_ANCHORS=( "Injection is always a clipboard paste" "no \`LSUIElement\`, no menu-bar-_only_ mode" "No \`KeyboardShortcuts\` package" - "Updates are download-only" "the real Keychain in tests" "Don't add backwards-compat shims for removed types." ) @@ -217,12 +211,10 @@ if [ "${1:-}" = "--self-test" ]; then # people route around. Each of these is a real line from this tree that sits # one character away from a rule above — the wire key we *do* send # (`stt_prompt`) against the array-of-turns field we no longer do, the test - # keychain against the production one, the download-only checker against a - # self-replacing updater. + # keychain against the production one. GOOD=( "let prompt: NSDictionary = [\"AXTrustedCheckOptionPrompt\": true]" "KeychainStore(service: \"dev.alex.blurt.tests\", account: \"test-\\(UUID().uuidString)\")" - "@MainActor public final class UpdateCheckModel: ObservableObject {" "case sttPrompt = \"stt_prompt\"" ) for good in "${GOOD[@]}"; do diff --git a/scripts/check.sh b/scripts/check.sh index 22bc6f63..6e6a0946 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -219,12 +219,14 @@ run_check() { return 0 } -# No-external-dependencies guard. The engine is dependency-free by rule and the -# app carries only the local BlurtEngine package (see AGENTS.md). A third-party -# dependency is the single biggest supply-chain risk, so fail the moment one is +# External-dependencies guard. The engine is dependency-free by rule, and the +# app carries the local BlurtEngine package plus exactly one remote package: +# Sparkle, the updater (see AGENTS.md → Updates). A third-party dependency is the +# single biggest supply-chain risk, so fail the moment any *other* one is # declared — in the engine's Package.swift or the app's project.yml. Extend # BlurtEngine rather than adding a package. Pure text parsing, so it runs in # both full and --portable modes and fails fast before the expensive steps. +ALLOWED_APP_PACKAGE_URL="https://github.com/sparkle-project/Sparkle" check_no_external_deps() { cd "$REPO_ROOT" || return 1 local violation=0 @@ -236,19 +238,24 @@ check_no_external_deps() { violation=1 fi - # App: only the local BlurtEngine (path:) package is allowed. A remote package - # is declared with a url:/github: key inside project.yml's `packages:` block, so - # extract that block and reject any such key. - local app_packages + # App: the local BlurtEngine (path:) package plus the one allowlisted remote. + # A remote package is declared with a url:/github: key inside project.yml's + # `packages:` block, so extract that block and reject any such key that isn't + # exactly the allowlisted URL. A github: shorthand is rejected outright, so the + # allowlist has one spelling to match. + local app_packages remote allowed_re + allowed_re="${ALLOWED_APP_PACKAGE_URL//./\\.}" app_packages="$(awk '/^packages:/{f=1;next} /^[^[:space:]]/{f=0} f' "$APP_DIR/project.yml")" - if printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' >/dev/null 2>&1; then - echo "error: App/Blurt/project.yml declares a remote SPM package — the app must carry only the local BlurtEngine:" >&2 - printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' >&2 + remote="$(printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' \ + | grep -vE "^[0-9]+:[[:space:]]*url:[[:space:]]*${allowed_re}[[:space:]]*$" || true)" + if [ -n "$remote" ]; then + echo "error: App/Blurt/project.yml declares a remote SPM package other than Sparkle:" >&2 + printf '%s\n' "$remote" >&2 violation=1 fi [ "$violation" -eq 0 ] || return 1 - echo "no external dependencies (engine dependency-free; app carries only local BlurtEngine)" + echo "no unexpected dependencies (engine dependency-free; app carries BlurtEngine + Sparkle only)" } run_check "no-external-dependencies guard" check_no_external_deps diff --git a/scripts/mutate.sh b/scripts/mutate.sh index b9424f7c..b2c5a223 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -60,9 +60,6 @@ DEFAULT_TARGETS=( "Sources/BlurtEngine/STT/KeytermsBoost.swift" "Sources/BlurtEngine/STT/SyncSTTLimits.swift" "Sources/BlurtEngine/StringNormalization.swift" - "Sources/BlurtEngine/Update/AutomaticUpdateCheck.swift" - "Sources/BlurtEngine/Update/SemanticVersion.swift" - "Sources/BlurtEngine/Update/UpdateAlertContent.swift" ) MAX_MUTANTS=0 # 0 = no cap diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 99598585..74df61e0 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -327,7 +327,7 @@ pretty_xcodebuild step "Preflight" require_tools --hint='brew install create-dmg if needed' \ - xcodegen xcodebuild xcrun hdiutil codesign spctl create-dmg awk shasum openssl + xcodegen xcodebuild xcrun hdiutil codesign spctl create-dmg awk shasum openssl swift xmllint # Destroy every credential this build materializes, no matter how we exit # (success, die, or a mid-build failure). Armed before the first one exists so @@ -387,6 +387,19 @@ step "Read version" VERSION="$(require_project_version "$APP_DIR/project.yml")" info "version: $VERSION" +# Checked before anything expensive: an app built with the placeholder public +# key would reject every future update, and a release with no private key can't +# produce the appcast those updates arrive through. +step "Sparkle keys" +SPARKLE_PUBLIC_KEY="$(parse_sparkle_public_key <"$APP_DIR/project.yml")" +sparkle_key_is_set "$SPARKLE_PUBLIC_KEY" \ + || die "SPARKLE_PUBLIC_ED_KEY in project.yml is still the placeholder — generate the key pair first (RELEASE.md → Sparkle updates)" +if [ -n "${BLURT_SPARKLE_ED_PRIVATE_KEY:-}" ]; then + info "EdDSA private key: from BLURT_SPARKLE_ED_PRIVATE_KEY" +else + info "EdDSA private key: from the login keychain (where Sparkle's generate_keys stores it)" +fi + step "Initial summary" info "build root: $BUILD_ROOT" info "identity: $IDENTITY" @@ -451,7 +464,22 @@ while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" --options runtime --timestamp "$f" NESTED_COUNT=$((NESTED_COUNT + 1)) done < <(find "$APP_STAGED" -type f \( -name "*.dylib" -o -name "*.so" \) -print0) -# 2. Embedded framework bundles, if any. Their mach-o binary has +# 2. Sparkle's helpers, which the framework loop below would not reach: the +# framework bundle's signature doesn't cover separately-signed code nested in +# it. Sparkle's documented order — XPC services, Autoupdate, Updater.app — and +# the Downloader keeps its entitlements (it has a network client one). +SPARKLE_B="$APP_STAGED/Contents/Frameworks/Sparkle.framework/Versions/B" +[ -d "$SPARKLE_B" ] || die "Sparkle.framework missing from the built app at $SPARKLE_B" +for helper in \ + "$SPARKLE_B/XPCServices/Installer.xpc" \ + "$SPARKLE_B/XPCServices/Downloader.xpc" \ + "$SPARKLE_B/Autoupdate" \ + "$SPARKLE_B/Updater.app"; do + [ -e "$helper" ] || continue + codesign --force --sign "$IDENTITY" --options runtime --timestamp --preserve-metadata=entitlements "$helper" + NESTED_COUNT=$((NESTED_COUNT + 1)) +done +# 3. Embedded framework bundles, if any. Their mach-o binary has # no dylib/so suffix, so step 1 misses it — sign the bundle so its signature is # refreshed. `-depth` yields the deepest frameworks first, so a nested framework # is signed before any framework that contains it. @@ -556,6 +584,51 @@ rmdir "$MOUNT_POINT" >/dev/null 2>&1 || true MOUNT_POINT="" info "dmg contents verified (Blurt.app $MOUNTED_VERSION, signed + stapled)" +# The feed every installed copy polls. Built here, next to the signing key, so +# the publish job only uploads it. The enclosure is the *versioned* DMG — a +# permanent URL on this release — not the moving Blurt.dmg, so a feed that is +# cached across a newer release still points at the bytes it signed. +step "Sparkle appcast" +SIGN_UPDATE="$(find "$DERIVED/SourcePackages/artifacts" -type f -name sign_update -path '*/bin/*' 2>/dev/null | head -n 1)" +[ -n "$SIGN_UPDATE" ] || die "Sparkle's sign_update not found under $DERIVED/SourcePackages/artifacts — did package resolution run?" +if [ -n "${BLURT_SPARKLE_ED_PRIVATE_KEY:-}" ]; then + # Over stdin, so the key never lands on disk or in a process list. + SIGN_OUT="$(printf '%s' "$BLURT_SPARKLE_ED_PRIVATE_KEY" | "$SIGN_UPDATE" --ed-key-file - "$DMG")" +else + SIGN_OUT="$("$SIGN_UPDATE" "$DMG")" +fi +ED_SIGNATURE="$(printf '%s\n' "$SIGN_OUT" | parse_sign_update_attr sparkle:edSignature)" +ED_LENGTH="$(printf '%s\n' "$SIGN_OUT" | parse_sign_update_attr length)" +if [ -z "$ED_SIGNATURE" ] || [ -z "$ED_LENGTH" ]; then + die "could not parse sign_update's output: $SIGN_OUT" +fi + +# Verify against the key the *built app* carries, not the one in project.yml: +# that is what installed copies check from the next release on, so a private +# key that doesn't match it fails here rather than on users' machines. The one +# exception is a key rotation's bridge release, which is signed with the key the +# *installed* copies still trust while carrying the new one — set +# BLURT_SPARKLE_VERIFY_PUBLIC_KEY to that old public key for that single build +# (RELEASE.md → Key custody and rotation). +APP_PUBLIC_KEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$APP_STAGED/Contents/Info.plist")" +[ "$APP_PUBLIC_KEY" = "$SPARKLE_PUBLIC_KEY" ] \ + || die "the built app's SUPublicEDKey ($APP_PUBLIC_KEY) is not project.yml's ($SPARKLE_PUBLIC_KEY)" +VERIFY_PUBLIC_KEY="${BLURT_SPARKLE_VERIFY_PUBLIC_KEY:-$APP_PUBLIC_KEY}" +[ "$VERIFY_PUBLIC_KEY" = "$APP_PUBLIC_KEY" ] \ + || info "key rotation: verifying against BLURT_SPARKLE_VERIFY_PUBLIC_KEY, not the app's new key" +swift "$REPO_ROOT/scripts/verify-sparkle-signature.swift" "$VERIFY_PUBLIC_KEY" "$ED_SIGNATURE" "$DMG" \ + || die "the DMG's EdDSA signature does not verify against $VERIFY_PUBLIC_KEY — wrong private key?" + +BUILD_NUMBER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_STAGED/Contents/Info.plist")" +APPCAST="$BUILD_ROOT/appcast.xml" +render_appcast "$VERSION" "$BUILD_NUMBER" \ + "https://github.com/AssemblyAI/blurt/releases/download/v$VERSION/Blurt-$VERSION.dmg" \ + "$ED_SIGNATURE" "$ED_LENGTH" \ + "https://github.com/AssemblyAI/blurt/releases/tag/v$VERSION" \ + "$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" >"$APPCAST" +xmllint --noout "$APPCAST" || die "generated appcast is not well-formed XML" +info "appcast: $APPCAST (build $BUILD_NUMBER, $ED_LENGTH bytes)" + step "Provenance" PROVENANCE="$BUILD_ROOT/build-info.txt" { @@ -575,7 +648,7 @@ info "provenance: $PROVENANCE" step "Checksums" CHECKSUMS="$BUILD_ROOT/SHA256SUMS" -(cd "$BUILD_ROOT" && shasum -a 256 "$(basename "$DMG")" "$(basename "$DSYM_ZIP")") >"$CHECKSUMS" +(cd "$BUILD_ROOT" && shasum -a 256 "$(basename "$DMG")" "$(basename "$DSYM_ZIP")" "$(basename "$APPCAST")") >"$CHECKSUMS" info "checksums: $CHECKSUMS" step "Summary" @@ -587,6 +660,7 @@ cat < + + + Blurt + + Version $short + $date + $build + $short + 15.0 + $notes + + + + +XML +} diff --git a/scripts/release-publish.sh b/scripts/release-publish.sh index 078bf5bf..a7323e88 100755 --- a/scripts/release-publish.sh +++ b/scripts/release-publish.sh @@ -37,12 +37,15 @@ VERSION="$(require_project_version "$APP_DIR/project.yml")" DMG="$BUILD_ROOT/Blurt-$VERSION.dmg" DSYM_ZIP="$BUILD_ROOT/Blurt-$VERSION.app.dSYM.zip" CHECKSUMS="$BUILD_ROOT/SHA256SUMS" +APPCAST="$BUILD_ROOT/appcast.xml" [ -f "$DMG" ] || die "DMG not found at $DMG — run scripts/release-build.sh first" [ -f "$DSYM_ZIP" ] || die "dSYM zip not found at $DSYM_ZIP — run scripts/release-build.sh first" [ -f "$CHECKSUMS" ] || die "SHA256SUMS not found at $CHECKSUMS — run scripts/release-build.sh first" +[ -f "$APPCAST" ] || die "appcast.xml not found at $APPCAST — run scripts/release-build.sh first" info "version: $VERSION" info "dmg: $DMG" info "dsym: $DSYM_ZIP" +info "appcast: $APPCAST" # The artifacts must have been built from the commit we are about to tag. # Without this, building at commit A and then pulling any commit that doesn't @@ -133,6 +136,11 @@ STABLE_DMG="$BUILD_ROOT/Blurt.dmg" STABLE_DSYM="$BUILD_ROOT/Blurt.app.dSYM.zip" ln -f "$DMG" "$STABLE_DMG" ln -f "$DSYM_ZIP" "$STABLE_DSYM" +# appcast.xml is the Sparkle feed. Its name is already stable, and the feed URL +# baked into every installed copy is releases/latest/download/appcast.xml — so +# the feed moves to this release at the same moment /latest does (the draft flip +# below), never before its DMG is verified. Its enclosure names the versioned DMG. +# # Also upload the versioned DMG ($DMG basename is Blurt-.dmg) — the # same notarized image under its archival name. SHA256SUMS (generated by # release-build.sh and folded into the notes below) keys its entry off that @@ -151,6 +159,7 @@ gh release create "$TAG" \ "$STABLE_DMG" \ "$DMG" \ "$STABLE_DSYM" \ + "$APPCAST" \ --title "$TAG" \ --generate-notes \ --draft @@ -168,7 +177,7 @@ step "Verify published assets" VERIFY_DIR="$(mktemp -d /tmp/blurt-verify.XXXXXX)" trap 'rm -rf "$VERIFY_DIR"' EXIT gh release download "$TAG" --dir "$VERIFY_DIR" \ - --pattern "Blurt-$VERSION.dmg" --pattern "Blurt.dmg" + --pattern "Blurt-$VERSION.dmg" --pattern "Blurt.dmg" --pattern "appcast.xml" WANT_SHA="$(sha_from_sums "Blurt-$VERSION.dmg" <"$CHECKSUMS")" [ -n "$WANT_SHA" ] || die "no checksum for Blurt-$VERSION.dmg in $CHECKSUMS" @@ -181,6 +190,11 @@ GOT_STABLE_SHA="$(sha256_of_file "$VERIFY_DIR/Blurt.dmg")" [ "$LOCAL_STABLE_SHA" = "$GOT_STABLE_SHA" ] \ || die "published Blurt.dmg differs from local build — re-run with --republish" +WANT_APPCAST_SHA="$(sha_from_sums appcast.xml <"$CHECKSUMS")" +[ -n "$WANT_APPCAST_SHA" ] || die "no checksum for appcast.xml in $CHECKSUMS" +[ "$WANT_APPCAST_SHA" = "$(sha256_of_file "$VERIFY_DIR/appcast.xml")" ] \ + || die "published appcast.xml differs from the one built — re-run with --republish" + xcrun stapler validate "$VERIFY_DIR/Blurt-$VERSION.dmg" >/dev/null \ || die "published Blurt-$VERSION.dmg is not stapled" xcrun stapler validate "$VERIFY_DIR/Blurt.dmg" >/dev/null \ @@ -188,7 +202,7 @@ xcrun stapler validate "$VERIFY_DIR/Blurt.dmg" >/dev/null \ rm -rf "$VERIFY_DIR" trap - EXIT -info "published assets verified (sha + staple match the local build)" +info "published assets verified (DMG sha + staple, appcast sha match the local build)" # Everything checked out — now make it visible and repoint /latest. step "Publish" diff --git a/scripts/release.test.sh b/scripts/release.test.sh index e9668e86..c2c45b7c 100755 --- a/scripts/release.test.sh +++ b/scripts/release.test.sh @@ -251,6 +251,26 @@ git -C "$SCRATCH_REPO" tag nightly git -C "$SCRATCH_REPO" tag v3.0 check "ignores prerelease, non-version, and short tags" "0.2.0" "$(latest_release_tag)" +echo "== Sparkle helpers ==" +check "reads the public key setting" "abc123+/=" \ + "$(printf ' SPARKLE_PUBLIC_ED_KEY: "abc123+/="\n' | parse_sparkle_public_key)" +# The placeholder project.yml ships with must never reach a release: an app +# built with it rejects every update signature for good. +checkfalse "the placeholder key is not a key" sparkle_key_is_set REPLACE_WITH_SPARKLE_PUBLIC_ED_KEY +checkfalse "an empty key is not a key" sparkle_key_is_set "" +checktrue "a real key is a key" sparkle_key_is_set "pfIShU4dEXqPd5ObYNfDBiQWcXozk7estwzTnF9BamQ=" +SIGN_OUT='sparkle:edSignature="c2lnbmF0dXJl+/=" length="12345"' +check "parses the signature" "c2lnbmF0dXJl+/=" "$(printf '%s\n' "$SIGN_OUT" | parse_sign_update_attr sparkle:edSignature)" +check "parses the length" "12345" "$(printf '%s\n' "$SIGN_OUT" | parse_sign_update_attr length)" +check "a missing attribute is empty" "" "$(printf 'garbage\n' | parse_sign_update_attr length)" +APPCAST="$(render_appcast 1.2.3 45 https://example.invalid/Blurt-1.2.3.dmg c2ln 99 https://example.invalid/notes "Mon, 01 Jan 2026 00:00:00 +0000")" +# Sparkle compares the build number, so it is the field that must be the +# CFBundleVersion — a short version there would never read as newer. +check "the appcast carries the build number as sparkle:version" "1" \ + "$(printf '%s\n' "$APPCAST" | grep -c '45')" +check "the enclosure carries the signature and length" "1" \ + "$(printf '%s\n' "$APPCAST" | grep -c 'url="https://example.invalid/Blurt-1.2.3.dmg" type="application/octet-stream" sparkle:edSignature="c2ln" length="99"')" + if [ "$fails" -eq 0 ]; then echo "release-lib.sh: all tests passed" else diff --git a/scripts/verify-sparkle-signature.swift b/scripts/verify-sparkle-signature.swift new file mode 100644 index 00000000..39a36f93 --- /dev/null +++ b/scripts/verify-sparkle-signature.swift @@ -0,0 +1,37 @@ +#!/usr/bin/env swift +// Checks an update archive's EdDSA signature against a Sparkle public key — the +// same Ed25519 verification Sparkle performs before it installs an update. +// release-build.sh runs it with the SUPublicEDKey read out of the *built* +// app's Info.plist, so a release whose appcast signature the shipped app would +// reject (a wrong or rotated private key, a placeholder public key) fails the +// build instead of stranding every user on the version they already have. +// Usage: swift scripts/verify-sparkle-signature.swift + +import CryptoKit +import Foundation + +guard CommandLine.arguments.count == 4 else { + print("usage: verify-sparkle-signature.swift ") + exit(2) +} + +guard let publicKeyData = Data(base64Encoded: CommandLine.arguments[1]), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) +else { + print("error: the public key is not a base64 Ed25519 key") + exit(1) +} +guard let signature = Data(base64Encoded: CommandLine.arguments[2]) else { + print("error: the signature is not base64") + exit(1) +} +guard let archive = FileManager.default.contents(atPath: CommandLine.arguments[3]) else { + print("error: could not read \(CommandLine.arguments[3])") + exit(1) +} + +guard publicKey.isValidSignature(signature, for: archive) else { + print("error: the signature does not verify against the public key") + exit(1) +} +print("EdDSA signature verifies against the app's public key") From ebc873bdeaca065c7ee8117aaec8e3434222a807 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:18:12 +0000 Subject: [PATCH 02/11] Polish the Updates section per HIG review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the button title to the menu item ("Check for Updates…"), use less update-ambiguous symbols, name updates in the auto-install toggle, and word the debug footer for the build in hand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FQUiqVMpAMeReVN9Ej2yBu --- App/Blurt/Blurt/Wizard/BlurtCommands.swift | 2 +- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/App/Blurt/Blurt/Wizard/BlurtCommands.swift b/App/Blurt/Blurt/Wizard/BlurtCommands.swift index 8e950fdf..1f869e2d 100644 --- a/App/Blurt/Blurt/Wizard/BlurtCommands.swift +++ b/App/Blurt/Blurt/Wizard/BlurtCommands.swift @@ -11,7 +11,7 @@ struct BlurtCommands: Commands { // uses (`CommandGroup(after: .appInfo)`). It drives the same Sparkle updater // as the Settings button (see `UpdaterModel`), and is disabled while a check // is already running or in builds that don't update. The ellipsis marks that - // it goes off and does work (and may present a dialog). + // it opens Sparkle's window, where an update still needs the user's go-ahead. CommandGroup(after: .appInfo) { CheckForUpdatesButton(model: appDelegate.updaterModel) } diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index d25c21fc..79f9cd04 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -9,7 +9,7 @@ import SwiftUI // rather than in `SettingsWindowRoot` because that file is at the repo's // file-length limit. -/// The running version, a "Check for Updates" button, and Sparkle's two +/// The running version, a "Check for Updates…" button, and Sparkle's two /// preferences. Every control drives the one `UpdaterModel` owned by /// `AppDelegate` — the same updater the app-menu command and the menu-bar item /// use — and Sparkle presents the check's progress and result itself. @@ -22,27 +22,29 @@ struct UpdatesSection: View { var body: some View { Section { - SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { - Button("Check for Updates") { model.checkForUpdates() } + // Ellipsis: it opens Sparkle's window, where an update still waits on the + // user's go-ahead — the same rule as "Reset…" below. + SettingRow(title: model.versionLabel, systemImage: "info.circle") { + Button("Check for Updates…") { model.checkForUpdates() } .disabled(!model.canCheckForUpdates) .accessibilityIdentifier(UITestIdentifiers.updateCheck) } Toggle(isOn: $model.automaticallyChecksForUpdates) { - SettingLabel(title: "Check for updates automatically", systemImage: "clock.arrow.circlepath") + SettingLabel(title: "Check for updates automatically", systemImage: "clock") } .disabled(!model.isEnabled) .accessibilityIdentifier(UITestIdentifiers.updateAutoCheck) // Installing on its own only means anything while Blurt is looking on its // own, so the second switch follows the first. Toggle(isOn: $model.automaticallyDownloadsUpdates) { - SettingLabel(title: "Download and install automatically", systemImage: "arrow.down.circle") + SettingLabel(title: "Download and install updates automatically", systemImage: "arrow.down.circle") } .disabled(!model.isEnabled || !model.automaticallyChecksForUpdates) } header: { Text("Updates") } footer: { if !model.isEnabled { - Text("Development builds don’t update themselves.") + Text("This development build doesn’t update itself.") } } } @@ -161,7 +163,7 @@ struct ResetSection: View { // opens something rather than completing the action. SettingRow(title: "Reset Blurt", systemImage: "arrow.counterclockwise") { // Red text, because `role: .destructive` alone draws a bordered - // button no differently from "Check for Updates" in the sections + // button no differently from "Check for Updates…" in the sections // above — and this one deletes the key, every setting and the logs. Button(role: .destructive) { prompt = .confirm From bf453fa88eefb571d8f3ad56f0842d291a1f52bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:20:43 +0000 Subject: [PATCH 03/11] Keep the update toggles live when Sparkle changes them Sparkle's update alert can switch automatic installs on; observe both preferences so an open Settings pane reflects it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FQUiqVMpAMeReVN9Ej2yBu --- App/Blurt/Blurt/Update/UpdaterModel.swift | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/App/Blurt/Blurt/Update/UpdaterModel.swift b/App/Blurt/Blurt/Update/UpdaterModel.swift index ef935312..54e91c4b 100644 --- a/App/Blurt/Blurt/Update/UpdaterModel.swift +++ b/App/Blurt/Blurt/Update/UpdaterModel.swift @@ -29,7 +29,7 @@ import Sparkle @Observable final class UpdaterModel { @ObservationIgnored private let controller: SPUStandardUpdaterController - @ObservationIgnored private var canCheckObservation: NSKeyValueObservation? + @ObservationIgnored private var observations: [NSKeyValueObservation] = [] /// Whether a user-initiated check can start right now. False while one is /// already in flight (Sparkle is showing its own progress UI) and always false @@ -49,11 +49,22 @@ final class UpdaterModel { controller = SPUStandardUpdaterController( startingUpdater: isEnabled, updaterDelegate: nil, userDriverDelegate: nil) versionLabel = Self.bundleVersionLabel() - // Sparkle publishes `canCheckForUpdates` on the main thread. + // Sparkle publishes these on the main thread. The two preferences are read + // live from Sparkle, so their observers only invalidate: Sparkle's own update + // alert can flip "install automatically", and an open Settings pane should + // show it. let updater = controller.updater - canCheckObservation = updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] observed, _ in - MainActor.assumeIsolated { self?.canCheckForUpdates = observed.canCheckForUpdates } - } + observations = [ + updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] observed, _ in + MainActor.assumeIsolated { self?.canCheckForUpdates = observed.canCheckForUpdates } + }, + updater.observe(\.automaticallyChecksForUpdates) { [weak self] _, _ in + MainActor.assumeIsolated { self?.withMutation(keyPath: \.automaticallyChecksForUpdates) {} } + }, + updater.observe(\.automaticallyDownloadsUpdates) { [weak self] _, _ in + MainActor.assumeIsolated { self?.withMutation(keyPath: \.automaticallyDownloadsUpdates) {} } + }, + ] } /// Runs a user-initiated check; Sparkle presents the result either way. From 0358a67f2e1e7e93275586e1434a371be05909fa Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 29 Sep 2026 16:51:56 -0700 Subject: [PATCH 04/11] Set the Sparkle public key and regenerate the Xcode project Replaces the SPARKLE_PUBLIC_ED_KEY placeholder with the real EdDSA public key so release-build.sh no longer refuses to run, and commits the xcodegen output for the Sparkle switch (package reference + SU* Info.plist keys). The private half is the SPARKLE_ED_PRIVATE_KEY secret on release-build. Co-Authored-By: Claude Opus 5.5 --- App/Blurt/Blurt.xcodeproj/project.pbxproj | 35 ++++++++++++++++++++--- App/Blurt/Blurt/Info.plist | 8 ++++++ App/Blurt/project.yml | 2 +- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 29113b63..ccb0291f 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 00A485EC5E147DCCF0B8B275 /* juno-85-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3BCCE1D8752C6A3A2AEF9009 /* juno-85-start.m4a */; }; 00E120625DDB12DDC4043C9F /* rom1a-9-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4B9209100F5A6E8514AF6FA7 /* rom1a-9-stop.m4a */; }; + 00F638EAD518C06C239EF51E /* UpdaterModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1CBD49CFC9AD1BF802F4625 /* UpdaterModel.swift */; }; 0132B51969D4F1FF318C01E0 /* juno-125-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 286A4475154226AC60716712 /* juno-125-start.m4a */; }; 0148EF067F8AF351D2B7F2C0 /* rom1b-16-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E617272806BFEB303D94F315 /* rom1b-16-stop.m4a */; }; 01A96FAF2732876CBC4F5E30 /* rom1b-0-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0618C6EE1B8864C57F02CC1E /* rom1b-0-stop.m4a */; }; @@ -77,6 +78,7 @@ 282E843FAECCBCA0CDB04E48 /* juno-93-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D85C603DADD81B58F7D390F7 /* juno-93-stop.m4a */; }; 2836B2C93B93114A056AF5D9 /* juno-97-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 763E3951E68EBB37F89C7F84 /* juno-97-start.m4a */; }; 286523D07CE6F05381799EDB /* juno-96-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D06381A69EEEC336CB847783 /* juno-96-stop.m4a */; }; + 28CA968C0302328A8C1EDA74 /* CheckForUpdatesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EC0DD9824FEF14E25A22FE /* CheckForUpdatesButton.swift */; }; 2A078AE336A8297310D41DEE /* juno-80-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E8BB482E825AD5C65B170E18 /* juno-80-stop.m4a */; }; 2A83AE5BD416B1FD22E6AFC9 /* juno-104-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2D862CB0D203330C1F8F58B0 /* juno-104-stop.m4a */; }; 2BF2181C1FB5401C449F61DA /* rom1b-30-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 45E6CF05E1334B3D2901E516 /* rom1b-30-stop.m4a */; }; @@ -283,6 +285,7 @@ A0DEFAB5DF5E5C7A6146A559 /* juno-90-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DA27B927EB6616D017D910CD /* juno-90-stop.m4a */; }; A13335FE97FA2E60A4F5CABC /* ReadyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383E03AB60BA21AAD92655D5 /* ReadyView.swift */; }; A1C101543CA3972691DF5367 /* rom1a-30-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7FC3A25550C39659E3E00A11 /* rom1a-30-start.m4a */; }; + A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; A3D012E25BD52949E2452B7B /* juno-17-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F37D9EAC78D140EE0CD438F2 /* juno-17-start.m4a */; }; A487D7B801A44C51807E0DCB /* rom1a-28-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AC2E79FB5ACE247FF90F50E4 /* rom1a-28-stop.m4a */; }; A5D51CD762229A68757463C8 /* CueSoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */; }; @@ -294,7 +297,6 @@ AA61D851350DECC5A7045594 /* rom1a-27-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A8FCDA16949B9CE1323E24F8 /* rom1a-27-start.m4a */; }; AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */; }; AB1C0077FA26EEDEA3542A07 /* juno-89-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 00768AE8171902B439F0602F /* juno-89-start.m4a */; }; - AB6CEBDE7BE4CA99EB469D8E /* UpdateCheckModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988A975044942D7A9BA19FE /* UpdateCheckModel.swift */; }; AC402D2E4BC71A0F8324DCFD /* juno-109-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EB08EC40A267C71B185EC782 /* juno-109-stop.m4a */; }; AD32046355138298E7FC40D0 /* MenuBarScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA9C9322D0AF3D9C529F06F /* MenuBarScene.swift */; }; AD6EA7A1932A9946A2EAE2E3 /* juno-55-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0FB812876EC4870E9A74BB50 /* juno-55-start.m4a */; }; @@ -762,7 +764,6 @@ A8AB9F55940B496EF7A8B633 /* juno-91-start.m4a */ = {isa = PBXFileReference; path = "juno-91-start.m4a"; sourceTree = ""; }; A8FCDA16949B9CE1323E24F8 /* rom1a-27-start.m4a */ = {isa = PBXFileReference; path = "rom1a-27-start.m4a"; sourceTree = ""; }; A95FFA1F1C07E8FAF8FEC0B8 /* juno-36-start.m4a */ = {isa = PBXFileReference; path = "juno-36-start.m4a"; sourceTree = ""; }; - A988A975044942D7A9BA19FE /* UpdateCheckModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckModel.swift; sourceTree = ""; }; AA1892B0DE69A5B9E8983A5D /* rom1a-23-start.m4a */ = {isa = PBXFileReference; path = "rom1a-23-start.m4a"; sourceTree = ""; }; AA55EF35D8FFB4A5EF7A8557 /* rom1a-16-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-16-stop.m4a"; sourceTree = ""; }; AAE4557FD2700BDA0EA11D6E /* juno-57-start.m4a */ = {isa = PBXFileReference; path = "juno-57-start.m4a"; sourceTree = ""; }; @@ -774,6 +775,7 @@ AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */ = {isa = PBXFileReference; path = "juno-70-start.m4a"; sourceTree = ""; }; AFCA36ECEFBA3D7C7B1AB1B7 /* rom1a-31-start.m4a */ = {isa = PBXFileReference; path = "rom1a-31-start.m4a"; sourceTree = ""; }; B0CB1B169A47290B83AD44C0 /* rom1b-20-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-20-stop.m4a"; sourceTree = ""; }; + B1CBD49CFC9AD1BF802F4625 /* UpdaterModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterModel.swift; sourceTree = ""; }; B24D0EACA5DCC94CFCAD9753 /* rom1b-16-start.m4a */ = {isa = PBXFileReference; path = "rom1b-16-start.m4a"; sourceTree = ""; }; B293D9F11DB3C3D268E82E4A /* rom1b-25-start.m4a */ = {isa = PBXFileReference; path = "rom1b-25-start.m4a"; sourceTree = ""; }; B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-15-stop.m4a"; sourceTree = ""; }; @@ -811,6 +813,7 @@ D06381A69EEEC336CB847783 /* juno-96-stop.m4a */ = {isa = PBXFileReference; path = "juno-96-stop.m4a"; sourceTree = ""; }; D19740AC9AF44D8E172CFD37 /* juno-46-stop.m4a */ = {isa = PBXFileReference; path = "juno-46-stop.m4a"; sourceTree = ""; }; D1D56A6899994805C9F0DBBD /* juno-71-stop.m4a */ = {isa = PBXFileReference; path = "juno-71-stop.m4a"; sourceTree = ""; }; + D1EC0DD9824FEF14E25A22FE /* CheckForUpdatesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckForUpdatesButton.swift; sourceTree = ""; }; D220A3AC36DCE278804F81A1 /* rom1b-18-start.m4a */ = {isa = PBXFileReference; path = "rom1b-18-start.m4a"; sourceTree = ""; }; D27DAF2436D272EEF031303B /* juno-47-start.m4a */ = {isa = PBXFileReference; path = "juno-47-start.m4a"; sourceTree = ""; }; D3681457B0FB13FF3FD6BAF6 /* juno-74-stop.m4a */ = {isa = PBXFileReference; path = "juno-74-stop.m4a"; sourceTree = ""; }; @@ -907,6 +910,7 @@ buildActionMask = 2147483647; files = ( 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */, + A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1462,7 +1466,8 @@ E7D37943B6B2CE3C0692AA48 /* Update */ = { isa = PBXGroup; children = ( - A988A975044942D7A9BA19FE /* UpdateCheckModel.swift */, + D1EC0DD9824FEF14E25A22FE /* CheckForUpdatesButton.swift */, + B1CBD49CFC9AD1BF802F4625 /* UpdaterModel.swift */, ); path = Update; sourceTree = ""; @@ -1518,6 +1523,7 @@ name = Blurt; packageProductDependencies = ( 371215E93A9C2CE60C6347BB /* BlurtEngine */, + 3BE06CE59B6A662701C56CE5 /* Sparkle */, ); productName = Blurt; productReference = 5E2309BDEF30ED9EFED5B849 /* Blurt.app */; @@ -1553,6 +1559,7 @@ mainGroup = 37EA71F5138E2E63171AFFBD; minimizedProjectReferenceProxies = 1; packageReferences = ( + 1F23C462C4138D3EA5BFA25E /* XCRemoteSwiftPackageReference "Sparkle" */, BFFF3588F0D8C2D0EEF7BC75 /* XCLocalSwiftPackageReference "../.." */, ); preferredProjectObjectVersion = 77; @@ -2024,6 +2031,7 @@ 76C1876DB538F8E190EB509E /* BlurtCommands.swift in Sources */, 121DC6CD82DEAC7CC0F5AC24 /* BoundTriggerKey.swift in Sources */, 68990A3D1D120CC927789338 /* BrandOrb.swift in Sources */, + 28CA968C0302328A8C1EDA74 /* CheckForUpdatesButton.swift in Sources */, A5D51CD762229A68757463C8 /* CueSoundPlayer.swift in Sources */, 4290831AB516CD38229449D8 /* DeveloperSection.swift in Sources */, 0F1317CCFA2A3245DAA96141 /* DictationComposition.swift in Sources */, @@ -2050,7 +2058,7 @@ F30578AA785CAE98E944F6B7 /* TextShortcutsSection.swift in Sources */, 1817ACD2F7D8F7AF3C9B1C7C /* UITestIdentifiers.swift in Sources */, E1CA5C692CB2A7E1BF231928 /* UITestSupport.swift in Sources */, - AB6CEBDE7BE4CA99EB469D8E /* UpdateCheckModel.swift in Sources */, + 00F638EAD518C06C239EF51E /* UpdaterModel.swift in Sources */, 91C1257866F6E2D4EE65BD09 /* WindowAccessor.swift in Sources */, B844779F8427A1225C45A9FB /* WizardController.swift in Sources */, DD800E4A2657C87DD45E4AF8 /* WizardView.swift in Sources */, @@ -2155,6 +2163,7 @@ ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SPARKLE_PUBLIC_ED_KEY = "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ="; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -2219,6 +2228,7 @@ MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SPARKLE_PUBLIC_ED_KEY = "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ="; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; @@ -2332,6 +2342,7 @@ ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SPARKLE_PUBLIC_ED_KEY = "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ="; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -2442,11 +2453,27 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + 1F23C462C4138D3EA5BFA25E /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.10.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 371215E93A9C2CE60C6347BB /* BlurtEngine */ = { isa = XCSwiftPackageProductDependency; productName = BlurtEngine; }; + 3BE06CE59B6A662701C56CE5 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 1F23C462C4138D3EA5BFA25E /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6CDA5FC4501C4BEA6B4517DA /* Project object */; diff --git a/App/Blurt/Blurt/Info.plist b/App/Blurt/Blurt/Info.plist index e1b0d40b..08e57fe3 100644 --- a/App/Blurt/Blurt/Info.plist +++ b/App/Blurt/Blurt/Info.plist @@ -26,5 +26,13 @@ 15.0 NSMicrophoneUsageDescription Blurt uses your microphone to transcribe your dictation with AssemblyAI. + SUEnableAutomaticChecks + + SUFeedURL + https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml + SUPublicEDKey + $(SPARKLE_PUBLIC_ED_KEY) + SUScheduledCheckInterval + 86400 diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index d4bbafa6..343775dd 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -41,7 +41,7 @@ settings: # SUPublicEDKey. Generated once with Sparkle's `generate_keys`; the private # half never leaves the maintainer's keychain and the release-build secret. # release-build.sh refuses to ship while this is still the placeholder. - SPARKLE_PUBLIC_ED_KEY: "REPLACE_WITH_SPARKLE_PUBLIC_ED_KEY" + SPARKLE_PUBLIC_ED_KEY: "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ=" configs: Release: VALIDATE_PRODUCT: YES From 021d44920850d1f22088e3f5bec49c9c23ffc759 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 29 Sep 2026 18:15:42 -0700 Subject: [PATCH 05/11] Rehearse Sparkle updates on a staging feed; ship hand-written notes - SUFeedURL is now the BLURT_SPARKLE_FEED_URL build setting, so `release-build.sh --staging` can build an app that polls the sparkle-staging prerelease (never marked latest) instead of /latest. The build reads SUFeedURL back out of the built app, release-stage.sh (and the workflow's `stage` job) uploads there, and release-publish.sh refuses a staging build. - The appcast carries a Markdown for the update window. A release requires docs/release-notes/X.Y.Z.md: release-bump.sh scaffolds it with the commits as a comment plus a TODO, and check-release-notes.sh (in check.sh and the build) fails on the TODO, filler phrases, emoji, PR numbers, identifiers, and over-long lists. Only a staging build falls back to filtered commit subjects. - RELEASE.md documents the notes rules and the update rehearsal. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-bump.yml | 4 + .github/workflows/release.yml | 51 ++++- App/Blurt/Blurt.xcodeproj/project.pbxproj | 3 + App/Blurt/Blurt/Info.plist | 2 +- App/Blurt/project.yml | 16 +- RELEASE.md | 110 ++++++++++- scripts/check-release-notes.sh | 36 ++++ scripts/check.sh | 5 + scripts/release-build.sh | 77 +++++++- scripts/release-bump.sh | 19 +- scripts/release-lib.sh | 215 +++++++++++++++++++++- scripts/release-publish.sh | 6 + scripts/release-stage.sh | 74 ++++++++ scripts/release.test.sh | 104 +++++++++++ 14 files changed, 695 insertions(+), 27 deletions(-) create mode 100755 scripts/check-release-notes.sh create mode 100755 scripts/release-stage.sh diff --git a/.github/workflows/release-bump.yml b/.github/workflows/release-bump.yml index 3ab9dcf7..afdf3fde 100644 --- a/.github/workflows/release-bump.yml +++ b/.github/workflows/release-bump.yml @@ -208,6 +208,10 @@ jobs: echo echo "[**Open the pull request**](https://github.com/$REPO/compare/main...release/v$VERSION?expand=1)" echo + echo "First write the update-window notes: replace the TODO in" + echo "\`docs/release-notes/$VERSION.md\` on that branch. \`check\` fails until you do," + echo "and the release build refuses to run without them (RELEASE.md → Release notes)." + echo echo "Merging it starts the \`release\` workflow by itself — the version landing on" echo "\`main\` is the trigger, so there is nothing left to dispatch. Publishing then" echo "waits on the \`release-publish\` approval once you've tested the DMG." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef25dacf..a87d443e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,10 @@ on: description: "Skip the launch smoke test (use if the runner has no usable GUI session)" type: boolean default: false + staging: + description: "Update rehearsal: build for the sparkle-staging feed and stage it there instead of publishing (RELEASE.md → Rehearsing an update)" + type: boolean + default: false push: branches: [main] # Narrow on purpose. project.yml is the only file a version bump touches @@ -169,6 +173,10 @@ jobs: # job checks out the same sha, so the tag can never land on a commit # other than the one that was actually built. ref: ${{ github.sha }} + # The appcast's changelog is the commits since the previous release + # tag, so the build needs history and tags — release-build.sh fails + # fast on a shallow clone rather than shipping an empty one. + fetch-depth: 0 persist-credentials: false - name: Guard the release version @@ -252,11 +260,13 @@ jobs: # nobody is standing there to judge whether skipping was safe. SKIP_CHECKS: ${{ inputs.skip_checks }} SKIP_SMOKE: ${{ inputs.skip_smoke }} + STAGING: ${{ inputs.staging }} run: | set -euo pipefail args=() if [ "$SKIP_CHECKS" = "true" ]; then args+=(--skip-checks); fi if [ "$SKIP_SMOKE" = "true" ]; then args+=(--skip-smoke); fi + if [ "$STAGING" = "true" ]; then args+=(--staging); fi scripts/release-build.sh ${args[@]+"${args[@]}"} - name: Upload release artifacts @@ -283,8 +293,10 @@ jobs: publish: needs: [resolve, build] # Releases ship from main. A dispatch from any other ref stops after the - # build job, which makes this workflow safe to exercise as a dry run. - if: github.ref == 'refs/heads/main' + # build job, which makes this workflow safe to exercise as a dry run. A + # staging build never publishes — `stage` below takes it instead (and + # release-publish.sh would refuse it anyway). + if: github.ref == 'refs/heads/main' && inputs.staging != true runs-on: macos-26 timeout-minutes: 30 environment: release-publish @@ -325,3 +337,38 @@ jobs: args=(--yes) if [ "$REPUBLISH" = "true" ]; then args+=(--republish); fi scripts/release-publish.sh "${args[@]}" + + # Update rehearsal: put a --staging build on the sparkle-staging prerelease, + # the feed its app polls (RELEASE.md → Rehearsing an update). Runs from any + # ref the build job could — a rehearsal lives on a release-dry-run* branch, + # temporarily allowed on release-build — and needs no approval gate: + # the prerelease is never marked latest, so no shipped copy can see it. Holds + # no signing secrets; contents: write is only for the prerelease upload. + stage: + needs: [resolve, build] + if: inputs.staging == true + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + + steps: + - name: Checkout blurt + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: blurt + ref: ${{ github.sha }} + persist-credentials: false + + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-${{ needs.resolve.outputs.version }} + path: blurt/build/release + + - name: Stage + working-directory: blurt + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: scripts/release-stage.sh diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index ccb0291f..a8b05b10 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -2105,6 +2105,7 @@ ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BLURT_SPARKLE_FEED_URL = "https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -2177,6 +2178,7 @@ ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BLURT_SPARKLE_FEED_URL = "https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -2284,6 +2286,7 @@ ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BLURT_SPARKLE_FEED_URL = "https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; diff --git a/App/Blurt/Blurt/Info.plist b/App/Blurt/Blurt/Info.plist index 08e57fe3..2bf16fb1 100644 --- a/App/Blurt/Blurt/Info.plist +++ b/App/Blurt/Blurt/Info.plist @@ -29,7 +29,7 @@ SUEnableAutomaticChecks SUFeedURL - https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml + $(BLURT_SPARKLE_FEED_URL) SUPublicEDKey $(SPARKLE_PUBLIC_ED_KEY) SUScheduledCheckInterval diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index 343775dd..aeb4ecba 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -42,6 +42,13 @@ settings: # half never leaves the maintainer's keychain and the release-build secret. # release-build.sh refuses to ship while this is still the placeholder. SPARKLE_PUBLIC_ED_KEY: "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ=" + # The appcast every copy polls, baked into Info.plist as SUFeedURL. A build + # setting rather than a literal only so `release-build.sh --staging` can + # point an update rehearsal at the sparkle-staging prerelease instead; every + # other build — and every release — gets this value, and release-build.sh + # reads it back out of the built app against `sparkle_feed_url` in + # release-lib.sh. + BLURT_SPARKLE_FEED_URL: https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml configs: Release: VALIDATE_PRODUCT: YES @@ -146,15 +153,16 @@ targets: LSMinimumSystemVersion: "15.0" LSApplicationCategoryType: public.app-category.productivity NSMicrophoneUsageDescription: Blurt uses your microphone to transcribe your dictation with AssemblyAI. - # Sparkle. The feed is a stable release-asset URL: release-publish.sh - # attaches appcast.xml to every release, so /latest/download/ always - # resolves to the newest one. The public EdDSA key verifies each update + # Sparkle. The feed is a stable release-asset URL (the + # BLURT_SPARKLE_FEED_URL setting above): release-publish.sh attaches + # appcast.xml to every release, so /latest/download/ always resolves to + # the newest one. The public EdDSA key verifies each update # before it is installed — its private half is the SPARKLE_ED_PRIVATE_KEY # secret release-build.sh signs with (see RELEASE.md). Checks are on by # default (which also skips Sparkle's "check automatically?" prompt) and # daily; installing without asking stays opt-in. Only the shipping bundle # id starts the updater at all (see UpdaterModel). - SUFeedURL: https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml + SUFeedURL: $(BLURT_SPARKLE_FEED_URL) SUPublicEDKey: $(SPARKLE_PUBLIC_ED_KEY) SUEnableAutomaticChecks: true SUScheduledCheckInterval: 86400 diff --git a/RELEASE.md b/RELEASE.md index 28502a03..a8fadab0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -265,7 +265,8 @@ it cannot sign. Installed copies update themselves with [Sparkle](https://sparkle-project.org). Each release carries an `appcast.xml` asset; every installed copy polls `https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml` -(`SUFeedURL` in `project.yml`), so the feed moves to a release at the moment +(`SUFeedURL`, from the `BLURT_SPARKLE_FEED_URL` build setting in `project.yml`), +so the feed moves to a release at the moment `release-publish.sh` flips it live and repoints `/latest` — never before its assets are verified. The appcast's one item names the versioned `Blurt-X.Y.Z.dmg` on that release, with the DMG's EdDSA signature and length. @@ -275,13 +276,107 @@ assets are verified. The appcast's one item names the versioned `scripts/verify-sparkle-signature.swift` checks that signature against the `SUPublicEDKey` read out of the **built app** — the key every installed copy will check — before the appcast is written. A private key that doesn't match -fails the build, not users' updates. The build also refuses to start while -`SPARKLE_PUBLIC_ED_KEY` is still the placeholder. +fails the build, not users' updates. The build reads `SUFeedURL` back out of +the built app the same way, and refuses to start while `SPARKLE_PUBLIC_ED_KEY` +is still the placeholder. Sparkle compares `CFBundleVersion` (`sparkle:version`), not the marketing version, so the build number must rise with every release — `release-bump.sh` already increments it on each bump. +### Release notes + +The update window shows the appcast item's ``, which +`release-build.sh` writes as Markdown (Sparkle renders it natively) from +**`docs/release-notes/X.Y.Z.md`** — written by a person, every release: + +- **The bump PR scaffolds it.** `release-bump.sh` commits the file with the + commits since the previous release in an HTML comment (context for the writer + and the reviewer; comments never ship) and a `TODO` where the notes go. + `check` fails while the `TODO` is there, so the bump PR can't merge until the + notes are written — and approving that PR is the editorial review. +- **A release build refuses to run without it.** There is no automatic + fallback for a release; commit subjects are written for reviewers, not users. + (A `--staging` rehearsal alone falls back to filtered commit subjects, since + only the tester sees them.) +- **`scripts/check-release-notes.sh` lints it** (via `check.sh`, and again in the + build), using `lint_release_notes` in `release-lib.sh`: + - bullets only, at most **6**, each at most **100** characters; + - each starts with a capital letter — lead with the verb ("Add…", "Fix…", + "Paste now works in…") — and none with "We"; + - no filler: _seamless, robust, enhanced, streamlined, leverage, elevate, + delve, a variety of, various improvements, under the hood, we're excited_, + and more (`RELEASE_NOTES_BANNED` — add to it when a new tic shows up); + - no emoji; + - no developer leakage: PR numbers, `chore:`/`fix:` prefixes, backticks, + filenames, `snake_case` or `camelCase` identifiers (real product names like + macOS or YouTube are allow-listed in `RELEASE_NOTES_PROPER_NOUNS`). + +The lint only catches the obvious. Write for someone who has never seen the +repo: what changed for them, in plain words, most important first. Then read +it where users will — the rehearsal below shows the real window. + +Because the rehearsal fallback and the bump scaffold both measure from tags, the +build job checks out with full history; a shallow checkout fails fast. + +### Rehearsing an update + +A shipped build whose updater is broken can't be fixed by an update, so every +user it reaches has to download again by hand. Before the first Sparkle release +— and before any change to the updater, the feed, the signing, or the appcast — +prove that an installed build updates itself, using the real pipeline: +Developer ID, notarization, the CI secret, and GitHub's download redirects. + +The one difference is the feed. `release-build.sh --staging` builds an app that +polls the **`sparkle-staging`** prerelease instead of `/latest`, with an appcast +whose DMG lives there too. That prerelease is never marked latest, so +`/releases/latest/download/` — every shipped copy's feed — can't resolve to it, +and `release-publish.sh` refuses to publish a staging build. + +1. Push the rehearsal to a branch matching `release-dry-run*`, and temporarily + add that pattern to the `release-build` environment's deployment branches + (see [Dry-running the signing path](#dry-running-the-signing-path) — with the + recommended `main`-only policy, a dispatch from any other branch is blocked + before it builds). Dispatch **release** from that branch with **staging** + checked (and the version the branch carries). The build signs and notarizes as + usual; the `stage` job then uploads the DMG and appcast to `sparkle-staging`, + creating it the first time. This is **rc1**. +2. Download rc1's DMG from the run's artifacts (not from the prerelease, which + the next step overwrites) and install it into `/Applications`, the way a user + would. Grant its permissions and dictate once. +3. On the same branch, raise `CFBundleVersion` in `project.yml` by one (Sparkle + compares the build number), add or edit + `docs/release-notes/X.Y.Z.md` to see the real notes (without it, a staging + build shows filtered commit subjects), push, and dispatch again with + **staging** checked. This is **rc2**, and it replaces rc1 on the feed. +4. In rc1, choose **Check for Updates…**. + +Check, in order: + +- The update window names rc2 and shows the changelog — and the notes read + well there: short enough to take in at a glance, nothing a user would have to + look up. Adjust `docs/release-notes/X.Y.Z.md` until they do. +- It downloads, installs, and relaunches as rc2 (Settings → Updates shows the + version). +- **Accessibility survives**: dictate into another app right after the + relaunch. If the paste fails, the update broke the grant — don't ship. +- **Install on quit**: stage an rc3, turn on "Download and install updates + automatically", quit, relaunch, and confirm it is rc3. +- An update found mid-dictation doesn't interrupt the recording. +- Launched from `~/Downloads` instead of `/Applications` (App Translocation), + Sparkle either updates or says why — it doesn't fail silently. +- From a standard (non-admin) account, it asks for an administrator password + rather than failing. + +Remove the branch pattern from `release-build` when you're done. A rehearsed +copy keeps polling `sparkle-staging`. Delete it afterwards and +install the real release, or it will only ever see staged builds. + +For fast iteration on the update window's look alone, a local loop is quicker: +two locally signed builds with the shipping bundle id, a hand-written appcast, +and `python3 -m http.server`. It skips everything the rehearsal exists to +prove, so it is no substitute. + ### One-time setup: the key pair The EdDSA key pair is the updater's root of trust, separate from the Developer @@ -300,9 +395,12 @@ after any build): normal PR flow. 2. Paste the contents of `sparkle.key` into the `SPARKLE_ED_PRIVATE_KEY` secret on the `release-build` environment, then delete the file. -3. Back up the keychain item (it's the "Private key for signing Sparkle - updates" entry) the same way as the Developer ID `.p12`: offline, never - synced, never committed. +3. Back up the private key outside the keychain and never commit it. The + current backup is a 1Password document, "Blurt Sparkle EdDSA private key", + in the maintainer's private vault — not a shared one, since anyone holding + the key can sign an update installed copies accept (together with the + Developer ID key; see below). An offline copy beside the Developer ID `.p12` + is a sensible second. ### Key custody and rotation diff --git a/scripts/check-release-notes.sh b/scripts/check-release-notes.sh new file mode 100755 index 00000000..c7339190 --- /dev/null +++ b/scripts/check-release-notes.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Lint every docs/release-notes/X.Y.Z.md — the text Blurt's update window shows +# (RELEASE.md → Release notes). Fails on a leftover TODO from the bump PR's +# scaffold and on anything `lint_release_notes` in release-lib.sh rejects. +# Run by check.sh; also useful on its own while writing notes. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/release-lib.sh +source "$REPO_ROOT/scripts/release-lib.sh" + +shopt -s nullglob +files=("$REPO_ROOT"/docs/release-notes/*.md) +if [ "${#files[@]}" -eq 0 ]; then + echo "no release notes to lint" + exit 0 +fi + +failed=0 +for f in "${files[@]}"; do + name="${f#"$REPO_ROOT"/}" + version="$(basename "$f" .md)" + if ! is_semver "$version"; then + echo "$name: not named X.Y.Z.md — the release build looks notes up by version" + failed=1 + continue + fi + if out="$(lint_release_notes <"$f")"; then + echo "ok $name" + else + printf '%s\n' "$out" | sed "s|^|$name: |" + failed=1 + fi +done +exit "$failed" diff --git a/scripts/check.sh b/scripts/check.sh index 6e6a0946..79aa3786 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -549,6 +549,11 @@ cd "$REPO_ROOT" # or network dependencies, so they run everywhere check.sh runs. run_check "release-lib.sh unit tests" bash scripts/release.test.sh +# The update-window notes are user-facing copy, so they get linted like code: the +# bump PR's TODO scaffold fails here until someone writes the notes, and the +# rules reject filler and developer jargon. Pure bash + perl, so --portable too. +run_check "release notes (docs/release-notes)" bash scripts/check-release-notes.sh + # A failure above does NOT skip the block below, deliberately. A lint violation # and a failing test are independent facts about the branch, and stopping here # would put them back on separate runs — which is the thing this script's diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 74df61e0..be16ee85 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -25,10 +25,16 @@ readonly NOTARY_PROFILE="blurt-notary" SKIP_CHECKS=0 SKIP_SMOKE=0 +# `release` builds what ships. `staging` is the update rehearsal (RELEASE.md → +# Rehearsing an update): the same signed, notarized build, but its feed and its +# appcast enclosure point at the sparkle-staging prerelease instead of the +# release, and release-publish.sh refuses to ship it. +CHANNEL=release for arg in "$@"; do case "$arg" in --skip-checks) SKIP_CHECKS=1 ;; --skip-smoke) SKIP_SMOKE=1 ;; + --staging) CHANNEL=staging ;; *) echo "unknown arg: $arg" >&2 exit 2 @@ -400,6 +406,42 @@ else info "EdDSA private key: from the login keychain (where Sparkle's generate_keys stores it)" fi +FEED_URL="$(sparkle_feed_url "$CHANNEL")" +info "channel: $CHANNEL (feed: $FEED_URL)" + +# What the update window shows. Worked out here, before the build, so missing +# or sloppy notes fail in seconds rather than after notarization. A release +# ships only hand-written notes (docs/release-notes/X.Y.Z.md, which the bump PR +# scaffolds) that pass lint_release_notes. A staging rehearsal uses the file if +# there is one and otherwise falls back to the filtered commit subjects since +# the previous release — only the tester ever sees those. +step "Release notes" +NOTES_FILE="$(release_notes_path "$VERSION")" +if [ -f "$NOTES_FILE" ]; then + lint_release_notes <"$NOTES_FILE" \ + || die "$NOTES_FILE fails the release-notes rules above — fix it (RELEASE.md → Release notes)" + CHANGELOG="$(release_notes_body <"$NOTES_FILE")" + info "release notes: $NOTES_FILE" +elif [ "$CHANNEL" = release ]; then + die "no release notes at $NOTES_FILE — a release ships only hand-written notes (RELEASE.md → Release notes)" +else + PREV_VERSION="$(previous_release_tag "$VERSION")" + if [ -n "$PREV_VERSION" ]; then + CHANGELOG="$(git -C "$REPO_ROOT" log --format=%s "v$PREV_VERSION..HEAD" | release_notes_from_subjects)" + info "release notes: none written — staging falls back to commits since v$PREV_VERSION" + elif [ "$(git -C "$REPO_ROOT" rev-parse --is-shallow-repository)" = "true" ]; then + die "shallow checkout: no release tags to measure the changelog from — check out with fetch-depth: 0" + else + CHANGELOG="" + info "release notes: none (no earlier release tag)" + fi +fi +if [ -n "$CHANGELOG" ]; then + printf '%s\n' "$CHANGELOG" | sed 's/^/ /' +else + info "the update window will show no notes" +fi + step "Initial summary" info "build root: $BUILD_ROOT" info "identity: $IDENTITY" @@ -429,6 +471,7 @@ xcodebuild \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="$IDENTITY" \ DEVELOPMENT_TEAM="$TEAM_ID" \ + BLURT_SPARKLE_FEED_URL="$FEED_URL" \ build | "${PRETTY[@]}" APP_BUILT="$DERIVED/Build/Products/Release/Blurt.app" @@ -613,6 +656,12 @@ fi APP_PUBLIC_KEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$APP_STAGED/Contents/Info.plist")" [ "$APP_PUBLIC_KEY" = "$SPARKLE_PUBLIC_KEY" ] \ || die "the built app's SUPublicEDKey ($APP_PUBLIC_KEY) is not project.yml's ($SPARKLE_PUBLIC_KEY)" +# Same idea for the feed: a release build that polls the staging feed (or the +# reverse) would pass every other check and strand its users, so read it back +# out of the built app too. +APP_FEED_URL="$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$APP_STAGED/Contents/Info.plist")" +[ "$APP_FEED_URL" = "$FEED_URL" ] \ + || die "the built app's SUFeedURL ($APP_FEED_URL) is not the $CHANNEL feed ($FEED_URL)" VERIFY_PUBLIC_KEY="${BLURT_SPARKLE_VERIFY_PUBLIC_KEY:-$APP_PUBLIC_KEY}" [ "$VERIFY_PUBLIC_KEY" = "$APP_PUBLIC_KEY" ] \ || info "key rotation: verifying against BLURT_SPARKLE_VERIFY_PUBLIC_KEY, not the app's new key" @@ -621,11 +670,17 @@ swift "$REPO_ROOT/scripts/verify-sparkle-signature.swift" "$VERIFY_PUBLIC_KEY" " BUILD_NUMBER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_STAGED/Contents/Info.plist")" APPCAST="$BUILD_ROOT/appcast.xml" +if [ "$CHANNEL" = staging ]; then + RELEASE_PAGE="$BLURT_RELEASES_URL/tag/$SPARKLE_STAGING_TAG" +else + RELEASE_PAGE="$BLURT_RELEASES_URL/tag/v$VERSION" +fi render_appcast "$VERSION" "$BUILD_NUMBER" \ - "https://github.com/AssemblyAI/blurt/releases/download/v$VERSION/Blurt-$VERSION.dmg" \ + "$(sparkle_enclosure_url "$CHANNEL" "$VERSION")" \ "$ED_SIGNATURE" "$ED_LENGTH" \ - "https://github.com/AssemblyAI/blurt/releases/tag/v$VERSION" \ - "$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" >"$APPCAST" + "$RELEASE_PAGE" \ + "$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" \ + "$CHANGELOG" >"$APPCAST" xmllint --noout "$APPCAST" || die "generated appcast is not well-formed XML" info "appcast: $APPCAST (build $BUILD_NUMBER, $ED_LENGTH bytes)" @@ -633,6 +688,7 @@ step "Provenance" PROVENANCE="$BUILD_ROOT/build-info.txt" { echo "Blurt $VERSION" + echo "channel: $CHANNEL" echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "git: $(git -C "$REPO_ROOT" rev-parse HEAD) ($(git -C "$REPO_ROOT" rev-parse --short HEAD))" echo "xcode: $(xcodebuild -version | tr '\n' ' ')" @@ -652,6 +708,16 @@ CHECKSUMS="$BUILD_ROOT/SHA256SUMS" info "checksums: $CHECKSUMS" step "Summary" +if [ "$CHANNEL" = staging ]; then + NEXT=" A staging build: it polls and names the $SPARKLE_STAGING_TAG prerelease, and + release-publish.sh will refuse it. Put it on the staging feed with: + scripts/release-stage.sh # upload the DMG + appcast to $SPARKLE_STAGING_TAG" +else + NEXT=" In CI the release workflow uploads these as run artifacts and the gated + publish job takes it from here. Running locally, install to test and publish: + scripts/release-install.sh # install the notarized build to /Applications + scripts/release-publish.sh # tag, push, publish the GitHub Release" +fi SIZE="$(du -h "$DMG" | cut -f1)" SHA="$(sha256_of_file "$DMG")" cat <"$NOTES_FILE" + info "scaffolded $NOTES_FILE — write the notes in the bump PR" +fi + step "Commit" # xcodegen regenerates the (tracked) Info.plist from project.yml's version # properties, so it must be committed alongside the project files — otherwise # the bump leaves it dirty and the next build/publish trips the clean-tree gate. -git -C "$REPO_ROOT" add "$PROJECT_YML" "$APP_DIR/Blurt.xcodeproj" "$APP_DIR/Blurt/Info.plist" +git -C "$REPO_ROOT" add "$PROJECT_YML" "$APP_DIR/Blurt.xcodeproj" "$APP_DIR/Blurt/Info.plist" "$NOTES_FILE" git -C "$REPO_ROOT" commit -m "chore: bump to v$NEW_VERSION" step "Next steps" cat < " format; a leading "*" binary marker on the name is tolerated). # Empty output if the name isn't listed. @@ -263,14 +288,184 @@ parse_sign_update_attr() { sed -n "s/.*$1=\"\([^\"]*\)\".*/\1/p" | head -n 1 } +# Where the updater looks, per build channel. `release` is the feed every +# shipped copy polls: /latest/download/ follows whichever release is marked +# latest, so it moves only when release-publish.sh flips one live. `staging` is +# the rehearsal feed (RELEASE.md → Rehearsing an update): a fixed prerelease +# that /latest never resolves to, so nothing staged can reach a shipped copy. +BLURT_RELEASES_URL="https://github.com/AssemblyAI/blurt/releases" +SPARKLE_STAGING_TAG="sparkle-staging" + +# Echo the appcast URL for channel $1 (`release` or `staging`). +sparkle_feed_url() { + case "$1" in + release) printf '%s\n' "$BLURT_RELEASES_URL/latest/download/appcast.xml" ;; + staging) printf '%s\n' "$BLURT_RELEASES_URL/download/$SPARKLE_STAGING_TAG/appcast.xml" ;; + *) die "unknown Sparkle channel: $1" ;; + esac +} + +# Echo the DMG URL the appcast's enclosure names, for channel $1 and version $2. +# A release names its own versioned asset; staging names the one on the +# staging prerelease, which each staged build overwrites. +sparkle_enclosure_url() { + case "$1" in + release) printf '%s\n' "$BLURT_RELEASES_URL/download/v$2/Blurt-$2.dmg" ;; + staging) printf '%s\n' "$BLURT_RELEASES_URL/download/$SPARKLE_STAGING_TAG/Blurt-$2.dmg" ;; + *) die "unknown Sparkle channel: $1" ;; + esac +} + +# Turn commit subjects on stdin (newest first, as `git log --format=%s` prints +# them) into the Markdown bullet list the update window shows. Drops what a user +# has no use for: conventional-commit housekeeping (chore/ci/docs/test/build/ +# refactor/style), merge commits, and any change reverted within the same range +# together with its revert. Trailing PR numbers — "(#207)", or "(#206) (#208)" on +# a cherry-pick — are stripped. Empty output when nothing is left, which leaves +# the appcast without a description. Only a staging rehearsal ever ships this; +# a release requires hand-written docs/release-notes/X.Y.Z.md (see below). +release_notes_from_subjects() { + awk ' + function bare(s) { + while (sub(/[[:space:]]*\(#[0-9]+\)[[:space:]]*$/, "", s)) {} + return s + } + { subj[NR] = bare($0) } + END { + for (i = 1; i <= NR; i++) { + if (subj[i] ~ /^Revert "/) { + inner = subj[i] + sub(/^Revert "/, "", inner) + sub(/"$/, "", inner) + reverted[bare(inner)] = 1 + is_revert[i] = 1 + } + } + for (i = 1; i <= NR; i++) { + s = subj[i] + if (s == "" || (i in is_revert) || (s in reverted)) continue + if (tolower(s) ~ /^(chore|ci|docs|test|tests|build|refactor|style)(\(|:|!)/) continue + if (s ~ /^Merge /) continue + print "- " s + } + }' +} + +# --- Release notes (docs/release-notes/X.Y.Z.md) --- +# +# What the update window shows, written by a person: a release build refuses to +# run without the file, the bump PR scaffolds it with a TODO that check.sh fails +# on, and `lint_release_notes` rejects the usual generated-copy tells. Commit +# subjects are only ever the raw material (and the automatic fallback for a +# staging rehearsal, which nobody but the tester sees). + +# Path of the notes file for version $1. +release_notes_path() { + printf '%s\n' "$REPO_ROOT/docs/release-notes/$1.md" +} + +# Emit the scaffold release-bump.sh commits: the commits since v$1 as an HTML +# comment (context for the writer and the reviewer; stripped before shipping, so +# it can stay) and a TODO where the notes go. Subjects on stdin. +release_notes_template() { + local subjects + subjects="$(sed -e 's/--/- -/g' -e 's/^/ /')" + cat < + +TODO: write the release notes +EOF +} + +# Content on stdin with HTML comments removed and surrounding blank lines +# trimmed: the text that actually ships in the appcast. +release_notes_body() { + perl -0777 -pe 's///gs; s/\A\s+//; s/\s+\z/\n/' +} + +# Words and phrases that read as filler or generated copy. Matched +# case-insensitively as substrings; extend it when a new tic shows up. +RELEASE_NOTES_BANNED=( + "seamless" "robust" "enhanced" "enhancement" "streamline" "leverag" "elevate" + "delve" "a variety of" "various improvements" "various fixes" "under the hood" + "we're excited" "we are excited" "excited to" "game-changer" "game changer" + "cutting-edge" "effortless" "supercharge" "unlock" "empower" "revolutioniz" + "bug fixes and improvements" "and more" "overall experience" "user experience" +) + +# Allowed despite matching the code-identifier check (lowercase-then-uppercase). +RELEASE_NOTES_PROPER_NOUNS=(macOS iOS iCloud iPhone iPad visionOS watchOS AssemblyAI YouTube GitHub WhatsApp LinkedIn ChatGPT JetBrains PyCharm IntelliJ VoiceOver FaceTime AirPods) + +# Lint release-notes content on stdin (the whole file; comments are stripped +# first). Prints one "line N: problem" per violation to stdout; returns 1 if any. +lint_release_notes() { + local body line n=0 bullets=0 problems=0 lower phrase word stripped + body="$(release_notes_body)" + _notes_problem() { + printf 'line %s: %s\n' "$1" "$2" + problems=$((problems + 1)) + } + if [ -z "$body" ]; then + printf 'line 0: no release notes — write at least one bullet\n' + return 1 + fi + while IFS= read -r line; do + n=$((n + 1)) + [ -n "$line" ] || continue + if [[ "$line" == *TODO* ]]; then + _notes_problem "$n" "still has the TODO — write the notes" + continue + fi + if [[ "$line" != "- "* ]]; then + _notes_problem "$n" "every line must be a '- ' bullet (no headings or prose): $line" + continue + fi + bullets=$((bullets + 1)) + line="${line#- }" + [ "${#line}" -le 100 ] || _notes_problem "$n" "${#line} characters (max 100) — say less: $line" + [[ "$line" =~ ^[A-Z] ]] || _notes_problem "$n" "start with a capitalized verb (\"Add…\", \"Fix…\"): $line" + [[ ! "$line" =~ ^[Ww]e[[:space:]\'] ]] || _notes_problem "$n" "say what changed, not what \"we\" did: $line" + lower="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')" + for phrase in "${RELEASE_NOTES_BANNED[@]}"; do + [[ "$lower" != *"$phrase"* ]] || _notes_problem "$n" "filler phrase \"$phrase\": $line" + done + [[ ! "$line" =~ \#[0-9]+ ]] || _notes_problem "$n" "PR/issue number — users can't use it: $line" + [[ ! "$lower" =~ ^(chore|ci|docs|test|tests|build|refactor|style|feat|fix|perf)(\(.*\))?!?: ]] \ + || _notes_problem "$n" "commit-style prefix: $line" + [[ "$line" != *'`'* ]] || _notes_problem "$n" "code formatting — describe it in words: $line" + [[ ! "$line" =~ [A-Za-z0-9_]\.(swift|sh|ya?ml|json|plist|md|py|m|h)([^A-Za-z]|$) ]] \ + || _notes_problem "$n" "filename: $line" + [[ ! "$line" =~ [A-Za-z0-9]_[A-Za-z0-9] ]] || _notes_problem "$n" "snake_case identifier: $line" + stripped="$line" + for word in "${RELEASE_NOTES_PROPER_NOUNS[@]}"; do stripped="${stripped//$word/}"; done + [[ ! "$stripped" =~ [a-z][A-Z] ]] || _notes_problem "$n" "camelCase identifier (or add a proper noun to RELEASE_NOTES_PROPER_NOUNS): $line" + if printf '%s' "$line" | perl -CS -ne 'exit(/[\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{FE0F}]/ ? 0 : 1)'; then + _notes_problem "$n" "emoji: $line" + fi + done <<<"$body" + [ "$bullets" -le 6 ] || _notes_problem 0 "$bullets bullets (max 6) — keep what matters most to users" + [ "$problems" -eq 0 ] +} + # Render a one-item Sparkle appcast to stdout. Arguments, in order: the short # version (X.Y.Z), the build number (CFBundleVersion — what Sparkle actually # compares), the enclosure URL, its EdDSA signature, its byte length, the -# release-notes page, and the pubDate (RFC 822). One item is all the feed needs: -# Sparkle offers the newest applicable item, and the feed URL resolves through -# /releases/latest/download/, so it is always the current release's own copy. +# release-notes page, the pubDate (RFC 822), and the changelog as Markdown +# (optional). One item is all the feed needs: Sparkle offers the newest +# applicable item, and the feed URL is always the current release's own copy. +# +# The changelog is what the update window shows. It goes inline as a Markdown +# (Sparkle renders it natively, no web view) because the appcast +# is built, signed off, and checksummed before the release page exists — a +# `releaseNotesLink` to the GitHub page would load all of github.com's chrome +# into the window. `fullReleaseNotesLink` stays the "Version History" link. render_appcast() { - local short="$1" build="$2" url="$3" sig="$4" length="$5" notes="$6" date="$7" + local short="$1" build="$2" url="$3" sig="$4" length="$5" notes="$6" date="$7" changelog="${8:-}" cat < @@ -283,6 +478,14 @@ render_appcast() { $short 15.0 $notes +XML + if [ -n "$changelog" ]; then + # CDATA can hold anything but its own terminator; split any "]]>" across two + # sections so a subject containing one can't end the block early. + printf ' \n' \ + "${changelog//]]>/]]]]>}" + fi + cat < diff --git a/scripts/release-publish.sh b/scripts/release-publish.sh index a7323e88..2196b7da 100755 --- a/scripts/release-publish.sh +++ b/scripts/release-publish.sh @@ -62,6 +62,12 @@ HEAD_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)" [ "$BUILT_SHA" = "$HEAD_SHA" ] \ || die "DMG was built from $BUILT_SHA but HEAD is $HEAD_SHA — rebuild with scripts/release-build.sh before publishing" info "built at: $BUILT_SHA (matches HEAD)" +# A staging build polls the staging feed and names a DMG on the staging +# prerelease; shipping it would leave every user updating from a feed nobody +# publishes to. +BUILT_CHANNEL="$(parse_build_info_channel <"$BUILD_INFO")" +[ "${BUILT_CHANNEL:-release}" = release ] \ + || die "these artifacts are a $BUILT_CHANNEL build — stage them with scripts/release-stage.sh, or rebuild without --staging to publish" step "Validate staple" xcrun stapler validate "$DMG" >/dev/null || die "DMG not stapled — rebuild with release-build.sh" diff --git a/scripts/release-stage.sh b/scripts/release-stage.sh new file mode 100755 index 00000000..a1e9dc4b --- /dev/null +++ b/scripts/release-stage.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Put a `release-build.sh --staging` build on the Sparkle staging feed: upload +# its DMG and appcast.xml to the sparkle-staging prerelease, creating that +# prerelease the first time. The update rehearsal in RELEASE.md installs one +# staged build by hand, stages a newer one with this script, and checks that the +# first updates itself to the second. +# +# The staging prerelease is never marked latest, so /releases/latest/download/ — +# the feed every shipped copy polls — can never resolve to it. Each run +# overwrites the previous staged assets; only the newest staged build matters. +# +# Normally runs as the release workflow's `stage` job. Also runnable locally +# after a local staging build, with `gh` signed in. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_DIR="$REPO_ROOT/App/Blurt" +BUILD_ROOT="$REPO_ROOT/build/release" + +for arg in "$@"; do + echo "unknown arg: $arg" >&2 + exit 2 +done + +# shellcheck source=scripts/release-lib.sh +source "$REPO_ROOT/scripts/release-lib.sh" + +step "Preflight" +require_tools gh git awk shasum + +VERSION="$(require_project_version "$APP_DIR/project.yml")" +DMG="$BUILD_ROOT/Blurt-$VERSION.dmg" +APPCAST="$BUILD_ROOT/appcast.xml" +CHECKSUMS="$BUILD_ROOT/SHA256SUMS" +BUILD_INFO="$BUILD_ROOT/build-info.txt" +for f in "$DMG" "$APPCAST" "$CHECKSUMS" "$BUILD_INFO"; do + [ -f "$f" ] || die "$f not found — run scripts/release-build.sh --staging first" +done +CHANNEL="$(parse_build_info_channel <"$BUILD_INFO")" +[ "$CHANNEL" = staging ] \ + || die "$BUILD_INFO says channel '${CHANNEL:-release}' — only a --staging build may be staged (its feed must point here)" +info "version: $VERSION (channel: staging)" + +step "Staging prerelease" +TAG="$SPARKLE_STAGING_TAG" +if gh release view "$TAG" >/dev/null 2>&1; then + info "$TAG exists — overwriting its assets" +else + gh release create "$TAG" \ + --target "$(git -C "$REPO_ROOT" rev-parse HEAD)" \ + --prerelease \ + --latest=false \ + --title "Sparkle staging (not a release)" \ + --notes "The update-rehearsal feed (RELEASE.md → Rehearsing an update). Not a Blurt release — download Blurt from the latest release instead." + info "created $TAG" +fi + +step "Upload" +# DMG before appcast: the feed should never name a DMG that isn't there yet. +gh release upload "$TAG" "$DMG" --clobber +gh release upload "$TAG" "$APPCAST" --clobber + +step "Verify" +VERIFY_DIR="$(mktemp -d /tmp/blurt-stage.XXXXXX)" +trap 'rm -rf "$VERIFY_DIR"' EXIT +gh release download "$TAG" --dir "$VERIFY_DIR" --pattern "Blurt-$VERSION.dmg" --pattern appcast.xml +for name in "Blurt-$VERSION.dmg" appcast.xml; do + want="$(sha_from_sums "$name" <"$CHECKSUMS")" + [ -n "$want" ] || die "no checksum for $name in $CHECKSUMS" + [ "$want" = "$(sha256_of_file "$VERIFY_DIR/$name")" ] || die "staged $name differs from the one built — re-run" +done +info "staged assets verified against SHA256SUMS" +info "feed: $(sparkle_feed_url staging)" diff --git a/scripts/release.test.sh b/scripts/release.test.sh index c2c45b7c..c525ecd1 100755 --- a/scripts/release.test.sh +++ b/scripts/release.test.sh @@ -141,6 +141,14 @@ check "ignores a commented-out key" "32" \ check "ignores a longer key sharing the prefix" "32" \ "$(printf ' CFBundleVersionSomethingElse: "99"\n CFBundleVersion: "32"\n' | parse_bundle_version)" +echo "== parse_build_info_channel ==" +check "reads the channel" "staging" \ + "$(printf 'Blurt 0.1.7\nchannel: staging\nbuilt: today\n' | parse_build_info_channel)" +# Pre-channel build-info files must read as empty, which publish treats as a +# release — not as an unknown channel that blocks shipping a rebuilt artifact. +check "a build-info without a channel is empty" "" \ + "$(printf 'Blurt 0.1.7\nbuilt: today\n' | parse_build_info_channel)" + echo "== parse_build_info_git_sha ==" check "parses build provenance sha" "0123456789abcdef0123456789abcdef01234567" \ "$(printf 'Blurt 0.1.7\nbuilt: today\ngit: 0123456789abcdef0123456789abcdef01234567 (0123456)\n' | parse_build_info_git_sha)" @@ -251,6 +259,91 @@ git -C "$SCRATCH_REPO" tag nightly git -C "$SCRATCH_REPO" tag v3.0 check "ignores prerelease, non-version, and short tags" "0.2.0" "$(latest_release_tag)" +echo "== previous_release_tag ==" +git -C "$SCRATCH_REPO" tag sparkle-staging +check "the release below a new version" "0.2.0" "$(previous_release_tag 0.2.1)" +# A republish rebuilds a version that is already tagged; its changelog must +# still run from the release before it, not be empty. +check "a republished version measures from the one before it" "0.1.10" "$(previous_release_tag 0.2.0)" +check "numerically, not lexically" "0.1.9" "$(previous_release_tag 0.1.10)" +check "nothing below the first release" "" "$(previous_release_tag 0.1.9)" + +echo "== Sparkle channels ==" +check "the release feed follows /latest" \ + "https://github.com/AssemblyAI/blurt/releases/latest/download/appcast.xml" "$(sparkle_feed_url release)" +check "the staging feed is the staging prerelease" \ + "https://github.com/AssemblyAI/blurt/releases/download/sparkle-staging/appcast.xml" "$(sparkle_feed_url staging)" +check "a release enclosure names its own versioned DMG" \ + "https://github.com/AssemblyAI/blurt/releases/download/v1.2.3/Blurt-1.2.3.dmg" "$(sparkle_enclosure_url release 1.2.3)" +check "a staging enclosure names the staged DMG" \ + "https://github.com/AssemblyAI/blurt/releases/download/sparkle-staging/Blurt-1.2.3.dmg" "$(sparkle_enclosure_url staging 1.2.3)" +checkdie "an unknown channel is refused" "unknown Sparkle channel: beta" sparkle_feed_url beta +# The feed every shipped copy polls is written twice — the build-setting default +# in project.yml and the release channel here, which release-build.sh checks the +# built app against. Drift fails every release build, so catch it at test time. +check "project.yml's default feed is the release feed" "$(sparkle_feed_url release)" \ + "$(parse_yaml_scalar BLURT_SPARKLE_FEED_URL <"$DIR/../App/Blurt/project.yml")" + +echo "== release_notes_from_subjects ==" +NOTES="$(printf '%s\n' \ + 'chore: bump to v0.1.57 (#212)' \ + 'Add fn as a trigger key (#209)' \ + 'Revert "Add "Speak all punctuation" mode (#207)" (#210)' \ + 'Add "Speak all punctuation" mode (#207)' \ + 'Drop the same-window separator (#206) (#208)' \ + 'ci: Bump the github-actions group (#202)' \ + 'docs(readme): refresh screenshots' \ + 'Merge branch main into feature' \ + 'Paste into Electron apps like Codex' | release_notes_from_subjects)" +check "keeps user-facing changes, newest first, without PR numbers" \ + "$(printf '%s\n' '- Add fn as a trigger key' '- Drop the same-window separator' '- Paste into Electron apps like Codex')" \ + "$NOTES" +check "housekeeping only -> empty" "" "$(printf 'chore: bump\nci: pin actions\n' | release_notes_from_subjects)" +check "a revert of something outside the range is dropped on its own" "- Keep this" \ + "$(printf '%s\n' 'Revert "Older change (#1)" (#2)' 'Keep this (#3)' | release_notes_from_subjects)" + +echo "== release notes ==" +TEMPLATE="$(printf '%s\n' 'Add fn as a trigger key (#209)' 'Odd -- subject' | release_notes_template 0.1.56)" +check "the scaffold lists the commits as context" "1" "$(printf '%s\n' "$TEMPLATE" | grep -c '^ Add fn as a trigger key (#209)$')" +# "--" can't appear inside an HTML comment; left alone it ends the comment early +# on some renderers and the commit list would ship. +check "the scaffold defuses -- inside the comment" "0" \ + "$(printf '%s\n' "$TEMPLATE" | sed -n '//p' | sed '1d;$d' | grep -c -- '--')" +# The whole point of the scaffold: the bump PR can't pass check until it's written. +checkfalse "the untouched scaffold fails lint" lint_release_notes <<<"$TEMPLATE" +check "the context comment never ships" "- Add fn as a trigger key" \ + "$(printf '%s\n' '' '' '- Add fn as a trigger key' '' | release_notes_body)" +checktrue "good notes pass" lint_release_notes <<<"$(printf '%s\n' \ + '' \ + '- Add fn as a trigger key' \ + '- Paste now works in Codex and other Electron apps on macOS' \ + '- Fix a stray space before dictated text in Google Docs')" +checkfalse "no bullets fails" lint_release_notes <<<'' +# Each rule gets its own offender, so a rule that silently stops matching fails +# here rather than letting that kind of copy through. +lint_rule() { #