diff --git a/AGENTS.md b/AGENTS.md index 5154197..1705b9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ and the roll-forward-only policy for a bad release are documented in Update checking is **manual and download-only** — there is no in-place install and no background auto-updater (both were removed with `mxcl/AppUpdater`; see [Things deliberately not here](#things-deliberately-not-here)). Two pieces: -- **`UpdateChecker`** (`Sources/BlurtEngine/Update/UpdateChecker.swift`, engine) — 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(current:)` or `.available(version:, dmgURL:)`. It throws on network failure, malformed JSON, an unparseable tag, or a newer release with no `.dmg` asset. The `fetch` closure is injected so tests run against fixture JSON (`UpdateCheckerTests`); no AppKit here. +- **`UpdateChecker`** (`Sources/BlurtEngine/Update/UpdateChecker.swift`, engine) — 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 network call goes through the injected `HTTPTransport`, so tests run against fixture JSON (`UpdateCheckerTests`); no AppKit here. - **`UpdateCheckModel`** (`App/Blurt/Blurt/Update/UpdateCheckModel.swift`, app, `@MainActor`) — runs a user-initiated check and reports the result 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_. It presents via `beginSheetModal` on the host window (a nested `runModal()` loop was reported as an app hang; `runModal()` is the fallback only when no window can host a sheet), and an `isChecking` guard stops the Settings button and the app-menu command from stacking two alerts. A single shared instance (owned by `AppDelegate`) backs both entry points. ## Architecture diff --git a/App/Blurt/Blurt/Update/UpdateCheckModel.swift b/App/Blurt/Blurt/Update/UpdateCheckModel.swift index 3845197..995a498 100644 --- a/App/Blurt/Blurt/Update/UpdateCheckModel.swift +++ b/App/Blurt/Blurt/Update/UpdateCheckModel.swift @@ -53,8 +53,8 @@ final class UpdateCheckModel { defer { isChecking = false } do { switch try await checker.check(current: currentVersion) { - case .upToDate(let current): - await presentUpToDate(current: current) + case .upToDate: + await presentUpToDate(current: currentVersion) case .available(let version, let dmgURL): await presentAvailable(current: currentVersion, version: version, dmgURL: dmgURL) } diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index ac20955..f1805f5 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -25,10 +25,10 @@ struct SettingsWindowRoot: View { if let coordinator = appDelegate.coordinator { TabView(selection: $tab) { GeneralSettingsTab(coordinator: coordinator) - .tabItem { Label("General", systemImage: "gearshape") } + .tabItem { Label(UITestIdentifiers.generalSettingsTab, systemImage: "gearshape") } .tag(Tab.general) AdvancedSettingsTab(updateModel: appDelegate.updateCheckModel) - .tabItem { Label("Advanced", systemImage: "gearshape.2") } + .tabItem { Label(UITestIdentifiers.advancedSettingsTab, systemImage: "gearshape.2") } .tag(Tab.advanced) } .frame(width: 480) @@ -38,21 +38,32 @@ struct SettingsWindowRoot: View { } } +/// The chrome every settings pane shares: a grouped, non-scrolling `Form` that +/// hugs its content, so each pane sizes the window to exactly its sections and +/// the panes can't drift apart in layout. +private struct SettingsPane: View { + @ViewBuilder var content: Content + + var body: some View { + Form { content } + .formStyle(.grouped) + .scrollDisabled(true) + .fixedSize(horizontal: false, vertical: true) + } +} + /// The everyday setup a user changes: the AssemblyAI key, the dictation /// shortcut, the cue sound, and the transcription key terms. private struct GeneralSettingsTab: View { let coordinator: AppCoordinator var body: some View { - Form { + SettingsPane { APIKeyStepView(apiKey: coordinator.apiKey) HotkeyStepView(coordinator: coordinator) SoundStepView(coordinator: coordinator) KeyTermsStepView() } - .formStyle(.grouped) - .scrollDisabled(true) - .fixedSize(horizontal: false, vertical: true) } } @@ -62,13 +73,10 @@ private struct AdvancedSettingsTab: View { let updateModel: UpdateCheckModel var body: some View { - Form { + SettingsPane { UpdateSection(model: updateModel) DeveloperSection() } - .formStyle(.grouped) - .scrollDisabled(true) - .fixedSize(horizontal: false, vertical: true) } } diff --git a/App/Blurt/BlurtUITests/BlurtUITestSupport.swift b/App/Blurt/BlurtUITests/BlurtUITestSupport.swift index 2476029..3d46dac 100644 --- a/App/Blurt/BlurtUITests/BlurtUITestSupport.swift +++ b/App/Blurt/BlurtUITests/BlurtUITestSupport.swift @@ -7,15 +7,12 @@ import XCTest // production views and these suites can no longer drift out of sync. extension UITestIdentifiers { - /// The Settings window's title. The `Settings` scene hosts a `TabView` - /// (General / Advanced), and macOS titles a preference window after its - /// selected pane — so the window opens titled "General" (the first tab), not - /// " Settings". Test-bundle-only (the app never declares it). - static let settingsWindowTitle = "General" - - /// The label of the Advanced settings pane's tab, where the Updates and - /// Developer sections live. - static let advancedSettingsTab = "Advanced" + /// The Settings window's title. The `Settings` scene hosts a `TabView`, and + /// macOS titles a preference window after its selected pane — so the window + /// opens titled after the first tab, not " Settings". The label + /// itself lives in the shared file; only this framework-derived aliasing is + /// test-bundle knowledge. + static let settingsWindowTitle = generalSettingsTab } /// Base case that launches Blurt in UI-test mode before each test and tears it @@ -79,16 +76,21 @@ class BlurtUITestCase: XCTestCase { /// Selects a Settings pane (a `TabView` tab in the preferences toolbar) by its /// visible name. macOS exposes a preference tab as a radio button or a plain /// button depending on the OS build, so try the radio group first and fall - /// back to a button. - func selectSettingsTab(_ window: XCUIElement, named name: String) { + /// back to a button. Returns a fresh proxy for the settings window: selecting + /// a pane retitles the window to the pane's name, so a proxy captured before + /// the switch (e.g. `openSettingsWindow()`'s) goes stale — scope follow-up + /// queries to the returned one. + @discardableResult + func selectSettingsTab(_ window: XCUIElement, named name: String) -> XCUIElement { let radio = window.radioButtons[name] if radio.waitForExistence(timeout: 5) { radio.click() - return + } else { + let button = window.buttons[name] + XCTAssertTrue(button.waitForExistence(timeout: 3), "Settings tab '\(name)' not found") + button.click() } - let button = window.buttons[name] - XCTAssertTrue(button.waitForExistence(timeout: 3), "Settings tab '\(name)' not found") - button.click() + return app.windows[name] } /// The UI-test harness window (auto-presented at launch in test mode). Closes diff --git a/App/Blurt/BlurtUITests/README.md b/App/Blurt/BlurtUITests/README.md index ed636e8..95bc485 100644 --- a/App/Blurt/BlurtUITests/README.md +++ b/App/Blurt/BlurtUITests/README.md @@ -47,8 +47,8 @@ RUN_UI_TESTS=1 scripts/check.sh # full health check including the UI suite ## Maintenance - Element lookups use `accessibilityIdentifier`s set in the app. The string - constants are mirrored in `BlurtUITestSupport.swift` (`UITestIDs`) because the - test bundle can't import the app's internal `UITestID`/`UITestKeys` — keep both - sides in sync. + constants live in `App/Blurt/Shared/UITestIdentifiers.swift`, which is compiled + into both the app target and this bundle — declare any new identifier there so + both sides agree by construction. - After changing `App/Blurt/project.yml`, run `xcodegen generate` and commit the regenerated `project.pbxproj` (CI fails on drift). diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 7694405..77aa3e5 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -90,11 +90,9 @@ final class SettingsUITests: BlurtUITestCase { /// that tab first. func testDeveloperModeTogglesOn() { let settings = openSettingsWindow() - selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) + let advanced = selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) - // Query the toggle app-wide: selecting the Advanced tab retitles the window, - // so a window-scoped proxy captured before the switch would go stale. - let toggle = app.descendants(matching: .any) + let toggle = advanced.descendants(matching: .any) .matching(identifier: UITestIdentifiers.developerToggle).firstMatch XCTAssertTrue(toggle.waitForExistence(timeout: 10), "Developer mode toggle not found") XCTAssertEqual("\(toggle.value ?? "")", "0", "Developer mode should start switched off") @@ -110,11 +108,9 @@ final class SettingsUITests: BlurtUITestCase { /// result sheet deterministically (no network). func testCheckForUpdatesShowsResultAlert() { let settings = openSettingsWindow() - selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) + let advanced = selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) - // Query app-wide: selecting the Advanced tab retitles the window, so a - // window-scoped proxy captured beforehand would go stale. - let button = app.descendants(matching: .any) + let button = advanced.descendants(matching: .any) .matching(identifier: UITestIdentifiers.updateCheck).firstMatch XCTAssertTrue(button.waitForExistence(timeout: 10), "Check for Updates button not found") button.click() diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index 7f5e412..6b765a4 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -20,13 +20,19 @@ enum UITestIdentifiers { static let readyLaunchArgument = "-BlurtUITestReady" // Window titles the XCUITest suite queries, sourced from the `Window(_:id:)` - // declarations in `App.swift`. (The framework-derived Settings title, which the - // app never declares, is a test-bundle-only constant in BlurtUITestSupport.) + // declarations in `App.swift`. static let mainWindowTitle = "Blurt" static let harnessWindowTitle = "Blurt UI Test Harness" /// The harness `Window`'s scene id. static let harnessWindowID = "uitest.harness" + // The Settings panes' tab labels (`SettingsWindowRoot` renders them; the + // XCUITest suite clicks them). macOS also titles a preferences window after + // its selected pane, so the General label doubles as the Settings window's + // opening title (see `settingsWindowTitle` in BlurtUITestSupport). + static let generalSettingsTab = "General" + static let advancedSettingsTab = "Advanced" + // Test-harness controls (set in `UITestSupport.swift`). static let transcriptField = "uitest.transcript" static let setKeyButton = "uitest.setKey" diff --git a/BLURTENGINE.md b/BLURTENGINE.md index c286696..f6b28da 100644 --- a/BLURTENGINE.md +++ b/BLURTENGINE.md @@ -132,7 +132,7 @@ func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?) asyn func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://sync.assemblyai.com/transcribe` per utterance: the audio as raw S16LE PCM (the `pcm` blob, byte-for-byte) in the `audio` multipart part, plus a JSON `config` part (`sample_rate`, `channels`, and the rendered `prompt`), with `X-AAI-Model: u3-sync-pro` and the API key in `Authorization`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.get`), a `baseURL`, and a `URLSession` — inject a mock session (see `Tests/BlurtEngineTests/Stubs/MockURLProtocol.swift`) to test against canned responses. `warmUp()` fires a throwaway GET at the host root to pre-pool the connection; it never throws and any failure just means the real request pays connection setup as before. +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://sync.assemblyai.com/transcribe` per utterance: the audio as raw S16LE PCM (the `pcm` blob, byte-for-byte) in the `audio` multipart part, plus a JSON `config` part (`sample_rate`, `channels`, and the rendered `prompt`), with `X-AAI-Model: u3-sync-pro` and the API key in `Authorization`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.get`), a `baseURL`, and an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses. `warmUp()` fires a throwaway GET at the host root to pre-pool the connection; it never throws and any failure just means the real request pays connection setup as before. The model's limits live in `SyncSTTLimits` (16 kHz sample rate, ~0.1 s–120 s audio, and the auto-release math) — the single source shared by the mic, the session, and the request so recorded and declared geometry can't drift. @@ -172,7 +172,7 @@ Map the router's actions onto the session with `submit`: `.start` → `submit(.p ## Testing your integration -The engine's own tests are the template. They use **Swift Testing** (`@Suite`/`@Test`/`#expect`), not XCTest, and stub all three seams — see `Tests/BlurtEngineTests/Stubs/` (`StubMicCapture`, `StubTranscriber`, `StubInjector`, plus `MockURLProtocol` for transport-level transcriber tests): +The engine's own tests are the template. They use **Swift Testing** (`@Suite`/`@Test`/`#expect`), not XCTest, and stub all three seams — see `Tests/BlurtEngineTests/Stubs/` (`StubMicCapture`, `StubTranscriber`, `StubInjector`, plus `FakeHTTPTransport` for transport-level transcriber tests): ```swift let session = DictationSession( diff --git a/Sources/BlurtEngine/Update/GitHubRelease.swift b/Sources/BlurtEngine/Update/GitHubRelease.swift index 4649bf6..40b1346 100644 --- a/Sources/BlurtEngine/Update/GitHubRelease.swift +++ b/Sources/BlurtEngine/Update/GitHubRelease.swift @@ -24,9 +24,10 @@ struct GitHubRelease: Decodable, Sendable { case assets } - /// The first asset whose name ends in `.dmg` — the release pipeline publishes - /// exactly one, `Blurt-.dmg` (see `scripts/release-publish.sh`). Nil - /// when the release carries no DMG. + /// 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/UpdateChecker.swift b/Sources/BlurtEngine/Update/UpdateChecker.swift index 611a92f..cc45451 100644 --- a/Sources/BlurtEngine/Update/UpdateChecker.swift +++ b/Sources/BlurtEngine/Update/UpdateChecker.swift @@ -3,7 +3,7 @@ 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(current: SemanticVersion) + case upToDate /// A newer release exists; `dmgURL` downloads its DMG in the browser. case available(version: SemanticVersion, dmgURL: URL) } @@ -36,7 +36,7 @@ public struct UpdateChecker: Sendable { throw UpdateCheckError.malformedResponse } guard current < latest else { - return .upToDate(current: current) + return .upToDate } guard let asset = release.dmgAsset else { throw UpdateCheckError.malformedResponse diff --git a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift index 0983757..2edf512 100644 --- a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift +++ b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift @@ -84,6 +84,6 @@ extension HTTPClientTests { // MARK: - helpers private func makeValidator(_ transport: any HTTPTransport) -> APIKeyValidator { - APIKeyValidator(baseURL: URL(string: "https://api.assemblyai.com")!, transport: transport) + APIKeyValidator(transport: transport) } } diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift index 43bb7a6..df63efd 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift @@ -252,11 +252,7 @@ struct HTTPClientTests { apiKey: String?, transport: any HTTPTransport = FakeHTTPTransport { _ in (500, Data()) } ) -> AssemblyAITranscriber { - AssemblyAITranscriber( - apiKeyProvider: { apiKey }, - baseURL: URL(string: "https://sync.assemblyai.com")!, - transport: transport - ) + AssemblyAITranscriber(apiKeyProvider: { apiKey }, transport: transport) } private func collectTranscript(_ transcriber: AssemblyAITranscriber) async throws -> String { diff --git a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift index 451d3f2..48f04e0 100644 --- a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift @@ -2,13 +2,14 @@ import Foundation @testable import BlurtEngine -/// A `DictationSession` plus the three stubs it was wired to, so a test can -/// configure a collaborator (e.g. `mic.setStartError`, `injector.setError`) or -/// assert against it after driving the session. +/// A `DictationSession` plus the stubs a test configures or asserts against +/// after driving the session (e.g. `mic.setStartError`, `injector.setError`). +/// The transcriber stub is deliberately absent: its behavior is fully +/// determined up front by `makeSession`'s `mode`, so no test needs it +/// afterwards. struct SessionFixture { let session: DictationSession let mic: StubMicCapture - let stt: StubTranscriber let injector: StubInjector } @@ -20,13 +21,12 @@ func makeSession( onTranscriptDelivered: (@Sendable (String) -> Void)? = nil ) -> SessionFixture { let mic = StubMicCapture() - let stt = StubTranscriber(mode: mode) let injector = StubInjector() let session = DictationSession( - mic: mic, transcriber: stt, injector: injector, + mic: mic, transcriber: StubTranscriber(mode: mode), injector: injector, maxRecordingSeconds: maxRecordingSeconds, onTranscriptDelivered: onTranscriptDelivered) - return SessionFixture(session: session, mic: mic, stt: stt, injector: injector) + return SessionFixture(session: session, mic: mic, injector: injector) } extension DictationSession { diff --git a/Tests/BlurtEngineTests/UpdateCheckerTests.swift b/Tests/BlurtEngineTests/UpdateCheckerTests.swift index 671d803..52794b1 100644 --- a/Tests/BlurtEngineTests/UpdateCheckerTests.swift +++ b/Tests/BlurtEngineTests/UpdateCheckerTests.swift @@ -41,7 +41,7 @@ struct UpdateCheckerTests { let checker = checker(returning: releaseJSON(tag: "v0.1.30")) let current = try #require(SemanticVersion("0.1.30")) let result = try await checker.check(current: current) - #expect(result == .upToDate(current: current)) + #expect(result == .upToDate) } @Test("reports .upToDate when the release is older than the current build") @@ -49,7 +49,7 @@ struct UpdateCheckerTests { let checker = checker(returning: releaseJSON(tag: "v0.1.0")) let current = try #require(SemanticVersion("0.1.30")) let result = try await checker.check(current: current) - #expect(result == .upToDate(current: current)) + #expect(result == .upToDate) } @Test("throws when a newer release has no .dmg asset") diff --git a/scripts/release-publish.sh b/scripts/release-publish.sh index 4474e07..8322629 100755 --- a/scripts/release-publish.sh +++ b/scripts/release-publish.sh @@ -103,20 +103,15 @@ 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" -# Also upload the versioned DMG ($DMG basename is Blurt-.dmg). The -# in-app updater (mxcl/AppUpdater) only recognizes a release asset whose name -# matches "-" case-insensitively — i.e. blurt- — so the -# stable Blurt.dmg above is invisible to it. Both point at the same notarized -# image. See App/Blurt/Blurt/Update/AutoUpdater.swift. +# 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 +# versioned filename, and it's the name the "Verify published assets" step +# re-downloads, so keep both uploads. # -# SHA256SUMS is deliberately NOT attached as a release asset. mxcl/AppUpdater -# decodes *every* asset in the GitHub /releases feed and rejects any whose -# content type it doesn't recognize (it only handles dmg/zip/tar). GitHub serves -# a checksums file as application/octet-stream, so attaching it makes the whole -# feed fail to decode — silently disabling auto-update for every release. The -# notarized Developer-ID signature is the real integrity/authenticity guarantee -# (Gatekeeper on first open, same-signer check on update); the checksums are -# informational, so they go in the release notes body instead. +# SHA256SUMS itself is NOT attached as an asset: the notarized Developer-ID +# signature is the real integrity/authenticity guarantee (Gatekeeper on first +# open), so the checksums are informational and live in the release notes body. gh release create "$TAG" \ "$STABLE_DMG" \ "$DMG" \