@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,871 @@
1
+ import AppKit
1
2
  import Testing
2
3
  @testable import RecordingsLib
3
4
 
4
5
  struct PasteTargetTests {
6
+ @Test("selection is frozen when recording starts, not after transcription")
7
+ func selectionCapturePolicy() {
8
+ #expect(RecordingEngine.shouldCaptureSelection(
9
+ targetPid: 42,
10
+ accessibilityTrusted: true,
11
+ intentDetectionEnabled: true
12
+ ))
13
+ #expect(!RecordingEngine.shouldCaptureSelection(
14
+ targetPid: nil,
15
+ accessibilityTrusted: true,
16
+ intentDetectionEnabled: true
17
+ ))
18
+ #expect(!RecordingEngine.shouldCaptureSelection(
19
+ targetPid: 42,
20
+ accessibilityTrusted: false,
21
+ intentDetectionEnabled: true
22
+ ))
23
+ #expect(!RecordingEngine.shouldCaptureSelection(
24
+ targetPid: 42,
25
+ accessibilityTrusted: true,
26
+ intentDetectionEnabled: false
27
+ ))
28
+ }
29
+
30
+ @Test("serialized paste transactions write their own payload immediately before one post")
31
+ @MainActor
32
+ func serializedPasteTransactionsKeepPayloadsIsolated() {
33
+ var scheduled: [@MainActor @Sendable () -> Void] = []
34
+ var pasteboardText = "original"
35
+ var postedPayloads: [String] = []
36
+ var completions: [(UInt64?, PasteDeliveryOutcome)] = []
37
+ var preparations = 0
38
+ let coordinator = PasteTransactionCoordinator(
39
+ schedule: { _, operation in scheduled.append(operation) },
40
+ writeAndVerify: { text in
41
+ pasteboardText = text
42
+ return PasteboardWriteResult(
43
+ verified: pasteboardText == text,
44
+ ownershipChangeCount: 1
45
+ )
46
+ },
47
+ postPaste: {
48
+ postedPayloads.append(pasteboardText)
49
+ return true
50
+ }
51
+ )
52
+
53
+ #expect(coordinator.submit(
54
+ text: "recording A",
55
+ generation: 41,
56
+ delay: 0.5,
57
+ settlementDelay: 0.6,
58
+ prepare: { preparations += 1 }
59
+ ) { transaction, outcome in
60
+ completions.append((transaction.generation, outcome))
61
+ })
62
+ pasteboardText = "unrelated clipboard mutation"
63
+ #expect(!coordinator.submit(text: "recording B", generation: 42, delay: 0.15) { _, _ in })
64
+ #expect(coordinator.hasPendingTransaction)
65
+ #expect(scheduled.count == 1)
66
+
67
+ let scheduledA = scheduled.removeFirst()
68
+ scheduledA()
69
+ scheduledA()
70
+ #expect(postedPayloads == ["recording A"])
71
+ #expect(preparations == 1)
72
+ #expect(completions.count == 1)
73
+ #expect(completions.first?.0 == 41)
74
+ #expect(completions.first?.1 == .pasted)
75
+ #expect(coordinator.hasPendingTransaction)
76
+ #expect(!coordinator.submit(text: "recording B", generation: 42, delay: 0) { _, _ in })
77
+
78
+ scheduled.removeFirst()()
79
+ #expect(!coordinator.hasPendingTransaction)
80
+
81
+ #expect(coordinator.submit(text: "recording B", generation: 42, delay: 0) { transaction, outcome in
82
+ completions.append((transaction.generation, outcome))
83
+ })
84
+ pasteboardText = "another mutation"
85
+ scheduled.removeFirst()()
86
+ #expect(postedPayloads == ["recording A", "recording B"])
87
+ #expect(completions.map(\.0) == [41, 42])
88
+ #expect(completions.map(\.1) == [.pasted, .pasted])
89
+ }
90
+
91
+ @Test("every idle transition — submit, failure, and delayed settlement — announces itself")
92
+ @MainActor
93
+ func pendingTransitionsAreObservable() {
94
+ var scheduled: [@MainActor @Sendable () -> Void] = []
95
+ var announcements = 0
96
+ let coordinator = PasteTransactionCoordinator(
97
+ schedule: { _, operation in scheduled.append(operation) },
98
+ writeAndVerify: { _ in PasteboardWriteResult(verified: true, ownershipChangeCount: 1) },
99
+ postPaste: { true }
100
+ )
101
+ coordinator.pendingTransactionWillChange = { announcements += 1 }
102
+
103
+ #expect(coordinator.submit(text: "A", generation: 1, delay: 0.5, settlementDelay: 0.6) { _, _ in })
104
+ #expect(announcements == 1, "entering the pending state must announce")
105
+
106
+ scheduled.removeFirst()()
107
+ #expect(announcements == 1, "scheduled → settling keeps hasPendingTransaction true — no announcement")
108
+ #expect(coordinator.hasPendingTransaction)
109
+
110
+ scheduled.removeFirst()()
111
+ #expect(announcements == 2, "delayed settlement back to idle must announce — the menu bar recomputes from it")
112
+ #expect(!coordinator.hasPendingTransaction)
113
+
114
+ // A failing transaction announces its return to idle from the completion turn.
115
+ let failing = PasteTransactionCoordinator(
116
+ schedule: { _, operation in scheduled.append(operation) },
117
+ writeAndVerify: { _ in PasteboardWriteResult(verified: false, ownershipChangeCount: 1) },
118
+ postPaste: { false }
119
+ )
120
+ var failureAnnouncements = 0
121
+ failing.pendingTransactionWillChange = { failureAnnouncements += 1 }
122
+ #expect(failing.submit(text: "B", generation: 2, delay: 0) { _, _ in })
123
+ scheduled.removeFirst()()
124
+ #expect(failureAnnouncements == 2)
125
+ #expect(!failing.hasPendingTransaction)
126
+ }
127
+
128
+ @Test("paste failures complete once with exact write and post outcomes")
129
+ @MainActor
130
+ func pasteFailureOutcomesAreExact() {
131
+ var scheduled: [@MainActor @Sendable () -> Void] = []
132
+ var postCalls = 0
133
+ var outcomes: [PasteDeliveryOutcome] = []
134
+ var settlements: [PasteDeliveryOutcome] = []
135
+ let coordinator = PasteTransactionCoordinator(
136
+ schedule: { _, operation in scheduled.append(operation) },
137
+ writeAndVerify: { _ in PasteboardWriteResult(verified: false, ownershipChangeCount: 1) },
138
+ postPaste: {
139
+ postCalls += 1
140
+ return false
141
+ }
142
+ )
143
+
144
+ #expect(coordinator.submit(text: "A", generation: 1, delay: 0) { _, outcome in
145
+ outcomes.append(outcome)
146
+ } settlement: { _, outcome in
147
+ settlements.append(outcome)
148
+ })
149
+ let writeFailure = scheduled.removeFirst()
150
+ writeFailure()
151
+ writeFailure()
152
+ #expect(outcomes == [.clipboardWriteFailed])
153
+ #expect(settlements == [.clipboardWriteFailed])
154
+ #expect(postCalls == 0)
155
+ #expect(!coordinator.hasPendingTransaction)
156
+
157
+ let postFailureCoordinator = PasteTransactionCoordinator(
158
+ schedule: { _, operation in scheduled.append(operation) },
159
+ writeAndVerify: { _ in PasteboardWriteResult(verified: true, ownershipChangeCount: 1) },
160
+ postPaste: {
161
+ postCalls += 1
162
+ return false
163
+ }
164
+ )
165
+ #expect(postFailureCoordinator.submit(text: "B", generation: 2, delay: 0) { _, outcome in
166
+ outcomes.append(outcome)
167
+ } settlement: { _, outcome in
168
+ settlements.append(outcome)
169
+ })
170
+ let postFailure = scheduled.removeFirst()
171
+ postFailure()
172
+ postFailure()
173
+ #expect(outcomes == [.clipboardWriteFailed, .eventPostFailed])
174
+ #expect(settlements == [.clipboardWriteFailed, .eventPostFailed])
175
+ #expect(postCalls == 1)
176
+ #expect(!postFailureCoordinator.hasPendingTransaction)
177
+ }
178
+
179
+ @Test("paste target is revalidated immediately before clipboard write and event post")
180
+ @MainActor
181
+ func lostPasteTargetCancelsTransactionExactlyOnce() {
182
+ var scheduled: [@MainActor @Sendable () -> Void] = []
183
+ var writeCalls = 0
184
+ var postCalls = 0
185
+ var completions: [PasteDeliveryOutcome] = []
186
+ var settlements: [PasteDeliveryOutcome] = []
187
+ let coordinator = PasteTransactionCoordinator(
188
+ schedule: { _, operation in scheduled.append(operation) },
189
+ writeAndVerify: { _ in
190
+ writeCalls += 1
191
+ return PasteboardWriteResult(verified: true, ownershipChangeCount: 1)
192
+ },
193
+ postPaste: {
194
+ postCalls += 1
195
+ return true
196
+ }
197
+ )
198
+
199
+ #expect(coordinator.submit(
200
+ text: "A",
201
+ generation: 1,
202
+ delay: 0.5,
203
+ targetIsReady: { false }
204
+ ) { _, outcome in
205
+ completions.append(outcome)
206
+ } settlement: { _, outcome in
207
+ settlements.append(outcome)
208
+ })
209
+ let operation = scheduled.removeFirst()
210
+ operation()
211
+ operation()
212
+
213
+ #expect(writeCalls == 0)
214
+ #expect(postCalls == 0)
215
+ #expect(completions == [.targetUnavailable])
216
+ #expect(settlements == [.targetUnavailable])
217
+ #expect(!coordinator.hasPendingTransaction)
218
+ }
219
+
220
+ @Test("paste target is revalidated after snapshot preparation immediately before write")
221
+ @MainActor
222
+ func targetLostDuringPreparationNeverWrites() {
223
+ var scheduled: [@MainActor @Sendable () -> Void] = []
224
+ var targetIsReady = true
225
+ var writeCalls = 0
226
+ var completions: [PasteDeliveryOutcome] = []
227
+ let coordinator = PasteTransactionCoordinator(
228
+ schedule: { _, operation in scheduled.append(operation) },
229
+ writeAndVerify: { _ in
230
+ writeCalls += 1
231
+ return PasteboardWriteResult(verified: true, ownershipChangeCount: 1)
232
+ },
233
+ postPaste: { true }
234
+ )
235
+
236
+ #expect(coordinator.submit(
237
+ text: "A",
238
+ generation: 1,
239
+ delay: 0,
240
+ targetIsReady: { targetIsReady },
241
+ prepare: { targetIsReady = false }
242
+ ) { _, outcome in
243
+ completions.append(outcome)
244
+ })
245
+ scheduled.removeFirst()()
246
+
247
+ #expect(writeCalls == 0)
248
+ #expect(completions == [.targetUnavailable])
249
+ #expect(!coordinator.hasPendingTransaction)
250
+ }
251
+
252
+ @Test("paste target is revalidated again after clipboard write before event post")
253
+ @MainActor
254
+ func targetLostDuringClipboardWriteNeverPosts() {
255
+ var scheduled: [@MainActor @Sendable () -> Void] = []
256
+ var targetIsReady = true
257
+ var postCalls = 0
258
+ var completions: [PasteDeliveryOutcome] = []
259
+ var settlements: [PasteDeliveryOutcome] = []
260
+ let coordinator = PasteTransactionCoordinator(
261
+ schedule: { _, operation in scheduled.append(operation) },
262
+ writeAndVerify: { _ in
263
+ targetIsReady = false
264
+ return PasteboardWriteResult(verified: true, ownershipChangeCount: 1)
265
+ },
266
+ postPaste: {
267
+ postCalls += 1
268
+ return true
269
+ }
270
+ )
271
+
272
+ #expect(coordinator.submit(
273
+ text: "A",
274
+ generation: 1,
275
+ delay: 0.5,
276
+ targetIsReady: { targetIsReady }
277
+ ) { _, outcome in
278
+ completions.append(outcome)
279
+ } settlement: { _, outcome in
280
+ settlements.append(outcome)
281
+ })
282
+ scheduled.removeFirst()()
283
+
284
+ #expect(postCalls == 0)
285
+ #expect(completions == [.targetUnavailable])
286
+ #expect(settlements == [.targetUnavailable])
287
+ #expect(!coordinator.hasPendingTransaction)
288
+ }
289
+
290
+ @Test("clipboard payload ownership is revalidated immediately before event post")
291
+ @MainActor
292
+ func payloadLostAfterWriteNeverPosts() {
293
+ var scheduled: [@MainActor @Sendable () -> Void] = []
294
+ var payloadIsReady = true
295
+ var postCalls = 0
296
+ var completions: [PasteDeliveryOutcome] = []
297
+ let coordinator = PasteTransactionCoordinator(
298
+ schedule: { _, operation in scheduled.append(operation) },
299
+ writeAndVerify: { _ in
300
+ payloadIsReady = false
301
+ return PasteboardWriteResult(verified: true, ownershipChangeCount: 1)
302
+ },
303
+ postPaste: {
304
+ postCalls += 1
305
+ return true
306
+ }
307
+ )
308
+
309
+ #expect(coordinator.submit(
310
+ text: "A",
311
+ generation: 1,
312
+ delay: 0,
313
+ payloadIsReady: { payloadIsReady }
314
+ ) { _, outcome in
315
+ completions.append(outcome)
316
+ })
317
+ scheduled.removeFirst()()
318
+
319
+ #expect(postCalls == 0)
320
+ #expect(completions == [.clipboardOwnershipLost])
321
+ #expect(!coordinator.hasPendingTransaction)
322
+ }
323
+
324
+ @Test("command selection uses exact AX text only while the captured target remains frontmost")
325
+ func commandSelectionRequiresFocusedAccessibilityText() {
326
+ #expect(RecordingEngine.validAccessibilitySelection(
327
+ nil,
328
+ targetStillFrontmost: true
329
+ ) == nil)
330
+ #expect(RecordingEngine.validAccessibilitySelection(
331
+ " \n\t ",
332
+ targetStillFrontmost: true
333
+ ) == nil)
334
+ #expect(RecordingEngine.validAccessibilitySelection(
335
+ "fresh selection",
336
+ targetStillFrontmost: false
337
+ ) == nil)
338
+ #expect(RecordingEngine.validAccessibilitySelection(
339
+ " fresh\nselection\t ",
340
+ targetStillFrontmost: true
341
+ ) == " fresh\nselection\t ")
342
+ }
343
+
344
+ @Test("command rewrite rejects a different focused field in the same app")
345
+ func commandSelectionRejectsDifferentFocusedElement() {
346
+ let captured = AccessibilitySelectionIdentity(
347
+ element: "field-a",
348
+ window: "window-a",
349
+ documentIdentifier: "file:///document-a",
350
+ rangeLocation: 4,
351
+ rangeLength: 7,
352
+ selectedText: "rewrite"
353
+ )
354
+
355
+ #expect(!captured.matches(
356
+ element: "field-b",
357
+ window: "window-a",
358
+ documentIdentifier: "file:///document-a",
359
+ rangeLocation: 4,
360
+ rangeLength: 7,
361
+ selectedText: "rewrite"
362
+ ))
363
+ }
364
+
365
+ @Test("command rewrite rejects a changed selection in the same focused field")
366
+ func commandSelectionRejectsChangedRangeOrText() {
367
+ let captured = AccessibilitySelectionIdentity(
368
+ element: "field-a",
369
+ window: "window-a",
370
+ documentIdentifier: "file:///document-a",
371
+ rangeLocation: 4,
372
+ rangeLength: 7,
373
+ selectedText: "rewrite"
374
+ )
375
+
376
+ #expect(!captured.matches(
377
+ element: "field-a",
378
+ window: "window-a",
379
+ documentIdentifier: "file:///document-a",
380
+ rangeLocation: 5,
381
+ rangeLength: 7,
382
+ selectedText: "rewrite"
383
+ ))
384
+ #expect(!captured.matches(
385
+ element: "field-a",
386
+ window: "window-a",
387
+ documentIdentifier: "file:///document-a",
388
+ rangeLocation: 4,
389
+ rangeLength: 7,
390
+ selectedText: "changed"
391
+ ))
392
+ #expect(captured.matches(
393
+ element: "field-a",
394
+ window: "window-a",
395
+ documentIdentifier: "file:///document-a",
396
+ rangeLocation: 4,
397
+ rangeLength: 7,
398
+ selectedText: "rewrite"
399
+ ))
400
+ }
401
+
402
+ @Test("command rewrite rejects a changed window or document in the same focused field")
403
+ func commandSelectionRejectsChangedDocumentContext() {
404
+ let captured = AccessibilitySelectionIdentity(
405
+ element: "shared-editor",
406
+ window: "window-a",
407
+ documentIdentifier: "file:///document-a",
408
+ rangeLocation: 4,
409
+ rangeLength: 7,
410
+ selectedText: "rewrite"
411
+ )
412
+
413
+ #expect(!captured.matches(
414
+ element: "shared-editor",
415
+ window: "window-b",
416
+ documentIdentifier: "file:///document-a",
417
+ rangeLocation: 4,
418
+ rangeLength: 7,
419
+ selectedText: "rewrite"
420
+ ))
421
+ #expect(!captured.matches(
422
+ element: "shared-editor",
423
+ window: "window-a",
424
+ documentIdentifier: "file:///document-b",
425
+ rangeLocation: 4,
426
+ rangeLength: 7,
427
+ selectedText: "rewrite"
428
+ ))
429
+ }
430
+
431
+ @Test("command selection requires a stable AX document identity")
432
+ func commandSelectionRejectsMissingDocumentIdentity() {
433
+ #expect(RecordingEngine.stableAccessibilityDocumentIdentifier(nil) == nil)
434
+ #expect(RecordingEngine.stableAccessibilityDocumentIdentifier(" \n ") == nil)
435
+ #expect(RecordingEngine.stableAccessibilityDocumentIdentifier(
436
+ "file:///document-a"
437
+ ) == "file:///document-a")
438
+ }
439
+
440
+ @Test("documentless command selection fails closed even with a stable AX control identifier")
441
+ func commandSelectionRejectsControlIdentifierWithoutDocumentIdentity() {
442
+ #expect(RecordingEngine.stableAccessibilityContextIdentifier(
443
+ documentIdentifier: nil,
444
+ elementIdentifier: "editor-field"
445
+ ) == nil)
446
+ #expect(RecordingEngine.stableAccessibilityContextIdentifier(
447
+ documentIdentifier: "file:///document-a",
448
+ elementIdentifier: "editor-field"
449
+ ) == "document:file:///document-a")
450
+ #expect(RecordingEngine.stableAccessibilityContextIdentifier(
451
+ documentIdentifier: nil,
452
+ elementIdentifier: " \n "
453
+ ) == nil)
454
+ }
455
+
456
+ @Test("paste readiness requires the exact frontmost pid and current Accessibility trust")
457
+ func pasteReadinessIncludesAccessibility() {
458
+ #expect(RecordingEngine.pasteTargetIsReady(
459
+ expectedPid: 20,
460
+ expectedBundleIdentifier: "com.editor",
461
+ frontmostPid: 20,
462
+ frontmostBundleIdentifier: "com.editor",
463
+ accessibilityTrusted: true
464
+ ))
465
+ #expect(!RecordingEngine.pasteTargetIsReady(
466
+ expectedPid: 20,
467
+ expectedBundleIdentifier: "com.editor",
468
+ frontmostPid: 30,
469
+ frontmostBundleIdentifier: "com.editor",
470
+ accessibilityTrusted: true
471
+ ))
472
+ #expect(!RecordingEngine.pasteTargetIsReady(
473
+ expectedPid: 20,
474
+ expectedBundleIdentifier: "com.editor",
475
+ frontmostPid: 20,
476
+ frontmostBundleIdentifier: "com.editor",
477
+ accessibilityTrusted: false
478
+ ))
479
+ #expect(!RecordingEngine.pasteTargetIsReady(
480
+ expectedPid: 20,
481
+ expectedBundleIdentifier: "com.editor",
482
+ frontmostPid: 20,
483
+ frontmostBundleIdentifier: "com.editor",
484
+ accessibilityTrusted: true,
485
+ expectedLaunchDate: Date(timeIntervalSince1970: 1_000),
486
+ frontmostLaunchDate: Date(timeIntervalSince1970: 2_000),
487
+ requiresProcessIdentity: true
488
+ ))
489
+ #expect(RecordingEngine.pasteTargetIsReady(
490
+ expectedPid: 20,
491
+ expectedBundleIdentifier: "com.editor",
492
+ frontmostPid: 20,
493
+ frontmostBundleIdentifier: "com.editor",
494
+ accessibilityTrusted: true,
495
+ expectedLaunchDate: Date(timeIntervalSince1970: 1_000),
496
+ frontmostLaunchDate: Date(timeIntervalSince1970: 1_000),
497
+ requiresProcessIdentity: true
498
+ ))
499
+ #expect(!RecordingEngine.pasteTargetIsReady(
500
+ expectedPid: 20,
501
+ expectedBundleIdentifier: "com.editor",
502
+ frontmostPid: 20,
503
+ frontmostBundleIdentifier: "com.other",
504
+ accessibilityTrusted: true
505
+ ))
506
+ #expect(!RecordingEngine.pasteTargetIsReady(
507
+ expectedPid: 20,
508
+ expectedBundleIdentifier: nil,
509
+ frontmostPid: 20,
510
+ frontmostBundleIdentifier: "com.other",
511
+ accessibilityTrusted: true
512
+ ))
513
+ }
514
+
515
+ @Test("Accessibility denial preserves dictation but command rewrite remains fail-closed")
516
+ func accessibilityDeniedDeliveryPolicyDistinguishesDictationFromCommand() {
517
+ #expect(RecordingEngine.shouldCopyPasteFallback(deliveryKind: .ordinaryDictation))
518
+ #expect(RecordingEngine.shouldCopyPasteFallback(deliveryKind: .manualPaste))
519
+ #expect(!RecordingEngine.shouldCopyPasteFallback(deliveryKind: .commandRewrite))
520
+ #expect(RecordingEngine.shouldCopyAfterPasteFailure(
521
+ outcome: .targetUnavailable,
522
+ deliveryKind: .ordinaryDictation,
523
+ accessibilityTrusted: false
524
+ ))
525
+ #expect(!RecordingEngine.shouldCopyAfterPasteFailure(
526
+ outcome: .targetUnavailable,
527
+ deliveryKind: .ordinaryDictation,
528
+ accessibilityTrusted: true
529
+ ))
530
+ #expect(!RecordingEngine.shouldCopyAfterPasteFailure(
531
+ outcome: .targetUnavailable,
532
+ deliveryKind: .commandRewrite,
533
+ accessibilityTrusted: false
534
+ ))
535
+ #expect(!RecordingEngine.shouldCopyAfterPasteFailure(
536
+ outcome: .clipboardOwnershipLost,
537
+ deliveryKind: .ordinaryDictation,
538
+ accessibilityTrusted: false
539
+ ))
540
+ #expect(!RecordingEngine.shouldCopyAfterPasteFailure(
541
+ outcome: .targetUnavailable,
542
+ deliveryKind: .ordinaryDictation,
543
+ accessibilityTrusted: false,
544
+ clipboardOwnershipWasLost: true
545
+ ))
546
+ #expect(!RecordingEngine.shouldCopyAfterPasteFailure(
547
+ outcome: .targetUnavailable,
548
+ deliveryKind: .ordinaryDictation,
549
+ accessibilityTrusted: false,
550
+ completedTranscriptAlreadyOnClipboard: true
551
+ ))
552
+ #expect(RecordingEngine.targetUnavailableDeliveryStatus(
553
+ deliveryKind: .manualPaste,
554
+ accessibilityTrusted: true,
555
+ clipboardOwnershipWasLost: false,
556
+ completedTranscriptAlreadyOnClipboard: true,
557
+ fallbackWriteRequested: false,
558
+ fallbackWriteSucceeded: false
559
+ ) == "Copied — target app lost focus")
560
+ #expect(RecordingEngine.targetUnavailableDeliveryStatus(
561
+ deliveryKind: .commandRewrite,
562
+ accessibilityTrusted: false,
563
+ clipboardOwnershipWasLost: false,
564
+ completedTranscriptAlreadyOnClipboard: false,
565
+ fallbackWriteRequested: false,
566
+ fallbackWriteSucceeded: false
567
+ ) == "Paste cancelled because Accessibility permission changed")
568
+ #expect(RecordingEngine.targetUnavailableDeliveryStatus(
569
+ deliveryKind: .ordinaryDictation,
570
+ accessibilityTrusted: false,
571
+ clipboardOwnershipWasLost: true,
572
+ completedTranscriptAlreadyOnClipboard: false,
573
+ fallbackWriteRequested: false,
574
+ fallbackWriteSucceeded: false
575
+ ) == "Paste cancelled because the clipboard changed")
576
+ }
577
+
578
+ @Test("manual paste records lost clipboard ownership before delayed Accessibility fallback")
579
+ @MainActor
580
+ func manualPasteDoesNotOverwriteNewClipboardOwnerAfterAccessibilityLoss() {
581
+ var scheduled: [@MainActor @Sendable () -> Void] = []
582
+ var targetChecks = 0
583
+ var clipboardOwnershipWasLost = false
584
+ var fallbackCopied = false
585
+ let coordinator = PasteTransactionCoordinator(
586
+ schedule: { _, operation in scheduled.append(operation) },
587
+ writeAndVerify: { _ in
588
+ PasteboardWriteResult(verified: true, ownershipChangeCount: 1)
589
+ },
590
+ postPaste: { true }
591
+ )
592
+
593
+ #expect(coordinator.submit(
594
+ text: "completed dictation",
595
+ generation: 1,
596
+ delay: 0,
597
+ targetIsReady: {
598
+ targetChecks += 1
599
+ return targetChecks < 3
600
+ }
601
+ ) { _, outcome in
602
+ fallbackCopied = RecordingEngine.shouldCopyAfterPasteFailure(
603
+ outcome: outcome,
604
+ deliveryKind: .manualPaste,
605
+ accessibilityTrusted: false,
606
+ clipboardOwnershipWasLost: clipboardOwnershipWasLost
607
+ )
608
+ } settlement: { _, outcome in
609
+ clipboardOwnershipWasLost = RecordingEngine.clipboardOwnershipWasLostAfterPasteFailure(
610
+ outcome: outcome,
611
+ hasOwnershipToken: true,
612
+ stillOwnsPayload: false
613
+ )
614
+ })
615
+
616
+ scheduled.removeFirst()()
617
+
618
+ #expect(clipboardOwnershipWasLost)
619
+ #expect(!fallbackCopied)
620
+ #expect(!coordinator.hasPendingTransaction)
621
+ }
622
+
623
+ @Test("manual paste keeps its owned transcript without rewriting the clipboard")
624
+ @MainActor
625
+ func manualPasteDoesNotRewriteOwnedTranscriptAfterAccessibilityLoss() {
626
+ var scheduled: [@MainActor @Sendable () -> Void] = []
627
+ var targetChecks = 0
628
+ var completedTranscriptAlreadyOnClipboard = false
629
+ var fallbackWriteRequested = true
630
+ let coordinator = PasteTransactionCoordinator(
631
+ schedule: { _, operation in scheduled.append(operation) },
632
+ writeAndVerify: { _ in
633
+ PasteboardWriteResult(verified: true, ownershipChangeCount: 1)
634
+ },
635
+ postPaste: { true }
636
+ )
637
+
638
+ #expect(coordinator.submit(
639
+ text: "completed dictation",
640
+ generation: 1,
641
+ delay: 0,
642
+ targetIsReady: {
643
+ targetChecks += 1
644
+ return targetChecks < 3
645
+ }
646
+ ) { _, outcome in
647
+ fallbackWriteRequested = RecordingEngine.shouldCopyAfterPasteFailure(
648
+ outcome: outcome,
649
+ deliveryKind: .manualPaste,
650
+ accessibilityTrusted: false,
651
+ completedTranscriptAlreadyOnClipboard: completedTranscriptAlreadyOnClipboard
652
+ )
653
+ } settlement: { _, outcome in
654
+ completedTranscriptAlreadyOnClipboard = outcome == .targetUnavailable
655
+ })
656
+
657
+ scheduled.removeFirst()()
658
+
659
+ #expect(completedTranscriptAlreadyOnClipboard)
660
+ #expect(!fallbackWriteRequested)
661
+ #expect(!coordinator.hasPendingTransaction)
662
+ }
663
+
664
+ @Test("ordinary dictation fallback leaves the completed transcript on the clipboard")
665
+ func dictationFallbackCopiesCompletedTranscript() {
666
+ let pasteboard = NSPasteboard(name: NSPasteboard.Name("recordings-dictation-fallback-\(UUID().uuidString)"))
667
+ defer { pasteboard.releaseGlobally() }
668
+ pasteboard.clearContents()
669
+ pasteboard.setString("previous value", forType: .string)
670
+
671
+ #expect(RecordingEngine.writeClipboardPreservingOnFailure(
672
+ "completed dictation",
673
+ to: pasteboard
674
+ ))
675
+ #expect(pasteboard.string(forType: .string) == "completed dictation")
676
+ }
677
+
678
+ @Test("named pasteboard runtime preserves A/B payload isolation")
679
+ @MainActor
680
+ func namedPasteboardRuntimeProbe() {
681
+ let pasteboard = NSPasteboard(name: NSPasteboard.Name("recordings-paste-\(UUID().uuidString)"))
682
+ defer { pasteboard.releaseGlobally() }
683
+ var scheduled: [@MainActor @Sendable () -> Void] = []
684
+ var postedPayloads: [String] = []
685
+ let coordinator = PasteTransactionCoordinator(
686
+ schedule: { _, operation in scheduled.append(operation) },
687
+ writeAndVerify: { text in RecordingEngine.writeClipboardAttempt(text, to: pasteboard) },
688
+ postPaste: {
689
+ guard let payload = pasteboard.string(forType: .string) else { return false }
690
+ postedPayloads.append(payload)
691
+ return true
692
+ }
693
+ )
694
+
695
+ #expect(coordinator.submit(text: "A", generation: 1, delay: 0.5) { _, _ in })
696
+ pasteboard.clearContents()
697
+ pasteboard.setString("external", forType: .string)
698
+ scheduled.removeFirst()()
699
+ #expect(coordinator.submit(text: "B", generation: 2, delay: 0.15) { _, _ in })
700
+ pasteboard.clearContents()
701
+ pasteboard.setString("external again", forType: .string)
702
+ scheduled.removeFirst()()
703
+
704
+ #expect(postedPayloads == ["A", "B"])
705
+ }
706
+
707
+ @Test("named pasteboard restoration does not overwrite a same-text newer owner")
708
+ @MainActor
709
+ func namedPasteboardChangeCountPreventsABARestore() {
710
+ let pasteboard = NSPasteboard(name: NSPasteboard.Name("recordings-paste-aba-\(UUID().uuidString)"))
711
+ defer { pasteboard.releaseGlobally() }
712
+ pasteboard.clearContents()
713
+ pasteboard.setString("original", forType: .string)
714
+
715
+ var scheduled: [@MainActor @Sendable () -> Void] = []
716
+ var ownedChangeCount: Int?
717
+ let coordinator = PasteTransactionCoordinator(
718
+ schedule: { _, operation in scheduled.append(operation) },
719
+ writeAndVerify: { text in RecordingEngine.writeClipboardAttempt(text, to: pasteboard) },
720
+ postPaste: { true }
721
+ )
722
+
723
+ #expect(coordinator.submit(
724
+ text: "A",
725
+ generation: 1,
726
+ delay: 0,
727
+ settlementDelay: 0.6,
728
+ writeAttempted: { ownedChangeCount = $0.ownershipChangeCount }
729
+ ) { _, _ in
730
+ } settlement: { transaction, outcome in
731
+ guard outcome == .pasted,
732
+ pasteboard.changeCount == ownedChangeCount,
733
+ pasteboard.string(forType: .string) == transaction.text else { return }
734
+ pasteboard.clearContents()
735
+ pasteboard.setString("original", forType: .string)
736
+ })
737
+ scheduled.removeFirst()()
738
+
739
+ pasteboard.clearContents()
740
+ pasteboard.setString("A", forType: .string)
741
+ let externalChangeCount = pasteboard.changeCount
742
+ scheduled.removeFirst()()
743
+
744
+ #expect(pasteboard.string(forType: .string) == "A")
745
+ #expect(pasteboard.changeCount == externalChangeCount)
746
+ #expect(!coordinator.hasPendingTransaction)
747
+ }
748
+
749
+ @Test("failed write restoration does not overwrite a newer clipboard owner")
750
+ @MainActor
751
+ func failedWriteRestoresOnlyWhileOwningChangeCount() {
752
+ let pasteboard = NSPasteboard(name: NSPasteboard.Name("recordings-write-failure-\(UUID().uuidString)"))
753
+ defer { pasteboard.releaseGlobally() }
754
+ pasteboard.clearContents()
755
+ pasteboard.setString("original", forType: .string)
756
+
757
+ var scheduled: [@MainActor @Sendable () -> Void] = []
758
+ var attemptedChangeCount: Int?
759
+ let coordinator = PasteTransactionCoordinator(
760
+ schedule: { _, operation in scheduled.append(operation) },
761
+ writeAndVerify: { text in
762
+ let attempted = RecordingEngine.writeClipboardAttempt(text, to: pasteboard)
763
+ pasteboard.clearContents()
764
+ pasteboard.setString("new external value", forType: .string)
765
+ return PasteboardWriteResult(
766
+ verified: false,
767
+ ownershipChangeCount: attempted.ownershipChangeCount
768
+ )
769
+ },
770
+ postPaste: { true }
771
+ )
772
+
773
+ #expect(coordinator.submit(
774
+ text: "A",
775
+ generation: 1,
776
+ delay: 0,
777
+ writeAttempted: { attemptedChangeCount = $0.ownershipChangeCount }
778
+ ) { _, _ in
779
+ } settlement: { _, outcome in
780
+ guard outcome == .clipboardWriteFailed,
781
+ pasteboard.changeCount == attemptedChangeCount else { return }
782
+ pasteboard.clearContents()
783
+ pasteboard.setString("original", forType: .string)
784
+ })
785
+ scheduled.removeFirst()()
786
+
787
+ #expect(pasteboard.string(forType: .string) == "new external value")
788
+ }
789
+
790
+ @Test("separate command copy and paste transactions preserve a newer clipboard owner")
791
+ @MainActor
792
+ func commandRewriteDelayPreservesNewClipboardOwner() {
793
+ let pasteboard = NSPasteboard(name: NSPasteboard.Name("recordings-command-copy-paste-\(UUID().uuidString)"))
794
+ defer { pasteboard.releaseGlobally() }
795
+ pasteboard.clearContents()
796
+ pasteboard.setString("original", forType: .string)
797
+
798
+ pasteboard.clearContents()
799
+ pasteboard.setString("selected text", forType: .string)
800
+ let selectionChangeCount = pasteboard.changeCount
801
+ #expect(RecordingEngine.clipboardStillOwned(
802
+ pasteboard,
803
+ text: "selected text",
804
+ changeCount: selectionChangeCount
805
+ ))
806
+ pasteboard.clearContents()
807
+ pasteboard.setString("original", forType: .string)
808
+
809
+ // A separate owner updates the clipboard while the helper rewrites in memory.
810
+ pasteboard.clearContents()
811
+ pasteboard.setString("new external value", forType: .string)
812
+ var scheduled: [@MainActor @Sendable () -> Void] = []
813
+ var prePasteValue: String?
814
+ var ownedChangeCount: Int?
815
+ let coordinator = PasteTransactionCoordinator(
816
+ schedule: { _, operation in scheduled.append(operation) },
817
+ writeAndVerify: { text in RecordingEngine.writeClipboardAttempt(text, to: pasteboard) },
818
+ postPaste: { true }
819
+ )
820
+
821
+ #expect(coordinator.submit(
822
+ text: "rewritten text",
823
+ generation: 1,
824
+ delay: 0,
825
+ settlementDelay: 0.6,
826
+ prepare: { prePasteValue = pasteboard.string(forType: .string) },
827
+ writeAttempted: { ownedChangeCount = $0.ownershipChangeCount }
828
+ ) { _, _ in
829
+ } settlement: { transaction, outcome in
830
+ guard outcome == .pasted,
831
+ let ownedChangeCount,
832
+ RecordingEngine.clipboardStillOwned(
833
+ pasteboard,
834
+ text: transaction.text,
835
+ changeCount: ownedChangeCount
836
+ ) else { return }
837
+ pasteboard.clearContents()
838
+ pasteboard.setString(prePasteValue ?? "", forType: .string)
839
+ })
840
+ scheduled.removeFirst()()
841
+ scheduled.removeFirst()()
842
+
843
+ #expect(pasteboard.string(forType: .string) == "new external value")
844
+ }
845
+
846
+ @Test("same-text clipboard mutation loses ownership by change count")
847
+ @MainActor
848
+ func sameTextMutationLosesClipboardOwnership() {
849
+ let pasteboard = NSPasteboard(name: NSPasteboard.Name("recordings-copy-owner-\(UUID().uuidString)"))
850
+ defer { pasteboard.releaseGlobally() }
851
+ pasteboard.clearContents()
852
+ pasteboard.setString("marker", forType: .string)
853
+ let ownedChangeCount = pasteboard.changeCount
854
+ #expect(RecordingEngine.clipboardStillOwned(
855
+ pasteboard,
856
+ text: "marker",
857
+ changeCount: ownedChangeCount
858
+ ))
859
+
860
+ pasteboard.clearContents()
861
+ pasteboard.setString("marker", forType: .string)
862
+ #expect(!RecordingEngine.clipboardStillOwned(
863
+ pasteboard,
864
+ text: "marker",
865
+ changeCount: ownedChangeCount
866
+ ))
867
+ }
868
+
5
869
  @Test("paste target prefers captured process id over bundle fallback")
6
870
  func prefersCapturedPid() {
7
871
  let candidates = [
@@ -82,11 +946,133 @@ struct PasteTargetTests {
82
946
  let selected = RecordingEngine.selectPasteTarget(
83
947
  candidates: candidates,
84
948
  currentPid: 99,
85
- targetBundleIdentifier: nil,
949
+ targetBundleIdentifier: "com.editor",
86
950
  targetPid: 10,
87
951
  frontmostPid: 30
88
952
  )
89
953
 
90
954
  #expect(selected?.pid == 10)
91
955
  }
956
+
957
+ @Test("missing captured pid never falls back to another app or same-bundle process")
958
+ func missingCapturedPidHasNoFallback() {
959
+ let candidates = [
960
+ PasteTargetCandidate(pid: 10, bundleIdentifier: "com.editor", isRegularApp: true),
961
+ PasteTargetCandidate(pid: 30, bundleIdentifier: "com.notes", isRegularApp: true),
962
+ ]
963
+
964
+ let selected = RecordingEngine.selectPasteTarget(
965
+ candidates: candidates,
966
+ currentPid: 99,
967
+ targetBundleIdentifier: "com.editor",
968
+ targetPid: 20,
969
+ frontmostPid: 30
970
+ )
971
+
972
+ #expect(selected == nil)
973
+ }
974
+
975
+ @Test("reused captured pid with a different bundle is rejected")
976
+ func reusedCapturedPidRequiresBundleIdentity() {
977
+ let selected = RecordingEngine.selectPasteTarget(
978
+ candidates: [
979
+ PasteTargetCandidate(pid: 20, bundleIdentifier: "com.other", isRegularApp: true),
980
+ ],
981
+ currentPid: 99,
982
+ targetBundleIdentifier: "com.editor",
983
+ targetPid: 20,
984
+ frontmostPid: 20
985
+ )
986
+
987
+ #expect(selected == nil)
988
+
989
+ let nilBundleCapture = RecordingEngine.selectPasteTarget(
990
+ candidates: [
991
+ PasteTargetCandidate(pid: 20, bundleIdentifier: "com.other", isRegularApp: true),
992
+ ],
993
+ currentPid: 99,
994
+ targetBundleIdentifier: nil,
995
+ targetPid: 20,
996
+ frontmostPid: 20
997
+ )
998
+ #expect(nilBundleCapture == nil)
999
+ }
1000
+
1001
+ @Test("captured pid with no bundle identity is rejected instead of trusting pid reuse")
1002
+ func nilBundleCaptureFailsClosed() {
1003
+ let selected = RecordingEngine.selectPasteTarget(
1004
+ candidates: [
1005
+ PasteTargetCandidate(pid: 20, bundleIdentifier: nil, isRegularApp: true),
1006
+ ],
1007
+ currentPid: 99,
1008
+ targetBundleIdentifier: nil,
1009
+ targetPid: 20,
1010
+ frontmostPid: 20
1011
+ )
1012
+
1013
+ #expect(selected == nil)
1014
+ }
1015
+
1016
+ @Test("same-bundle PID reuse with a different process launch identity is rejected")
1017
+ func reusedCapturedPidRequiresProcessBirthIdentity() {
1018
+ let capturedLaunchDate = Date(timeIntervalSince1970: 1_000)
1019
+ let replacementLaunchDate = Date(timeIntervalSince1970: 2_000)
1020
+ let capturedIdentity = PasteTargetProcessIdentity(
1021
+ pid: 20,
1022
+ bundleIdentifier: "com.editor",
1023
+ launchDate: capturedLaunchDate
1024
+ )
1025
+
1026
+ let rejected = RecordingEngine.selectPasteTarget(
1027
+ candidates: [
1028
+ PasteTargetCandidate(
1029
+ pid: 20,
1030
+ bundleIdentifier: "com.editor",
1031
+ isRegularApp: true,
1032
+ launchDate: replacementLaunchDate
1033
+ ),
1034
+ ],
1035
+ currentPid: 99,
1036
+ targetBundleIdentifier: "com.editor",
1037
+ targetPid: 20,
1038
+ frontmostPid: 20,
1039
+ requiredProcessIdentity: capturedIdentity,
1040
+ requiresProcessIdentity: true
1041
+ )
1042
+ let accepted = RecordingEngine.selectPasteTarget(
1043
+ candidates: [
1044
+ PasteTargetCandidate(
1045
+ pid: 20,
1046
+ bundleIdentifier: "com.editor",
1047
+ isRegularApp: true,
1048
+ launchDate: capturedLaunchDate
1049
+ ),
1050
+ ],
1051
+ currentPid: 99,
1052
+ targetBundleIdentifier: "com.editor",
1053
+ targetPid: 20,
1054
+ frontmostPid: 20,
1055
+ requiredProcessIdentity: capturedIdentity,
1056
+ requiresProcessIdentity: true
1057
+ )
1058
+ let missingIdentity = RecordingEngine.selectPasteTarget(
1059
+ candidates: [
1060
+ PasteTargetCandidate(
1061
+ pid: 20,
1062
+ bundleIdentifier: "com.editor",
1063
+ isRegularApp: true,
1064
+ launchDate: capturedLaunchDate
1065
+ ),
1066
+ ],
1067
+ currentPid: 99,
1068
+ targetBundleIdentifier: "com.editor",
1069
+ targetPid: 20,
1070
+ frontmostPid: 20,
1071
+ requiresProcessIdentity: true
1072
+ )
1073
+
1074
+ #expect(rejected == nil)
1075
+ #expect(accepted?.pid == 20)
1076
+ #expect(missingIdentity == nil)
1077
+ }
92
1078
  }