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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions App/Blurt/Blurt/Update/UpdateCheckModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
28 changes: 18 additions & 10 deletions App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<Content: View>: 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)
}
}

Expand All @@ -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)
}
}

Expand Down
32 changes: 17 additions & 15 deletions App/Blurt/BlurtUITests/BlurtUITestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// "<bundle name> 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 "<bundle name> 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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions App/Blurt/BlurtUITests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
12 changes: 4 additions & 8 deletions App/Blurt/BlurtUITests/SettingsUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand Down
10 changes: 8 additions & 2 deletions App/Blurt/Shared/UITestIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions BLURTENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions Sources/BlurtEngine/Update/GitHubRelease.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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-<version>.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") }
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/BlurtEngine/Update/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Tests/BlurtEngineTests/APIKeyValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 1 addition & 5 deletions Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 7 additions & 7 deletions Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Tests/BlurtEngineTests/UpdateCheckerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ 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")
func upToDateWhenOlder() async throws {
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")
Expand Down
21 changes: 8 additions & 13 deletions scripts/release-publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.dmg). The
# in-app updater (mxcl/AppUpdater) only recognizes a release asset whose name
# matches "<repo>-<tag>" case-insensitively — i.e. blurt-<version> — 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-<version>.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" \
Expand Down