@hasna/recordings 0.3.13 → 0.4.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 (105) hide show
  1. package/README.md +223 -37
  2. package/bun.lock +37 -38
  3. package/dist/cli/index.js +1884 -615
  4. package/dist/cli/macos-shortcut.d.ts +2 -2
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/pg-migrations.d.ts.map +1 -1
  7. package/dist/db/recordings.d.ts +6 -0
  8. package/dist/db/recordings.d.ts.map +1 -1
  9. package/dist/http/client.d.ts +183 -22
  10. package/dist/http/client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2858 -2288
  14. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  15. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  16. package/dist/lib/capture-probe.d.ts +3 -3
  17. package/dist/lib/capture-probe.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/local-opt-in.d.ts +132 -0
  20. package/dist/lib/local-opt-in.d.ts.map +1 -0
  21. package/dist/lib/macos-bundle.d.ts +1 -1
  22. package/dist/lib/persistence-probe.d.ts +56 -17
  23. package/dist/lib/persistence-probe.d.ts.map +1 -1
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1689 -456
  27. package/dist/sdk/index.d.ts +13 -5
  28. package/dist/sdk/index.d.ts.map +1 -1
  29. package/dist/sdk/index.js +904 -1
  30. package/dist/sdk/resolve.d.ts +53 -0
  31. package/dist/sdk/resolve.d.ts.map +1 -0
  32. package/dist/sdk/v1.generated.d.ts +6 -0
  33. package/dist/sdk/v1.generated.d.ts.map +1 -1
  34. package/dist/server/client-ip.d.ts +39 -0
  35. package/dist/server/client-ip.d.ts.map +1 -0
  36. package/dist/server/cloud-config.d.ts +12 -0
  37. package/dist/server/cloud-config.d.ts.map +1 -1
  38. package/dist/server/cloud-readiness.d.ts.map +1 -1
  39. package/dist/server/cloud.d.ts +20 -2
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +566 -284
  42. package/dist/server/migrate-command.d.ts +1 -1
  43. package/dist/server/migrate-command.d.ts.map +1 -1
  44. package/dist/server/openapi.d.ts +21 -0
  45. package/dist/server/openapi.d.ts.map +1 -1
  46. package/dist/server/repo.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/storage.d.ts +2 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +1334 -871
  51. package/dist/store.d.ts +12 -3
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +15 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/package.json +9 -8
  56. package/packaging/macos/build_release_pkg.sh +3 -3
  57. package/packaging/macos/managed_bootstrap.sh +4 -4
  58. package/packaging/macos/scripts/postinstall +2 -2
  59. package/packaging/macos/scripts/preinstall +2 -2
  60. package/scripts/build_companion_cli.sh +7 -1
  61. package/scripts/install_macos_app.sh +27 -24
  62. package/scripts/macos_artifact.ts +20 -16
  63. package/scripts/migrate.ts +4 -20
  64. package/scripts/smoke_macos_app.sh +21 -21
  65. package/src/native/Recordings/App/ContentView.swift +53 -68
  66. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  67. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  68. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  69. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  70. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  71. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  72. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  73. package/src/native/Recordings/App/Theme.swift +73 -61
  74. package/src/native/Recordings/Package.swift +1 -0
  75. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  76. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  77. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  78. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  79. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
  80. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  81. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
  82. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  83. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  84. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  85. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  86. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  87. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
  88. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  89. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  90. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  91. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
  92. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  93. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
  94. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  95. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  96. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  97. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  98. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  99. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  100. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  101. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  102. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  103. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  104. package/src/native/Recordings/build.sh +6 -6
  105. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -8,7 +8,7 @@ struct CLIRunnerTests {
8
8
  func bundledCLIIsPreferred() throws {
9
9
  let root = FileManager.default.temporaryDirectory
10
10
  .appendingPathComponent("recordings-bundled-cli-\(UUID().uuidString)")
11
- let app = root.appendingPathComponent("HasnaRecordings.app")
11
+ let app = root.appendingPathComponent("Hasna Recordings.app")
12
12
  let helper = app.appendingPathComponent("Contents/Helpers/recordings")
13
13
  let home = root.appendingPathComponent("home")
14
14
  let external = home.appendingPathComponent(".bun/bin/recordings")
@@ -34,7 +34,7 @@ struct CLIRunnerTests {
34
34
  func packagedAppDoesNotUseGlobalFallback() throws {
35
35
  let root = FileManager.default.temporaryDirectory
36
36
  .appendingPathComponent("recordings-missing-companion-\(UUID().uuidString)")
37
- let app = root.appendingPathComponent("HasnaRecordings.app")
37
+ let app = root.appendingPathComponent("Hasna Recordings.app")
38
38
  let home = root.appendingPathComponent("home")
39
39
  let external = home.appendingPathComponent(".bun/bin/recordings")
40
40
  try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true)
@@ -1275,6 +1275,12 @@ struct CLIRunnerTests {
1275
1275
  #expect(CLIRunner.parseError(input) == "OpenAI quota exceeded — check the OpenAI account billing")
1276
1276
  }
1277
1277
 
1278
+ @Test("Service authentication and quota errors identify the Recordings API")
1279
+ func serviceErrors() {
1280
+ #expect(CLIRunner.parseError("ERROR: 401 Unauthorized", serviceAPI: true) == "Recordings API authentication failed — check the API connection in Settings")
1281
+ #expect(CLIRunner.parseError("ERROR: 429 Too Many Requests", serviceAPI: true) == "Recordings API request limit reached — try again later")
1282
+ }
1283
+
1278
1284
  @Test("parseError truncates long messages to 120 chars")
1279
1285
  func truncation() {
1280
1286
  let longMsg = String(repeating: "a", count: 200)
@@ -1297,9 +1303,12 @@ struct CLIRunnerTests {
1297
1303
  #expect(result?.contains(token) == false)
1298
1304
  #expect(result?.contains("[REDACTED]") == true)
1299
1305
 
1306
+ let plainFixture = "plain-synthetic-secret"
1307
+ let clientFixture = "client-synthetic-secret"
1308
+ let passwordFixture = "password-synthetic-secret"
1300
1309
  let structured = CLIRunner.parseError(
1301
- "ERROR: OPENAI_API_KEY=plain-synthetic-secret CLIENT_SECRET=client-synthetic-secret "
1302
- + "PASSWORD=password-synthetic-secret payload={\"api_key\":\"json-synthetic-secret\","
1310
+ "ERROR: OPENAI_API_KEY=\(plainFixture) CLIENT_SECRET=\(clientFixture) "
1311
+ + "PASSWORD=\(passwordFixture) payload={\"api_key\":\"json-synthetic-secret\","
1303
1312
  + "\"private_key\":\"private-synthetic-secret\"}"
1304
1313
  )
1305
1314
  #expect(structured?.contains("plain-synthetic-secret") == false)
@@ -86,7 +86,8 @@ struct OpenAIAPIKeyStoreTests {
86
86
  .appendingPathComponent("hasnaxyz")
87
87
  .appendingPathComponent("openai")
88
88
  try FileManager.default.createDirectory(at: secretDir, withIntermediateDirectories: true)
89
- try "export OPENAI_API_KEY='secret-key'\n".write(
89
+ let fixture = "secret-key"
90
+ try "export OPENAI_API_KEY='\(fixture)'\n".write(
90
91
  to: secretDir.appendingPathComponent("live.env"),
91
92
  atomically: true,
92
93
  encoding: .utf8
@@ -100,53 +101,57 @@ struct OpenAIAPIKeyStoreTests {
100
101
  #expect(key == "secret-key")
101
102
  }
102
103
 
103
- @Test("Saving a key writes it to config.json so the CLI uses the same key")
104
- func saveWritesConfig() throws {
104
+ @Test("Keychain key precedes legacy preferences and config")
105
+ func keychainPrecedence() throws {
105
106
  let home = try makeHome()
106
-
107
- try OpenAIAPIKeyStore.save(key: "sk-new-key", homePath: home.path)
108
-
109
- let key = OpenAIAPIKeyStore.load(
110
- homePath: home.path,
111
- environment: [:],
112
- userDefaultKey: nil
113
- )
114
- #expect(key == "sk-new-key")
107
+ try writeConfig(home: home, ["openai_api_key": "old-config"])
108
+ #expect(OpenAIAPIKeyStore.load(homePath: home.path, environment: [:],
109
+ userDefaultKey: "old-default", keychainLoader: { "keychain-fixture" }) == "keychain-fixture")
115
110
  }
116
111
 
117
- @Test("Saving a key preserves unrelated config.json fields")
118
- func savePreservesOtherFields() throws {
112
+ @Test("Save writes only Keychain and removes legacy plaintext while preserving settings")
113
+ func secureSave() throws {
119
114
  let home = try makeHome()
120
- try writeConfig(home: home, [
121
- "openai_api_key": "old-key",
122
- "transcription_model": "gpt-4o-transcribe",
123
- ])
124
-
125
- try OpenAIAPIKeyStore.save(key: "sk-rotated", homePath: home.path)
126
-
127
- let configURL = home
128
- .appendingPathComponent(".hasna")
129
- .appendingPathComponent("recordings")
130
- .appendingPathComponent("config.json")
131
- let data = try Data(contentsOf: configURL)
115
+ let suite = "provider-test-\(UUID().uuidString)"
116
+ let defaults = try #require(UserDefaults(suiteName: suite))
117
+ defer { defaults.removePersistentDomain(forName: suite) }
118
+ defaults.set("old-default", forKey: "openAIAPIKey")
119
+ try writeConfig(home: home, ["openai_api_key": "old-key", "api_key": "older-key",
120
+ "transcription_model": "gpt-4o-transcribe"])
121
+ var stored = ""
122
+ try OpenAIAPIKeyStore.save(key: " keychain-fixture ", homePath: home.path,
123
+ defaults: defaults, keychainWriter: { stored = $0 })
124
+ #expect(stored == "keychain-fixture")
125
+ #expect(defaults.string(forKey: "openAIAPIKey") == nil)
126
+ let data = try Data(contentsOf: home.appendingPathComponent(".hasna/recordings/config.json"))
132
127
  let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
133
- #expect(json["openai_api_key"] as? String == "sk-rotated")
128
+ #expect(json["openai_api_key"] == nil)
129
+ #expect(json["api_key"] == nil)
134
130
  #expect(json["transcription_model"] as? String == "gpt-4o-transcribe")
131
+ #expect(String(data: data, encoding: .utf8)?.contains(stored) == false)
135
132
  }
136
133
 
137
- @Test("Saving an empty key removes it from config.json")
138
- func saveEmptyRemovesKey() throws {
134
+ @Test("Keychain failure preserves existing configuration")
135
+ func failedSave() throws {
139
136
  let home = try makeHome()
140
- try writeConfig(home: home, ["openai_api_key": "old-key"])
141
-
142
- try OpenAIAPIKeyStore.save(key: " ", homePath: home.path)
137
+ try writeConfig(home: home, ["openai_api_key": "legacy-fixture"])
138
+ #expect(throws: OpenAIAPIKeyStore.KeychainFailure.self) {
139
+ try OpenAIAPIKeyStore.save(key: "new-fixture", homePath: home.path,
140
+ keychainWriter: { _ in throw OpenAIAPIKeyStore.KeychainFailure(status: -50) })
141
+ }
142
+ #expect(OpenAIAPIKeyStore.load(homePath: home.path, environment: [:], userDefaultKey: nil) == "legacy-fixture")
143
+ }
143
144
 
144
- let key = OpenAIAPIKeyStore.load(
145
- homePath: home.path,
146
- environment: [:],
147
- userDefaultKey: nil
148
- )
149
- #expect(key == "")
145
+ @Test("Provider key reaches the helper without becoming service authentication")
146
+ func separateProviderEnvironment() {
147
+ let result = OpenAIAPIKeyStore.childEnvironment(base: [
148
+ "RECORDINGS_API_KEY": "provider-fixture",
149
+ "HASNA_RECORDINGS_API_KEY_OVERRIDE": "service-fixture",
150
+ ], homePath: "/tmp/test-home", keyLoader: { "provider-fixture" })
151
+ #expect(result["RECORDINGS_API_KEY"] == nil)
152
+ #expect(result["OPENAI_API_KEY"] == "provider-fixture")
153
+ #expect(result["RECORDINGS_OPENAI_API_KEY"] == "provider-fixture")
154
+ #expect(result["HASNA_RECORDINGS_API_KEY_OVERRIDE"] == "service-fixture")
150
155
  }
151
156
 
152
157
  private func makeHome() throws -> URL {
@@ -0,0 +1,60 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ struct CaptureMonitorTests {
6
+ @Test func pauseExcludesAudioAndDuration() {
7
+ let monitor = CaptureMonitor()
8
+ let chunk = Data(repeating: 64, count: 4_800)
9
+ #expect(monitor.admit(chunk))
10
+ monitor.setPaused(true)
11
+ #expect(!monitor.admit(chunk))
12
+ #expect(monitor.snapshot().duration == 0.1)
13
+ monitor.setPaused(false)
14
+ #expect(monitor.admit(chunk))
15
+ #expect(monitor.snapshot().duration == 0.2)
16
+ }
17
+
18
+ @Test func meterReflectsActualPCM() {
19
+ let monitor = CaptureMonitor()
20
+ #expect(monitor.admit(Data(repeating: 0, count: 4_800)))
21
+ #expect(monitor.snapshot().level == 0)
22
+ #expect(monitor.admit(Data(repeating: 64, count: 4_800)))
23
+ #expect(monitor.snapshot().level > 0.5)
24
+ }
25
+ }
26
+
27
+ @MainActor
28
+ struct RecorderSurfaceDeliveryTests {
29
+ @Test func discardingPausedCaptureRestoresIdleControls() {
30
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("discard-paused"), installsGlobalHandlers: false)
31
+ engine.configureVisualPreview()
32
+ engine.togglePause()
33
+ engine.cancelRecording()
34
+ #expect(!engine.isPaused)
35
+ #expect(engine.recordingDuration == 0)
36
+ #expect(engine.audioLevel == 0)
37
+ }
38
+
39
+ @Test func disabledAutoPasteNeverReachesPasteBoundary() async {
40
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("auto-paste-off"))
41
+ engine.autoPasteEnabled = false
42
+ defer { engine.autoPasteEnabled = true }
43
+ var pasted = false
44
+ engine.pasteInterceptorForTesting = { _, _, _ in pasted = true }
45
+ let generation = engine.beginPipelineForTesting()
46
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
47
+ engine.finishWithText("Blue sky", rawTranscript: "Blue sky", targetAppBundleIdentifier: nil,
48
+ targetAppPid: nil, selectionToken: nil, canonicalProjectId: nil, activeProjectId: nil,
49
+ activeProjectName: nil,
50
+ processingConfiguration: RecordingProcessingConfiguration(transcriptionPrompt: "", transcriberPrompt: "",
51
+ postProcessingMode: "off", transcriptionLanguage: "en", transcriptionModel: "test", transcriberModel: "test",
52
+ enhancementModel: "test", intentModel: "test", intentDetectionEnabled: false,
53
+ enhanceTriggersJSON: "[]", keywordTransformsJSON: "{}"),
54
+ pipelineTrace: nil, pipelineGeneration: generation, deliveryCompleted: { continuation.resume() })
55
+ }
56
+ #expect(!pasted)
57
+ #expect(engine.recentTranscriptions.first?.displayText == "Blue sky")
58
+ #expect(engine.statusMessage == "Transcript ready — auto-paste is off")
59
+ }
60
+ }
@@ -0,0 +1,26 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ struct RecordingDateTests {
6
+ @Test("History accepts PostgreSQL, ISO, and SQLite dates", arguments: [
7
+ "2026-09-06 10:52:42.523193+00",
8
+ "2026-09-06 10:52:42+00",
9
+ "2026-09-06 16:22:42.523193+05:30",
10
+ "2026-09-06 16:22:42+0530",
11
+ "2026-09-06 06:52:42-04",
12
+ "2026-09-06T10:52:42.523Z",
13
+ "2026-09-06T10:52:42Z",
14
+ "2026-09-06 10:52:42",
15
+ ])
16
+ func supportedDates(value: String) throws {
17
+ let expected = try #require(ISO8601DateFormatter().date(from: "2026-09-06T10:52:42Z"))
18
+ let actual = try #require(Recording.parseDate(value))
19
+ #expect(abs(actual.timeIntervalSince(expected)) < 1)
20
+ }
21
+
22
+ @Test("Missing and invalid dates remain absent", arguments: ["", "invalid", "2026-09-06 10:52:42+99"])
23
+ func invalidDates(value: String) {
24
+ #expect(Recording.parseDate(value) == nil)
25
+ }
26
+ }
@@ -0,0 +1,195 @@
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(set) var stopped = false
10
+ func attach(_ callback: @escaping @Sendable (Data) -> Void) { lock.withLock { self.callback = callback } }
11
+ func emit(_ data: Data) { lock.withLock { callback }?(data) }
12
+ func start() throws {}
13
+ func stop() { lock.withLock { stopped = true } }
14
+ }
15
+
16
+ private final class ProviderTestSession: RecordingTranscriptionSession, @unchecked Sendable {
17
+ private let lock = NSLock()
18
+ private var audio = Data()
19
+ private var request: RecordingTranscriptionRequest?
20
+ private var partial: (@Sendable (String) -> Void)?
21
+ private var cancelled = false
22
+ private var gate: CheckedContinuation<RecordingProviderResult, Never>?
23
+ let delayFinish: Bool
24
+
25
+ init(delayFinish: Bool = false) { self.delayFinish = delayFinish }
26
+ var snapshot: (Data, RecordingTranscriptionRequest?, Bool) { lock.withLock { (audio, request, cancelled) } }
27
+ func configure(_ partial: @escaping @Sendable (String) -> Void) { lock.withLock { self.partial = partial } }
28
+ func emitPartial(_ text: String) { lock.withLock { partial }?(text) }
29
+ func appendPCM(_ data: Data) { lock.withLock { if !cancelled { audio.append(data) } } }
30
+ func finish(_ request: RecordingTranscriptionRequest) async throws -> RecordingProviderResult {
31
+ if delayFinish {
32
+ return await withCheckedContinuation { continuation in
33
+ lock.withLock { self.request = request; gate = continuation }
34
+ }
35
+ }
36
+ lock.withLock { self.request = request }
37
+ return RecordingProviderResult(rawText: "spoken words", processedText: "Spoken words.")
38
+ }
39
+ func cancel() { lock.withLock { cancelled = true } }
40
+ func resolve() {
41
+ let continuation = lock.withLock { let value = gate; gate = nil; return value }
42
+ continuation?.resume(returning: RecordingProviderResult(rawText: "late result"))
43
+ }
44
+ }
45
+
46
+ private struct ProviderTestAdapter: RecordingTranscriptionProvider {
47
+ let session: ProviderTestSession
48
+ func makeSession(configuration: RecordingProviderSessionConfiguration, onPartialTranscript: @escaping @Sendable (String) -> Void) throws -> any RecordingTranscriptionSession {
49
+ #expect(configuration.sampleRate == 24_000)
50
+ #expect(configuration.channels == 1)
51
+ #expect(configuration.bitsPerSample == 16)
52
+ session.configure(onPartialTranscript)
53
+ return session
54
+ }
55
+ }
56
+
57
+ @MainActor
58
+ struct RecordingProviderTests {
59
+ private func engine(_ session: ProviderTestSession, recorder: ProviderTestRecorder, suite: String = "recordings-provider-tests-\(UUID().uuidString)") throws -> RecordingEngine {
60
+ let configuration = try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("provider"), preferencesSuiteName: suite)
61
+ let engine = RecordingEngine(configuration: configuration, transcriptionProvider: ProviderTestAdapter(session: session))
62
+ engine.autoPasteEnabled = false
63
+ engine.microphoneAuthorization = { .authorized }
64
+ engine.accessibilityTrustCheck = { false }
65
+ engine.frontmostAppSnapshot = { nil }
66
+ engine.focusedWindowTitleLookup = { _ in nil }
67
+ engine.openAIAPIKeyProvider = { Issue.record("Isolated provider consulted legacy credentials"); return "" }
68
+ engine.commandCLI = { _, _, _ in Issue.record("Isolated provider launched legacy CLI"); return "ERROR: forbidden" }
69
+ engine.recorderFactory = { callback in recorder.attach(callback); return recorder }
70
+ return engine
71
+ }
72
+
73
+ private func eventually(_ condition: @MainActor () -> Bool) async -> Bool {
74
+ for _ in 0..<200 {
75
+ if condition() { return true }
76
+ try? await Task.sleep(for: .milliseconds(10))
77
+ }
78
+ return condition()
79
+ }
80
+
81
+ @Test("PCM streams before stop, pause excludes samples, and the final WAV matches the stream")
82
+ func liveStreamingAndPause() async throws {
83
+ let session = ProviderTestSession()
84
+ let recorder = ProviderTestRecorder()
85
+ let engine = try engine(session, recorder: recorder)
86
+ engine.startRecording()
87
+ let first = Data(repeating: 1, count: 4_800)
88
+ recorder.emit(first)
89
+ #expect(await eventually { engine.isRecording && session.snapshot.0 == first })
90
+ #expect(session.snapshot.1 == nil, "Streaming must precede finish")
91
+ session.emitPartial("spoken")
92
+ #expect(await eventually { engine.liveTranscriptionText == "spoken" })
93
+ engine.togglePause()
94
+ recorder.emit(Data(repeating: 9, count: 4_800))
95
+ engine.togglePause()
96
+ let tail = Data(repeating: 2, count: 1_600)
97
+ recorder.emit(tail)
98
+ engine.stopAndTranscribe()
99
+ #expect(await eventually { !engine.isTranscribing })
100
+ let request = try #require(session.snapshot.1)
101
+ #expect(session.snapshot.0 == first + tail)
102
+ #expect(request.duration == Double(6_400) / 48_000)
103
+ let wav = try Data(contentsOf: request.audioURL)
104
+ #expect(String(data: wav.prefix(4), encoding: .utf8) == "RIFF")
105
+ #expect(Data(wav.dropFirst(44)) == first + tail)
106
+ let transcript = try #require(engine.recentTranscriptions.first)
107
+ #expect(transcript.captureID == request.captureID)
108
+ #expect(transcript.audioURL == request.audioURL)
109
+ #expect(transcript.rawText == "spoken words")
110
+ #expect(transcript.displayText == "Spoken words.")
111
+ #expect(engine.flowPhase == .ready("Transcript ready — auto-paste is off"))
112
+ #expect(engine.persistedRecordingRevision == 0, "An adapter result is not a persistence receipt")
113
+ #expect(recorder.stopped)
114
+ }
115
+
116
+ @Test("cancel during provider finalization drops late text and allows another capture")
117
+ func cancellationDropsStaleCompletions() async throws {
118
+ let session = ProviderTestSession(delayFinish: true)
119
+ let recorder = ProviderTestRecorder()
120
+ let engine = try engine(session, recorder: recorder)
121
+ engine.startRecording()
122
+ recorder.emit(Data(repeating: 1, count: 4_800))
123
+ #expect(await eventually { engine.isRecording })
124
+ engine.stopAndTranscribe()
125
+ #expect(await eventually { session.snapshot.1 != nil })
126
+ engine.cancelRecording()
127
+ #expect(session.snapshot.2)
128
+ #expect(engine.canStartRecording)
129
+ session.emitPartial("late partial")
130
+ session.resolve()
131
+ try await Task.sleep(for: .milliseconds(50))
132
+ #expect(engine.liveTranscriptionText.isEmpty)
133
+ #expect(engine.recentTranscriptions.isEmpty)
134
+ #expect(engine.flowPhase == .idle)
135
+ }
136
+
137
+ @Test("discard during warm-up cancels the provider without a final request")
138
+ func warmupCancellation() throws {
139
+ let session = ProviderTestSession()
140
+ let recorder = ProviderTestRecorder()
141
+ let engine = try engine(session, recorder: recorder)
142
+ engine.startRecording()
143
+ engine.cancelRecording()
144
+ #expect(session.snapshot.2)
145
+ #expect(session.snapshot.1 == nil)
146
+ #expect(engine.canStartRecording)
147
+ #expect(recorder.stopped)
148
+ }
149
+
150
+ @Test("preferences are persistent per candidate suite and never write the legacy defaults")
151
+ func preferencesIsolation() throws {
152
+ let keys = ["useFnKey", "intentDetectionEnabled", "recordingsAutoPaste", "recordingsLanguage"]
153
+ let before = keys.map { UserDefaults.standard.object(forKey: $0) as? NSObject }
154
+ let firstSuite = "recordings-provider-tests-\(UUID().uuidString)"
155
+ let secondSuite = "recordings-provider-tests-\(UUID().uuidString)"
156
+ defer {
157
+ UserDefaults(suiteName: firstSuite)?.removePersistentDomain(forName: firstSuite)
158
+ UserDefaults(suiteName: secondSuite)?.removePersistentDomain(forName: secondSuite)
159
+ }
160
+ let first = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: firstSuite)
161
+ first.useFnKey = true // Must not install any monitor even when the preference changes.
162
+ first.intentDetectionEnabled = true
163
+ first.transcriptionLanguage = "ro"
164
+ first.autoPasteEnabled = true
165
+ let second = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: secondSuite)
166
+ #expect(!second.useFnKey)
167
+ #expect(!second.intentDetectionEnabled)
168
+ #expect(second.transcriptionLanguage == "en")
169
+ let reloaded = try engine(ProviderTestSession(), recorder: ProviderTestRecorder(), suite: firstSuite)
170
+ #expect(reloaded.useFnKey)
171
+ #expect(reloaded.intentDetectionEnabled)
172
+ #expect(reloaded.transcriptionLanguage == "ro")
173
+ #expect(keys.map { UserDefaults.standard.object(forKey: $0) as? NSObject } == before)
174
+ #expect(!FileManager.default.fileExists(atPath: "\(first.home)/.hasna/recordings/config.json"))
175
+ }
176
+
177
+ @Test("the isolated initializer rejects the installed app's state and defaults")
178
+ func rejectsLiveRoots() throws {
179
+ #expect(throws: RecordingProviderError.self) {
180
+ try RecordingEngineConfiguration(isolatedHomePath: FileManager.default.homeDirectoryForCurrentUser.path, preferencesSuiteName: "candidate")
181
+ }
182
+ #expect(throws: RecordingProviderError.self) {
183
+ try RecordingEngineConfiguration(isolatedHomePath: makeIsolatedTestHome("provider"), preferencesSuiteName: "com.hasna.recordings")
184
+ }
185
+ }
186
+
187
+ @Test("public paste receipts distinguish an unverified post from evidence of failure")
188
+ func typedPasteOutcomes() {
189
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .pasted) == .confirmed)
190
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .deliveredUnverified(.readBackNotAttempted)) == .unconfirmed)
191
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .deliveryNotObserved) == .notDelivered)
192
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .clipboardWriteFailed) == .notDelivered)
193
+ #expect(RecordingEngine.recentPasteDeliveryStatus(for: .targetUnavailable) == .notDelivered)
194
+ }
195
+ }
@@ -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
  }
@@ -188,6 +196,28 @@ struct RecordingStartTimingTests {
188
196
  #expect(engine.canStartRecording)
189
197
  }
190
198
 
199
+ @Test("slow microphone shutdown keeps the UI responsive and finishes draining before transcription")
200
+ func stopDoesNotBlockMainActor() async {
201
+ let stopGate = DispatchSemaphore(value: 0)
202
+ let recorder = FakePCMRecorder(stopGate: stopGate)
203
+ let engine = makeStartableEngine(recorder: recorder)
204
+ engine.startRecording(trigger: .manual)
205
+ #expect(await confirmCapture(engine, recorder))
206
+
207
+ let started = ContinuousClock.now
208
+ engine.stopAndTranscribe()
209
+ #expect(ContinuousClock.now - started < .milliseconds(300))
210
+ #expect(await waitUntil { recorder.isStopping })
211
+ #expect(engine.isTranscribing)
212
+ #expect(!engine.canStartRecording)
213
+ #expect(!recorder.isStopped)
214
+
215
+ stopGate.signal()
216
+ #expect(await waitUntil { !engine.isTranscribing })
217
+ #expect(recorder.isStopped)
218
+ #expect(engine.statusMessage == "No audio captured")
219
+ }
220
+
191
221
  @Test("a released key before the capture resolves still cancels cleanly")
192
222
  func cancelDuringPendingCapture() async {
193
223
  let captureGate = DispatchSemaphore(value: 0)
@@ -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
+ }
@@ -51,7 +51,7 @@ enum RecordingsBootstrapPreflight {
51
51
  let manifest = try JSONDecoder().decode(ReleaseManifest.self, from: manifestData)
52
52
  try validateManifest(manifest, payload: payload)
53
53
 
54
- let app = arguments.payloadRoot + "/Applications/HasnaRecordings.app"
54
+ let app = arguments.payloadRoot + "/Applications/Hasna Recordings.app"
55
55
  let client = app + "/" + RecordingsUpdateConstants.updateClientRelativePath
56
56
  let broker = arguments.payloadRoot + "/Library/PrivilegedHelperTools/com.hasna.recordings.updater"
57
57
  let artifactVerifier = arguments.payloadRoot
@@ -7,7 +7,7 @@ import RecordingsUpdateProtocol
7
7
  /// operation; first installs use exclusive rename semantics and can never replace a
8
8
  /// leaf created concurrently in `/Applications`.
9
9
  final class ApplicationNamespace {
10
- private static let liveLeaf = "HasnaRecordings.app"
10
+ private static let liveLeaf = "Hasna Recordings.app"
11
11
  private static let previousLeaf = "previous.app"
12
12
  private static let failedCandidateLeaf = "failed-candidate.app"
13
13
 
@@ -22,7 +22,7 @@ final class ApplicationNamespace {
22
22
  .deletingLastPathComponent
23
23
  guard journal.applicationPath == RecordingsUpdateConstants.applicationPath,
24
24
  journal.candidateApplicationPath == expectedTransactionDirectory
25
- + "/candidate/HasnaRecordings.app",
25
+ + "/candidate/Hasna Recordings.app",
26
26
  journal.previousApplicationPath == nil ||
27
27
  journal.previousApplicationPath == expectedTransactionDirectory + "/previous.app"
28
28
  else {
@@ -276,7 +276,7 @@ final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
276
276
  guard mkdir(candidateRoot, 0o700) == 0 else {
277
277
  throw BrokerOperationError.candidateRejected
278
278
  }
279
- let candidateApplication = candidateRoot + "/HasnaRecordings.app"
279
+ let candidateApplication = candidateRoot + "/Hasna Recordings.app"
280
280
  let verifierIDs = try verifierAccountIDs()
281
281
  try CanonicalTreeCopier.copyApplication(
282
282
  from: verifierOutput,
@@ -22,7 +22,7 @@ enum CanonicalTreeCopier {
22
22
  }
23
23
  defer { Darwin.close(source) }
24
24
  let destinationRoot = URL(fileURLWithPath: rootCandidatePath).deletingLastPathComponent().path
25
- guard URL(fileURLWithPath: rootCandidatePath).lastPathComponent == "HasnaRecordings.app" else {
25
+ guard URL(fileURLWithPath: rootCandidatePath).lastPathComponent == "Hasna Recordings.app" else {
26
26
  throw CanonicalCopyError.nonCanonicalRoot
27
27
  }
28
28
  let destination = Darwin.open(