@hasna/recordings 0.2.14 → 0.3.2

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 (123) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2094 -280
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +19 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +635 -50
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +141 -57
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +188 -80
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +109 -50
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/ci-linux-suite.ts +403 -0
  59. package/scripts/ci-native-build.ts +402 -0
  60. package/scripts/enforce_identity_migration.sh +110 -0
  61. package/scripts/generate-sdk.ts +1 -1
  62. package/scripts/install_macos_app.sh +192 -31
  63. package/scripts/macos_artifact.ts +323 -43
  64. package/scripts/native/realtime-settle-probe/README.md +39 -0
  65. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  66. package/scripts/policy/local-only-approved-targets.txt +21 -0
  67. package/scripts/read_local_only_targets.sh +203 -0
  68. package/scripts/scan-artifact.ts +40 -0
  69. package/scripts/set-version.ts +145 -0
  70. package/scripts/smoke_macos_app.sh +62 -16
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
  81. package/src/native/Recordings/App/RecordingsApp.swift +37 -1
  82. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  83. package/src/native/Recordings/App/SidebarView.swift +1 -1
  84. package/src/native/Recordings/Package.swift +24 -5
  85. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  86. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  87. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  88. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  89. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  90. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  91. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
  92. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  93. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  94. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  95. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  96. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  97. package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
  98. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  99. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  100. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  101. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  102. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  103. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  104. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  105. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  106. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  107. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  108. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  109. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  110. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  111. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  112. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  113. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  114. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  115. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  116. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  117. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  118. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  119. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  120. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  121. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  122. package/src/native/Recordings/build.sh +226 -20
  123. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -0,0 +1,200 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ // MARK: - Realtime settlement benchmark (opt-in, network-bound)
6
+
7
+ /// Measures how long real OpenAI realtime sessions take to settle after release —
8
+ /// the number the settlement budget in `RecordingEngine` is calibrated against.
9
+ ///
10
+ /// This is a live-network benchmark, not a unit test: it opens real realtime
11
+ /// transcription sessions with the machine's configured API key and paces real PCM
12
+ /// through them exactly like the capture pipeline does (4,800-byte chunks every
13
+ /// 100 ms, a periodic commit every 900 ms). It never writes to the production
14
+ /// recordings home — client logs go to a temporary directory.
15
+ ///
16
+ /// Disabled unless RECORDINGS_BENCH_REALTIME=1. Configuration:
17
+ /// RECORDINGS_BENCH_WAVS colon-separated 24 kHz/16-bit/mono WAV paths (required)
18
+ /// RECORDINGS_BENCH_RUNS runs per WAV (default 5)
19
+ /// RECORDINGS_BENCH_FINISH_BUDGET_MS finish budget passed to the client (default 10000,
20
+ /// deliberately far above production budgets so the
21
+ /// observed wait is the true settle latency)
22
+ /// RECORDINGS_BENCH_HOME home directory the API key is resolved under; the
23
+ /// key store reads <home>/.hasna/recordings/config.json
24
+ /// (default: the user home)
25
+ ///
26
+ /// Results are emitted as BENCH_RESULT lines on stdout, one per run.
27
+ struct RealtimeSettleBenchmark {
28
+ private static var enabled: Bool {
29
+ ProcessInfo.processInfo.environment["RECORDINGS_BENCH_REALTIME"] == "1"
30
+ }
31
+
32
+ @Test("realtime settle latency", .enabled(if: RealtimeSettleBenchmark.enabled))
33
+ @MainActor
34
+ func settleLatency() async throws {
35
+ let environment = ProcessInfo.processInfo.environment
36
+ let wavPaths = (environment["RECORDINGS_BENCH_WAVS"] ?? "")
37
+ .split(separator: ":").map(String.init)
38
+ try #require(!wavPaths.isEmpty, "RECORDINGS_BENCH_WAVS is required")
39
+ let runsPerWav = Int(environment["RECORDINGS_BENCH_RUNS"] ?? "") ?? 5
40
+ let finishBudget = UInt64(environment["RECORDINGS_BENCH_FINISH_BUDGET_MS"] ?? "") ?? 10_000
41
+ // OpenAIAPIKeyStore appends .hasna/recordings/config.json to the home it is
42
+ // given, so this must be the user home itself — not the recordings directory.
43
+ let keyHome = environment["RECORDINGS_BENCH_HOME"] ?? NSHomeDirectory()
44
+
45
+ let apiKey = OpenAIAPIKeyStore.load(homePath: keyHome)
46
+ try #require(!apiKey.isEmpty, "no API key available from \(keyHome)")
47
+ let language = OpenAIAPIKeyStore.apiLanguageHint(
48
+ for: OpenAIAPIKeyStore.loadLanguage(homePath: keyHome)
49
+ )
50
+
51
+ // Client logs must never land in the production Recordings.log.
52
+ let benchHome = NSTemporaryDirectory() + "recordings-settle-bench-" + UUID().uuidString
53
+ try FileManager.default.createDirectory(
54
+ atPath: benchHome, withIntermediateDirectories: true
55
+ )
56
+ print("BENCH_HOME \(benchHome)")
57
+
58
+ var coldRun = true
59
+ for wavPath in wavPaths {
60
+ let pcm = try Self.pcmData(fromWavAtPath: wavPath)
61
+ let audioSeconds = Double(pcm.count) / 48_000.0
62
+ for run in 1...runsPerWav {
63
+ let result = await Self.streamOnce(
64
+ pcm: pcm,
65
+ apiKey: apiKey,
66
+ language: language,
67
+ homePath: benchHome,
68
+ finishBudgetMilliseconds: finishBudget
69
+ )
70
+ let errorField = result.error?
71
+ .replacingOccurrences(of: " ", with: "_") ?? ""
72
+ print(
73
+ "BENCH_RESULT wav=\(wavPath) run=\(run) cold=\(coldRun) "
74
+ + "audio_seconds=\(String(format: "%.1f", audioSeconds)) "
75
+ + "pcm_bytes=\(pcm.count) budget_ms=\(finishBudget) "
76
+ + "stream_start_ms=\(result.streamStartMilliseconds) "
77
+ + "finish_wait_ms=\(result.finishWaitMilliseconds) "
78
+ + "settled=\(result.settled) chars=\(result.textCount) "
79
+ + "error=\(errorField)"
80
+ )
81
+ coldRun = false
82
+ }
83
+ }
84
+ }
85
+
86
+ private struct RunResult {
87
+ let streamStartMilliseconds: UInt64
88
+ let finishWaitMilliseconds: UInt64
89
+ let settled: Bool
90
+ let textCount: Int
91
+ let error: String?
92
+ }
93
+
94
+ /// Streams one WAV through a fresh client at capture pace and measures the
95
+ /// finish/settle wait. A fresh client per run mirrors production: every
96
+ /// recording builds a new WebSocket session.
97
+ @MainActor
98
+ private static func streamOnce(
99
+ pcm: Data,
100
+ apiKey: String,
101
+ language: String,
102
+ homePath: String,
103
+ finishBudgetMilliseconds: UInt64
104
+ ) async -> RunResult {
105
+ let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: homePath)
106
+
107
+ let startBegan = monotonicMilliseconds()
108
+ await client.startStreaming(language: language)
109
+ let streamStart = monotonicMilliseconds() - startBegan
110
+ guard client.isStreaming else {
111
+ let text = client.stop()
112
+ return RunResult(
113
+ streamStartMilliseconds: streamStart,
114
+ finishWaitMilliseconds: 0,
115
+ settled: false,
116
+ textCount: text.count,
117
+ error: client.error ?? "failed to start streaming"
118
+ )
119
+ }
120
+
121
+ // Capture pacing: 4,800 bytes per 100 ms tick (24 kHz * 2 bytes / 10),
122
+ // periodic commit every 900 ms — the same cadence RecordingEngine uses.
123
+ let chunkSize = 4_800
124
+ var offset = 0
125
+ var lastCommitAt = monotonicMilliseconds()
126
+ while offset < pcm.count {
127
+ let end = min(offset + chunkSize, pcm.count)
128
+ client.sendAudio(pcm.subdata(in: offset..<end))
129
+ offset = end
130
+ try? await Task.sleep(for: .milliseconds(100))
131
+ let now = monotonicMilliseconds()
132
+ if now - lastCommitAt >= 900 {
133
+ if await client.commitInput(reason: "periodic") {
134
+ lastCommitAt = now
135
+ }
136
+ }
137
+ }
138
+
139
+ let finishBegan = monotonicMilliseconds()
140
+ let finishResult = await client.finish(
141
+ timeoutMilliseconds: finishBudgetMilliseconds,
142
+ pipelineID: UUID().uuidString,
143
+ pipelineStartedUptimeMilliseconds: finishBegan
144
+ )
145
+ let finishWait = monotonicMilliseconds() - finishBegan
146
+ return RunResult(
147
+ streamStartMilliseconds: streamStart,
148
+ finishWaitMilliseconds: finishWait,
149
+ settled: finishResult.settled,
150
+ textCount: finishResult.text.count,
151
+ error: finishResult.error
152
+ )
153
+ }
154
+
155
+ /// Extracts raw PCM from a canonical RIFF/WAVE file, asserting the capture
156
+ /// pipeline's format (PCM, mono, 24 kHz, 16-bit) so a mispacked fixture cannot
157
+ /// silently skew the measurement.
158
+ private static func pcmData(fromWavAtPath path: String) throws -> Data {
159
+ let data = try Data(contentsOf: URL(fileURLWithPath: path))
160
+ try #require(data.count > 44, "WAV too small: \(path)")
161
+ try #require(
162
+ String(data: data[0..<4], encoding: .ascii) == "RIFF"
163
+ && String(data: data[8..<12], encoding: .ascii) == "WAVE",
164
+ "not a RIFF/WAVE file: \(path)"
165
+ )
166
+
167
+ var index = 12
168
+ var pcm: Data?
169
+ while index + 8 <= data.count {
170
+ let chunkID = String(data: data[index..<index + 4], encoding: .ascii) ?? ""
171
+ let chunkSize = Int(
172
+ UInt32(data[index + 4]) | UInt32(data[index + 5]) << 8
173
+ | UInt32(data[index + 6]) << 16 | UInt32(data[index + 7]) << 24
174
+ )
175
+ let body = index + 8
176
+ guard body + chunkSize <= data.count else { break }
177
+ if chunkID == "fmt ", chunkSize >= 16 {
178
+ let format = UInt16(data[body]) | UInt16(data[body + 1]) << 8
179
+ let channels = UInt16(data[body + 2]) | UInt16(data[body + 3]) << 8
180
+ let sampleRate = UInt32(data[body + 4]) | UInt32(data[body + 5]) << 8
181
+ | UInt32(data[body + 6]) << 16 | UInt32(data[body + 7]) << 24
182
+ let bits = UInt16(data[body + 14]) | UInt16(data[body + 15]) << 8
183
+ try #require(
184
+ format == 1 && channels == 1 && sampleRate == 24_000 && bits == 16,
185
+ "bench WAV must be PCM/mono/24kHz/16-bit: \(path)"
186
+ )
187
+ } else if chunkID == "data" {
188
+ pcm = data.subdata(in: body..<body + chunkSize)
189
+ }
190
+ // Chunks are word-aligned: odd sizes carry one padding byte.
191
+ index = body + chunkSize + (chunkSize % 2)
192
+ }
193
+ let payload = try #require(pcm, "no data chunk in \(path)")
194
+ return payload
195
+ }
196
+
197
+ private static func monotonicMilliseconds() -> UInt64 {
198
+ UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
199
+ }
200
+ }
@@ -0,0 +1,80 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ // MARK: - Realtime settlement budget
6
+
7
+ struct RealtimeSettleBudgetTests {
8
+ /// Capture byte rate the budget converts from: 24 kHz, 16-bit, mono.
9
+ private let bytesPerSecond = 48_000
10
+
11
+ @Test("empty and pathological captures get the floor budget")
12
+ func floorBudget() {
13
+ #expect(RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 0) == 1_500)
14
+ #expect(RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: -1) == 1_500)
15
+ #expect(RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 1) == 1_500)
16
+ }
17
+
18
+ @Test("the budget grows with recording length because the fallback cost does")
19
+ func scalesWithAudioSeconds() {
20
+ // A ~5 s dictation: the old fixed 700 ms budget was routinely missed by real
21
+ // settle times (0.6-1.6 s measured); the scaled budget stays comfortably above.
22
+ #expect(
23
+ RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 5 * bytesPerSecond)
24
+ == 1_500 + 5 * 25
25
+ )
26
+ // A one-minute recording is worth a 3 s settlement wait: its fallback pass
27
+ // costs ~19 s (measured).
28
+ #expect(
29
+ RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 60 * bytesPerSecond)
30
+ == 3_000
31
+ )
32
+ }
33
+
34
+ @Test("very long recordings cap at the ceiling instead of stalling the user")
35
+ func ceilingBudget() {
36
+ // The 4m07s dictation from the field report: uncapped scaling would ask for
37
+ // 1_500 + 247*25 = 7_675 ms; the ceiling keeps the wait bounded.
38
+ #expect(
39
+ RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: 247 * bytesPerSecond)
40
+ == 5_000
41
+ )
42
+ #expect(
43
+ RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: Int.max) == 5_000
44
+ )
45
+ }
46
+
47
+ @Test("partial seconds are rounded down, never up past the ceiling")
48
+ func partialSeconds() {
49
+ // 1.9 s of audio counts as 1 whole second of budget scaling.
50
+ let bytes = Int(Double(bytesPerSecond) * 1.9)
51
+ #expect(
52
+ RecordingEngine.realtimeSettleBudgetMilliseconds(pcmByteCount: bytes) == 1_525
53
+ )
54
+ }
55
+ }
56
+
57
+ // MARK: - Realtime outbound send deadlines
58
+
59
+ struct RealtimeSendDeadlineTests {
60
+ @Test("the configure send absorbs a cold connection handshake")
61
+ func configureDeadlineCoversColdHandshake() {
62
+ // Cold DNS + TCP + TLS + WebSocket upgrade measured 0.77-1.0 s against the
63
+ // live endpoint from a fresh process; the configure deadline must clear it
64
+ // with margin or a cold connection poisons the whole session.
65
+ #expect(RealtimeTranscriptionClient.configureSendTimeoutMilliseconds >= 2_000)
66
+ }
67
+
68
+ @Test("post-configure sends tolerate jitter without outliving the configure budget")
69
+ func steadyStateDeadlineToleratesJitter() {
70
+ // One send past the deadline permanently vetoes the fast path, so the
71
+ // deadline must clear ordinary jitter (a 500 ms value poisoned 3/5 of
72
+ // two-minute live sessions) while staying under the configure budget —
73
+ // the bounded outbound queue, not this deadline, is the stall detector.
74
+ #expect(RealtimeTranscriptionClient.outboundSendTimeoutMilliseconds >= 2_000)
75
+ #expect(
76
+ RealtimeTranscriptionClient.outboundSendTimeoutMilliseconds
77
+ < RealtimeTranscriptionClient.configureSendTimeoutMilliseconds
78
+ )
79
+ }
80
+ }
@@ -428,6 +428,21 @@ struct RealtimeTranscriptionTests {
428
428
  ) == "final words")
429
429
  }
430
430
 
431
+ @Test("A settled transcript survives an unrelated server error")
432
+ func settledTranscriptSurvivesIncidentalServerError() {
433
+ let finishResult = RealtimeFinishResult(
434
+ text: "complete settled transcript",
435
+ settled: true,
436
+ error: "server_error: unrelated event"
437
+ )
438
+
439
+ #expect(RecordingEngine.settledRealtimeFallbackTranscript(
440
+ finishResult: finishResult,
441
+ pcmByteCount: 96_000,
442
+ language: "en"
443
+ ) == "complete settled transcript")
444
+ }
445
+
431
446
  @Test("Settlement deadline arithmetic saturates at UInt64 bounds")
432
447
  @MainActor
433
448
  func settlementDeadlineDoesNotOverflow() async {
@@ -560,19 +575,22 @@ struct RealtimeTranscriptionTests {
560
575
  postProcessingMode: PostProcessingMode.off.rawValue,
561
576
  transcript: "settled realtime text",
562
577
  hasSelection: false,
563
- intentDetectionEnabled: true
578
+ intentDetectionEnabled: true,
579
+ enhanceTriggersJSON: "[]"
564
580
  ))
565
581
  #expect(RecordingEngine.shouldPasteBeforePersistence(
566
582
  postProcessingMode: PostProcessingMode.off.rawValue,
567
583
  transcript: "rewrite this to be more formal",
568
584
  hasSelection: true,
569
- intentDetectionEnabled: true
585
+ intentDetectionEnabled: true,
586
+ enhanceTriggersJSON: "[]"
570
587
  ) == false)
571
588
  #expect(RecordingEngine.shouldPasteBeforePersistence(
572
589
  postProcessingMode: PostProcessingMode.always.rawValue,
573
590
  transcript: "settled realtime text",
574
591
  hasSelection: false,
575
- intentDetectionEnabled: true
592
+ intentDetectionEnabled: true,
593
+ enhanceTriggersJSON: "[]"
576
594
  ) == false)
577
595
  #expect(RecordingEngine.shouldLabelRewriting(
578
596
  postProcessingMode: PostProcessingMode.always.rawValue
@@ -666,7 +684,7 @@ struct RealtimeTranscriptionTests {
666
684
  @Test("Persistence revision advances only for confirmed saves")
667
685
  @MainActor
668
686
  func persistenceRevisionTracksConfirmedSaves() {
669
- let engine = RecordingEngine()
687
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("realtime-tests"))
670
688
 
671
689
  #expect(engine.persistedRecordingRevision == 0)
672
690
  engine.recordPersistenceCompletion(savedText: nil)
@@ -67,7 +67,7 @@ private func makeEngine(
67
67
  accessibilityTrusted: Bool = true,
68
68
  pasteRecorder: PasteRecorder? = nil
69
69
  ) -> RecordingEngine {
70
- let engine = RecordingEngine()
70
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("delivery-tests"))
71
71
  engine.openAIAPIKeyProvider = { "" }
72
72
  engine.microphoneAuthorization = { .denied }
73
73
  engine.accessibilityTrustCheck = { accessibilityTrusted }
@@ -295,7 +295,7 @@ struct VoiceShortcutRoutingTests {
295
295
  func shortcutBoundaries() async {
296
296
  let recorder = PasteRecorder()
297
297
  let engine = makeEngine(pasteRecorder: recorder)
298
- let shortcuts = VoiceShortcuts()
298
+ let shortcuts = VoiceShortcuts(homePath: makeIsolatedTestHome("voice-shortcuts"))
299
299
  shortcuts.shortcuts = [VoiceShortcut(trigger: "add disclaimer", content: "This is not legal advice.")]
300
300
  engine.voiceShortcuts = shortcuts
301
301
 
@@ -534,7 +534,7 @@ struct PasteSettlementObservationTests {
534
534
  let coordinator = engine.installPasteCoordinatorForTesting(
535
535
  schedule: { _, operation in scheduled.append(operation) },
536
536
  writeAndVerify: { _ in PasteboardWriteResult(verified: true, ownershipChangeCount: 7) },
537
- postPaste: { true }
537
+ postPaste: { .posted }
538
538
  )
539
539
  var observations = 0
540
540
  let subscription = engine.objectWillChange.sink { _ in observations += 1 }
@@ -562,8 +562,10 @@ struct PasteSettlementObservationTests {
562
562
  #expect(engine.canStartRecording)
563
563
  let presentation = MenuBarPresentation(
564
564
  isRecording: engine.isRecording,
565
+ isWarmingUpCapture: engine.isWarmingUpCapture,
565
566
  canStartRecording: engine.canStartRecording,
566
- statusMessage: "Ready"
567
+ statusMessage: "Ready",
568
+ blockedReason: nil
567
569
  )
568
570
  #expect(presentation.iconName == "mic.fill")
569
571
  #expect(presentation.primaryActionEnabled, "Start must re-enable from the settlement event alone")
@@ -716,8 +718,10 @@ struct MenuBarEngineContractTests {
716
718
  #expect(!engine.canStartRecording)
717
719
  let busy = MenuBarPresentation(
718
720
  isRecording: engine.isRecording,
721
+ isWarmingUpCapture: engine.isWarmingUpCapture,
719
722
  canStartRecording: engine.canStartRecording,
720
- statusMessage: engine.statusMessage
723
+ statusMessage: engine.statusMessage,
724
+ blockedReason: nil
721
725
  )
722
726
  #expect(busy.iconName == "ellipsis.circle")
723
727
  #expect(!busy.primaryActionEnabled)
@@ -732,8 +736,10 @@ struct MenuBarEngineContractTests {
732
736
  _ = await waitUntil { engine.canStartRecording }
733
737
  let idle = MenuBarPresentation(
734
738
  isRecording: engine.isRecording,
739
+ isWarmingUpCapture: engine.isWarmingUpCapture,
735
740
  canStartRecording: engine.canStartRecording,
736
- statusMessage: "Ready"
741
+ statusMessage: "Ready",
742
+ blockedReason: nil
737
743
  )
738
744
  #expect(idle.iconName == "mic.fill")
739
745
  #expect(idle.primaryActionEnabled)
@@ -0,0 +1,67 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ /// Locks the `homePath` seam that keeps a test run out of the operator's real
6
+ /// `~/.hasna/recordings`. `RecordingEngine.init` already creates the audio spool and logs
7
+ /// before it returns, so the seam is only worth anything if it is honoured that early —
8
+ /// these tests assert against artifacts `init` itself produced.
9
+ @MainActor
10
+ struct RecordingEngineHomeIsolationTests {
11
+ @Test("init writes its log line and audio spool under the injected home, not the real one")
12
+ func initArtifactsLandUnderInjectedHome() throws {
13
+ let home = makeIsolatedTestHome("home-isolation")
14
+
15
+ let engine = RecordingEngine(homePath: home)
16
+
17
+ #expect(engine.home == home)
18
+ #expect(FileManager.default.fileExists(atPath: "\(home)/.hasna/recordings/audio"))
19
+
20
+ // Reading the line back proves the seam reaches NativeAppLog rather than merely
21
+ // being stored: an unthreaded `home` would leave this file absent here and append
22
+ // to the real log instead.
23
+ let logged = try String(
24
+ contentsOfFile: "\(home)/.hasna/recordings/Recordings.log",
25
+ encoding: .utf8
26
+ )
27
+ #expect(logged.contains("RecordingEngine init"))
28
+ }
29
+
30
+ @Test("every log write the engine makes is addressed to the injected home")
31
+ func engineLogWritesStayUnderInjectedHome() throws {
32
+ let first = makeIsolatedTestHome("home-isolation-first")
33
+ let second = makeIsolatedTestHome("home-isolation-second")
34
+
35
+ _ = RecordingEngine(homePath: first)
36
+ _ = RecordingEngine(homePath: second)
37
+
38
+ // Two engines with two homes must not cross-write. A shared or process-global home
39
+ // would put both init lines in one file and leave the other empty or missing.
40
+ for home in [first, second] {
41
+ let logged = try String(
42
+ contentsOfFile: "\(home)/.hasna/recordings/Recordings.log",
43
+ encoding: .utf8
44
+ )
45
+ #expect(logged.components(separatedBy: "RecordingEngine init").count - 1 == 1)
46
+ }
47
+ }
48
+
49
+ /// The engine is not the only type that roots itself at the live home. `VoiceShortcuts`
50
+ /// persists `voice-shortcuts.json` the same way, so a test that adds a shortcut used to
51
+ /// rewrite the operator's real file. Ported from the same seam work; the structural half of
52
+ /// this rule is enforced in `src/__tests__/native-test-log-isolation-contract.test.ts`,
53
+ /// which — unlike this file — can actually run without a Swift toolchain.
54
+ @Test("voice shortcuts persist under the injected home, not the live one")
55
+ func voiceShortcutsPersistUnderInjectedHome() throws {
56
+ let home = makeIsolatedTestHome("home-isolation-shortcuts")
57
+
58
+ let shortcuts = VoiceShortcuts(homePath: home)
59
+ shortcuts.add(trigger: "containment probe", content: "written to a temp home")
60
+
61
+ let text = try String(
62
+ contentsOfFile: "\(home)/.hasna/recordings/voice-shortcuts.json",
63
+ encoding: .utf8
64
+ )
65
+ #expect(text.contains("containment probe"))
66
+ }
67
+ }
@@ -219,19 +219,28 @@ struct RecordingStartGateTests {
219
219
 
220
220
  @Test("recording cannot begin while already recording or transcribing")
221
221
  func cannotBeginWhenBusy() {
222
- #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false) == true)
223
- #expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false) == false)
224
- #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true) == false)
222
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false, isWarmingUpCapture: false) == true)
223
+ #expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false, isWarmingUpCapture: false) == false)
224
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true, isWarmingUpCapture: false) == false)
225
225
  #expect(RecordingEngine.canBeginRecording(
226
226
  isRecording: false,
227
227
  isTranscribing: false,
228
+ isWarmingUpCapture: false,
228
229
  isAwaitingMicrophonePermission: true
229
230
  ) == false)
230
231
  #expect(RecordingEngine.canBeginRecording(
231
232
  isRecording: false,
232
233
  isTranscribing: false,
234
+ isWarmingUpCapture: false,
233
235
  isDeliveryPending: true
234
236
  ) == false)
237
+ // The warm-up window: `isRecording` is false because no audio has arrived, but the
238
+ // microphone is open and a second start would open a second recorder on top of it.
239
+ #expect(RecordingEngine.canBeginRecording(
240
+ isRecording: false,
241
+ isTranscribing: false,
242
+ isWarmingUpCapture: true
243
+ ) == false)
235
244
  }
236
245
 
237
246
  @Test("recording status is derived from the trigger")