@hasna/recordings 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +349 -69
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1919 -98
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1973 -236
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -1,7 +1,392 @@
1
+ import Foundation
1
2
  import Testing
2
3
  @testable import RecordingsLib
3
4
 
4
5
  struct ProjectStoreTests {
6
+ private struct SyntheticReadError: LocalizedError {
7
+ var errorDescription: String? { "synthetic read failure" }
8
+ }
9
+
10
+ @Test("legacy project color survives settings decode and encode")
11
+ func legacyProjectColorRoundTrip() throws {
12
+ let input = Data(##"{"globalSystemPrompt":"Global","postProcessingMode":"always","activeProjectId":"legacy-id","projects":[{"id":"legacy-id","name":"Legacy","path":"/tmp/legacy","systemPrompt":"Keep bullets","appBundleIds":["com.example.app"],"canonicalPath":"recordings-app://projects/legacy-id","color":"#12AB34"}]}"##.utf8)
13
+
14
+ let settings = try JSONDecoder().decode(ProjectSettings.self, from: input)
15
+ let encoded = try JSONEncoder().encode(settings)
16
+ let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
17
+ let projects = try #require(object["projects"] as? [[String: Any]])
18
+ let project = try #require(projects.first)
19
+
20
+ #expect(project["color"] as? String == "#12AB34")
21
+ #expect(project["path"] as? String == "/tmp/legacy")
22
+ #expect(project["systemPrompt"] as? String == "Keep bullets")
23
+ #expect((project["appBundleIds"] as? [String]) == ["com.example.app"])
24
+ #expect(object["postProcessingMode"] as? String == "always")
25
+ }
26
+
27
+ @Test("legacy app projects migrate to canonical Store ids without losing metadata")
28
+ @MainActor
29
+ func migratesProjectsToCanonicalStore() async throws {
30
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
31
+ defer { try? FileManager.default.removeItem(at: root) }
32
+ let bin = root.appendingPathComponent(".bun/bin")
33
+ try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
34
+ let executable = bin.appendingPathComponent("recordings")
35
+ let script = """
36
+ #!/bin/sh
37
+ printf '%s' '{"id":"canonical-id","name":"Legacy","path":"recordings-app://projects/legacy-id"}'
38
+ """
39
+ try script.write(to: executable, atomically: true, encoding: .utf8)
40
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
41
+
42
+ let store = ProjectStore(filePath: root.appendingPathComponent("projects.json").path)
43
+ store.settings.projects = [
44
+ RecProject(
45
+ id: "legacy-id",
46
+ name: "Legacy",
47
+ path: nil,
48
+ systemPrompt: "Keep bullets",
49
+ appBundleIds: ["com.example.app"],
50
+ color: "#12AB34"
51
+ )
52
+ ]
53
+ store.settings.activeProjectId = "legacy-id"
54
+ try store.save()
55
+
56
+ try await store.reconcileWithCanonicalStore(home: root.path)
57
+
58
+ let migrated = try #require(store.settings.projects.first)
59
+ #expect(migrated.id == "canonical-id")
60
+ #expect(migrated.name == "Legacy")
61
+ #expect(migrated.path == nil)
62
+ #expect(migrated.systemPrompt == "Keep bullets")
63
+ #expect(migrated.appBundleIds == ["com.example.app"])
64
+ #expect(migrated.canonicalPath == "recordings-app://projects/legacy-id")
65
+ #expect(migrated.color == "#12AB34")
66
+ #expect(store.settings.activeProjectId == "canonical-id")
67
+ #expect(store.isReadyForRecording)
68
+
69
+ var edited = migrated
70
+ edited.name = "Edited Legacy"
71
+ try store.updateProject(edited)
72
+ let persisted = try JSONDecoder().decode(
73
+ ProjectSettings.self,
74
+ from: Data(contentsOf: root.appendingPathComponent("projects.json"))
75
+ )
76
+ #expect(persisted.projects.first?.name == "Edited Legacy")
77
+ #expect(persisted.projects.first?.color == "#12AB34")
78
+ }
79
+
80
+ @Test("duplicate legacy project ids reconcile without crashing or duplicating canonical rows")
81
+ @MainActor
82
+ func duplicateProjectIDsAreDeduplicated() async throws {
83
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
84
+ defer { try? FileManager.default.removeItem(at: root) }
85
+ let bin = root.appendingPathComponent(".bun/bin")
86
+ try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
87
+ let executable = bin.appendingPathComponent("recordings")
88
+ let script = """
89
+ #!/bin/sh
90
+ printf '%s' '{"id":"canonical-id","name":"Legacy","path":"recordings-app://projects/duplicate-id"}'
91
+ """
92
+ try script.write(to: executable, atomically: true, encoding: .utf8)
93
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
94
+
95
+ let store = ProjectStore(filePath: root.appendingPathComponent("projects.json").path)
96
+ store.settings.projects = [
97
+ RecProject(id: "duplicate-id", name: "First"),
98
+ RecProject(id: "duplicate-id", name: "Second"),
99
+ ]
100
+ store.settings.activeProjectId = "duplicate-id"
101
+ try store.save()
102
+
103
+ try await store.reconcileWithCanonicalStore(home: root.path)
104
+
105
+ #expect(store.settings.projects.count == 1)
106
+ #expect(store.settings.projects.first?.id == "canonical-id")
107
+ #expect(store.settings.activeProjectId == "canonical-id")
108
+ }
109
+
110
+ @Test("failed canonical registration preserves recording metadata and can be retried")
111
+ @MainActor
112
+ func failedRegistrationDoesNotDisableCaptureForAppLifetime() async throws {
113
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
114
+ defer { try? FileManager.default.removeItem(at: root) }
115
+ let bin = root.appendingPathComponent(".bun/bin")
116
+ try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
117
+ let executable = bin.appendingPathComponent("recordings")
118
+ let failureScript = """
119
+ #!/bin/sh
120
+ echo 'ERROR: synthetic registration failure' >&2
121
+ exit 1
122
+ """
123
+ try failureScript.write(to: executable, atomically: true, encoding: .utf8)
124
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
125
+
126
+ let store = ProjectStore(filePath: root.appendingPathComponent("projects.json").path)
127
+ store.settings.projects = [
128
+ RecProject(id: "local-id", name: "Active Local", systemPrompt: "Keep this context")
129
+ ]
130
+ store.settings.activeProjectId = "local-id"
131
+ try store.save()
132
+
133
+ await #expect(throws: (any Error).self) {
134
+ try await store.reconcileWithCanonicalStore(home: root.path)
135
+ }
136
+ #expect(!store.isReadyForRecording)
137
+ #expect(store.synchronizationError?.contains("synthetic registration failure") == true)
138
+ #expect(store.persistenceError?.contains("synthetic registration failure") == true)
139
+ #expect(store.activeProject?.id == "local-id")
140
+ #expect(store.activeCanonicalProjectIdForRecording == nil)
141
+ #expect(store.effectiveSystemPrompt == "Keep this context")
142
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false))
143
+
144
+ let successScript = """
145
+ #!/bin/sh
146
+ printf '%s' '{"id":"canonical-id","name":"Active Local","path":"recordings-app://projects/local-id"}'
147
+ """
148
+ try successScript.write(to: executable, atomically: true, encoding: .utf8)
149
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
150
+
151
+ try await store.reconcileWithCanonicalStore(home: root.path)
152
+ #expect(store.isReadyForRecording)
153
+ #expect(store.synchronizationError == nil)
154
+ #expect(store.persistenceError == nil)
155
+ #expect(store.activeProject?.id == "canonical-id")
156
+ #expect(store.activeCanonicalProjectIdForRecording == "canonical-id")
157
+ #expect(store.effectiveSystemPrompt == "Keep this context")
158
+ }
159
+
160
+ @Test("persisted canonical project remains safe during startup reconciliation")
161
+ @MainActor
162
+ func canonicalProjectIdSurvivesStartupDegradedState() throws {
163
+ var settings = ProjectSettings()
164
+ settings.projects = [
165
+ RecProject(
166
+ id: "canonical-id",
167
+ name: "Canonical",
168
+ canonicalPath: "recordings-app://projects/local-id",
169
+ canonicalStoreId: "canonical-id"
170
+ )
171
+ ]
172
+ settings.activeProjectId = "canonical-id"
173
+ let data = try JSONEncoder().encode(settings)
174
+ let store = ProjectStore(filePath: "/tmp/projects.json") { _ in data }
175
+
176
+ #expect(!store.isReadyForRecording)
177
+ #expect(store.activeCanonicalProjectIdForRecording == "canonical-id")
178
+ }
179
+
180
+ @Test("adding a project preserves color in canonical local metadata")
181
+ @MainActor
182
+ func addProjectPreservesColor() async throws {
183
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
184
+ defer { try? FileManager.default.removeItem(at: root) }
185
+ let bin = root.appendingPathComponent(".bun/bin")
186
+ try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
187
+ let executable = bin.appendingPathComponent("recordings")
188
+ let script = """
189
+ #!/bin/sh
190
+ printf '%s' '{"id":"canonical-added","name":"Added","path":"recordings-app://projects/added"}'
191
+ """
192
+ try script.write(to: executable, atomically: true, encoding: .utf8)
193
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
194
+
195
+ let file = root.appendingPathComponent("projects.json")
196
+ let store = ProjectStore(filePath: file.path)
197
+ try await store.addProject(name: "Added", color: "#AABBCC", home: root.path)
198
+
199
+ #expect(store.settings.projects.first?.id == "canonical-added")
200
+ #expect(store.settings.projects.first?.color == "#AABBCC")
201
+ let persisted = try JSONDecoder().decode(ProjectSettings.self, from: Data(contentsOf: file))
202
+ #expect(persisted.projects.first?.color == "#AABBCC")
203
+ }
204
+
205
+ @Test("project persistence failures remain visible to the UI")
206
+ @MainActor
207
+ func reportsPersistenceFailure() throws {
208
+ // An unwritable path fails at load, so the surfaced message is the load failure —
209
+ // what matters to the UI contract is that a failed save leaves a visible error.
210
+ let store = ProjectStore(filePath: "/dev/null/projects.json")
211
+ store.settings.projects = [RecProject(name: "Cannot Save")]
212
+
213
+ #expect(throws: (any Error).self) {
214
+ try store.save()
215
+ }
216
+ let persistenceError = try #require(store.persistenceError)
217
+ #expect(persistenceError.contains("projects"))
218
+ }
219
+
220
+ @Test("project mutations are rejected while canonical reconciliation is in flight")
221
+ @MainActor
222
+ func serializesReconciliationAndMutations() async throws {
223
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
224
+ defer { try? FileManager.default.removeItem(at: root) }
225
+ let bin = root.appendingPathComponent(".bun/bin")
226
+ try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
227
+ let executable = bin.appendingPathComponent("recordings")
228
+ let script = """
229
+ #!/bin/sh
230
+ root="$(cd "$(dirname "$0")/../.." && pwd)"
231
+ touch "$root/started"
232
+ while [ ! -f "$root/release" ]; do sleep 0.01; done
233
+ printf '%s' '{"id":"canonical-id","name":"Legacy","path":"recordings-app://projects/legacy-id"}'
234
+ """
235
+ try script.write(to: executable, atomically: true, encoding: .utf8)
236
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
237
+
238
+ let store = ProjectStore(filePath: root.appendingPathComponent("projects.json").path)
239
+ store.settings.projects = [RecProject(id: "legacy-id", name: "Legacy")]
240
+ try store.save()
241
+ let reconciliation = Task { try await store.reconcileWithCanonicalStore(home: root.path) }
242
+ for _ in 0..<200 where !FileManager.default.fileExists(atPath: root.appendingPathComponent("started").path) {
243
+ try await Task.sleep(for: .milliseconds(10))
244
+ }
245
+ #expect(store.isSynchronizingProjects)
246
+ var edited = try #require(store.settings.projects.first)
247
+ edited.name = "Must Not Be Lost"
248
+ #expect(throws: ProjectStoreError.self) {
249
+ try store.updateProject(edited)
250
+ }
251
+ try Data().write(to: root.appendingPathComponent("release"))
252
+ try await reconciliation.value
253
+ #expect(store.settings.projects.first?.name == "Legacy")
254
+ #expect(!store.isSynchronizingProjects)
255
+ }
256
+
257
+ @Test("project decode failures are visible and prevent readiness")
258
+ @MainActor
259
+ func reportsLoadFailure() async throws {
260
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
261
+ defer { try? FileManager.default.removeItem(at: root) }
262
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
263
+ let file = root.appendingPathComponent("projects.json")
264
+ try Data("not-json".utf8).write(to: file)
265
+
266
+ let store = ProjectStore(filePath: file.path)
267
+
268
+ #expect(store.persistenceError?.contains("Failed to load projects") == true)
269
+ #expect(!store.isReadyForRecording)
270
+ #expect(!store.canMutateProjects)
271
+ #expect(throws: ProjectStoreError.self) {
272
+ try store.save()
273
+ }
274
+ await #expect(throws: ProjectStoreError.self) {
275
+ try await store.reconcileWithCanonicalStore(home: root.path)
276
+ }
277
+ #expect(try Data(contentsOf: file) == Data("not-json".utf8))
278
+ }
279
+
280
+ @Test("unreadable project data blocks every mutation without overwriting the file")
281
+ @MainActor
282
+ func unreadableDataBlocksMutations() async throws {
283
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
284
+ defer { try? FileManager.default.removeItem(at: root) }
285
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
286
+ let file = root.appendingPathComponent("projects.json")
287
+ let sentinel = Data("existing-project-data".utf8)
288
+ try sentinel.write(to: file)
289
+
290
+ let store = ProjectStore(filePath: file.path) { _ in
291
+ throw SyntheticReadError()
292
+ }
293
+ store.settings.projects = [RecProject(id: "existing", name: "Existing")]
294
+
295
+ #expect(store.persistenceError?.contains("synthetic read failure") == true)
296
+ #expect(!store.isReadyForRecording)
297
+ #expect(!store.canMutateProjects)
298
+ #expect(throws: ProjectStoreError.self) { try store.save() }
299
+ #expect(throws: ProjectStoreError.self) {
300
+ try store.updateProject(RecProject(id: "existing", name: "Changed"))
301
+ }
302
+ #expect(throws: ProjectStoreError.self) { try store.removeProject(id: "existing") }
303
+ #expect(throws: ProjectStoreError.self) { try store.setActive("existing") }
304
+ await #expect(throws: ProjectStoreError.self) {
305
+ try await store.addProject(name: "Added", home: root.path)
306
+ }
307
+ #expect(try Data(contentsOf: file) == sentinel)
308
+ }
309
+
310
+ @Test("a project file that becomes unreadable after launch is never overwritten")
311
+ @MainActor
312
+ func postLaunchReadFailureBlocksMutations() throws {
313
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
314
+ defer { try? FileManager.default.removeItem(at: root) }
315
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
316
+ let file = root.appendingPathComponent("projects.json")
317
+ let initial = try JSONEncoder().encode(ProjectSettings())
318
+ try initial.write(to: file)
319
+ var readError: SyntheticReadError?
320
+ let store = ProjectStore(filePath: file.path) { path in
321
+ if let readError { throw readError }
322
+ return try Data(contentsOf: URL(fileURLWithPath: path))
323
+ }
324
+
325
+ readError = SyntheticReadError()
326
+ store.settings.globalSystemPrompt = "Must not be persisted"
327
+
328
+ #expect(throws: ProjectStoreError.self) { try store.save() }
329
+ #expect(!store.canMutateProjects)
330
+ #expect(store.persistenceError?.contains("synthetic read failure") == true)
331
+ #expect(try Data(contentsOf: file) == initial)
332
+ }
333
+
334
+ @Test("external project file changes are never replaced by stale in-memory settings")
335
+ @MainActor
336
+ func externalChangeBlocksMutations() throws {
337
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
338
+ defer { try? FileManager.default.removeItem(at: root) }
339
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
340
+ let file = root.appendingPathComponent("projects.json")
341
+ try JSONEncoder().encode(ProjectSettings()).write(to: file)
342
+ let store = ProjectStore(filePath: file.path)
343
+ let external = Data("externally-replaced-data".utf8)
344
+ try external.write(to: file)
345
+
346
+ store.settings.globalSystemPrompt = "Must not replace external data"
347
+
348
+ #expect(throws: ProjectStoreError.self) { try store.save() }
349
+ #expect(!store.canMutateProjects)
350
+ #expect(store.persistenceError?.contains("changed on disk") == true)
351
+ #expect(try Data(contentsOf: file) == external)
352
+ }
353
+
354
+ @Test("a stale app instance cannot overwrite a newer project save")
355
+ @MainActor
356
+ func staleStoreCannotOverwriteNewerSave() throws {
357
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
358
+ defer { try? FileManager.default.removeItem(at: root) }
359
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
360
+ let file = root.appendingPathComponent("projects.json")
361
+ try JSONEncoder().encode(ProjectSettings()).write(to: file)
362
+ let first = ProjectStore(filePath: file.path)
363
+ let stale = ProjectStore(filePath: file.path)
364
+
365
+ first.settings.globalSystemPrompt = "newer value"
366
+ try first.save()
367
+ let newerData = try Data(contentsOf: file)
368
+ stale.settings.globalSystemPrompt = "stale value"
369
+
370
+ #expect(throws: ProjectStoreError.self) { try stale.save() }
371
+ #expect(!stale.canMutateProjects)
372
+ #expect(try Data(contentsOf: file) == newerData)
373
+ let persisted = try JSONDecoder().decode(ProjectSettings.self, from: newerData)
374
+ #expect(persisted.globalSystemPrompt == "newer value")
375
+ }
376
+
377
+ @Test("absent project data remains a writable empty store")
378
+ @MainActor
379
+ func absentDataIsWritable() {
380
+ let file = FileManager.default.temporaryDirectory
381
+ .appendingPathComponent(UUID().uuidString)
382
+ .appendingPathComponent("projects.json")
383
+ let store = ProjectStore(filePath: file.path) { _ in nil }
384
+
385
+ #expect(store.persistenceError == nil)
386
+ #expect(store.isReadyForRecording)
387
+ #expect(store.canMutateProjects)
388
+ }
389
+
5
390
  @Test("matchProject by bundle ID")
6
391
  func matchByBundleId() {
7
392
  let projects = [