@hasna/recordings 0.2.11 → 0.2.13

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 (167) hide show
  1. package/README.md +349 -69
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1919 -98
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1973 -236
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -0,0 +1,42 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct RecordingStartControlPresentationTests {
5
+ @Test(
6
+ "every recording start control follows the engine start gate",
7
+ arguments: RecordingStartControlPresentation.Kind.allCases
8
+ )
9
+ func startControlsFollowEngineGate(kind: RecordingStartControlPresentation.Kind) {
10
+ let settling = RecordingStartControlPresentation(
11
+ kind: kind,
12
+ canStartRecording: false
13
+ )
14
+ let available = RecordingStartControlPresentation(
15
+ kind: kind,
16
+ canStartRecording: true
17
+ )
18
+
19
+ #expect(!settling.isEnabled)
20
+ #expect(available.isEnabled)
21
+ #expect(!settling.accessibilityLabel.isEmpty)
22
+ }
23
+
24
+ @Test("ready and error controls keep their accessible action names")
25
+ func retryControlLabels() {
26
+ let ready = RecordingStartControlPresentation(
27
+ kind: .recordAgain,
28
+ canStartRecording: false
29
+ )
30
+ let failed = RecordingStartControlPresentation(
31
+ kind: .tryAgain,
32
+ canStartRecording: false
33
+ )
34
+
35
+ #expect(ready.title == "Record Again")
36
+ #expect(ready.accessibilityLabel == "Start a new recording")
37
+ #expect(!ready.isEnabled)
38
+ #expect(failed.title == "Try Again")
39
+ #expect(failed.accessibilityLabel == "Try recording again")
40
+ #expect(!failed.isEnabled)
41
+ }
42
+ }
@@ -1,12 +1,276 @@
1
+ import Foundation
1
2
  import Testing
2
3
  @testable import RecordingsLib
3
4
 
4
5
  struct RecordingStartGateTests {
6
+ @Test("permission-only launch skips global handlers and terminates after handling")
7
+ func permissionHelperLaunchPlan() {
8
+ let plan = PermissionRequestLaunchPlan(arguments: [
9
+ "/Applications/Recordings.app/Contents/MacOS/Recordings",
10
+ "--request-permissions",
11
+ "--open-permission-settings",
12
+ ])
13
+
14
+ #expect(plan.isHelper)
15
+ #expect(plan.opensPermissionSettings)
16
+ #expect(!plan.installsGlobalHandlers)
17
+ #expect(!plan.declaresMainWindow)
18
+ #expect(!plan.declaresMenuBar)
19
+ #expect(plan.terminatesAfterHandling)
20
+ #expect(plan.requestsAccessibilityPrompt)
21
+ }
22
+
23
+ @Test("regular launch retains global handlers and never self-terminates")
24
+ func regularLaunchPlan() {
25
+ let plan = PermissionRequestLaunchPlan(arguments: ["Recordings"])
26
+
27
+ #expect(!plan.isHelper)
28
+ #expect(plan.installsGlobalHandlers)
29
+ #expect(plan.declaresMainWindow)
30
+ #expect(plan.declaresMenuBar)
31
+ #expect(!plan.terminatesAfterHandling)
32
+ #expect(!plan.requestsAccessibilityPrompt)
33
+ }
34
+
35
+ @Test("runtime smoke plans install no handlers and never request permissions")
36
+ func runtimeSmokeLaunchPlans() {
37
+ let normal = PermissionRequestLaunchPlan(arguments: [
38
+ "Recordings", "--runtime-smoke", "normal", "--runtime-smoke-output", "/tmp/result.json",
39
+ "--runtime-smoke-ack", "/tmp/result.ack",
40
+ "--runtime-smoke-completion", "/tmp/result.completion.json",
41
+ ])
42
+ #expect(normal.isRuntimeSmoke)
43
+ #expect(!normal.installsGlobalHandlers)
44
+ #expect(normal.declaresMenuBar)
45
+ #expect(normal.runtimeSmokeOutputPath == "/tmp/result.json")
46
+ #expect(normal.runtimeSmokeAcknowledgementPath == "/tmp/result.ack")
47
+ #expect(normal.runtimeSmokeCompletionPath == "/tmp/result.completion.json")
48
+ #expect(!normal.requestsAccessibilityPrompt)
49
+
50
+ let helper = PermissionRequestLaunchPlan(arguments: [
51
+ "Recordings", "--request-permissions", "--runtime-smoke", "permission-helper",
52
+ "--runtime-smoke-output", "/tmp/result.json",
53
+ ])
54
+ #expect(helper.isRuntimeSmoke)
55
+ #expect(!helper.installsGlobalHandlers)
56
+ #expect(!helper.declaresMenuBar)
57
+ #expect(helper.isHelper)
58
+ #expect(!helper.requestsAccessibilityPrompt)
59
+ }
60
+
61
+ @Test("first untrusted protected operation prompts once per process")
62
+ func firstProtectedOperationPromptsOnce() {
63
+ let gate = AccessibilityPromptGate()
64
+ var trustChecks = 0
65
+ var prompts = 0
66
+
67
+ let first = gate.trustForProtectedOperation(
68
+ isTrusted: {
69
+ trustChecks += 1
70
+ return false
71
+ },
72
+ requestPrompt: {
73
+ prompts += 1
74
+ return false
75
+ }
76
+ )
77
+ let second = gate.trustForProtectedOperation(
78
+ isTrusted: {
79
+ trustChecks += 1
80
+ return false
81
+ },
82
+ requestPrompt: {
83
+ prompts += 1
84
+ return false
85
+ }
86
+ )
87
+
88
+ #expect(!first.trusted)
89
+ #expect(first.didPrompt)
90
+ #expect(!second.trusted)
91
+ #expect(!second.didPrompt)
92
+ #expect(trustChecks == 2)
93
+ #expect(prompts == 1)
94
+ #expect(gate.promptRequestCount == 1)
95
+ }
96
+
97
+ @Test("explicit Accessibility action may prompt after the automatic attempt")
98
+ func explicitAccessibilityActionMayPromptAgain() {
99
+ let gate = AccessibilityPromptGate()
100
+ var prompts = 0
101
+ var trusted = false
102
+
103
+ _ = gate.trustForProtectedOperation(
104
+ isTrusted: { false },
105
+ requestPrompt: {
106
+ prompts += 1
107
+ return false
108
+ }
109
+ )
110
+ let explicit = gate.requestExplicitly(
111
+ isTrusted: { trusted },
112
+ requestPrompt: {
113
+ prompts += 1
114
+ trusted = true
115
+ return false
116
+ }
117
+ )
118
+
119
+ #expect(explicit.trusted)
120
+ #expect(explicit.didPrompt)
121
+ #expect(prompts == 2)
122
+ #expect(gate.promptRequestCount == 2)
123
+ }
124
+
125
+ @Test("permission helper rechecks Accessibility at completion after a delayed grant")
126
+ func permissionHelperRechecksDelayedAccessibilityGrant() {
127
+ let gate = AccessibilityPromptGate()
128
+ var checks = 0
129
+ let promptResult = AccessibilityTrustResult(trusted: false, didPrompt: true)
130
+
131
+ let completion = gate.waitForExplicitRequestCompletion(
132
+ promptResult,
133
+ attempts: 3,
134
+ waitBetweenAttempts: {},
135
+ isTrusted: {
136
+ checks += 1
137
+ return checks == 3
138
+ }
139
+ )
140
+
141
+ #expect(completion.trusted)
142
+ #expect(completion.didPrompt)
143
+ #expect(checks == 3)
144
+ }
145
+
146
+ @Test("permission helper remains denied when Accessibility is still untrusted")
147
+ func permissionHelperReportsStillDenied() {
148
+ let gate = AccessibilityPromptGate()
149
+ var waits = 0
150
+ let completion = gate.waitForExplicitRequestCompletion(
151
+ AccessibilityTrustResult(trusted: true, didPrompt: true),
152
+ attempts: 3,
153
+ waitBetweenAttempts: { waits += 1 },
154
+ isTrusted: { false }
155
+ )
156
+
157
+ #expect(!completion.trusted)
158
+ #expect(completion.didPrompt)
159
+ #expect(waits == 2)
160
+ }
161
+
162
+ @Test("trusted protected operation never invokes the prompt API")
163
+ func trustedProtectedOperationDoesNotPrompt() {
164
+ let gate = AccessibilityPromptGate()
165
+ var prompts = 0
166
+
167
+ let result = gate.trustForProtectedOperation(
168
+ isTrusted: { true },
169
+ requestPrompt: {
170
+ prompts += 1
171
+ return true
172
+ }
173
+ )
174
+
175
+ #expect(result.trusted)
176
+ #expect(!result.didPrompt)
177
+ #expect(prompts == 0)
178
+ #expect(gate.promptRequestCount == 0)
179
+ }
180
+
181
+ @Test("explicit prompt consumes the automatic allowance for later protected operations")
182
+ func explicitPromptPreventsUnexpectedAutomaticReprompt() {
183
+ let gate = AccessibilityPromptGate()
184
+ var prompts = 0
185
+
186
+ _ = gate.requestExplicitly {
187
+ prompts += 1
188
+ return false
189
+ }
190
+ let protectedOperation = gate.trustForProtectedOperation(
191
+ isTrusted: { false },
192
+ requestPrompt: {
193
+ prompts += 1
194
+ return false
195
+ }
196
+ )
197
+
198
+ #expect(!protectedOperation.trusted)
199
+ #expect(!protectedOperation.didPrompt)
200
+ #expect(prompts == 1)
201
+ #expect(gate.promptRequestCount == 1)
202
+ }
203
+
204
+ @Test("permission helper outcome reports both permission results truthfully")
205
+ func permissionHelperOutcome() {
206
+ #expect(PermissionRequestOutcome(
207
+ microphoneGranted: true,
208
+ accessibilityTrusted: true
209
+ ).succeeded)
210
+ #expect(!PermissionRequestOutcome(
211
+ microphoneGranted: false,
212
+ accessibilityTrusted: true
213
+ ).succeeded)
214
+ #expect(!PermissionRequestOutcome(
215
+ microphoneGranted: true,
216
+ accessibilityTrusted: false
217
+ ).succeeded)
218
+ }
219
+
5
220
  @Test("recording cannot begin while already recording or transcribing")
6
221
  func cannotBeginWhenBusy() {
7
222
  #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false) == true)
8
223
  #expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false) == false)
9
224
  #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true) == false)
225
+ #expect(RecordingEngine.canBeginRecording(
226
+ isRecording: false,
227
+ isTranscribing: false,
228
+ isAwaitingMicrophonePermission: true
229
+ ) == false)
230
+ #expect(RecordingEngine.canBeginRecording(
231
+ isRecording: false,
232
+ isTranscribing: false,
233
+ isDeliveryPending: true
234
+ ) == false)
235
+ }
236
+
237
+ @Test("recording status is derived from the trigger")
238
+ func statusUsesTrigger() {
239
+ #expect(RecordingEngine.recordingStatus(
240
+ trigger: .keyboardShortcut
241
+ ) == "Recording — release to stop")
242
+ #expect(RecordingEngine.recordingStatus(
243
+ trigger: .fnKey
244
+ ) == "Recording — release to stop")
245
+ #expect(RecordingEngine.recordingStatus(
246
+ trigger: .manual
247
+ ) == "Recording — click Stop when finished")
248
+ }
249
+
250
+ @Test("microphone permission start gate admits one current continuation")
251
+ func permissionStartGateRejectsDuplicateAndStaleContinuations() {
252
+ let firstRequestID = UUID()
253
+ let currentRequestID = UUID()
254
+ var gate = MicrophonePermissionStartGate()
255
+
256
+ let firstReservation = gate.reserve(requestID: firstRequestID)
257
+ #expect(firstReservation == firstRequestID)
258
+ #expect(gate.isAwaitingResponse)
259
+ let duplicateReservation = gate.reserve(requestID: currentRequestID)
260
+ #expect(duplicateReservation == nil)
261
+ #expect(gate.activeRequestID == firstRequestID)
262
+
263
+ gate.cancel()
264
+ let currentReservation = gate.reserve(requestID: currentRequestID)
265
+ #expect(currentReservation == currentRequestID)
266
+ let staleResponseConsumed = gate.consumeResponse(for: firstRequestID)
267
+ #expect(!staleResponseConsumed)
268
+ #expect(gate.activeRequestID == currentRequestID)
269
+ let currentResponseConsumed = gate.consumeResponse(for: currentRequestID)
270
+ #expect(currentResponseConsumed)
271
+ #expect(!gate.isAwaitingResponse)
272
+ let repeatedResponseConsumed = gate.consumeResponse(for: currentRequestID)
273
+ #expect(!repeatedResponseConsumed)
10
274
  }
11
275
 
12
276
  @Test("manual recording can continue after permission grant")
@@ -0,0 +1,129 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Testing
4
+ @testable import RecordingsLib
5
+
6
+ /// A recorder that starts instantly and records when it was asked to. Lets the production
7
+ /// `startRecording` path run without microphone hardware or TCC grants.
8
+ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
9
+ private let lock = NSLock()
10
+ private var startedFlag = false
11
+
12
+ var started: Bool {
13
+ lock.withLock { startedFlag }
14
+ }
15
+
16
+ func start() throws {
17
+ lock.withLock { startedFlag = true }
18
+ }
19
+
20
+ func stop() {}
21
+ }
22
+
23
+ @MainActor
24
+ private func makeStartableEngine(
25
+ recorder: FakePCMRecorder,
26
+ selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken?
27
+ ) -> RecordingEngine {
28
+ let engine = RecordingEngine()
29
+ engine.openAIAPIKeyProvider = { "" }
30
+ engine.microphoneAuthorization = { .authorized }
31
+ engine.accessibilityTrustCheck = { true }
32
+ engine.protectedOperationTrust = { AccessibilityTrustResult(trusted: true, didPrompt: false) }
33
+ engine.frontmostAppSnapshot = {
34
+ FrontmostAppSnapshot(pid: 99_999, bundleIdentifier: "com.example.editor", launchDate: Date())
35
+ }
36
+ engine.recorderFactory = { _ in recorder }
37
+ engine.selectionCapture = selectionCapture
38
+ engine.focusedWindowTitleLookup = { _ in nil }
39
+ engine.pasteInterceptorForTesting = { _, _, _ in }
40
+ return engine
41
+ }
42
+
43
+ @MainActor
44
+ private func waitUntil(
45
+ timeout: TimeInterval = 5,
46
+ _ condition: @MainActor () -> Bool
47
+ ) async -> Bool {
48
+ let deadline = Date().addingTimeInterval(timeout)
49
+ while Date() < deadline {
50
+ if condition() { return true }
51
+ try? await Task.sleep(for: .milliseconds(20))
52
+ }
53
+ return condition()
54
+ }
55
+
56
+ @MainActor
57
+ struct RecordingStartTimingTests {
58
+ @Test("the recorder starts on keydown even while the AX selection capture is blocked")
59
+ func recorderStartDoesNotWaitOnSelectionCapture() {
60
+ let captureGate = DispatchSemaphore(value: 0)
61
+ let recorder = FakePCMRecorder()
62
+ let engine = makeStartableEngine(recorder: recorder) { _ in
63
+ // Simulates a beachballing target app: the capture IPC hangs far longer than
64
+ // any acceptable start budget.
65
+ captureGate.wait()
66
+ return nil
67
+ }
68
+
69
+ // `startRecording` runs synchronously on the MainActor through recorder start.
70
+ // With the pre-fix ordering (capture before recorder start, on the MainActor) this
71
+ // call would block on the semaphore and the test would time out; with the fixed
72
+ // ordering the recorder is live before the capture has produced anything.
73
+ engine.startRecording(trigger: .manual)
74
+ #expect(recorder.started, "recorder must start while AX capture is still pending")
75
+ #expect(engine.isRecording)
76
+ #expect(engine.flowPhase == .listening)
77
+
78
+ captureGate.signal()
79
+ engine.cancelRecording()
80
+ #expect(engine.flowPhase == .idle)
81
+ }
82
+
83
+ @Test("stopping waits for the generation-bound start context instead of dropping the frozen target")
84
+ func stopAwaitsFrozenStartContext() async {
85
+ let captureGate = DispatchSemaphore(value: 0)
86
+ let recorder = FakePCMRecorder()
87
+ let engine = makeStartableEngine(recorder: recorder) { _ in
88
+ captureGate.wait()
89
+ return AccessibilitySelectionToken.unsafeTestToken(selectedText: "frozen words")
90
+ }
91
+
92
+ engine.startRecording(trigger: .manual)
93
+ #expect(engine.isRecording)
94
+ engine.stopAndTranscribe()
95
+ #expect(engine.isTranscribing)
96
+
97
+ // The pipeline must hold in finalizing while the frozen context is unresolved —
98
+ // it may not deliver without the selection frozen at start.
99
+ try? await Task.sleep(for: .milliseconds(150))
100
+ #expect(engine.flowPhase == .finalizing)
101
+
102
+ captureGate.signal()
103
+ // No audio was produced by the fake recorder, so the pipeline ends in the
104
+ // fail-closed no-audio state — importantly, only after the context resolved.
105
+ #expect(await waitUntil {
106
+ if case .failed = engine.flowPhase { return true }
107
+ return false
108
+ })
109
+ #expect(engine.statusMessage == "No audio captured")
110
+ #expect(engine.canStartRecording)
111
+ }
112
+
113
+ @Test("a released key before the capture resolves still cancels cleanly")
114
+ func cancelDuringPendingCapture() {
115
+ let captureGate = DispatchSemaphore(value: 0)
116
+ let recorder = FakePCMRecorder()
117
+ let engine = makeStartableEngine(recorder: recorder) { _ in
118
+ captureGate.wait()
119
+ return nil
120
+ }
121
+ engine.startRecording(trigger: .manual)
122
+ #expect(engine.isRecording)
123
+ engine.cancelRecording()
124
+ #expect(!engine.isRecording)
125
+ #expect(engine.flowPhase == .idle)
126
+ captureGate.signal()
127
+ #expect(engine.canStartRecording)
128
+ }
129
+ }