@hasna/recordings 0.2.13 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2078 -277
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +10 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +634 -49
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +140 -56
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +187 -79
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +108 -49
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/build_native_fs_guard.sh +15 -2
  59. package/scripts/ci-linux-suite.ts +403 -0
  60. package/scripts/ci-native-build.ts +402 -0
  61. package/scripts/enforce_identity_migration.sh +110 -0
  62. package/scripts/generate-sdk.ts +1 -1
  63. package/scripts/install_macos_app.sh +138 -24
  64. package/scripts/macos_artifact.ts +205 -33
  65. package/scripts/native/realtime-settle-probe/README.md +39 -0
  66. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  67. package/scripts/policy/local-only-approved-targets.txt +21 -0
  68. package/scripts/read_local_only_targets.sh +203 -0
  69. package/scripts/scan-artifact.ts +40 -0
  70. package/scripts/set-version.ts +145 -0
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +14 -7
  81. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  82. package/src/native/Recordings/App/SidebarView.swift +1 -1
  83. package/src/native/Recordings/Package.swift +24 -5
  84. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  85. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  86. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  87. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  88. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  89. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  90. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  91. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  92. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  93. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  94. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  95. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  96. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  97. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  98. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  99. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  100. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  101. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  102. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  103. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  104. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  105. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  106. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  107. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  108. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  109. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  110. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  111. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  112. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  113. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  114. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  115. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  116. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  117. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  118. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  119. package/src/native/Recordings/build.sh +75 -9
  120. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -59,17 +59,61 @@ public struct SettingsView: View {
59
59
  HStack {
60
60
  Text("Shortcut")
61
61
  Spacer()
62
+ // Re-evaluate on change, not just log: a chord the system already
63
+ // reserves has to be reported the moment it is picked, not at next launch.
62
64
  KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
63
- engine.updateStatus()
65
+ engine.refreshTriggerDiagnostics()
64
66
  }
65
67
  Button("Reset to F5") {
66
68
  KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
67
- engine.updateStatus()
69
+ engine.refreshTriggerDiagnostics()
68
70
  }
69
71
  }
72
+ // No .onChange here: `RecordingEngine.useFnKey`'s own didSet re-arms the
73
+ // monitor and logs the resolved trigger, so it also covers a change made
74
+ // from the CLI or anywhere else — a view-local hook would not.
70
75
  Toggle("Use fn/Globe as recording key", isOn: $engine.useFnKey)
71
- Text("Hold to record, release to transcribe and paste.")
76
+ Text("Hold to record, release to transcribe and paste. fn needs Accessibility; the hotkey above needs no permission.")
72
77
  .foregroundStyle(.secondary)
78
+ // A trigger that is switched on but cannot arm must say so next to its own
79
+ // switch. Silence here is what made 51 recorded hotkey presses look like a
80
+ // working trigger while nothing was delivered.
81
+ //
82
+ // Scoped to the trigger sources, and the button keyed to each reason's own
83
+ // remedy. Rendering the composed `engine.blockedReason` here instead meant a
84
+ // secure-input paste failure showed "transcript copied, press Cmd-V" under
85
+ // Recording Shortcut beside an "Open Accessibility Settings" button — wrong
86
+ // remedy, wrong section, wrong cause. A hotkey collision took that button too,
87
+ // and the Accessibility pane does nothing for a chord clash either.
88
+ ForEach(engine.blockedReasonEntries.filter(\.isTriggerHealth)) { entry in
89
+ Label(entry.message, systemImage: "exclamationmark.triangle.fill")
90
+ .foregroundStyle(.orange)
91
+ // `switch`, not `==`. This file's engine forbids `==` against an enum case in
92
+ // three separate comments for the same reason: it answers `false` for every
93
+ // case added later, so a fourth remedy carrying a button would silently render
94
+ // none. Exhaustive here means adding one forces the decision.
95
+ switch entry.remedy {
96
+ case .openAccessibilitySettings:
97
+ Button("Open Accessibility Settings") {
98
+ engine.openAccessibilitySettings()
99
+ }
100
+ case .chooseAnotherShortcut, .messageOnly:
101
+ EmptyView()
102
+ }
103
+ }
104
+ }
105
+
106
+ // Delivery reasons are not trigger health, but they are the ONLY message telling the
107
+ // owner their transcript is still recoverable — so they need a surface here, not just
108
+ // a wordless warning triangle in the menu bar and a caption behind a click. Scoping
109
+ // the trigger section was right; dropping these entirely was not.
110
+ if !engine.blockedReasonEntries.filter({ !$0.isTriggerHealth }).isEmpty {
111
+ Section("Last Delivery") {
112
+ ForEach(engine.blockedReasonEntries.filter { !$0.isTriggerHealth }) { entry in
113
+ Label(entry.message, systemImage: "exclamationmark.triangle.fill")
114
+ .foregroundStyle(.orange)
115
+ }
116
+ }
73
117
  }
74
118
 
75
119
  Section("Permissions") {
@@ -20,12 +20,17 @@ public struct VoiceShortcut: Identifiable, Codable {
20
20
  public final class VoiceShortcuts: ObservableObject {
21
21
  @Published public var shortcuts: [VoiceShortcut] = []
22
22
 
23
- private let storageURL: URL = {
24
- let home = FileManager.default.homeDirectoryForCurrentUser
25
- return home.appendingPathComponent(".hasna/recordings/voice-shortcuts.json")
26
- }()
27
-
28
- public init() {
23
+ private let storageURL: URL
24
+
25
+ /// Injected for the same reason as `RecordingEngine.home`, and named to match the
26
+ /// `homePath:` seam the rest of the package already uses: the default resolves through the
27
+ /// password database (`getpwuid`), not `$HOME`, so a test cannot redirect this write by
28
+ /// overriding the environment and has to pass a temp directory explicitly. Without it a
29
+ /// test that touches voice shortcuts rewrites the operator's real
30
+ /// `~/.hasna/recordings/voice-shortcuts.json`.
31
+ public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
32
+ storageURL = URL(fileURLWithPath: homePath)
33
+ .appendingPathComponent(".hasna/recordings/voice-shortcuts.json")
29
34
  load()
30
35
  }
31
36
 
@@ -360,7 +360,7 @@ struct CLIRunnerTests {
360
360
  // commandCLI closure on a detached task, budgeted by commandRewriteTimeout — the
361
361
  // observable wall time must stay inside the budget even though the helper never
362
362
  // exits on its own and ignores SIGTERM.
363
- let runCLI = RecordingEngine().commandCLI
363
+ let runCLI = RecordingEngine(homePath: home.path).commandCLI
364
364
  let homePath = home.path
365
365
  let startedAt = ContinuousClock.now
366
366
  let output = await Task.detached {
@@ -454,7 +454,7 @@ struct CLIRunnerTests {
454
454
  // also runs to exhaustion. The observable wall time must still land under the
455
455
  // public ceiling — the return margin absorbs spawn, poll overshoot, capture
456
456
  // shutdown, and the task hops.
457
- let runCLI = RecordingEngine().commandCLI
457
+ let runCLI = RecordingEngine(homePath: home.path).commandCLI
458
458
  let homePath = home.path
459
459
  let startedAt = ContinuousClock.now
460
460
  let output = await Task.detached {
@@ -1113,7 +1113,7 @@ struct CLIRunnerTests {
1113
1113
  // SIGTERM and the silent holder keeps both pipes open, so every deadline in the
1114
1114
  // chain — execution window, termination grace, drain wait — runs to exhaustion
1115
1115
  // with zero bytes ever arriving to wake a reader.
1116
- let runCLI = RecordingEngine().commandCLI
1116
+ let runCLI = RecordingEngine(homePath: home.path).commandCLI
1117
1117
  let homePath = home.path
1118
1118
  let startedAt = ContinuousClock.now
1119
1119
  let output = await Task.detached {
@@ -0,0 +1,191 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ // MARK: - Cross-language enhancement fixtures
6
+
7
+ /// Fixture table shared verbatim with `src/__tests__/enhancer.test.ts`
8
+ /// ("cross-language fixtures pinned to EnhancementScreenTests.swift"). The CLI's
9
+ /// `needsEnhancement` and the app's `EnhancementScreen` must agree on every row;
10
+ /// change either implementation and both suites must be updated together.
11
+ enum EnhancementFixtures {
12
+ /// The default trigger list from `src/lib/config.ts`, frozen the same way the
13
+ /// engine forwards it to the helper (`--enhance-triggers-json`).
14
+ static let defaultTriggersJSON = """
15
+ ["say it better","rewrite this","make it sound","clean this up","fix this",\
16
+ "rephrase","write it properly","make it professional","improve this","polish this"]
17
+ """
18
+
19
+ /// Rows the CLI enhances in `auto` mode: one per built-in instruction pattern,
20
+ /// plus an explicit configured trigger.
21
+ static let mayEnhance: [String] = [
22
+ "this is my draft say it better",
23
+ "write an email to the team about the outage",
24
+ "give them full instructions for the deploy",
25
+ "tell them to restart the ingest service",
26
+ "make this sound more professional",
27
+ "okay so write that we are postponing the launch",
28
+ "i need the agent to build a dashboard for the metrics",
29
+ ]
30
+
31
+ /// Rows the CLI passes through untouched, including near-misses of the
32
+ /// instruction patterns ("draft beer", "asked them").
33
+ static let plainDictation: [String] = [
34
+ "meet me at noon by the north entrance",
35
+ "the deploy finished and the metrics look stable",
36
+ "draft beer is better than bottled in my opinion",
37
+ "she asked them about the schedule yesterday",
38
+ ]
39
+ }
40
+
41
+ struct EnhancementScreenTests {
42
+ @Test("every enhancement-eligible fixture is screened as may-enhance")
43
+ func detectsEligibleRows() {
44
+ for text in EnhancementFixtures.mayEnhance {
45
+ #expect(
46
+ EnhancementScreen.mayRequireEnhancement(
47
+ text: text,
48
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
49
+ ),
50
+ "expected may-enhance: \(text)"
51
+ )
52
+ }
53
+ }
54
+
55
+ @Test("plain dictation fixtures are provably not enhanceable")
56
+ func passesPlainDictation() {
57
+ for text in EnhancementFixtures.plainDictation {
58
+ #expect(
59
+ !EnhancementScreen.mayRequireEnhancement(
60
+ text: text,
61
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
62
+ ),
63
+ "expected plain dictation: \(text)"
64
+ )
65
+ }
66
+ }
67
+
68
+ @Test("trigger matching is case-insensitive, like the CLI's")
69
+ func caseInsensitiveTriggers() {
70
+ #expect(EnhancementScreen.mayRequireEnhancement(
71
+ text: "REWRITE THIS whole paragraph",
72
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
73
+ ))
74
+ }
75
+
76
+ @Test("an empty trigger payload still applies the built-in instruction patterns")
77
+ func emptyTriggersKeepPatterns() {
78
+ #expect(EnhancementScreen.mayRequireEnhancement(
79
+ text: "write an email to the team about the outage",
80
+ enhanceTriggersJSON: ""
81
+ ))
82
+ #expect(!EnhancementScreen.mayRequireEnhancement(
83
+ text: "meet me at noon by the north entrance",
84
+ enhanceTriggersJSON: ""
85
+ ))
86
+ }
87
+
88
+ @Test("an empty trigger STRING inside the payload fails closed to may-enhance")
89
+ func emptyTriggerRowFailsClosed() {
90
+ // The CLI's `lower.includes("")` is true for every transcript, so one empty row in the
91
+ // configured list means the helper rewrites everything. The mirror special-cases the row
92
+ // instead of testing it, because Swift's answer for an empty needle depends on which
93
+ // `contains` overload resolves and nothing executes this suite (see EnhancementScreen).
94
+ #expect(EnhancementScreen.mayRequireEnhancement(
95
+ text: "meet me at noon by the north entrance",
96
+ enhanceTriggersJSON: #"[""]"#
97
+ ))
98
+ #expect(EnhancementScreen.mayRequireEnhancement(
99
+ text: "meet me at noon by the north entrance",
100
+ enhanceTriggersJSON: #"["rewrite this",""]"#
101
+ ))
102
+ }
103
+
104
+ @Test("undecodable trigger payloads fail closed to may-enhance")
105
+ func malformedTriggersFailClosed() {
106
+ #expect(EnhancementScreen.mayRequireEnhancement(
107
+ text: "meet me at noon by the north entrance",
108
+ enhanceTriggersJSON: "not json"
109
+ ))
110
+ #expect(EnhancementScreen.mayRequireEnhancement(
111
+ text: "meet me at noon by the north entrance",
112
+ enhanceTriggersJSON: #"{"triggers":[]}"#
113
+ ))
114
+ }
115
+ }
116
+
117
+ // MARK: - Paste-before-persistence in auto mode
118
+
119
+ struct AutoModePasteBeforePersistenceTests {
120
+ @Test("auto mode pastes plain dictation ahead of persistence")
121
+ func autoDictationPastesFirst() {
122
+ #expect(RecordingEngine.shouldPasteBeforePersistence(
123
+ postProcessingMode: PostProcessingMode.auto.rawValue,
124
+ transcript: "meet me at noon by the north entrance",
125
+ hasSelection: false,
126
+ intentDetectionEnabled: true,
127
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
128
+ ))
129
+ }
130
+
131
+ @Test("auto mode persists first whenever the helper could rewrite the transcript")
132
+ func autoEnhanceableSpeechPersistsFirst() {
133
+ for text in EnhancementFixtures.mayEnhance {
134
+ #expect(
135
+ !RecordingEngine.shouldPasteBeforePersistence(
136
+ postProcessingMode: PostProcessingMode.auto.rawValue,
137
+ transcript: text,
138
+ hasSelection: false,
139
+ intentDetectionEnabled: true,
140
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
141
+ ),
142
+ "expected persist-first: \(text)"
143
+ )
144
+ }
145
+ }
146
+
147
+ @Test("auto mode still defers to the intent screens for non-dictation shapes")
148
+ func autoNonDictationPersistsFirst() {
149
+ // Question-shaped speech may become a conversation — persist first.
150
+ #expect(!RecordingEngine.shouldPasteBeforePersistence(
151
+ postProcessingMode: PostProcessingMode.auto.rawValue,
152
+ transcript: "what's the capital of France?",
153
+ hasSelection: false,
154
+ intentDetectionEnabled: true,
155
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
156
+ ))
157
+ }
158
+
159
+ @Test("auto mode fails closed to persist-first on a malformed trigger payload")
160
+ func autoMalformedTriggersPersistFirst() {
161
+ #expect(!RecordingEngine.shouldPasteBeforePersistence(
162
+ postProcessingMode: PostProcessingMode.auto.rawValue,
163
+ transcript: "meet me at noon by the north entrance",
164
+ hasSelection: false,
165
+ intentDetectionEnabled: true,
166
+ enhanceTriggersJSON: "not json"
167
+ ))
168
+ }
169
+
170
+ @Test("always mode keeps persisting first even for plain dictation")
171
+ func alwaysModePersistsFirst() {
172
+ #expect(!RecordingEngine.shouldPasteBeforePersistence(
173
+ postProcessingMode: PostProcessingMode.always.rawValue,
174
+ transcript: "meet me at noon by the north entrance",
175
+ hasSelection: false,
176
+ intentDetectionEnabled: true,
177
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
178
+ ))
179
+ }
180
+
181
+ @Test("off mode is unchanged by the trigger payload")
182
+ func offModeUnchanged() {
183
+ #expect(RecordingEngine.shouldPasteBeforePersistence(
184
+ postProcessingMode: PostProcessingMode.off.rawValue,
185
+ transcript: "this is my draft say it better",
186
+ hasSelection: false,
187
+ intentDetectionEnabled: true,
188
+ enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
189
+ ))
190
+ }
191
+ }
@@ -9,8 +9,10 @@ struct MenuBarPresentationTests {
9
9
  func idle() {
10
10
  let presentation = MenuBarPresentation(
11
11
  isRecording: false,
12
+ isWarmingUpCapture: false,
12
13
  canStartRecording: true,
13
- statusMessage: "Ready"
14
+ statusMessage: "Ready",
15
+ blockedReason: nil
14
16
  )
15
17
  #expect(presentation.iconName == "mic.fill")
16
18
  #expect(presentation.accessibilityLabel == "Recordings")
@@ -22,8 +24,10 @@ struct MenuBarPresentationTests {
22
24
  func recording() {
23
25
  let presentation = MenuBarPresentation(
24
26
  isRecording: true,
27
+ isWarmingUpCapture: false,
25
28
  canStartRecording: false,
26
- statusMessage: "Recording — release to stop"
29
+ statusMessage: "Recording — release to stop",
30
+ blockedReason: nil
27
31
  )
28
32
  #expect(presentation.iconName == "waveform")
29
33
  #expect(presentation.accessibilityLabel == "Recordings, recording")
@@ -36,8 +40,10 @@ struct MenuBarPresentationTests {
36
40
  for status in ["Transcribing...", "Deciding...", "Answering...", "Rewriting...", "Pasting..."] {
37
41
  let presentation = MenuBarPresentation(
38
42
  isRecording: false,
43
+ isWarmingUpCapture: false,
39
44
  canStartRecording: false,
40
- statusMessage: status
45
+ statusMessage: status,
46
+ blockedReason: nil
41
47
  )
42
48
  #expect(presentation.iconName == "ellipsis.circle", "expected busy icon for \(status)")
43
49
  #expect(!presentation.primaryActionEnabled, "Start must be disabled during \(status)")
@@ -47,6 +53,80 @@ struct MenuBarPresentationTests {
47
53
  }
48
54
  }
49
55
 
56
+ /// The defect this pins: the idle branch discarded `statusMessage` and set `mic.fill` /
57
+ /// "Recordings", and the always-visible menu-bar item renders ONLY the icon and the
58
+ /// accessibility label. So a blocked app was byte-identical to Ready for sighted and
59
+ /// VoiceOver users alike, and "press Cmd-V" lived only in a popover nobody had opened.
60
+ @Test("a blocked idle app is distinguishable from Ready by BOTH icon and label")
61
+ func blockedIsVisible() {
62
+ let reason = "This field blocks typing (secure input) — transcript copied, press Cmd-V"
63
+ let ready = MenuBarPresentation(
64
+ isRecording: false,
65
+ isWarmingUpCapture: false,
66
+ canStartRecording: true,
67
+ statusMessage: "Ready",
68
+ blockedReason: nil
69
+ )
70
+ let blocked = MenuBarPresentation(
71
+ isRecording: false,
72
+ isWarmingUpCapture: false,
73
+ canStartRecording: true,
74
+ statusMessage: "Ready",
75
+ blockedReason: reason
76
+ )
77
+
78
+ #expect(blocked.iconName != ready.iconName, "the icon is the only signal a sighted user gets")
79
+ #expect(
80
+ blocked.accessibilityLabel != ready.accessibilityLabel,
81
+ "the label is the only signal VoiceOver gets"
82
+ )
83
+ #expect(blocked.iconName == MenuBarPresentation.blockedIconName)
84
+ #expect(blocked.accessibilityLabel.contains(reason))
85
+ #expect(blocked.statusText == reason)
86
+ #expect(blocked.isBlocked)
87
+ #expect(!ready.isBlocked)
88
+ // A blocked trigger or a blocked paste does not make Start unavailable, and the
89
+ // presentation must keep matching `canStartRecording` exactly.
90
+ #expect(blocked.primaryActionEnabled)
91
+ }
92
+
93
+ @Test("an empty blocked reason is not a blocked state")
94
+ func emptyReasonIsNotBlocked() {
95
+ let presentation = MenuBarPresentation(
96
+ isRecording: false,
97
+ isWarmingUpCapture: false,
98
+ canStartRecording: true,
99
+ statusMessage: "Ready",
100
+ blockedReason: ""
101
+ )
102
+ #expect(presentation.iconName == MenuBarPresentation.idleIconName)
103
+ #expect(!presentation.isBlocked)
104
+ }
105
+
106
+ @Test("recording and busy states outrank a blocked reason")
107
+ func liveStatesWin() {
108
+ let recording = MenuBarPresentation(
109
+ isRecording: true,
110
+ isWarmingUpCapture: false,
111
+ canStartRecording: false,
112
+ statusMessage: "Recording",
113
+ blockedReason: "stale reason"
114
+ )
115
+ #expect(recording.iconName == "waveform")
116
+ #expect(!recording.isBlocked)
117
+
118
+ let busy = MenuBarPresentation(
119
+ isRecording: false,
120
+ isWarmingUpCapture: false,
121
+ canStartRecording: false,
122
+ statusMessage: "Transcribing...",
123
+ blockedReason: "stale reason"
124
+ )
125
+ #expect(busy.iconName == "ellipsis.circle")
126
+ #expect(!busy.primaryActionEnabled)
127
+ #expect(!busy.isBlocked)
128
+ }
129
+
50
130
  @Test("presentation follows canStartRecording exactly, not isTranscribing alone")
51
131
  func presentationTracksTheStartGate() {
52
132
  // A pending intent decision keeps canStartRecording false even though
@@ -54,12 +134,72 @@ struct MenuBarPresentationTests {
54
134
  // the click. The contract requires busy.
55
135
  let deciding = MenuBarPresentation(
56
136
  isRecording: false,
137
+ isWarmingUpCapture: false,
57
138
  canStartRecording: false,
58
- statusMessage: "Deciding..."
139
+ statusMessage: "Deciding...",
140
+ blockedReason: nil
59
141
  )
60
142
  #expect(!deciding.primaryActionEnabled)
61
143
  #expect(deciding.statusText == "Deciding")
62
144
  }
145
+
146
+ @Test("the warm-up window presents as recording, never as busy or idle")
147
+ func warmUpPresentsAsRecording() {
148
+ // Between `recorder.start()` returning and the first PCM chunk the user is holding the
149
+ // key. `isRecording` is false there — no audio exists yet — so without this branch the
150
+ // glyph drops to the busy ellipsis for ~100 ms in the middle of a hold.
151
+ let presentation = MenuBarPresentation(
152
+ isRecording: false,
153
+ isWarmingUpCapture: true,
154
+ canStartRecording: false,
155
+ statusMessage: "Recording — release to stop",
156
+ blockedReason: nil
157
+ )
158
+ #expect(presentation.iconName == "waveform")
159
+ #expect(presentation.accessibilityLabel == "Recordings, recording")
160
+ #expect(presentation.statusText == "Recording")
161
+ #expect(presentation.primaryActionEnabled, "Stop must be live during warm-up")
162
+ #expect(!presentation.isBlocked)
163
+ }
164
+
165
+ @Test("an attempt that captured nothing is disclosed on the glyph, not just in a status line")
166
+ func emptyAttemptIsDisclosedOnTheGlyph() {
167
+ // Recordings is LSUIElement: a status line behind a popover click is not feedback. The
168
+ // engine routes these through `blockedReason`, so they must reach the always-visible
169
+ // icon and the VoiceOver label, and must not look like plain idle.
170
+ for alert in [RecordingAttemptAlert.releasedBeforeAudio, .noAudioCaptured] {
171
+ let presentation = MenuBarPresentation(
172
+ isRecording: false,
173
+ isWarmingUpCapture: false,
174
+ canStartRecording: true,
175
+ statusMessage: "Ready",
176
+ blockedReason: alert.message
177
+ )
178
+ #expect(presentation.iconName == MenuBarPresentation.blockedIconName, "expected the blocked glyph for \(alert)")
179
+ #expect(presentation.iconName != MenuBarPresentation.idleIconName)
180
+ #expect(presentation.statusText == alert.message)
181
+ #expect(presentation.accessibilityLabel == "Recordings, blocked: \(alert.message)")
182
+ #expect(presentation.isBlocked)
183
+ #expect(presentation.primaryActionEnabled, "the attempt is over — Start stays available")
184
+ }
185
+ }
186
+
187
+ @Test("a hold in progress outranks a still-visible empty-attempt disclosure")
188
+ func recordingOutranksADisclosure() {
189
+ // `blockedReason` is cleared by the next `startRecording`, but the warm-up branch must
190
+ // win regardless: showing "released too soon" over a live hold would be a lie.
191
+ for warmingUp in [true, false] {
192
+ let presentation = MenuBarPresentation(
193
+ isRecording: !warmingUp,
194
+ isWarmingUpCapture: warmingUp,
195
+ canStartRecording: false,
196
+ statusMessage: "Recording — release to stop",
197
+ blockedReason: RecordingAttemptAlert.releasedBeforeAudio.message
198
+ )
199
+ #expect(presentation.iconName == "waveform")
200
+ #expect(!presentation.isBlocked)
201
+ }
202
+ }
63
203
  }
64
204
 
65
205
  /// Reduce Transparency surface contract: chrome must resolve to an opaque surface — never