@hasna/recordings 0.2.10 → 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.
- package/README.md +350 -35
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +59 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1922 -77
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1990 -29
- 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:
|
|
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
|
}
|