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
47 changes: 37 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package)
README.md the engine's developer guide (quick start, seams, error table)
Audio/ MicCapture (+meter/+warm), MicLiveness (mic bring-up gate), AudioRoute
(+Monitor)/AudioTransport — CoreAudio routing, 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
Config/ Keychain-backed API key, key terms, developer mode, DefaultsKey +
PersistedSettings (every defaults key, and the reset sweep over them)
FocusCapture/ Accessibility reads of the frontmost app / focused field
Expand All @@ -66,7 +69,9 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package)
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/, 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
BlurtUITests/ XCUITest bundle (see Tests)
Tests/BlurtEngineTests/ Swift Testing suites; Stubs/ holds the seam doubles
Expand All @@ -78,7 +83,7 @@ scripts/ check.sh, check-site.sh, check-portability.sh, chec
beautify.swift (the site's window imagery, capture then
composite), generate-branding-images.sh (records how the logo
assets were generated — provenance, not a renderer),
generate-sounds.swift (regenerates the cues AND
generate-sounds.swift (regenerates the cues AND the app's
SoundPackCatalog.swift together)
Brewfile Homebrew-managed check.sh tools — the whole toolchain
evals/ offline decision support — the repo's only Python, none of it
Expand Down Expand Up @@ -122,8 +127,8 @@ the two checks that genuinely need the build products stay behind them (step 12)
1. **Repo-integrity guards** — no external SPM dependencies; sound-catalog integrity (every
`SoundPackCatalog` voice has both cue files, no orphans, no duplicate or reserved ids); and
**site integrity** (`scripts/check-site.sh`). All three run in `--portable` too. The catalog and
the audio are generated together but ship from different targets, so drift plays silence with
nothing raising an error. The site guard exists for the same reason on the web: `pages.yml`
the audio are generated together — both into the app target, one as source and one as
resources — so drift plays silence with nothing raising an error. The site guard exists for the same reason on the web: `pages.yml`
uploads `site/` verbatim with no build step, so a renamed asset, a stale absolute URL, or a
missing `CNAME` produces no error in this repo — just a 404 on the live site.

Expand Down Expand Up @@ -274,7 +279,7 @@ satisfied and toggling the row does nothing. Two defences, in this order:
`AppDelegate.runAccessibilityGrantMigration` is the self-heal. It compares `SigningIdentity.current()`
— the app's **designated requirement**, serialized and namespaced (`dr:<requirement>`) — against the
identity recorded in `accessibility.lastSigningTeam`; if it changed and the app is untrusted, it runs
`tccutil reset Accessibility` on **the running bundle id** (never `BlurtIdentity.subsystem`, which
`tccutil reset Accessibility` on **the running bundle id** (never `HostIdentity.current.subsystem`, which
would have a dev build clearing the release's grant) so the wizard's normal grant flow captures a
matching requirement, and records the new identity only when the reset succeeded.
`SigningIdentityMigration` is the pure decision; `SigningIdentity` is the thin `Security`/`tccutil`
Expand Down Expand Up @@ -769,6 +774,20 @@ the omit-vs-`[]` rule live; `KeytermsWireTests` pins both.

## Settings, persistence, and cues

**`HostIdentity`** comes first, because most of what follows is namespaced by it: the Keychain
service the API key lands in, the `os_log` subsystem (and the engine's dispatch-queue labels), the
`UserDefaults` prefix every `DefaultsKey` composes with, the `~/Library/Logs` directory the
developer-mode logs go to, the product name update alerts say, and the GitHub release the update
check reads. These were hard constants, which made them Blurt's with no opt-out — a second app
embedding the engine wrote into _Blurt's_ Keychain item, log directory and defaults keys. They are
one value now; `HostIdentity.current` is what the engine reads, `.blurt` is what an unconfigured host
inherits (so nothing about this app changed), and `BlurtApp.init` calls `HostIdentity.configure(_:)`
with it because the identity belongs to the host, not the engine. It is process-wide rather than
injected for the obvious reason: its readers are `static let` loggers, an enum of defaults keys and a
Keychain facade, none of which a caller constructs. The derivations are pure functions of the value
(`defaultsKey(_:)`, `logURL(_:)`, `queueLabel(_:)`, `logger(_:)`) so the tests exercise them against
a constructed identity instead of mutating the shared one, which every suite reads in parallel.

Engine-side stores, all `UserDefaults`-backed value types with the same shape:

- **`TriggerKeyStore`** (`BlurtTriggerKeyCode`), **`SoundPackStore`** (`BlurtSoundPack`),
Expand All @@ -791,8 +810,11 @@ 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.) Raw values are the on-disk contract: renaming one abandons every existing
user's setting, so rename cases freely and raw values never. The sweep lives next to the enum
the 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
and raw values never. The sweep lives next to the enum
(not in the shell) so a reset needing more than a defaults removal has one place to grow and stays
inside the test target; the roster itself is internal so no caller re-rolls its own sweep.
`SigningIdentityMigration.lastSigningIdentityDefaultsKey` is deliberately **outside** the roster:
Expand All @@ -806,9 +828,14 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape:

Record cues: **`SoundPack`** is a selectable start/stop chime voice (vintage synth samples;
`id` doubles as the bundled stem `<id>-start.m4a` / `<id>-stop.m4a` under
`App/Blurt/Blurt/Resources/Sounds/`), listed by **`SoundPackCatalog.swift`**, which is _generated_ by
`scripts/generate-sounds.swift` alongside the audio. Regenerate both halves together — `check.sh`'s
sound-catalog guard exists because a drift plays silence with no error. **`RecordingCueGate`** is the
`App/Blurt/Blurt/Resources/Sounds/`), and **`SoundPackCatalog`** is the set of them a host supplies —
picker sections, lookup, and the decode-with-default rule `SoundPackStore` and the `@AppStorage`
views share. The engine ships **no voices and no audio**: Blurt's 192 are generated into the app
target (`App/Blurt/Blurt/SoundPackCatalog.swift`) by `scripts/generate-sounds.swift` alongside the
`.m4a` files they name, because a voice list whose cues live in someone else's bundle is a picker in
which every choice plays silence. Regenerate both halves together — `check.sh`'s sound-catalog guard
exists because a drift plays silence with no error — and note that adding or removing a voice changes
the app target's file list, so `xcodegen generate` and a committed `.pbxproj` are part of that edit. **`RecordingCueGate`** is the
pure edge detector deciding when the chimes fire — on the `.recording` edge, i.e. once audio is
actually flowing, never at the press (see the latency notes above for why that ordering is
load-bearing); the AppKit `CueSoundPlayer` just plays what it resolves.
Expand Down
4 changes: 4 additions & 0 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@
8DEF999DFD616C2FD17923DE /* juno-119-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 114E0EA1827EA005CF188FC8 /* juno-119-start.m4a */; };
8E70ED1163B872F8D691CDC5 /* juno-118-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E084A62CF8F167165466AE30 /* juno-118-start.m4a */; };
8E89114ADA5FCE4AF9DFB3C0 /* juno-116-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 486F5FE65FF8C13A35C28271 /* juno-116-start.m4a */; };
8F3F1CC7203A148A68DFCCFF /* SoundPackCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C5D6E7BD14D68FEB9BA215 /* SoundPackCatalog.swift */; };
8FE4B9B139BB9557FBC5589B /* OverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27D7F43295DE2A81EFFDDBC /* OverlayWindowController.swift */; };
91C1257866F6E2D4EE65BD09 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2312DE4BC0F4F070830AD7 /* WindowAccessor.swift */; };
91D775ACB2351468D47731FF /* rom1a-12-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E2BCA8E305B596F960CEE1AE /* rom1a-12-start.m4a */; };
Expand Down Expand Up @@ -671,6 +672,7 @@
77C74C74945D0A990578D911 /* rom1b-10-start.m4a */ = {isa = PBXFileReference; path = "rom1b-10-start.m4a"; sourceTree = "<group>"; };
77E9E7F17972483F6FBF1CCD /* Blurt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Blurt.entitlements; sourceTree = "<group>"; };
786672855B44AB80CAF4D782 /* rom1b-29-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-29-stop.m4a"; sourceTree = "<group>"; };
78C5D6E7BD14D68FEB9BA215 /* SoundPackCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPackCatalog.swift; sourceTree = "<group>"; };
791EFC0EE82C0E91F316B51E /* juno-97-stop.m4a */ = {isa = PBXFileReference; path = "juno-97-stop.m4a"; sourceTree = "<group>"; };
792258F8AAF3AF35104EE0CF /* rom1a-19-start.m4a */ = {isa = PBXFileReference; path = "rom1a-19-start.m4a"; sourceTree = "<group>"; };
79F8F36D7C5D074BC5135FEA /* juno-90-start.m4a */ = {isa = PBXFileReference; path = "juno-90-start.m4a"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -917,6 +919,7 @@
7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */,
08A7417835D8538AC4A7E2FB /* DictationComposition.swift */,
0566F4FC45C72A6DA586126E /* Info.plist */,
78C5D6E7BD14D68FEB9BA215 /* SoundPackCatalog.swift */,
6072A6B0AC8219B5484326B3 /* UITestSupport.swift */,
7D6DE85EA50E7CD4CC977211 /* Branding */,
DF92BBDB4B9C15FAE100B783 /* Hotkey */,
Expand Down Expand Up @@ -2011,6 +2014,7 @@
B5ADC67B78CA3EA70D6BAEB3 /* RecentDictationsSection.swift in Sources */,
6A1402BFED3758AAD9C84ADF /* SettingRow.swift in Sources */,
1D1100AB3D47EFC80D47B04B /* SettingsWindowRoot.swift in Sources */,
8F3F1CC7203A148A68DFCCFF /* SoundPackCatalog.swift in Sources */,
69E27229D8C64D7119918B7A /* SoundStepView.swift in Sources */,
1817ACD2F7D8F7AF3C9B1C7C /* UITestIdentifiers.swift in Sources */,
E1CA5C692CB2A7E1BF231928 /* UITestSupport.swift in Sources */,
Expand Down
14 changes: 14 additions & 0 deletions App/Blurt/Blurt/App.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import BlurtEngine
import SwiftUI

@main
struct BlurtApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

/// 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.
///
/// `.blurt` *is* the engine's default, so this call changes nothing about how
/// Blurt behaves. It is here because it is the composition root: the identity
/// belongs to the host, and a fork or a second embedder replaces exactly this
/// one line rather than hunting constants through the engine. Earliest hook a
/// SwiftUI `App` has, which is what `HostIdentity.configure(_:)` asks for.
init() {
HostIdentity.configure(.blurt)
}

var body: some Scene {
// Primary window: the setup wizard until the app is fully configured, then
// the "ready" screen (see `MainWindowRoot`).
Expand Down
4 changes: 2 additions & 2 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
lastIdentity: defaults.string(forKey: key),
currentIdentity: SigningIdentity.current(),
isTrusted: AXIsProcessTrusted(),
// The *running* bundle id, not `BlurtIdentity.subsystem`: debug builds ship
// The *running* bundle id, not `HostIdentity.current.subsystem`: debug builds ship
// under `dev.alex.blurt.dev` (see `project.yml`), and resetting the constant
// would clear the released Blurt's grant from a dev build — the one app
// whose permissions this process has no business touching. The constant is
// the fallback for the unreachable case of a bundle with no id at all.
reset: {
SigningIdentity.resetAccessibilityGrant(
bundleID: Bundle.main.bundleIdentifier ?? BlurtIdentity.subsystem)
bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem)
}
)
if let persist { defaults.set(persist, forKey: key) }
Expand Down
2 changes: 1 addition & 1 deletion App/Blurt/Blurt/CueSoundPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ final class CueSoundPlayer {
/// `force` skips the already-loaded short-circuit, for a re-prime where the
/// selection is unchanged and only the output route moved.
private func loadCurrentPack(force: Bool = false) async {
let pack = SoundPackStore().soundPack
let pack = SoundPackStore(catalog: .blurt).soundPack
guard force || pack != loadedPack else { return }
loadGeneration += 1
let generation = loadGeneration
Expand Down
3 changes: 1 addition & 2 deletions App/Blurt/Blurt/Hotkey/DictationKeyTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ import os
/// prove single-threaded access to the router state instead of guarding it with
/// a hand-held lock.
final class DictationKeyTap {
private static let logger = Logger(
subsystem: BlurtIdentity.subsystem, category: "DictationKeyTap")
private static let logger = HostIdentity.current.logger("DictationKeyTap")

private let onStart: @Sendable () -> Void
private let onStop: @Sendable () -> Void
Expand Down
Loading
Loading