@hasna/recordings 0.2.10 → 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 +350 -35
  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 +1922 -77
  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 +1990 -29
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -0,0 +1,98 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ /// The menu bar's state contract: its presentation must agree with the engine's own start
6
+ /// gate in every state, so the surface can never offer a Start it would reject.
7
+ struct MenuBarPresentationTests {
8
+ @Test("idle presents the mic and an enabled Start")
9
+ func idle() {
10
+ let presentation = MenuBarPresentation(
11
+ isRecording: false,
12
+ canStartRecording: true,
13
+ statusMessage: "Ready"
14
+ )
15
+ #expect(presentation.iconName == "mic.fill")
16
+ #expect(presentation.accessibilityLabel == "Recordings")
17
+ #expect(presentation.statusText == "Ready")
18
+ #expect(presentation.primaryActionEnabled)
19
+ }
20
+
21
+ @Test("recording presents the waveform and an enabled Stop affordance")
22
+ func recording() {
23
+ let presentation = MenuBarPresentation(
24
+ isRecording: true,
25
+ canStartRecording: false,
26
+ statusMessage: "Recording — release to stop"
27
+ )
28
+ #expect(presentation.iconName == "waveform")
29
+ #expect(presentation.accessibilityLabel == "Recordings, recording")
30
+ #expect(presentation.statusText == "Recording")
31
+ #expect(presentation.primaryActionEnabled, "Stop must stay available while recording")
32
+ }
33
+
34
+ @Test("every non-startable processing state presents busy and disables Start")
35
+ func busyStatesAreTruthful() {
36
+ for status in ["Transcribing...", "Deciding...", "Answering...", "Rewriting...", "Pasting..."] {
37
+ let presentation = MenuBarPresentation(
38
+ isRecording: false,
39
+ canStartRecording: false,
40
+ statusMessage: status
41
+ )
42
+ #expect(presentation.iconName == "ellipsis.circle", "expected busy icon for \(status)")
43
+ #expect(!presentation.primaryActionEnabled, "Start must be disabled during \(status)")
44
+ let trimmed = status.trimmingCharacters(in: .punctuationCharacters)
45
+ #expect(presentation.statusText == trimmed)
46
+ #expect(presentation.accessibilityLabel == "Recordings, \(trimmed.lowercased())")
47
+ }
48
+ }
49
+
50
+ @Test("presentation follows canStartRecording exactly, not isTranscribing alone")
51
+ func presentationTracksTheStartGate() {
52
+ // A pending intent decision keeps canStartRecording false even though
53
+ // isTranscribing is false — the old menu bar showed idle here and then rejected
54
+ // the click. The contract requires busy.
55
+ let deciding = MenuBarPresentation(
56
+ isRecording: false,
57
+ canStartRecording: false,
58
+ statusMessage: "Deciding..."
59
+ )
60
+ #expect(!deciding.primaryActionEnabled)
61
+ #expect(deciding.statusText == "Deciding")
62
+ }
63
+ }
64
+
65
+ /// Reduce Transparency surface contract: chrome must resolve to an opaque surface — never
66
+ /// a translucent material — whenever the user has reduced transparency.
67
+ struct ChromeSurfaceTests {
68
+ @Test("Reduce Transparency resolves to the opaque surface; otherwise Liquid Glass")
69
+ func reducedTransparencyIsOpaque() {
70
+ #expect(ChromeSurface.forReducedTransparency(true) == .opaque)
71
+ #expect(ChromeSurface.forReducedTransparency(false) == .liquidGlass)
72
+ }
73
+ }
74
+
75
+ /// Voice-shortcut matching contract: exact utterance only.
76
+ struct VoiceShortcutMatchingTests {
77
+ @Test("a shortcut fires for the exact utterance, ignoring case, punctuation, and spacing")
78
+ func exactUtteranceMatches() {
79
+ #expect(VoiceShortcuts.matches(trigger: "add disclaimer", transcript: "add disclaimer"))
80
+ #expect(VoiceShortcuts.matches(trigger: "add disclaimer", transcript: "Add disclaimer."))
81
+ #expect(VoiceShortcuts.matches(trigger: "add disclaimer", transcript: " ADD DISCLAIMER! "))
82
+ #expect(VoiceShortcuts.matches(trigger: "Add Disclaimer", transcript: "add disclaimer"))
83
+ }
84
+
85
+ @Test("ordinary sentences containing the trigger cannot hijack routing")
86
+ func embeddedTriggerDoesNotMatch() {
87
+ #expect(!VoiceShortcuts.matches(trigger: "add disclaimer", transcript: "what does add disclaimer mean?"))
88
+ #expect(!VoiceShortcuts.matches(trigger: "add disclaimer", transcript: "please add disclaimer to the doc"))
89
+ #expect(!VoiceShortcuts.matches(trigger: "add disclaimer", transcript: "add disclaimers"))
90
+ #expect(!VoiceShortcuts.matches(trigger: "sig", transcript: "the signature looks wrong"))
91
+ }
92
+
93
+ @Test("empty or whitespace triggers never match")
94
+ func emptyTriggerNeverMatches() {
95
+ #expect(!VoiceShortcuts.matches(trigger: "", transcript: ""))
96
+ #expect(!VoiceShortcuts.matches(trigger: " ", transcript: "anything"))
97
+ }
98
+ }
@@ -14,6 +14,26 @@ struct NativeAppDiagnosticsTests {
14
14
  #expect(text.contains("diagnostic-test"))
15
15
  }
16
16
 
17
+ @Test("Native app log redacts credentials before persistence")
18
+ func logRedactsCredentials() throws {
19
+ let home = try makeHome()
20
+ let keyFragment = "sk-" + "synthetic-log-fragment-123456"
21
+ let bearerFragment = "synthetic-log-bearer-123456"
22
+ let tokenFragment = "synthetic-log-token-123456"
23
+
24
+ NativeAppLog.write(
25
+ "request failed key=\(keyFragment) Authorization: Bearer \(bearerFragment) endpoint?api_key=\(tokenFragment)",
26
+ homePath: home
27
+ )
28
+
29
+ let path = "\(home)/.hasna/recordings/Recordings.log"
30
+ let text = try String(contentsOfFile: path, encoding: .utf8)
31
+ #expect(!text.contains(keyFragment))
32
+ #expect(!text.contains(bearerFragment))
33
+ #expect(!text.contains(tokenFragment))
34
+ #expect(text.contains("[REDACTED]"))
35
+ }
36
+
17
37
  private func makeHome() throws -> String {
18
38
  let url = FileManager.default.temporaryDirectory
19
39
  .appendingPathComponent("recordings-diagnostics-tests-\(UUID().uuidString)")
@@ -1,4 +1,4 @@
1
- import AVFoundation
1
+ @preconcurrency import AVFoundation
2
2
  import Testing
3
3
  @testable import RecordingsLib
4
4
 
@@ -30,4 +30,373 @@ struct NativePCMRecorderTests {
30
30
  #expect(data.count == 8)
31
31
  #expect(data.withUnsafeBytes { $0.load(as: Int16.self) } == 1)
32
32
  }
33
+
34
+ @Test("stop waits for an admitted callback, rejects later callbacks, then emits converter tail")
35
+ func stopDrainsAdmittedCallbackAndConverterTail() throws {
36
+ let inputFormat = try #require(AVAudioFormat(
37
+ standardFormatWithSampleRate: 48_000,
38
+ channels: 1
39
+ ))
40
+ let outputFormat = try #require(NativePCMRecorder.realtimeOutputFormat())
41
+ let converter = try #require(AVAudioConverter(from: inputFormat, to: outputFormat))
42
+ converter.primeMethod = .normal
43
+ let buffer = try #require(AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: 480))
44
+ buffer.frameLength = 480
45
+ let samples = try #require(buffer.floatChannelData?[0])
46
+ for index in 0..<Int(buffer.frameLength) {
47
+ samples[index] = Float(index % 32) / 32
48
+ }
49
+ let bufferBox = NativeRecorderAudioBufferBox(buffer)
50
+
51
+ let probe = NativeRecorderLifecycleProbe()
52
+ let tail = Data([0x54, 0x41, 0x49, 0x4c])
53
+ let recorder = NativePCMRecorder(
54
+ testingInputFormat: inputFormat,
55
+ outputFormat: outputFormat,
56
+ converter: converter,
57
+ stopCapture: { probe.captureStopped.signal() },
58
+ onCallbackAdmitted: { probe.callbackAdmitted() },
59
+ finalizeConverter: { _, _ in
60
+ probe.finalizerRan()
61
+ return [tail]
62
+ },
63
+ onPCM: { probe.emit($0) }
64
+ )
65
+
66
+ DispatchQueue.global(qos: .userInitiated).async {
67
+ recorder.processInputBufferForTesting(bufferBox.value)
68
+ probe.callbackReturned.signal()
69
+ }
70
+ #expect(probe.firstCallbackEntered.wait(timeout: .now() + 1) == .success)
71
+
72
+ DispatchQueue.global(qos: .userInitiated).async {
73
+ recorder.stop()
74
+ probe.stopReturned.signal()
75
+ }
76
+ #expect(probe.captureStopped.wait(timeout: .now() + 1) == .success)
77
+
78
+ recorder.processInputBufferForTesting(bufferBox.value)
79
+ #expect(probe.admissionCount == 1)
80
+ #expect(probe.finalizationCount == 0)
81
+
82
+ probe.releaseFirstCallback.signal()
83
+ #expect(probe.callbackReturned.wait(timeout: .now() + 1) == .success)
84
+ #expect(probe.stopReturned.wait(timeout: .now() + 1) == .success)
85
+ #expect(probe.finalizationCount == 1)
86
+ #expect(probe.emissions.last == tail)
87
+ }
88
+
89
+ @Test("stop executes AVAudioConverter end-of-stream finalization")
90
+ func stopFinalizesActualConverter() throws {
91
+ let inputFormat = try #require(AVAudioFormat(
92
+ standardFormatWithSampleRate: 44_100,
93
+ channels: 1
94
+ ))
95
+ let outputFormat = try #require(NativePCMRecorder.realtimeOutputFormat())
96
+ let converter = try #require(AVAudioConverter(from: inputFormat, to: outputFormat))
97
+ converter.primeMethod = .normal
98
+ let buffer = try #require(AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: 441))
99
+ buffer.frameLength = 441
100
+ let samples = try #require(buffer.floatChannelData?[0])
101
+ for index in 0..<Int(buffer.frameLength) {
102
+ samples[index] = Float(index % 16) / 16
103
+ }
104
+
105
+ let probe = NativeRecorderLifecycleProbe()
106
+ let recorder = NativePCMRecorder(
107
+ testingInputFormat: inputFormat,
108
+ outputFormat: outputFormat,
109
+ converter: converter,
110
+ stopCapture: {},
111
+ onCallbackAdmitted: {},
112
+ finalizeConverter: { converter, format in
113
+ probe.finalizerRan()
114
+ return NativePCMRecorder.finalizeConverterTail(converter, outputFormat: format)
115
+ },
116
+ onPCM: { probe.emit($0) }
117
+ )
118
+
119
+ recorder.processInputBufferForTesting(buffer)
120
+ let bytesBeforeStop = probe.emittedByteCount
121
+ recorder.stop()
122
+
123
+ #expect(probe.finalizationCount == 1)
124
+ #expect(probe.emittedByteCount > bytesBeforeStop)
125
+ }
126
+
127
+ @Test("start reports busy while recorder is running or another public start is failing")
128
+ func concurrentStartNeverReportsFalseSuccess() throws {
129
+ let inputFormat = try #require(AVAudioFormat(
130
+ standardFormatWithSampleRate: 48_000,
131
+ channels: 1
132
+ ))
133
+ let outputFormat = try #require(NativePCMRecorder.realtimeOutputFormat())
134
+ let converter = try #require(AVAudioConverter(from: inputFormat, to: outputFormat))
135
+ let runningRecorder = NativePCMRecorder(
136
+ testingInputFormat: inputFormat,
137
+ outputFormat: outputFormat,
138
+ converter: converter,
139
+ stopCapture: {},
140
+ onCallbackAdmitted: {},
141
+ finalizeConverter: { _, _ in [] },
142
+ onPCM: { _ in }
143
+ )
144
+
145
+ expectBusyStart(runningRecorder)
146
+ runningRecorder.stop()
147
+
148
+ let probe = NativeRecorderLifecycleProbe()
149
+ let startingRecorder = NativePCMRecorder(
150
+ testingInputFormat: inputFormat,
151
+ outputFormat: outputFormat,
152
+ converter: converter,
153
+ stopCapture: {},
154
+ startCapture: {
155
+ probe.startCaptureEntered.signal()
156
+ probe.releaseStartCapture.wait()
157
+ throw NativeRecorderTestError.syntheticStartFailure
158
+ },
159
+ onCallbackAdmitted: {},
160
+ finalizeConverter: { _, _ in [] },
161
+ startsRunning: false,
162
+ onPCM: { _ in }
163
+ )
164
+
165
+ DispatchQueue.global(qos: .userInitiated).async {
166
+ do {
167
+ try startingRecorder.start()
168
+ probe.recordStartResult("success")
169
+ } catch NativePCMRecorderError.failedToStart(_) {
170
+ probe.recordStartResult("failed")
171
+ } catch {
172
+ probe.recordStartResult("wrong-error")
173
+ }
174
+ probe.startReturned.signal()
175
+ }
176
+ #expect(probe.startCaptureEntered.wait(timeout: .now() + 1) == .success)
177
+
178
+ expectBusyStart(startingRecorder)
179
+ probe.releaseStartCapture.signal()
180
+ #expect(probe.startReturned.wait(timeout: .now() + 1) == .success)
181
+ #expect(probe.startResult == "failed")
182
+ #expect(startingRecorder.isIdleForTesting)
183
+ }
184
+
185
+ @Test("start reports busy while stop owns the recorder")
186
+ func startRacingStopReportsBusy() throws {
187
+ let inputFormat = try #require(AVAudioFormat(
188
+ standardFormatWithSampleRate: 48_000,
189
+ channels: 1
190
+ ))
191
+ let outputFormat = try #require(NativePCMRecorder.realtimeOutputFormat())
192
+ let converter = try #require(AVAudioConverter(from: inputFormat, to: outputFormat))
193
+ let probe = NativeRecorderLifecycleProbe()
194
+ let recorder = NativePCMRecorder(
195
+ testingInputFormat: inputFormat,
196
+ outputFormat: outputFormat,
197
+ converter: converter,
198
+ stopCapture: {
199
+ probe.captureStopped.signal()
200
+ probe.releaseCaptureStop.wait()
201
+ },
202
+ onCallbackAdmitted: {},
203
+ finalizeConverter: { _, _ in [] },
204
+ onPCM: { _ in }
205
+ )
206
+
207
+ DispatchQueue.global(qos: .userInitiated).async {
208
+ recorder.stop()
209
+ probe.stopReturned.signal()
210
+ }
211
+ #expect(probe.captureStopped.wait(timeout: .now() + 1) == .success)
212
+
213
+ expectBusyStart(recorder)
214
+ probe.releaseCaptureStop.signal()
215
+ #expect(probe.stopReturned.wait(timeout: .now() + 1) == .success)
216
+ #expect(recorder.isIdleForTesting)
217
+ }
218
+
219
+ @Test("stop called from a normal PCM delivery finishes without waiting on itself")
220
+ func reentrantStopFromPCMDelivery() throws {
221
+ let inputFormat = try #require(AVAudioFormat(
222
+ standardFormatWithSampleRate: 48_000,
223
+ channels: 1
224
+ ))
225
+ let outputFormat = try #require(NativePCMRecorder.realtimeOutputFormat())
226
+ let converter = try #require(AVAudioConverter(from: inputFormat, to: outputFormat))
227
+ let recorderBox = NativeRecorderReferenceBox()
228
+ let probe = NativeRecorderLifecycleProbe()
229
+ let recorder = NativePCMRecorder(
230
+ testingInputFormat: inputFormat,
231
+ outputFormat: outputFormat,
232
+ converter: converter,
233
+ stopCapture: { probe.captureStopped.signal() },
234
+ onCallbackAdmitted: {},
235
+ finalizeConverter: { _, _ in
236
+ probe.finalizerRan()
237
+ return []
238
+ },
239
+ onPCM: { _ in
240
+ guard probe.claimFirstReentrantStop() else { return }
241
+ recorderBox.recorder?.stop()
242
+ probe.reentrantStopReturned.signal()
243
+ }
244
+ )
245
+ recorderBox.recorder = recorder
246
+
247
+ DispatchQueue.global(qos: .userInitiated).async {
248
+ recorder.deliverPCMForTesting(Data([0x50, 0x43, 0x4d]))
249
+ probe.callbackReturned.signal()
250
+ }
251
+
252
+ #expect(probe.reentrantStopReturned.wait(timeout: .now() + 1) == .success)
253
+ #expect(probe.callbackReturned.wait(timeout: .now() + 1) == .success)
254
+ recorder.stop()
255
+ #expect(probe.finalizationCount == 1)
256
+ #expect(recorder.isIdleForTesting)
257
+ }
258
+
259
+ @Test("stop called from converter tail delivery returns without deadlock")
260
+ func reentrantStopFromTailDelivery() throws {
261
+ let inputFormat = try #require(AVAudioFormat(
262
+ standardFormatWithSampleRate: 48_000,
263
+ channels: 1
264
+ ))
265
+ let outputFormat = try #require(NativePCMRecorder.realtimeOutputFormat())
266
+ let converter = try #require(AVAudioConverter(from: inputFormat, to: outputFormat))
267
+ let recorderBox = NativeRecorderReferenceBox()
268
+ let probe = NativeRecorderLifecycleProbe()
269
+ let recorder = NativePCMRecorder(
270
+ testingInputFormat: inputFormat,
271
+ outputFormat: outputFormat,
272
+ converter: converter,
273
+ stopCapture: {},
274
+ onCallbackAdmitted: {},
275
+ finalizeConverter: { _, _ in [Data([0x54])] },
276
+ onPCM: { _ in
277
+ recorderBox.recorder?.stop()
278
+ probe.reentrantStopReturned.signal()
279
+ }
280
+ )
281
+ recorderBox.recorder = recorder
282
+
283
+ recorder.stop()
284
+
285
+ #expect(probe.reentrantStopReturned.wait(timeout: .now() + 1) == .success)
286
+ #expect(recorder.isIdleForTesting)
287
+ }
288
+
289
+ private func expectBusyStart(_ recorder: NativePCMRecorder) {
290
+ do {
291
+ try recorder.start()
292
+ Issue.record("start unexpectedly reported success for a non-idle recorder")
293
+ } catch NativePCMRecorderError.alreadyActive {
294
+ // Expected: a competing caller must never observe false success.
295
+ } catch {
296
+ Issue.record("start returned the wrong error: \(error)")
297
+ }
298
+ }
299
+ }
300
+
301
+ private final class NativeRecorderAudioBufferBox: @unchecked Sendable {
302
+ let value: AVAudioPCMBuffer
303
+
304
+ init(_ value: AVAudioPCMBuffer) {
305
+ self.value = value
306
+ }
307
+ }
308
+
309
+ private final class NativeRecorderReferenceBox: @unchecked Sendable {
310
+ var recorder: NativePCMRecorder?
311
+ }
312
+
313
+ private enum NativeRecorderTestError: Error {
314
+ case syntheticStartFailure
315
+ }
316
+
317
+ private final class NativeRecorderLifecycleProbe: @unchecked Sendable {
318
+ let firstCallbackEntered = DispatchSemaphore(value: 0)
319
+ let releaseFirstCallback = DispatchSemaphore(value: 0)
320
+ let callbackReturned = DispatchSemaphore(value: 0)
321
+ let captureStopped = DispatchSemaphore(value: 0)
322
+ let stopReturned = DispatchSemaphore(value: 0)
323
+ let releaseCaptureStop = DispatchSemaphore(value: 0)
324
+ let reentrantStopReturned = DispatchSemaphore(value: 0)
325
+ let startCaptureEntered = DispatchSemaphore(value: 0)
326
+ let releaseStartCapture = DispatchSemaphore(value: 0)
327
+ let startReturned = DispatchSemaphore(value: 0)
328
+
329
+ private let lock = NSLock()
330
+ private var admissions = 0
331
+ private var finalizations = 0
332
+ private var emitted: [Data] = []
333
+ private var didClaimReentrantStop = false
334
+ private var storedStartResult: String?
335
+
336
+ var admissionCount: Int {
337
+ lock.lock()
338
+ defer { lock.unlock() }
339
+ return admissions
340
+ }
341
+
342
+ var finalizationCount: Int {
343
+ lock.lock()
344
+ defer { lock.unlock() }
345
+ return finalizations
346
+ }
347
+
348
+ var emissions: [Data] {
349
+ lock.lock()
350
+ defer { lock.unlock() }
351
+ return emitted
352
+ }
353
+
354
+ var emittedByteCount: Int {
355
+ lock.lock()
356
+ defer { lock.unlock() }
357
+ return emitted.reduce(0) { $0 + $1.count }
358
+ }
359
+
360
+ var startResult: String? {
361
+ lock.lock()
362
+ defer { lock.unlock() }
363
+ return storedStartResult
364
+ }
365
+
366
+ func callbackAdmitted() {
367
+ lock.lock()
368
+ admissions += 1
369
+ let shouldBlock = admissions == 1
370
+ lock.unlock()
371
+ if shouldBlock {
372
+ firstCallbackEntered.signal()
373
+ releaseFirstCallback.wait()
374
+ }
375
+ }
376
+
377
+ func finalizerRan() {
378
+ lock.lock()
379
+ finalizations += 1
380
+ lock.unlock()
381
+ }
382
+
383
+ func emit(_ data: Data) {
384
+ lock.lock()
385
+ emitted.append(data)
386
+ lock.unlock()
387
+ }
388
+
389
+ func claimFirstReentrantStop() -> Bool {
390
+ lock.lock()
391
+ defer { lock.unlock() }
392
+ guard !didClaimReentrantStop else { return false }
393
+ didClaimReentrantStop = true
394
+ return true
395
+ }
396
+
397
+ func recordStartResult(_ result: String) {
398
+ lock.lock()
399
+ storedStartResult = result
400
+ lock.unlock()
401
+ }
33
402
  }