@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.
- package/README.md +349 -69
- 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 +1919 -98
- 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 +1973 -236
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import Testing
|
|
2
|
+
@testable import RecordingsLib
|
|
3
|
+
|
|
4
|
+
struct RecordingStartControlPresentationTests {
|
|
5
|
+
@Test(
|
|
6
|
+
"every recording start control follows the engine start gate",
|
|
7
|
+
arguments: RecordingStartControlPresentation.Kind.allCases
|
|
8
|
+
)
|
|
9
|
+
func startControlsFollowEngineGate(kind: RecordingStartControlPresentation.Kind) {
|
|
10
|
+
let settling = RecordingStartControlPresentation(
|
|
11
|
+
kind: kind,
|
|
12
|
+
canStartRecording: false
|
|
13
|
+
)
|
|
14
|
+
let available = RecordingStartControlPresentation(
|
|
15
|
+
kind: kind,
|
|
16
|
+
canStartRecording: true
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
#expect(!settling.isEnabled)
|
|
20
|
+
#expect(available.isEnabled)
|
|
21
|
+
#expect(!settling.accessibilityLabel.isEmpty)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test("ready and error controls keep their accessible action names")
|
|
25
|
+
func retryControlLabels() {
|
|
26
|
+
let ready = RecordingStartControlPresentation(
|
|
27
|
+
kind: .recordAgain,
|
|
28
|
+
canStartRecording: false
|
|
29
|
+
)
|
|
30
|
+
let failed = RecordingStartControlPresentation(
|
|
31
|
+
kind: .tryAgain,
|
|
32
|
+
canStartRecording: false
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#expect(ready.title == "Record Again")
|
|
36
|
+
#expect(ready.accessibilityLabel == "Start a new recording")
|
|
37
|
+
#expect(!ready.isEnabled)
|
|
38
|
+
#expect(failed.title == "Try Again")
|
|
39
|
+
#expect(failed.accessibilityLabel == "Try recording again")
|
|
40
|
+
#expect(!failed.isEnabled)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,12 +1,276 @@
|
|
|
1
|
+
import Foundation
|
|
1
2
|
import Testing
|
|
2
3
|
@testable import RecordingsLib
|
|
3
4
|
|
|
4
5
|
struct RecordingStartGateTests {
|
|
6
|
+
@Test("permission-only launch skips global handlers and terminates after handling")
|
|
7
|
+
func permissionHelperLaunchPlan() {
|
|
8
|
+
let plan = PermissionRequestLaunchPlan(arguments: [
|
|
9
|
+
"/Applications/Recordings.app/Contents/MacOS/Recordings",
|
|
10
|
+
"--request-permissions",
|
|
11
|
+
"--open-permission-settings",
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
#expect(plan.isHelper)
|
|
15
|
+
#expect(plan.opensPermissionSettings)
|
|
16
|
+
#expect(!plan.installsGlobalHandlers)
|
|
17
|
+
#expect(!plan.declaresMainWindow)
|
|
18
|
+
#expect(!plan.declaresMenuBar)
|
|
19
|
+
#expect(plan.terminatesAfterHandling)
|
|
20
|
+
#expect(plan.requestsAccessibilityPrompt)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Test("regular launch retains global handlers and never self-terminates")
|
|
24
|
+
func regularLaunchPlan() {
|
|
25
|
+
let plan = PermissionRequestLaunchPlan(arguments: ["Recordings"])
|
|
26
|
+
|
|
27
|
+
#expect(!plan.isHelper)
|
|
28
|
+
#expect(plan.installsGlobalHandlers)
|
|
29
|
+
#expect(plan.declaresMainWindow)
|
|
30
|
+
#expect(plan.declaresMenuBar)
|
|
31
|
+
#expect(!plan.terminatesAfterHandling)
|
|
32
|
+
#expect(!plan.requestsAccessibilityPrompt)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test("runtime smoke plans install no handlers and never request permissions")
|
|
36
|
+
func runtimeSmokeLaunchPlans() {
|
|
37
|
+
let normal = PermissionRequestLaunchPlan(arguments: [
|
|
38
|
+
"Recordings", "--runtime-smoke", "normal", "--runtime-smoke-output", "/tmp/result.json",
|
|
39
|
+
"--runtime-smoke-ack", "/tmp/result.ack",
|
|
40
|
+
"--runtime-smoke-completion", "/tmp/result.completion.json",
|
|
41
|
+
])
|
|
42
|
+
#expect(normal.isRuntimeSmoke)
|
|
43
|
+
#expect(!normal.installsGlobalHandlers)
|
|
44
|
+
#expect(normal.declaresMenuBar)
|
|
45
|
+
#expect(normal.runtimeSmokeOutputPath == "/tmp/result.json")
|
|
46
|
+
#expect(normal.runtimeSmokeAcknowledgementPath == "/tmp/result.ack")
|
|
47
|
+
#expect(normal.runtimeSmokeCompletionPath == "/tmp/result.completion.json")
|
|
48
|
+
#expect(!normal.requestsAccessibilityPrompt)
|
|
49
|
+
|
|
50
|
+
let helper = PermissionRequestLaunchPlan(arguments: [
|
|
51
|
+
"Recordings", "--request-permissions", "--runtime-smoke", "permission-helper",
|
|
52
|
+
"--runtime-smoke-output", "/tmp/result.json",
|
|
53
|
+
])
|
|
54
|
+
#expect(helper.isRuntimeSmoke)
|
|
55
|
+
#expect(!helper.installsGlobalHandlers)
|
|
56
|
+
#expect(!helper.declaresMenuBar)
|
|
57
|
+
#expect(helper.isHelper)
|
|
58
|
+
#expect(!helper.requestsAccessibilityPrompt)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test("first untrusted protected operation prompts once per process")
|
|
62
|
+
func firstProtectedOperationPromptsOnce() {
|
|
63
|
+
let gate = AccessibilityPromptGate()
|
|
64
|
+
var trustChecks = 0
|
|
65
|
+
var prompts = 0
|
|
66
|
+
|
|
67
|
+
let first = gate.trustForProtectedOperation(
|
|
68
|
+
isTrusted: {
|
|
69
|
+
trustChecks += 1
|
|
70
|
+
return false
|
|
71
|
+
},
|
|
72
|
+
requestPrompt: {
|
|
73
|
+
prompts += 1
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
let second = gate.trustForProtectedOperation(
|
|
78
|
+
isTrusted: {
|
|
79
|
+
trustChecks += 1
|
|
80
|
+
return false
|
|
81
|
+
},
|
|
82
|
+
requestPrompt: {
|
|
83
|
+
prompts += 1
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
#expect(!first.trusted)
|
|
89
|
+
#expect(first.didPrompt)
|
|
90
|
+
#expect(!second.trusted)
|
|
91
|
+
#expect(!second.didPrompt)
|
|
92
|
+
#expect(trustChecks == 2)
|
|
93
|
+
#expect(prompts == 1)
|
|
94
|
+
#expect(gate.promptRequestCount == 1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test("explicit Accessibility action may prompt after the automatic attempt")
|
|
98
|
+
func explicitAccessibilityActionMayPromptAgain() {
|
|
99
|
+
let gate = AccessibilityPromptGate()
|
|
100
|
+
var prompts = 0
|
|
101
|
+
var trusted = false
|
|
102
|
+
|
|
103
|
+
_ = gate.trustForProtectedOperation(
|
|
104
|
+
isTrusted: { false },
|
|
105
|
+
requestPrompt: {
|
|
106
|
+
prompts += 1
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
let explicit = gate.requestExplicitly(
|
|
111
|
+
isTrusted: { trusted },
|
|
112
|
+
requestPrompt: {
|
|
113
|
+
prompts += 1
|
|
114
|
+
trusted = true
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
#expect(explicit.trusted)
|
|
120
|
+
#expect(explicit.didPrompt)
|
|
121
|
+
#expect(prompts == 2)
|
|
122
|
+
#expect(gate.promptRequestCount == 2)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test("permission helper rechecks Accessibility at completion after a delayed grant")
|
|
126
|
+
func permissionHelperRechecksDelayedAccessibilityGrant() {
|
|
127
|
+
let gate = AccessibilityPromptGate()
|
|
128
|
+
var checks = 0
|
|
129
|
+
let promptResult = AccessibilityTrustResult(trusted: false, didPrompt: true)
|
|
130
|
+
|
|
131
|
+
let completion = gate.waitForExplicitRequestCompletion(
|
|
132
|
+
promptResult,
|
|
133
|
+
attempts: 3,
|
|
134
|
+
waitBetweenAttempts: {},
|
|
135
|
+
isTrusted: {
|
|
136
|
+
checks += 1
|
|
137
|
+
return checks == 3
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
#expect(completion.trusted)
|
|
142
|
+
#expect(completion.didPrompt)
|
|
143
|
+
#expect(checks == 3)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@Test("permission helper remains denied when Accessibility is still untrusted")
|
|
147
|
+
func permissionHelperReportsStillDenied() {
|
|
148
|
+
let gate = AccessibilityPromptGate()
|
|
149
|
+
var waits = 0
|
|
150
|
+
let completion = gate.waitForExplicitRequestCompletion(
|
|
151
|
+
AccessibilityTrustResult(trusted: true, didPrompt: true),
|
|
152
|
+
attempts: 3,
|
|
153
|
+
waitBetweenAttempts: { waits += 1 },
|
|
154
|
+
isTrusted: { false }
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
#expect(!completion.trusted)
|
|
158
|
+
#expect(completion.didPrompt)
|
|
159
|
+
#expect(waits == 2)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@Test("trusted protected operation never invokes the prompt API")
|
|
163
|
+
func trustedProtectedOperationDoesNotPrompt() {
|
|
164
|
+
let gate = AccessibilityPromptGate()
|
|
165
|
+
var prompts = 0
|
|
166
|
+
|
|
167
|
+
let result = gate.trustForProtectedOperation(
|
|
168
|
+
isTrusted: { true },
|
|
169
|
+
requestPrompt: {
|
|
170
|
+
prompts += 1
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
#expect(result.trusted)
|
|
176
|
+
#expect(!result.didPrompt)
|
|
177
|
+
#expect(prompts == 0)
|
|
178
|
+
#expect(gate.promptRequestCount == 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@Test("explicit prompt consumes the automatic allowance for later protected operations")
|
|
182
|
+
func explicitPromptPreventsUnexpectedAutomaticReprompt() {
|
|
183
|
+
let gate = AccessibilityPromptGate()
|
|
184
|
+
var prompts = 0
|
|
185
|
+
|
|
186
|
+
_ = gate.requestExplicitly {
|
|
187
|
+
prompts += 1
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
let protectedOperation = gate.trustForProtectedOperation(
|
|
191
|
+
isTrusted: { false },
|
|
192
|
+
requestPrompt: {
|
|
193
|
+
prompts += 1
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
#expect(!protectedOperation.trusted)
|
|
199
|
+
#expect(!protectedOperation.didPrompt)
|
|
200
|
+
#expect(prompts == 1)
|
|
201
|
+
#expect(gate.promptRequestCount == 1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@Test("permission helper outcome reports both permission results truthfully")
|
|
205
|
+
func permissionHelperOutcome() {
|
|
206
|
+
#expect(PermissionRequestOutcome(
|
|
207
|
+
microphoneGranted: true,
|
|
208
|
+
accessibilityTrusted: true
|
|
209
|
+
).succeeded)
|
|
210
|
+
#expect(!PermissionRequestOutcome(
|
|
211
|
+
microphoneGranted: false,
|
|
212
|
+
accessibilityTrusted: true
|
|
213
|
+
).succeeded)
|
|
214
|
+
#expect(!PermissionRequestOutcome(
|
|
215
|
+
microphoneGranted: true,
|
|
216
|
+
accessibilityTrusted: false
|
|
217
|
+
).succeeded)
|
|
218
|
+
}
|
|
219
|
+
|
|
5
220
|
@Test("recording cannot begin while already recording or transcribing")
|
|
6
221
|
func cannotBeginWhenBusy() {
|
|
7
222
|
#expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false) == true)
|
|
8
223
|
#expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false) == false)
|
|
9
224
|
#expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true) == false)
|
|
225
|
+
#expect(RecordingEngine.canBeginRecording(
|
|
226
|
+
isRecording: false,
|
|
227
|
+
isTranscribing: false,
|
|
228
|
+
isAwaitingMicrophonePermission: true
|
|
229
|
+
) == false)
|
|
230
|
+
#expect(RecordingEngine.canBeginRecording(
|
|
231
|
+
isRecording: false,
|
|
232
|
+
isTranscribing: false,
|
|
233
|
+
isDeliveryPending: true
|
|
234
|
+
) == false)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@Test("recording status is derived from the trigger")
|
|
238
|
+
func statusUsesTrigger() {
|
|
239
|
+
#expect(RecordingEngine.recordingStatus(
|
|
240
|
+
trigger: .keyboardShortcut
|
|
241
|
+
) == "Recording — release to stop")
|
|
242
|
+
#expect(RecordingEngine.recordingStatus(
|
|
243
|
+
trigger: .fnKey
|
|
244
|
+
) == "Recording — release to stop")
|
|
245
|
+
#expect(RecordingEngine.recordingStatus(
|
|
246
|
+
trigger: .manual
|
|
247
|
+
) == "Recording — click Stop when finished")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@Test("microphone permission start gate admits one current continuation")
|
|
251
|
+
func permissionStartGateRejectsDuplicateAndStaleContinuations() {
|
|
252
|
+
let firstRequestID = UUID()
|
|
253
|
+
let currentRequestID = UUID()
|
|
254
|
+
var gate = MicrophonePermissionStartGate()
|
|
255
|
+
|
|
256
|
+
let firstReservation = gate.reserve(requestID: firstRequestID)
|
|
257
|
+
#expect(firstReservation == firstRequestID)
|
|
258
|
+
#expect(gate.isAwaitingResponse)
|
|
259
|
+
let duplicateReservation = gate.reserve(requestID: currentRequestID)
|
|
260
|
+
#expect(duplicateReservation == nil)
|
|
261
|
+
#expect(gate.activeRequestID == firstRequestID)
|
|
262
|
+
|
|
263
|
+
gate.cancel()
|
|
264
|
+
let currentReservation = gate.reserve(requestID: currentRequestID)
|
|
265
|
+
#expect(currentReservation == currentRequestID)
|
|
266
|
+
let staleResponseConsumed = gate.consumeResponse(for: firstRequestID)
|
|
267
|
+
#expect(!staleResponseConsumed)
|
|
268
|
+
#expect(gate.activeRequestID == currentRequestID)
|
|
269
|
+
let currentResponseConsumed = gate.consumeResponse(for: currentRequestID)
|
|
270
|
+
#expect(currentResponseConsumed)
|
|
271
|
+
#expect(!gate.isAwaitingResponse)
|
|
272
|
+
let repeatedResponseConsumed = gate.consumeResponse(for: currentRequestID)
|
|
273
|
+
#expect(!repeatedResponseConsumed)
|
|
10
274
|
}
|
|
11
275
|
|
|
12
276
|
@Test("manual recording can continue after permission grant")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import Testing
|
|
4
|
+
@testable import RecordingsLib
|
|
5
|
+
|
|
6
|
+
/// A recorder that starts instantly and records when it was asked to. Lets the production
|
|
7
|
+
/// `startRecording` path run without microphone hardware or TCC grants.
|
|
8
|
+
private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
|
|
9
|
+
private let lock = NSLock()
|
|
10
|
+
private var startedFlag = false
|
|
11
|
+
|
|
12
|
+
var started: Bool {
|
|
13
|
+
lock.withLock { startedFlag }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func start() throws {
|
|
17
|
+
lock.withLock { startedFlag = true }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func stop() {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@MainActor
|
|
24
|
+
private func makeStartableEngine(
|
|
25
|
+
recorder: FakePCMRecorder,
|
|
26
|
+
selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken?
|
|
27
|
+
) -> RecordingEngine {
|
|
28
|
+
let engine = RecordingEngine()
|
|
29
|
+
engine.openAIAPIKeyProvider = { "" }
|
|
30
|
+
engine.microphoneAuthorization = { .authorized }
|
|
31
|
+
engine.accessibilityTrustCheck = { true }
|
|
32
|
+
engine.protectedOperationTrust = { AccessibilityTrustResult(trusted: true, didPrompt: false) }
|
|
33
|
+
engine.frontmostAppSnapshot = {
|
|
34
|
+
FrontmostAppSnapshot(pid: 99_999, bundleIdentifier: "com.example.editor", launchDate: Date())
|
|
35
|
+
}
|
|
36
|
+
engine.recorderFactory = { _ in recorder }
|
|
37
|
+
engine.selectionCapture = selectionCapture
|
|
38
|
+
engine.focusedWindowTitleLookup = { _ in nil }
|
|
39
|
+
engine.pasteInterceptorForTesting = { _, _, _ in }
|
|
40
|
+
return engine
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@MainActor
|
|
44
|
+
private func waitUntil(
|
|
45
|
+
timeout: TimeInterval = 5,
|
|
46
|
+
_ condition: @MainActor () -> Bool
|
|
47
|
+
) async -> Bool {
|
|
48
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
49
|
+
while Date() < deadline {
|
|
50
|
+
if condition() { return true }
|
|
51
|
+
try? await Task.sleep(for: .milliseconds(20))
|
|
52
|
+
}
|
|
53
|
+
return condition()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@MainActor
|
|
57
|
+
struct RecordingStartTimingTests {
|
|
58
|
+
@Test("the recorder starts on keydown even while the AX selection capture is blocked")
|
|
59
|
+
func recorderStartDoesNotWaitOnSelectionCapture() {
|
|
60
|
+
let captureGate = DispatchSemaphore(value: 0)
|
|
61
|
+
let recorder = FakePCMRecorder()
|
|
62
|
+
let engine = makeStartableEngine(recorder: recorder) { _ in
|
|
63
|
+
// Simulates a beachballing target app: the capture IPC hangs far longer than
|
|
64
|
+
// any acceptable start budget.
|
|
65
|
+
captureGate.wait()
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `startRecording` runs synchronously on the MainActor through recorder start.
|
|
70
|
+
// With the pre-fix ordering (capture before recorder start, on the MainActor) this
|
|
71
|
+
// call would block on the semaphore and the test would time out; with the fixed
|
|
72
|
+
// ordering the recorder is live before the capture has produced anything.
|
|
73
|
+
engine.startRecording(trigger: .manual)
|
|
74
|
+
#expect(recorder.started, "recorder must start while AX capture is still pending")
|
|
75
|
+
#expect(engine.isRecording)
|
|
76
|
+
#expect(engine.flowPhase == .listening)
|
|
77
|
+
|
|
78
|
+
captureGate.signal()
|
|
79
|
+
engine.cancelRecording()
|
|
80
|
+
#expect(engine.flowPhase == .idle)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test("stopping waits for the generation-bound start context instead of dropping the frozen target")
|
|
84
|
+
func stopAwaitsFrozenStartContext() async {
|
|
85
|
+
let captureGate = DispatchSemaphore(value: 0)
|
|
86
|
+
let recorder = FakePCMRecorder()
|
|
87
|
+
let engine = makeStartableEngine(recorder: recorder) { _ in
|
|
88
|
+
captureGate.wait()
|
|
89
|
+
return AccessibilitySelectionToken.unsafeTestToken(selectedText: "frozen words")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
engine.startRecording(trigger: .manual)
|
|
93
|
+
#expect(engine.isRecording)
|
|
94
|
+
engine.stopAndTranscribe()
|
|
95
|
+
#expect(engine.isTranscribing)
|
|
96
|
+
|
|
97
|
+
// The pipeline must hold in finalizing while the frozen context is unresolved —
|
|
98
|
+
// it may not deliver without the selection frozen at start.
|
|
99
|
+
try? await Task.sleep(for: .milliseconds(150))
|
|
100
|
+
#expect(engine.flowPhase == .finalizing)
|
|
101
|
+
|
|
102
|
+
captureGate.signal()
|
|
103
|
+
// No audio was produced by the fake recorder, so the pipeline ends in the
|
|
104
|
+
// fail-closed no-audio state — importantly, only after the context resolved.
|
|
105
|
+
#expect(await waitUntil {
|
|
106
|
+
if case .failed = engine.flowPhase { return true }
|
|
107
|
+
return false
|
|
108
|
+
})
|
|
109
|
+
#expect(engine.statusMessage == "No audio captured")
|
|
110
|
+
#expect(engine.canStartRecording)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test("a released key before the capture resolves still cancels cleanly")
|
|
114
|
+
func cancelDuringPendingCapture() {
|
|
115
|
+
let captureGate = DispatchSemaphore(value: 0)
|
|
116
|
+
let recorder = FakePCMRecorder()
|
|
117
|
+
let engine = makeStartableEngine(recorder: recorder) { _ in
|
|
118
|
+
captureGate.wait()
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
engine.startRecording(trigger: .manual)
|
|
122
|
+
#expect(engine.isRecording)
|
|
123
|
+
engine.cancelRecording()
|
|
124
|
+
#expect(!engine.isRecording)
|
|
125
|
+
#expect(engine.flowPhase == .idle)
|
|
126
|
+
captureGate.signal()
|
|
127
|
+
#expect(engine.canStartRecording)
|
|
128
|
+
}
|
|
129
|
+
}
|