@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,43 +1,136 @@
1
1
  import Foundation
2
+ import Darwin
3
+
4
+ public enum PostProcessingMode: String, CaseIterable, Identifiable, Codable, Sendable {
5
+ case off
6
+ case auto
7
+ case always
8
+
9
+ public var id: String { rawValue }
10
+
11
+ public var label: String {
12
+ switch self {
13
+ case .off: return "Raw"
14
+ case .auto: return "Auto"
15
+ case .always: return "Always"
16
+ }
17
+ }
18
+ }
19
+
20
+ public enum ProjectStoreError: Error, LocalizedError {
21
+ case synchronizationInProgress
22
+ case persistenceFailure(String)
23
+
24
+ public var errorDescription: String? {
25
+ switch self {
26
+ case .synchronizationInProgress: return "Project synchronization is in progress"
27
+ case .persistenceFailure(let message): return message
28
+ }
29
+ }
30
+ }
2
31
 
3
32
  public struct RecProject: Codable, Identifiable, Sendable {
4
- public let id: String
33
+ public var id: String
5
34
  public var name: String
6
35
  var path: String?
7
36
  var systemPrompt: String?
8
37
  var appBundleIds: [String]?
38
+ var canonicalPath: String?
39
+ var canonicalStoreId: String?
40
+ var color: String?
9
41
 
10
- public init(name: String, path: String? = nil, systemPrompt: String? = nil, appBundleIds: [String]? = nil) {
11
- self.id = UUID().uuidString
42
+ public init(
43
+ id: String = UUID().uuidString,
44
+ name: String,
45
+ path: String? = nil,
46
+ systemPrompt: String? = nil,
47
+ appBundleIds: [String]? = nil,
48
+ canonicalPath: String? = nil,
49
+ canonicalStoreId: String? = nil,
50
+ color: String? = nil
51
+ ) {
52
+ self.id = id
12
53
  self.name = name
13
54
  self.path = path
14
55
  self.systemPrompt = systemPrompt
15
56
  self.appBundleIds = appBundleIds
57
+ self.canonicalPath = canonicalPath
58
+ self.canonicalStoreId = canonicalStoreId
59
+ self.color = color
60
+ }
61
+
62
+ var registrationPath: String {
63
+ let stored = canonicalPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
64
+ if !stored.isEmpty { return stored }
65
+ let configured = path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
66
+ if !configured.isEmpty { return configured }
67
+ return "recordings-app://projects/\(id)"
16
68
  }
17
69
  }
18
70
 
19
71
  public struct ProjectSettings: Codable, Sendable {
20
- var globalSystemPrompt: String
21
- var projects: [RecProject]
22
- var activeProjectId: String?
72
+ public var globalSystemPrompt: String
73
+ public var postProcessingMode: String
74
+ public var projects: [RecProject]
75
+ public var activeProjectId: String?
23
76
 
24
77
  public init() {
25
78
  globalSystemPrompt = ""
79
+ postProcessingMode = PostProcessingMode.auto.rawValue
26
80
  projects = []
27
81
  activeProjectId = nil
28
82
  }
83
+
84
+ enum CodingKeys: String, CodingKey {
85
+ case globalSystemPrompt
86
+ case postProcessingMode
87
+ case projects
88
+ case activeProjectId
89
+ }
90
+
91
+ public init(from decoder: Decoder) throws {
92
+ let container = try decoder.container(keyedBy: CodingKeys.self)
93
+ globalSystemPrompt = try container.decodeIfPresent(String.self, forKey: .globalSystemPrompt) ?? ""
94
+ postProcessingMode = try container.decodeIfPresent(String.self, forKey: .postProcessingMode) ?? PostProcessingMode.auto.rawValue
95
+ projects = try container.decodeIfPresent([RecProject].self, forKey: .projects) ?? []
96
+ activeProjectId = try container.decodeIfPresent(String.self, forKey: .activeProjectId)
97
+ }
29
98
  }
30
99
 
31
100
  @MainActor
32
101
  public final class ProjectStore: ObservableObject {
102
+ typealias SettingsDataLoader = (String) throws -> Data?
103
+
33
104
  @Published public var settings = ProjectSettings()
105
+ @Published public private(set) var isReadyForRecording = false
106
+ @Published public private(set) var isSynchronizingProjects = false
107
+ @Published public private(set) var synchronizationError: String?
108
+ @Published public private(set) var persistenceError: String?
34
109
 
35
110
  private let filePath: String
111
+ private let dataLoader: SettingsDataLoader
112
+ private var loadSucceeded = true
113
+ private var loadFailureMessage: String?
114
+ private var persistedData: Data?
115
+
116
+ public var canMutateProjects: Bool {
117
+ loadSucceeded && !isSynchronizingProjects
118
+ }
36
119
 
37
120
  public var activeProject: RecProject? {
38
121
  settings.projects.first { $0.id == settings.activeProjectId }
39
122
  }
40
123
 
124
+ /// Only canonical Store ids are safe to pass to the CLI's `--project` option.
125
+ /// Legacy local ids remain available for display and prompting while synchronization
126
+ /// is degraded, but omitting them here prevents a recordings foreign-key failure.
127
+ public var activeCanonicalProjectIdForRecording: String? {
128
+ guard let activeProject else { return nil }
129
+ let canonicalStoreId = activeProject.canonicalStoreId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
130
+ if !canonicalStoreId.isEmpty { return canonicalStoreId }
131
+ return isReadyForRecording ? activeProject.id : nil
132
+ }
133
+
41
134
  public var effectiveSystemPrompt: String {
42
135
  let global = settings.globalSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
43
136
  let project = activeProject?.systemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -47,54 +140,257 @@ public final class ProjectStore: ObservableObject {
47
140
  return "\(global)\n\n\(project)"
48
141
  }
49
142
 
50
- public init() {
143
+ public var effectivePostProcessingMode: String {
144
+ let mode = PostProcessingMode(rawValue: settings.postProcessingMode) ?? .auto
145
+ return mode.rawValue
146
+ }
147
+
148
+ public convenience init(filePath: String? = nil) {
51
149
  let home = FileManager.default.homeDirectoryForCurrentUser.path
52
- filePath = "\(home)/.hasna/recordings/projects.json"
53
- load()
150
+ self.init(
151
+ filePath: filePath ?? "\(home)/.hasna/recordings/projects.json",
152
+ dataLoader: Self.readSettingsData
153
+ )
154
+ }
155
+
156
+ init(filePath: String, dataLoader: @escaping SettingsDataLoader) {
157
+ self.filePath = filePath
158
+ self.dataLoader = dataLoader
159
+ let loaded = load()
160
+ loadSucceeded = loaded
161
+ isReadyForRecording = loaded && settings.projects.isEmpty
54
162
  }
55
163
 
56
- func load() {
57
- guard FileManager.default.fileExists(atPath: filePath),
58
- let data = FileManager.default.contents(atPath: filePath) else { return }
164
+ nonisolated private static func readSettingsData(atPath path: String) throws -> Data? {
59
165
  do {
166
+ return try Data(contentsOf: URL(fileURLWithPath: path))
167
+ } catch {
168
+ let cocoaError = error as NSError
169
+ if cocoaError.domain == NSCocoaErrorDomain,
170
+ cocoaError.code == NSFileReadNoSuchFileError || cocoaError.code == NSFileNoSuchFileError {
171
+ return nil
172
+ }
173
+ throw error
174
+ }
175
+ }
176
+
177
+ @discardableResult
178
+ func load() -> Bool {
179
+ do {
180
+ guard let data = try dataLoader(filePath) else {
181
+ persistedData = nil
182
+ loadFailureMessage = nil
183
+ persistenceError = nil
184
+ return true
185
+ }
60
186
  settings = try JSONDecoder().decode(ProjectSettings.self, from: data)
187
+ persistedData = data
188
+ loadFailureMessage = nil
189
+ persistenceError = nil
190
+ return true
61
191
  } catch {
62
- fputs("[ProjectStore] Failed to load: \(error)\n", stderr)
192
+ let message = "Failed to load projects: \(error.localizedDescription)"
193
+ blockMutations(message: message)
194
+ return false
63
195
  }
64
196
  }
65
197
 
66
- func save() {
198
+ public func save() throws {
199
+ try requireWritableState()
200
+ try persistSettings()
201
+ }
202
+
203
+ private func requireWritableState() throws {
204
+ guard loadSucceeded else {
205
+ throw ProjectStoreError.persistenceFailure(loadFailureMessage ?? "Failed to load projects")
206
+ }
207
+ guard !isSynchronizingProjects else { throw ProjectStoreError.synchronizationInProgress }
208
+ try validatePersistedData()
209
+ }
210
+
211
+ private func validatePersistedData() throws {
212
+ guard loadSucceeded else {
213
+ throw ProjectStoreError.persistenceFailure(loadFailureMessage ?? "Failed to load projects")
214
+ }
215
+ do {
216
+ guard try dataLoader(filePath) == persistedData else {
217
+ let message = "Project settings changed on disk. Restart Recordings before making changes."
218
+ blockMutations(message: message)
219
+ throw ProjectStoreError.persistenceFailure(message)
220
+ }
221
+ } catch let error as ProjectStoreError {
222
+ throw error
223
+ } catch {
224
+ let message = "Failed to read projects before saving: \(error.localizedDescription)"
225
+ blockMutations(message: message)
226
+ throw ProjectStoreError.persistenceFailure(message)
227
+ }
228
+ }
229
+
230
+ private func blockMutations(message: String) {
231
+ loadSucceeded = false
232
+ isReadyForRecording = false
233
+ loadFailureMessage = message
234
+ persistenceError = message
235
+ }
236
+
237
+ private func persistSettings() throws {
67
238
  do {
68
239
  let data = try JSONEncoder().encode(settings)
69
240
  let dir = (filePath as NSString).deletingLastPathComponent
70
241
  try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
71
- try data.write(to: URL(fileURLWithPath: filePath))
242
+ try withExclusivePersistenceLock {
243
+ try validatePersistedData()
244
+ try data.write(to: URL(fileURLWithPath: filePath), options: .atomic)
245
+ persistedData = data
246
+ }
247
+ persistenceError = nil
72
248
  } catch {
73
- fputs("[ProjectStore] Failed to save: \(error)\n", stderr)
249
+ if error is ProjectStoreError { throw error }
250
+ persistenceError = "Failed to save projects: \(error.localizedDescription)"
251
+ throw error
74
252
  }
75
253
  }
76
254
 
77
- func addProject(name: String, path: String? = nil, systemPrompt: String? = nil) {
78
- let project = RecProject(name: name, path: path, systemPrompt: systemPrompt)
255
+ private func withExclusivePersistenceLock<T>(_ operation: () throws -> T) throws -> T {
256
+ let lockPath = "\(filePath).lock"
257
+ let descriptor = Darwin.open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR))
258
+ guard descriptor >= 0 else { throw posixError() }
259
+ defer { Darwin.close(descriptor) }
260
+ guard Darwin.lockf(descriptor, F_LOCK, 0) == 0 else { throw posixError() }
261
+ defer { Darwin.lockf(descriptor, F_ULOCK, 0) }
262
+ return try operation()
263
+ }
264
+
265
+ private func posixError() -> NSError {
266
+ let code = errno
267
+ return NSError(
268
+ domain: NSPOSIXErrorDomain,
269
+ code: Int(code),
270
+ userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(code))]
271
+ )
272
+ }
273
+
274
+ public func clearPersistenceError() {
275
+ persistenceError = nil
276
+ }
277
+
278
+ public func reconcileWithCanonicalStore(home: String = RecordingsCLI.defaultHome) async throws {
279
+ try requireWritableState()
280
+ isSynchronizingProjects = true
281
+ defer { isSynchronizingProjects = false }
282
+ let original = settings
283
+ guard !original.projects.isEmpty else {
284
+ isReadyForRecording = true
285
+ synchronizationError = nil
286
+ return
287
+ }
288
+ isReadyForRecording = false
289
+ let registrations: [(String, RecordingsCLI.CanonicalProject)]
290
+ do {
291
+ registrations = try await Task.detached(priority: .utility) {
292
+ try original.projects.map { project in
293
+ let canonical = try RecordingsCLI.registerProject(
294
+ name: project.name,
295
+ path: project.registrationPath,
296
+ home: home
297
+ )
298
+ return (project.id, canonical)
299
+ }
300
+ }.value
301
+ } catch {
302
+ let message = "Failed to register projects: \((error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription)"
303
+ synchronizationError = message
304
+ persistenceError = message
305
+ throw error
306
+ }
307
+ var migrated = original
308
+ var canonicalIDByLocalID: [String: String] = [:]
309
+ var seenCanonicalIDs = Set<String>()
310
+ migrated.projects = zip(original.projects, registrations).compactMap { pair -> RecProject? in
311
+ let (project, registration) = pair
312
+ let canonical = registration.1
313
+ if canonicalIDByLocalID[project.id] == nil {
314
+ canonicalIDByLocalID[project.id] = canonical.id
315
+ }
316
+ guard seenCanonicalIDs.insert(canonical.id).inserted else { return nil }
317
+ return RecProject(
318
+ id: canonical.id,
319
+ name: project.name,
320
+ path: project.path,
321
+ systemPrompt: project.systemPrompt,
322
+ appBundleIds: project.appBundleIds,
323
+ canonicalPath: canonical.path,
324
+ canonicalStoreId: canonical.id,
325
+ color: project.color
326
+ )
327
+ }
328
+ if let active = original.activeProjectId {
329
+ migrated.activeProjectId = canonicalIDByLocalID[active]
330
+ }
331
+ settings = migrated
332
+ do {
333
+ try persistSettings()
334
+ isReadyForRecording = true
335
+ synchronizationError = nil
336
+ } catch {
337
+ settings = original
338
+ synchronizationError = persistenceError ?? "Failed to persist synchronized projects"
339
+ throw error
340
+ }
341
+ }
342
+
343
+ public func addProject(name: String, path: String? = nil, systemPrompt: String? = nil, color: String? = nil, home: String = RecordingsCLI.defaultHome) async throws {
344
+ try requireWritableState()
345
+ isSynchronizingProjects = true
346
+ defer { isSynchronizingProjects = false }
347
+ let local = RecProject(name: name, path: path, systemPrompt: systemPrompt, color: color)
348
+ let canonical: RecordingsCLI.CanonicalProject
349
+ do {
350
+ canonical = try await Task.detached(priority: .userInitiated) {
351
+ try RecordingsCLI.registerProject(name: local.name, path: local.registrationPath, home: home)
352
+ }.value
353
+ } catch {
354
+ persistenceError = "Failed to register project: \((error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription)"
355
+ throw error
356
+ }
357
+ let project = RecProject(
358
+ id: canonical.id,
359
+ name: local.name,
360
+ path: local.path,
361
+ systemPrompt: local.systemPrompt,
362
+ appBundleIds: local.appBundleIds,
363
+ canonicalPath: canonical.path,
364
+ canonicalStoreId: canonical.id,
365
+ color: local.color
366
+ )
367
+ let original = settings
79
368
  settings.projects.append(project)
80
- save()
369
+ do { try persistSettings() } catch { settings = original; throw error }
370
+ isReadyForRecording = true
81
371
  }
82
372
 
83
- func updateProject(_ project: RecProject) {
373
+ public func updateProject(_ project: RecProject) throws {
374
+ try requireWritableState()
84
375
  guard let idx = settings.projects.firstIndex(where: { $0.id == project.id }) else { return }
376
+ let original = settings
85
377
  settings.projects[idx] = project
86
- save()
378
+ do { try save() } catch { settings = original; throw error }
87
379
  }
88
380
 
89
- func removeProject(id: String) {
381
+ public func removeProject(id: String) throws {
382
+ try requireWritableState()
383
+ let original = settings
90
384
  settings.projects.removeAll { $0.id == id }
91
385
  if settings.activeProjectId == id { settings.activeProjectId = nil }
92
- save()
386
+ do { try save() } catch { settings = original; throw error }
93
387
  }
94
388
 
95
- func setActive(_ id: String?) {
389
+ public func setActive(_ id: String?) throws {
390
+ try requireWritableState()
391
+ let original = settings
96
392
  settings.activeProjectId = id
97
- save()
393
+ do { try save() } catch { settings = original; throw error }
98
394
  }
99
395
 
100
396
  // MARK: - Auto-detection