@hasna/recordings 0.3.13 → 0.5.0

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 (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -0,0 +1,308 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Testing
4
+ @testable import RecordingsLib
5
+
6
+ private final class ProviderTestRecorder: PCMRecordingSource, @unchecked Sendable {
7
+ private let lock = NSLock()
8
+ private var callback: (@Sendable (Data) -> Void)?
9
+ private let stopPCM: Data
10
+ private(set) var stopped = false
11
+ init(stopPCM: Data = Data()) { self.stopPCM = stopPCM }
12
+ func attach(_ callback: @escaping @Sendable (Data) -> Void) { lock.withLock { self.callback = callback } }
13
+ func emit(_ data: Data) { lock.withLock { callback }?(data) }
14
+ func start() throws {}
15
+ func stop() {
16
+ let delivery = lock.withLock { () -> (@Sendable (Data) -> Void)? in
17
+ guard !stopped else { return nil }
18
+ stopped = true
19
+ return callback
20
+ }
21
+ if !stopPCM.isEmpty { delivery?(stopPCM) }
22
+ }
23
+ }
24
+
25
+ private final class ProviderTestSession: RecordingTranscriptionSession, @unchecked Sendable {
26
+ private let lock = NSLock()
27
+ private var audio = Data()
28
+ private var packets: [Data] = []
29
+ private var request: RecordingTranscriptionRequest?
30
+ private var partial: (@Sendable (String) -> Void)?
31
+ private var cancelled = false
32
+ private var inputEndings: [Data] = []
33
+ private var gate: CheckedContinuation<RecordingProviderResult, Never>?
34
+ let delayFinish: Bool
35
+
36
+ init(delayFinish: Bool = false) { self.delayFinish = delayFinish }
37
+ var snapshot: (Data, RecordingTranscriptionRequest?, Bool) { lock.withLock { (audio, request, cancelled) } }
38
+ var endedPCM: [Data] { lock.withLock { inputEndings } }
39
+ var receivedPackets: [Data] { lock.withLock { packets } }
40
+ func configure(_ partial: @escaping @Sendable (String) -> Void) { lock.withLock { self.partial = partial } }
41
+ func emitPartial(_ text: String) { lock.withLock { partial }?(text) }
42
+ func appendPCM(_ data: Data) {
43
+ lock.withLock { if !cancelled { audio.append(data); packets.append(data) } }
44
+ }
45
+ func inputEnded() { lock.withLock { if !cancelled { inputEndings.append(audio) } } }
46
+ func finish(_ request: RecordingTranscriptionRequest) async throws -> RecordingProviderResult {
47
+ if delayFinish {
48
+ return await withCheckedContinuation { continuation in
49
+ lock.withLock { self.request = request; gate = continuation }
50
+ }
51
+ }
52
+ lock.withLock { self.request = request }
53
+ return RecordingProviderResult(rawText: "spoken words", processedText: "Spoken words.")
54
+ }
55
+ func cancel() { lock.withLock { cancelled = true } }
56
+ func resolve() {
57
+ let continuation = lock.withLock { let value = gate; gate = nil; return value }
58
+ continuation?.resume(returning: RecordingProviderResult(rawText: "late result"))
59
+ }
60
+ }
61
+
62
+ private struct ProviderTestAdapter: RecordingTranscriptionProvider {
63
+ let session: ProviderTestSession
64
+ func makeSession(configuration: RecordingProviderSessionConfiguration, onPartialTranscript: @escaping @Sendable (String) -> Void) throws -> any RecordingTranscriptionSession {
65
+ #expect(configuration.sampleRate == 24_000)
66
+ #expect(configuration.channels == 1)
67
+ #expect(configuration.bitsPerSample == 16)
68
+ session.configure(onPartialTranscript)
69
+ return session
70
+ }
71
+ }
72
+
73
+ @MainActor
74
+ struct RecordingProviderTests {
75
+ private func engine(_ session: ProviderTestSession, recorder: ProviderTestRecorder, suite: String = "recordings-provider-tests-\(UUID().uuidString)") throws -> RecordingEngine {
76
+ let configuration = try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("provider"), preferencesSuiteName: suite)
77
+ let engine = RecordingEngine(configuration: configuration, transcriptionProvider: ProviderTestAdapter(session: session))
78
+ engine.autoPasteEnabled = false
79
+ engine.microphoneAuthorization = { .authorized }
80
+ engine.accessibilityTrustCheck = { false }
81
+ engine.frontmostAppSnapshot = { nil }
82
+ engine.focusedWindowTitleLookup = { _ in nil }
83
+ engine.openAIAPIKeyProvider = { Issue.record("Isolated provider consulted legacy credentials"); return "" }
84
+ engine.commandCLI = { _, _, _ in Issue.record("Isolated provider launched legacy CLI"); return "ERROR: forbidden" }
85
+ engine.recorderFactory = { callback in recorder.attach(callback); return recorder }
86
+ return engine
87
+ }
88
+
89
+ private func eventually(_ condition: @MainActor () -> Bool) async -> Bool {
90
+ for _ in 0..<200 {
91
+ if condition() { return true }
92
+ try? await Task.sleep(for: .milliseconds(10))
93
+ }
94
+ return condition()
95
+ }
96
+
97
+ @Test("PCM streams before stop, pause excludes samples, and the final WAV matches the stream")
98
+ func liveStreamingAndPause() async throws {
99
+ let session = ProviderTestSession()
100
+ let recorder = ProviderTestRecorder()
101
+ let engine = try engine(session, recorder: recorder)
102
+ engine.startRecording()
103
+ let first = Data(repeating: 1, count: 4_800)
104
+ recorder.emit(first)
105
+ #expect(await eventually { engine.isRecording && session.snapshot.0 == first })
106
+ #expect(session.snapshot.1 == nil, "Streaming must precede finish")
107
+ session.emitPartial("spoken")
108
+ #expect(await eventually { engine.liveTranscriptionText == "spoken" })
109
+ engine.togglePause()
110
+ recorder.emit(Data(repeating: 9, count: 4_800))
111
+ engine.togglePause()
112
+ let tail = Data(repeating: 2, count: 1_600)
113
+ recorder.emit(tail)
114
+ engine.stopAndTranscribe()
115
+ #expect(await eventually { !engine.isTranscribing })
116
+ let request = try #require(session.snapshot.1)
117
+ #expect(session.snapshot.0 == first + tail)
118
+ #expect(session.endedPCM == [first + tail], "Input ends exactly once after the short tail, before file-based finish")
119
+ #expect(request.duration == Double(6_400) / 48_000)
120
+ let wav = try Data(contentsOf: request.audioURL)
121
+ #expect(String(data: wav.prefix(4), encoding: .utf8) == "RIFF")
122
+ #expect(Data(wav.dropFirst(44)) == first + tail)
123
+ let transcript = try #require(engine.recentTranscriptions.first)
124
+ #expect(transcript.captureID == request.captureID)
125
+ #expect(transcript.audioURL == request.audioURL)
126
+ #expect(transcript.rawText == "spoken words")
127
+ #expect(transcript.displayText == "Spoken words.")
128
+ #expect(engine.flowPhase == .ready("Transcript ready — auto-paste is off"))
129
+ #expect(engine.persistedRecordingRevision == 0, "An adapter result is not a persistence receipt")
130
+ #expect(recorder.stopped)
131
+ }
132
+
133
+ @Test("A paused provider receives the entire first packet without waiting for a network chunk or Stop",
134
+ arguments: [2, 1_600, 3_888, 4_800, 4_802])
135
+ func subChunkPCMReachesProviderBeforeStop(byteCount: Int) async throws {
136
+ let session = ProviderTestSession()
137
+ let recorder = ProviderTestRecorder()
138
+ let engine = try engine(session, recorder: recorder)
139
+ defer { engine.cancelRecording() }
140
+ let pcm = Data((0..<byteCount).map { UInt8(truncatingIfNeeded: $0 * 17) })
141
+ engine.startRecording()
142
+ recorder.emit(pcm)
143
+ #expect(await eventually { engine.isRecording })
144
+ engine.togglePause()
145
+ recorder.emit(Data(repeating: 9, count: 4_800))
146
+ #expect(await eventually { session.snapshot.0 == pcm }, "Pause must not withhold a partial network chunk")
147
+ #expect(session.receivedPackets == [pcm])
148
+ #expect(session.endedPCM.isEmpty)
149
+ #expect(session.snapshot.1 == nil)
150
+
151
+ engine.stopAndTranscribe()
152
+ #expect(await eventually { !engine.isTranscribing })
153
+ let request = try #require(session.snapshot.1)
154
+ #expect(session.receivedPackets == [pcm], "Stop must not repeat already delivered PCM")
155
+ #expect(session.endedPCM == [pcm])
156
+ #expect(Data(try Data(contentsOf: request.audioURL).dropFirst(44)) == pcm)
157
+ #expect(request.duration == Double(pcm.count) / 48_000)
158
+ }
159
+
160
+ @Test("Pause and resume preserve packet order; an admitted converter tail precedes inputEnded",
161
+ arguments: [false, true])
162
+ func orderedPauseResumeAndConverterTail(stopWhilePaused: Bool) async throws {
163
+ let session = ProviderTestSession()
164
+ let converterTail = Data([0x10, 0x20, 0x30, 0x40])
165
+ let recorder = ProviderTestRecorder(stopPCM: converterTail)
166
+ let engine = try engine(session, recorder: recorder)
167
+ defer { engine.cancelRecording() }
168
+ let first = Data(repeating: 1, count: 4_800)
169
+ let pauseTail = Data(repeating: 2, count: 3_888)
170
+ let resumed = [Data([3, 4]), Data(repeating: 5, count: 4_798), Data(repeating: 6, count: 9_602)]
171
+ engine.startRecording()
172
+ recorder.emit(first)
173
+ #expect(await eventually { engine.isRecording && session.snapshot.0 == first })
174
+ recorder.emit(pauseTail)
175
+ engine.togglePause()
176
+ recorder.emit(Data(repeating: 9, count: 5_000))
177
+ #expect(await eventually { session.snapshot.0 == first + pauseTail })
178
+ #expect(session.receivedPackets == [first, pauseTail])
179
+ engine.togglePause()
180
+ recorder.emit(Data())
181
+ for packet in resumed { recorder.emit(packet) }
182
+ let beforeStopPackets = [first, pauseTail] + resumed
183
+ let beforeStopPCM = beforeStopPackets.reduce(into: Data()) { $0.append($1) }
184
+ #expect(await eventually { session.snapshot.0 == beforeStopPCM })
185
+ #expect(session.receivedPackets == beforeStopPackets)
186
+ #expect(session.endedPCM.isEmpty)
187
+ if stopWhilePaused { engine.togglePause() }
188
+
189
+ engine.stopAndTranscribe()
190
+ #expect(await eventually { !engine.isTranscribing })
191
+ let request = try #require(session.snapshot.1)
192
+ let expectedPackets = beforeStopPackets + (stopWhilePaused ? [] : [converterTail])
193
+ let expectedPCM = expectedPackets.reduce(into: Data()) { $0.append($1) }
194
+ #expect(session.receivedPackets == expectedPackets)
195
+ #expect(session.endedPCM == [expectedPCM], "All admitted PCM must arrive exactly once before inputEnded")
196
+ #expect(Data(try Data(contentsOf: request.audioURL).dropFirst(44)) == expectedPCM)
197
+ #expect(request.duration == Double(expectedPCM.count) / 48_000)
198
+ #expect(recorder.stopped)
199
+ }
200
+
201
+ @Test("explicit no-target capture saves transcript and records copy-only non-delivery without AX or a paste")
202
+ func frozenNoTargetReceipt() async throws {
203
+ for copied in [true, false] {
204
+ let session = ProviderTestSession()
205
+ let recorder = ProviderTestRecorder()
206
+ let engine = try engine(session, recorder: recorder)
207
+ engine.autoPasteEnabled = true
208
+ engine.protectedOperationTrust = { Issue.record("No-target delivery must not request Accessibility"); return AccessibilityTrustResult(trusted: false, didPrompt: false) }
209
+ var writes: [String] = []
210
+ engine.pasteFallbackWriter = { text in writes.append(text); return copied }
211
+ engine.startRecording(pasteTarget: .frozen(nil))
212
+ recorder.emit(Data(repeating: 1, count: 4_800))
213
+ #expect(await eventually { engine.isRecording })
214
+ engine.stopAndTranscribe()
215
+ #expect(await eventually { engine.recentPastes.count == 1 })
216
+ let transcript = try #require(engine.recentTranscriptions.first)
217
+ let receipt = try #require(engine.recentPastes.first)
218
+ #expect(receipt.captureID == transcript.captureID)
219
+ #expect(receipt.deliveryStatus == .notDelivered)
220
+ #expect(!receipt.verified)
221
+ #expect(receipt.bundleIdentifier == nil)
222
+ #expect(receipt.appName == "No target app")
223
+ #expect(receipt.location == (copied ? "Clipboard only" : ""))
224
+ #expect(writes == ["Spoken words."])
225
+ #expect(engine.canStartRecording)
226
+ }
227
+ }
228
+
229
+ @Test("cancel during provider finalization drops late text and allows another capture")
230
+ func cancellationDropsStaleCompletions() async throws {
231
+ let session = ProviderTestSession(delayFinish: true)
232
+ let recorder = ProviderTestRecorder()
233
+ let engine = try engine(session, recorder: recorder)
234
+ engine.startRecording()
235
+ recorder.emit(Data(repeating: 1, count: 4_800))
236
+ #expect(await eventually { engine.isRecording })
237
+ engine.stopAndTranscribe()
238
+ #expect(await eventually { session.snapshot.1 != nil })
239
+ engine.cancelRecording()
240
+ #expect(session.snapshot.2)
241
+ #expect(engine.canStartRecording)
242
+ session.emitPartial("late partial")
243
+ session.resolve()
244
+ try await Task.sleep(for: .milliseconds(50))
245
+ #expect(engine.liveTranscriptionText.isEmpty)
246
+ #expect(engine.recentTranscriptions.isEmpty)
247
+ #expect(engine.flowPhase == .idle)
248
+ }
249
+
250
+ @Test("discard during warm-up cancels the provider without a final request")
251
+ func warmupCancellation() throws {
252
+ let session = ProviderTestSession()
253
+ let recorder = ProviderTestRecorder()
254
+ let engine = try engine(session, recorder: recorder)
255
+ engine.startRecording()
256
+ engine.cancelRecording()
257
+ #expect(session.snapshot.2)
258
+ #expect(session.snapshot.1 == nil)
259
+ #expect(engine.canStartRecording)
260
+ #expect(recorder.stopped)
261
+ }
262
+
263
+ @Test("preferences are persistent per candidate suite and never write the legacy defaults")
264
+ func preferencesIsolation() throws {
265
+ let keys = ["useFnKey", "intentDetectionEnabled", "recordingsAutoPaste", "recordingsLanguage"]
266
+ let before = keys.map { UserDefaults.standard.object(forKey: $0) as? NSObject }
267
+ let firstSuite = "recordings-provider-tests-\(UUID().uuidString)"
268
+ let secondSuite = "recordings-provider-tests-\(UUID().uuidString)"
269
+ defer {
270
+ UserDefaults(suiteName: firstSuite)?.removePersistentDomain(forName: firstSuite)
271
+ UserDefaults(suiteName: secondSuite)?.removePersistentDomain(forName: secondSuite)
272
+ }
273
+ let first = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: firstSuite)
274
+ first.useFnKey = true // Must not install any monitor even when the preference changes.
275
+ first.intentDetectionEnabled = true
276
+ first.transcriptionLanguage = "ro"
277
+ first.autoPasteEnabled = true
278
+ let second = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: secondSuite)
279
+ #expect(!second.useFnKey)
280
+ #expect(!second.intentDetectionEnabled)
281
+ #expect(second.transcriptionLanguage == "en")
282
+ let reloaded = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: firstSuite)
283
+ #expect(reloaded.useFnKey)
284
+ #expect(reloaded.intentDetectionEnabled)
285
+ #expect(reloaded.transcriptionLanguage == "ro")
286
+ #expect(keys.map { UserDefaults.standard.object(forKey: $0) as? NSObject } == before)
287
+ #expect(!FileManager.default.fileExists(atPath: "\(first.home)/.hasna/recordings/config.json"))
288
+ }
289
+
290
+ @Test("the isolated initializer rejects the installed app's state and defaults")
291
+ func rejectsLiveRoots() throws {
292
+ #expect(throws: RecordingProviderError.self) {
293
+ try RecordingEngineConfiguration(isolatedHomePath: FileManager.default.homeDirectoryForCurrentUser.path, preferencesSuiteName: "candidate")
294
+ }
295
+ #expect(throws: RecordingProviderError.self) {
296
+ try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("provider"), preferencesSuiteName: "com.hasna.recordings")
297
+ }
298
+ }
299
+
300
+ @Test("public paste receipts distinguish an unverified post from evidence of failure")
301
+ func typedPasteOutcomes() {
302
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .pasted) == .confirmed)
303
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .deliveredUnverified(.readBackNotAttempted)) == .unconfirmed)
304
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .deliveryNotObserved) == .notDelivered)
305
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .clipboardWriteFailed) == .notDelivered)
306
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .targetUnavailable) == .notDelivered)
307
+ }
308
+ }
@@ -6,7 +6,7 @@ struct RecordingStartGateTests {
6
6
  @Test("permission-only launch skips global handlers and terminates after handling")
7
7
  func permissionHelperLaunchPlan() {
8
8
  let plan = PermissionRequestLaunchPlan(arguments: [
9
- "/Applications/HasnaRecordings.app/Contents/MacOS/Recordings",
9
+ "/Applications/Hasna Recordings.app/Contents/MacOS/Recordings",
10
10
  "--request-permissions",
11
11
  "--open-permission-settings",
12
12
  ])
@@ -11,8 +11,14 @@ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
11
11
  private let lock = NSLock()
12
12
  private var startedFlag = false
13
13
  private var stoppedFlag = false
14
+ private var stoppingFlag = false
15
+ private let stopGate: DispatchSemaphore?
14
16
  private var onPCM: (@Sendable (Data) -> Void)?
15
17
 
18
+ init(stopGate: DispatchSemaphore? = nil) { self.stopGate = stopGate }
19
+
20
+ var isStopping: Bool { lock.withLock { stoppingFlag } }
21
+
16
22
  var started: Bool {
17
23
  lock.withLock { startedFlag }
18
24
  }
@@ -46,6 +52,8 @@ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
46
52
  }
47
53
 
48
54
  func stop() {
55
+ lock.withLock { stoppingFlag = true }
56
+ _ = stopGate?.wait(timeout: .now() + 2)
49
57
  lock.withLock { stoppedFlag = true }
50
58
  }
51
59
  }
@@ -53,9 +61,24 @@ private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
53
61
  @MainActor
54
62
  private func makeStartableEngine(
55
63
  recorder: FakePCMRecorder,
64
+ intentDetectionEnabled: Bool = false,
56
65
  selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken? = { _ in nil }
57
66
  ) -> RecordingEngine {
58
- let engine = RecordingEngine(homePath: makeIsolatedTestHome("start-timing-tests"))
67
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("start-timing-tests"), installsGlobalHandlers: false)
68
+ // The legacy engine persists this setting. Keep the explicit mode on this instance,
69
+ // restoring the exact application-domain value before another MainActor test can run.
70
+ // This factory and the restoration are synchronous: there is no suspension in between.
71
+ let defaults = UserDefaults.standard
72
+ let domain = Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName
73
+ let priorIntent = defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"]
74
+ defer {
75
+ if let priorIntent {
76
+ defaults.set(priorIntent, forKey: "intentDetectionEnabled")
77
+ } else {
78
+ defaults.removeObject(forKey: "intentDetectionEnabled")
79
+ }
80
+ }
81
+ engine.intentDetectionEnabled = intentDetectionEnabled
59
82
  engine.openAIAPIKeyProvider = { "" }
60
83
  engine.microphoneAuthorization = { .authorized }
61
84
  engine.accessibilityTrustCheck = { true }
@@ -74,6 +97,27 @@ private func makeStartableEngine(
74
97
  return engine
75
98
  }
76
99
 
100
+ /// An entered callback is essential evidence: with intent detection disabled the engine
101
+ /// correctly skips selection capture, so merely installing a blocked closure proves nothing.
102
+ private final class BlockedSelectionCapture: @unchecked Sendable {
103
+ private let lock = NSLock()
104
+ private let gate = DispatchSemaphore(value: 0)
105
+ private var capturedPIDs: [pid_t] = []
106
+ private var returned = false
107
+
108
+ var pids: [pid_t] { lock.withLock { capturedPIDs } }
109
+ var isPending: Bool { lock.withLock { !capturedPIDs.isEmpty && !returned } }
110
+
111
+ func capture(_ pid: pid_t) -> AccessibilitySelectionToken? {
112
+ lock.withLock { capturedPIDs.append(pid) }
113
+ gate.wait()
114
+ lock.withLock { returned = true }
115
+ return AccessibilitySelectionToken.unsafeTestToken(selectedText: "frozen words")
116
+ }
117
+
118
+ func release() { gate.signal() }
119
+ }
120
+
77
121
  @MainActor
78
122
  private func waitUntil(
79
123
  timeout: TimeInterval = 5,
@@ -130,14 +174,12 @@ private func expectEmptyAttemptDisclosed(
130
174
  @MainActor
131
175
  struct RecordingStartTimingTests {
132
176
  @Test("the recorder starts on keydown even while the AX selection capture is blocked")
133
- func recorderStartDoesNotWaitOnSelectionCapture() async {
134
- let captureGate = DispatchSemaphore(value: 0)
177
+ func recorderStartDoesNotWaitOnSelectionCapture() async throws {
178
+ let capture = BlockedSelectionCapture()
179
+ defer { capture.release() }
135
180
  let recorder = FakePCMRecorder()
136
- let engine = makeStartableEngine(recorder: recorder) { _ in
137
- // Simulates a beachballing target app: the capture IPC hangs far longer than
138
- // any acceptable start budget.
139
- captureGate.wait()
140
- return nil
181
+ let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: true) { pid in
182
+ capture.capture(pid)
141
183
  }
142
184
 
143
185
  // `startRecording` runs synchronously on the MainActor through recorder start.
@@ -149,25 +191,29 @@ struct RecordingStartTimingTests {
149
191
  #expect(engine.isWarmingUpCapture, "start() returning is warm-up, not captured audio")
150
192
  #expect(engine.captureIsActive)
151
193
  #expect(engine.flowPhase == .listening)
194
+ try #require(await waitUntil { capture.isPending })
195
+ #expect(capture.pids == [99_999])
152
196
 
153
197
  #expect(await confirmCapture(engine, recorder))
154
198
  #expect(!engine.isWarmingUpCapture)
155
199
 
156
- captureGate.signal()
200
+ capture.release()
157
201
  engine.cancelRecording()
158
202
  #expect(engine.flowPhase == .idle)
159
203
  }
160
204
 
161
205
  @Test("stopping waits for the generation-bound start context instead of dropping the frozen target")
162
- func stopAwaitsFrozenStartContext() async {
163
- let captureGate = DispatchSemaphore(value: 0)
206
+ func stopAwaitsFrozenStartContext() async throws {
207
+ let capture = BlockedSelectionCapture()
208
+ defer { capture.release() }
164
209
  let recorder = FakePCMRecorder()
165
- let engine = makeStartableEngine(recorder: recorder) { _ in
166
- captureGate.wait()
167
- return AccessibilitySelectionToken.unsafeTestToken(selectedText: "frozen words")
210
+ let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: true) { pid in
211
+ capture.capture(pid)
168
212
  }
169
213
 
170
214
  engine.startRecording(trigger: .manual)
215
+ try #require(await waitUntil { capture.isPending })
216
+ #expect(capture.pids == [99_999])
171
217
  #expect(await confirmCapture(engine, recorder))
172
218
  engine.stopAndTranscribe()
173
219
  #expect(engine.isTranscribing)
@@ -175,9 +221,10 @@ struct RecordingStartTimingTests {
175
221
  // The pipeline must hold in finalizing while the frozen context is unresolved —
176
222
  // it may not deliver without the selection frozen at start.
177
223
  try? await Task.sleep(for: .milliseconds(150))
224
+ #expect(capture.isPending)
178
225
  #expect(engine.flowPhase == .finalizing)
179
226
 
180
- captureGate.signal()
227
+ capture.release()
181
228
  // No audio was produced by the fake recorder, so the pipeline ends in the
182
229
  // fail-closed no-audio state — importantly, only after the context resolved.
183
230
  #expect(await waitUntil {
@@ -188,20 +235,75 @@ struct RecordingStartTimingTests {
188
235
  #expect(engine.canStartRecording)
189
236
  }
190
237
 
238
+ @Test("intent detection disabled skips selection capture and can finish the empty attempt")
239
+ func disabledIntentDoesNotWaitForSelection() async throws {
240
+ let capture = BlockedSelectionCapture()
241
+ defer { capture.release() }
242
+ let recorder = FakePCMRecorder()
243
+ let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: false) { pid in
244
+ capture.capture(pid)
245
+ }
246
+ engine.startRecording(trigger: .manual)
247
+ try #require(await confirmCapture(engine, recorder))
248
+ engine.stopAndTranscribe()
249
+ try #require(await waitUntil { !engine.isTranscribing })
250
+ #expect(capture.pids.isEmpty)
251
+ #expect(engine.flowPhase == .failed("No audio captured"))
252
+ #expect(engine.canStartRecording)
253
+ }
254
+
255
+ @Test("fixture intent modes stay on their engine without changing the process preference")
256
+ func fixtureIntentModeIsInstanceScoped() {
257
+ let defaults = UserDefaults.standard
258
+ let domain = Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName
259
+ let before = defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"] as? NSObject
260
+ let enabled = makeStartableEngine(recorder: FakePCMRecorder(), intentDetectionEnabled: true)
261
+ #expect(enabled.intentDetectionEnabled)
262
+ #expect((defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"] as? NSObject) == before)
263
+ let disabled = makeStartableEngine(recorder: FakePCMRecorder())
264
+ #expect(!disabled.intentDetectionEnabled)
265
+ #expect(enabled.intentDetectionEnabled)
266
+ #expect((defaults.persistentDomain(forName: domain)?["intentDetectionEnabled"] as? NSObject) == before)
267
+ }
268
+
269
+ @Test("slow microphone shutdown keeps the UI responsive and finishes draining before transcription")
270
+ func stopDoesNotBlockMainActor() async {
271
+ let stopGate = DispatchSemaphore(value: 0)
272
+ let recorder = FakePCMRecorder(stopGate: stopGate)
273
+ let engine = makeStartableEngine(recorder: recorder)
274
+ engine.startRecording(trigger: .manual)
275
+ #expect(await confirmCapture(engine, recorder))
276
+
277
+ let started = ContinuousClock.now
278
+ engine.stopAndTranscribe()
279
+ #expect(ContinuousClock.now - started < .milliseconds(300))
280
+ #expect(await waitUntil { recorder.isStopping })
281
+ #expect(engine.isTranscribing)
282
+ #expect(!engine.canStartRecording)
283
+ #expect(!recorder.isStopped)
284
+
285
+ stopGate.signal()
286
+ #expect(await waitUntil { !engine.isTranscribing })
287
+ #expect(recorder.isStopped)
288
+ #expect(engine.statusMessage == "No audio captured")
289
+ }
290
+
191
291
  @Test("a released key before the capture resolves still cancels cleanly")
192
- func cancelDuringPendingCapture() async {
193
- let captureGate = DispatchSemaphore(value: 0)
292
+ func cancelDuringPendingCapture() async throws {
293
+ let capture = BlockedSelectionCapture()
294
+ defer { capture.release() }
194
295
  let recorder = FakePCMRecorder()
195
- let engine = makeStartableEngine(recorder: recorder) { _ in
196
- captureGate.wait()
197
- return nil
296
+ let engine = makeStartableEngine(recorder: recorder, intentDetectionEnabled: true) { pid in
297
+ capture.capture(pid)
198
298
  }
199
299
  engine.startRecording(trigger: .manual)
300
+ try #require(await waitUntil { capture.isPending })
301
+ #expect(capture.pids == [99_999])
200
302
  #expect(await confirmCapture(engine, recorder))
201
303
  engine.cancelRecording()
202
304
  #expect(!engine.isRecording)
203
305
  #expect(engine.flowPhase == .idle)
204
- captureGate.signal()
306
+ capture.release()
205
307
  #expect(engine.canStartRecording)
206
308
  }
207
309
  }
@@ -0,0 +1,75 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ struct ServiceAPIConfigurationTests {
6
+ @Test("a configurable service prefix keeps exactly one v1 segment", arguments: [
7
+ "https://api.example.com/recordings", "https://api.example.com/recordings/v1/",
8
+ ])
9
+ func normalizesServicePrefix(_ input: String) throws {
10
+ #expect(try ServiceAPIConfiguration.normalizedURL(input) == "https://api.example.com/recordings/v1")
11
+ }
12
+
13
+ @Test("invalid or credential-bearing URLs are rejected", arguments: [
14
+ "", "https://user:password@example.com/v1", "http://example.com/v1",
15
+ "https://example.com/v1?key=value", "https://example.com/v1#fragment",
16
+ ])
17
+ func rejectsUnsafeURL(_ input: String) {
18
+ #expect(throws: (any Error).self) { try ServiceAPIConfiguration.normalizedURL(input) }
19
+ }
20
+
21
+ @Test("Finder launches receive saved routing and an endpoint-bound Keychain credential")
22
+ func savedConnectionEnvironment() throws {
23
+ let suite = "recordings-api-test-\(UUID().uuidString)"
24
+ let defaults = try #require(UserDefaults(suiteName: suite))
25
+ defer { defaults.removePersistentDomain(forName: suite) }
26
+ defaults.set("https://service.example.test/recordings/v1/", forKey: ServiceAPIConfiguration.defaultsKey)
27
+ var lookedUp: [String] = []
28
+ let environment = try ServiceAPIConfiguration.childEnvironment(base: ["PATH": "/bin"], defaults: defaults) { endpoint in
29
+ lookedUp.append(endpoint)
30
+ return "fixture-service-credential"
31
+ }
32
+ #expect(lookedUp == ["https://service.example.test/recordings/v1"])
33
+ #expect(environment["HASNA_RECORDINGS_API_URL"] == lookedUp.first)
34
+ #expect(environment["HASNA_RECORDINGS_CLIENT_STORE"] == nil)
35
+ #expect(environment["HASNA_RECORDINGS_API_KEY_OVERRIDE"] != nil)
36
+ #expect(environment["OPENAI_API_KEY"] == nil)
37
+ #expect(environment["PATH"] == "/bin")
38
+ #expect(defaults.string(forKey: "HASNA_RECORDINGS_API_KEY_OVERRIDE") == nil)
39
+ for base in [["HASNA_RECORDINGS_API_URL": "https://another.example.test/v1"], ["HASNA_RECORDINGS_LOCAL": "1"], ["RECORDINGS_LOCAL": "1"]] {
40
+ let overridden = try ServiceAPIConfiguration.childEnvironment(base: base, defaults: defaults) { _ in
41
+ Issue.record("Environment routing must not read the saved endpoint credential")
42
+ return nil
43
+ }
44
+ #expect(overridden == base)
45
+ }
46
+ }
47
+
48
+ @Test("an unconfigured native client invents no endpoint or local fallback")
49
+ func noDefaultEndpoint() throws {
50
+ let suite = "recordings-empty-api-test-\(UUID().uuidString)"
51
+ let defaults = try #require(UserDefaults(suiteName: suite))
52
+ defer { defaults.removePersistentDomain(forName: suite) }
53
+ #expect(try ServiceAPIConfiguration.childEnvironment(base: [:], defaults: defaults) { _ in
54
+ Issue.record("No endpoint is configured")
55
+ return nil
56
+ }.isEmpty)
57
+ }
58
+
59
+ @MainActor
60
+ @Test("global recording preferences ignore legacy project selections and prompts")
61
+ func ignoresLegacyProjects() {
62
+ let home = makeIsolatedTestHome("global-recording-preferences")
63
+ let preferences = ProjectStore(filePath: "\(home)/projects.json")
64
+ preferences.settings.globalSystemPrompt = "Global cleanup"
65
+ preferences.settings.postProcessingMode = "always"
66
+ preferences.settings.projects = [RecProject(id: "legacy", name: "Legacy", systemPrompt: "Project cleanup")]
67
+ preferences.settings.activeProjectId = "legacy"
68
+ let engine = RecordingEngine(homePath: home, installsGlobalHandlers: false)
69
+ engine.globalRecordingPreferences = preferences
70
+ #expect(engine.projectStore == nil)
71
+ #expect(engine.recordingCleanupPreferences.prompt == "Global cleanup")
72
+ #expect(engine.recordingCleanupPreferences.mode == "always")
73
+ #expect(preferences.settings.activeProjectId == "legacy")
74
+ }
75
+ }
@@ -0,0 +1,28 @@
1
+ import AppKit
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ @MainActor
6
+ struct SettingsWindowControllerTests {
7
+ @Test("Settings creates one window and reopens it after closing")
8
+ func reusesSettingsWindow() {
9
+ let policy = NSApplication.shared.activationPolicy()
10
+ var creations = 0
11
+ let controller = SettingsWindowController {
12
+ creations += 1
13
+ return NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 500))
14
+ }
15
+ #expect(creations == 0)
16
+ let first = controller.show()
17
+ defer { first.close() }
18
+ #expect(first.isVisible)
19
+ #expect(first.title == "Hasna Recordings Settings")
20
+ first.close()
21
+ #expect(!first.isVisible)
22
+ let reopened = controller.show()
23
+ #expect(reopened === first)
24
+ #expect(reopened.isVisible)
25
+ #expect(creations == 1)
26
+ #expect(NSApplication.shared.activationPolicy() == policy)
27
+ }
28
+ }
@@ -16,7 +16,11 @@ import Foundation
16
16
  /// back only the engine and so have no scope to remove it from; `CLIRunnerTests` instead
17
17
  /// passes the temp home it already creates and already deletes.
18
18
  func makeIsolatedTestHome(_ label: String) -> String {
19
- let url = FileManager.default.temporaryDirectory
19
+ // Foundation can ignore TMPDIR under the Darwin test-bundle launcher. An
20
+ // explicit fixture root keeps confined tests inside their owned write scope.
21
+ let root = ProcessInfo.processInfo.environment["RECORDINGS_TEST_TMP_ROOT"]
22
+ .map { URL(fileURLWithPath: $0, isDirectory: true) } ?? FileManager.default.temporaryDirectory
23
+ let url = root
20
24
  .appendingPathComponent("recordings-\(label)-\(UUID().uuidString)")
21
25
  try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
22
26
  return url.path