@hasna/recordings 0.2.14 → 0.3.2
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/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2094 -280
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +19 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -50
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +141 -57
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +188 -80
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +109 -50
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +192 -31
- package/scripts/macos_artifact.ts +323 -43
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/smoke_macos_app.sh +62 -16
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
- package/src/native/Recordings/App/RecordingsApp.swift +37 -1
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +226 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
|
@@ -3,29 +3,59 @@ import Foundation
|
|
|
3
3
|
import Testing
|
|
4
4
|
@testable import RecordingsLib
|
|
5
5
|
|
|
6
|
-
/// A recorder that starts instantly and records when it was asked to
|
|
7
|
-
/// `startRecording` path run without
|
|
6
|
+
/// A recorder that starts instantly and records when it was asked to, and that can deliver its
|
|
7
|
+
/// first PCM callback on demand. Lets the production `startRecording` path run without
|
|
8
|
+
/// microphone hardware or TCC grants, including the warm-up window between `start()` returning
|
|
9
|
+
/// and the first sample arriving.
|
|
8
10
|
private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
|
|
9
11
|
private let lock = NSLock()
|
|
10
12
|
private var startedFlag = false
|
|
13
|
+
private var stoppedFlag = false
|
|
14
|
+
private var onPCM: (@Sendable (Data) -> Void)?
|
|
11
15
|
|
|
12
16
|
var started: Bool {
|
|
13
17
|
lock.withLock { startedFlag }
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/// Whether `stop()` was called. The microphone staying open is the worst outcome of a
|
|
21
|
+
/// botched teardown — it lights the macOS in-use indicator — so every abandon test asserts
|
|
22
|
+
/// this.
|
|
23
|
+
var isStopped: Bool {
|
|
24
|
+
lock.withLock { stoppedFlag }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func attach(onPCM: @escaping @Sendable (Data) -> Void) {
|
|
28
|
+
lock.withLock { self.onPCM = onPCM }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Delivers the recorder's first callback the way a real `AVAudioEngine` tap does, roughly
|
|
32
|
+
/// 100 ms after `start()` has already returned.
|
|
33
|
+
///
|
|
34
|
+
/// `bytes: 0` is a deliberate test seam: it drives the engine's first-chunk promotion —
|
|
35
|
+
/// which is what the ordering tests below are about — while leaving the stream pipe empty,
|
|
36
|
+
/// so the pipeline still ends in the deterministic no-audio state instead of writing a WAV
|
|
37
|
+
/// and shelling out to the CLI. `PCMStreamPipe` skips empty chunks, and the production
|
|
38
|
+
/// recorder never emits one.
|
|
39
|
+
func emitFirstChunk(bytes: Int = 0) {
|
|
40
|
+
let callback = lock.withLock { onPCM }
|
|
41
|
+
callback?(Data(repeating: 0, count: bytes))
|
|
42
|
+
}
|
|
43
|
+
|
|
16
44
|
func start() throws {
|
|
17
45
|
lock.withLock { startedFlag = true }
|
|
18
46
|
}
|
|
19
47
|
|
|
20
|
-
func stop() {
|
|
48
|
+
func stop() {
|
|
49
|
+
lock.withLock { stoppedFlag = true }
|
|
50
|
+
}
|
|
21
51
|
}
|
|
22
52
|
|
|
23
53
|
@MainActor
|
|
24
54
|
private func makeStartableEngine(
|
|
25
55
|
recorder: FakePCMRecorder,
|
|
26
|
-
selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken?
|
|
56
|
+
selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken? = { _ in nil }
|
|
27
57
|
) -> RecordingEngine {
|
|
28
|
-
let engine = RecordingEngine()
|
|
58
|
+
let engine = RecordingEngine(homePath: makeIsolatedTestHome("start-timing-tests"))
|
|
29
59
|
engine.openAIAPIKeyProvider = { "" }
|
|
30
60
|
engine.microphoneAuthorization = { .authorized }
|
|
31
61
|
engine.accessibilityTrustCheck = { true }
|
|
@@ -33,10 +63,14 @@ private func makeStartableEngine(
|
|
|
33
63
|
engine.frontmostAppSnapshot = {
|
|
34
64
|
FrontmostAppSnapshot(pid: 99_999, bundleIdentifier: "com.example.editor", launchDate: Date())
|
|
35
65
|
}
|
|
36
|
-
engine.recorderFactory = {
|
|
66
|
+
engine.recorderFactory = { onPCM in
|
|
67
|
+
recorder.attach(onPCM: onPCM)
|
|
68
|
+
return recorder
|
|
69
|
+
}
|
|
37
70
|
engine.selectionCapture = selectionCapture
|
|
38
71
|
engine.focusedWindowTitleLookup = { _ in nil }
|
|
39
72
|
engine.pasteInterceptorForTesting = { _, _, _ in }
|
|
73
|
+
engine.commandCLI = { _, _, _ in "ERROR: command CLI must not run in this test" }
|
|
40
74
|
return engine
|
|
41
75
|
}
|
|
42
76
|
|
|
@@ -53,10 +87,50 @@ private func waitUntil(
|
|
|
53
87
|
return condition()
|
|
54
88
|
}
|
|
55
89
|
|
|
90
|
+
/// Drives the capture to live the way the recorder does, and waits for the MainActor hop that
|
|
91
|
+
/// promotes it.
|
|
92
|
+
@MainActor
|
|
93
|
+
private func confirmCapture(_ engine: RecordingEngine, _ recorder: FakePCMRecorder) async -> Bool {
|
|
94
|
+
recorder.emitFirstChunk()
|
|
95
|
+
return await waitUntil { engine.isRecording }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Full teardown checklist for an abandoned attempt. Kept in one place because a partial
|
|
99
|
+
/// assertion here is indistinguishable from a pass: an abandon that forgets `recorder.stop()`
|
|
100
|
+
/// leaves the microphone open with the macOS indicator lit, and one that forgets
|
|
101
|
+
/// `isWarmingUpCapture = false` wedges the engine so the start gate refuses every later press.
|
|
102
|
+
@MainActor
|
|
103
|
+
private func expectFullyTornDown(
|
|
104
|
+
_ engine: RecordingEngine,
|
|
105
|
+
_ recorder: FakePCMRecorder,
|
|
106
|
+
sourceLocation: SourceLocation = #_sourceLocation
|
|
107
|
+
) {
|
|
108
|
+
#expect(recorder.isStopped, "the microphone must be released, not left open", sourceLocation: sourceLocation)
|
|
109
|
+
#expect(!engine.isWarmingUpCapture, "a warming flag left set wedges the start gate", sourceLocation: sourceLocation)
|
|
110
|
+
#expect(!engine.isRecording, sourceLocation: sourceLocation)
|
|
111
|
+
#expect(!engine.captureIsActive, sourceLocation: sourceLocation)
|
|
112
|
+
#expect(!engine.isTranscribing, "an empty buffer must never enter the transcription pipeline", sourceLocation: sourceLocation)
|
|
113
|
+
#expect(engine.canStartRecording, "the engine must be immediately ready to try again", sourceLocation: sourceLocation)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// The disclosure an empty attempt owes the user. Asserted on `blockedReason` rather than
|
|
117
|
+
/// `statusMessage` alone: only `blockedReason` reaches the always-visible menu-bar glyph, and
|
|
118
|
+
/// `statusMessage` is rewritten on every return to idle.
|
|
119
|
+
@MainActor
|
|
120
|
+
private func expectEmptyAttemptDisclosed(
|
|
121
|
+
_ engine: RecordingEngine,
|
|
122
|
+
_ alert: RecordingAttemptAlert,
|
|
123
|
+
sourceLocation: SourceLocation = #_sourceLocation
|
|
124
|
+
) {
|
|
125
|
+
#expect(engine.blockedReason == alert.message, "the outcome must reach the always-on surface", sourceLocation: sourceLocation)
|
|
126
|
+
#expect(engine.statusMessage == alert.message, sourceLocation: sourceLocation)
|
|
127
|
+
#expect(engine.canStartRecording, "a disclosure must not disable Start", sourceLocation: sourceLocation)
|
|
128
|
+
}
|
|
129
|
+
|
|
56
130
|
@MainActor
|
|
57
131
|
struct RecordingStartTimingTests {
|
|
58
132
|
@Test("the recorder starts on keydown even while the AX selection capture is blocked")
|
|
59
|
-
func recorderStartDoesNotWaitOnSelectionCapture() {
|
|
133
|
+
func recorderStartDoesNotWaitOnSelectionCapture() async {
|
|
60
134
|
let captureGate = DispatchSemaphore(value: 0)
|
|
61
135
|
let recorder = FakePCMRecorder()
|
|
62
136
|
let engine = makeStartableEngine(recorder: recorder) { _ in
|
|
@@ -72,9 +146,13 @@ struct RecordingStartTimingTests {
|
|
|
72
146
|
// ordering the recorder is live before the capture has produced anything.
|
|
73
147
|
engine.startRecording(trigger: .manual)
|
|
74
148
|
#expect(recorder.started, "recorder must start while AX capture is still pending")
|
|
75
|
-
#expect(engine.
|
|
149
|
+
#expect(engine.isWarmingUpCapture, "start() returning is warm-up, not captured audio")
|
|
150
|
+
#expect(engine.captureIsActive)
|
|
76
151
|
#expect(engine.flowPhase == .listening)
|
|
77
152
|
|
|
153
|
+
#expect(await confirmCapture(engine, recorder))
|
|
154
|
+
#expect(!engine.isWarmingUpCapture)
|
|
155
|
+
|
|
78
156
|
captureGate.signal()
|
|
79
157
|
engine.cancelRecording()
|
|
80
158
|
#expect(engine.flowPhase == .idle)
|
|
@@ -90,7 +168,7 @@ struct RecordingStartTimingTests {
|
|
|
90
168
|
}
|
|
91
169
|
|
|
92
170
|
engine.startRecording(trigger: .manual)
|
|
93
|
-
#expect(engine
|
|
171
|
+
#expect(await confirmCapture(engine, recorder))
|
|
94
172
|
engine.stopAndTranscribe()
|
|
95
173
|
#expect(engine.isTranscribing)
|
|
96
174
|
|
|
@@ -111,7 +189,7 @@ struct RecordingStartTimingTests {
|
|
|
111
189
|
}
|
|
112
190
|
|
|
113
191
|
@Test("a released key before the capture resolves still cancels cleanly")
|
|
114
|
-
func cancelDuringPendingCapture() {
|
|
192
|
+
func cancelDuringPendingCapture() async {
|
|
115
193
|
let captureGate = DispatchSemaphore(value: 0)
|
|
116
194
|
let recorder = FakePCMRecorder()
|
|
117
195
|
let engine = makeStartableEngine(recorder: recorder) { _ in
|
|
@@ -119,7 +197,7 @@ struct RecordingStartTimingTests {
|
|
|
119
197
|
return nil
|
|
120
198
|
}
|
|
121
199
|
engine.startRecording(trigger: .manual)
|
|
122
|
-
#expect(engine
|
|
200
|
+
#expect(await confirmCapture(engine, recorder))
|
|
123
201
|
engine.cancelRecording()
|
|
124
202
|
#expect(!engine.isRecording)
|
|
125
203
|
#expect(engine.flowPhase == .idle)
|
|
@@ -127,3 +205,121 @@ struct RecordingStartTimingTests {
|
|
|
127
205
|
#expect(engine.canStartRecording)
|
|
128
206
|
}
|
|
129
207
|
}
|
|
208
|
+
|
|
209
|
+
/// The defect this suite exists for: `recorder.start()` returns roughly 100 ms before the
|
|
210
|
+
/// microphone delivers a sample, so a trigger released inside that window captured nothing at
|
|
211
|
+
/// all. The engine already had the right branch for it — "released before recording started;
|
|
212
|
+
/// cancelling pending start" — and it was simply unreachable, because `isRecording` flipped on
|
|
213
|
+
/// `start()` returning. A 539 ms hold on the owner's machine missed the branch by 20 ms and ran
|
|
214
|
+
/// the whole transcription pipeline over an empty buffer, silently.
|
|
215
|
+
@MainActor
|
|
216
|
+
struct RecordingCaptureWarmUpTests {
|
|
217
|
+
@Test("a trigger released between recorder start and the first PCM chunk cancels instead of transcribing")
|
|
218
|
+
func releaseDuringWarmUpCancelsInsteadOfTranscribing() {
|
|
219
|
+
let recorder = FakePCMRecorder()
|
|
220
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
221
|
+
|
|
222
|
+
engine.startRecording(trigger: .fnKey)
|
|
223
|
+
#expect(recorder.started)
|
|
224
|
+
#expect(engine.isWarmingUpCapture)
|
|
225
|
+
#expect(!engine.isRecording, "no audio has arrived, so nothing is being recorded yet")
|
|
226
|
+
|
|
227
|
+
engine.handleTriggerRelease(.fnKey)
|
|
228
|
+
|
|
229
|
+
expectFullyTornDown(engine, recorder)
|
|
230
|
+
expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@Test("the configurable shortcut takes the same path as fn")
|
|
234
|
+
func releaseDuringWarmUpCancelsForTheKeyboardShortcut() {
|
|
235
|
+
let recorder = FakePCMRecorder()
|
|
236
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
237
|
+
|
|
238
|
+
engine.startRecording(trigger: .keyboardShortcut)
|
|
239
|
+
#expect(engine.isWarmingUpCapture)
|
|
240
|
+
engine.handleTriggerRelease(.keyboardShortcut)
|
|
241
|
+
|
|
242
|
+
expectFullyTornDown(engine, recorder)
|
|
243
|
+
expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@Test("a release after the first PCM chunk transcribes as before")
|
|
247
|
+
func releaseAfterFirstChunkTranscribes() async {
|
|
248
|
+
let recorder = FakePCMRecorder()
|
|
249
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
250
|
+
|
|
251
|
+
engine.startRecording(trigger: .fnKey)
|
|
252
|
+
#expect(await confirmCapture(engine, recorder))
|
|
253
|
+
#expect(!engine.isWarmingUpCapture)
|
|
254
|
+
|
|
255
|
+
engine.handleTriggerRelease(.fnKey)
|
|
256
|
+
#expect(engine.isTranscribing, "audio existed, so the pipeline must run")
|
|
257
|
+
#expect(engine.blockedReason == nil, "a recording that captured audio discloses nothing")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@Test("a PCM chunk that lands after the attempt was abandoned cannot resurrect it")
|
|
261
|
+
func lateChunkCannotResurrectAnAbandonedAttempt() async {
|
|
262
|
+
let recorder = FakePCMRecorder()
|
|
263
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
264
|
+
|
|
265
|
+
engine.startRecording(trigger: .fnKey)
|
|
266
|
+
engine.handleTriggerRelease(.fnKey)
|
|
267
|
+
expectFullyTornDown(engine, recorder)
|
|
268
|
+
|
|
269
|
+
// The recorder's delivery queue can still hand over a buffer that was in flight when
|
|
270
|
+
// the tap was torn down.
|
|
271
|
+
recorder.emitFirstChunk()
|
|
272
|
+
try? await Task.sleep(for: .milliseconds(150))
|
|
273
|
+
|
|
274
|
+
expectFullyTornDown(engine, recorder)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@Test("Stop clicked during warm-up abandons instead of transcribing silence")
|
|
278
|
+
func stopDuringWarmUpAbandons() {
|
|
279
|
+
let recorder = FakePCMRecorder()
|
|
280
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
281
|
+
|
|
282
|
+
engine.startRecording(trigger: .manual)
|
|
283
|
+
#expect(engine.isWarmingUpCapture)
|
|
284
|
+
engine.stopAndTranscribe()
|
|
285
|
+
|
|
286
|
+
expectFullyTornDown(engine, recorder)
|
|
287
|
+
expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@Test("Discard during warm-up tears down without raising an alert the user does not need")
|
|
291
|
+
func discardDuringWarmUpIsSilent() {
|
|
292
|
+
let recorder = FakePCMRecorder()
|
|
293
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
294
|
+
|
|
295
|
+
engine.startRecording(trigger: .manual)
|
|
296
|
+
engine.cancelRecording()
|
|
297
|
+
|
|
298
|
+
expectFullyTornDown(engine, recorder)
|
|
299
|
+
#expect(engine.blockedReason == nil, "the user asked for the discard; do not alarm them")
|
|
300
|
+
#expect(engine.flowPhase == .idle)
|
|
301
|
+
#expect(engine.statusMessage == "Ready")
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@Test("warm-up blocks a second start, and a new start clears the previous alert")
|
|
305
|
+
func warmUpBlocksASecondStartAndANewStartClearsTheAlert() {
|
|
306
|
+
let recorder = FakePCMRecorder()
|
|
307
|
+
let engine = makeStartableEngine(recorder: recorder)
|
|
308
|
+
|
|
309
|
+
engine.startRecording(trigger: .manual)
|
|
310
|
+
#expect(engine.isWarmingUpCapture)
|
|
311
|
+
#expect(!engine.canStartRecording, "the surface must not offer Start during warm-up")
|
|
312
|
+
#expect(!RecordingEngine.canBeginRecording(
|
|
313
|
+
isRecording: false,
|
|
314
|
+
isTranscribing: false,
|
|
315
|
+
isWarmingUpCapture: true
|
|
316
|
+
))
|
|
317
|
+
|
|
318
|
+
// `.manual` has no key to release; Stop is the only way out of a warming manual start.
|
|
319
|
+
engine.stopAndTranscribe()
|
|
320
|
+
expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
|
|
321
|
+
|
|
322
|
+
engine.startRecording(trigger: .manual)
|
|
323
|
+
#expect(engine.blockedReason == nil, "a live recording must not sit under a stale disclosure")
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -481,40 +481,51 @@ struct IntentFlowStateTests {
|
|
|
481
481
|
|
|
482
482
|
@Test("delivery only runs ahead of persistence for locally-screened plain dictation")
|
|
483
483
|
func pasteBeforePersistenceIsRouteAware() {
|
|
484
|
+
// `enhanceTriggersJSON` has no default by design (review F2 on #30): "[]" decodes to
|
|
485
|
+
// "no configured triggers" and would fail OPEN for a caller that omitted it. These five
|
|
486
|
+
// cases are all `off`/`always`, where the screen is never consulted, so they state the
|
|
487
|
+
// empty list explicitly rather than relying on an absent default.
|
|
484
488
|
// Plain dictation with cleanup off: paste first, persist milliseconds later.
|
|
485
489
|
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
486
490
|
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
487
491
|
transcript: "meet me at noon by the north entrance",
|
|
488
492
|
hasSelection: false,
|
|
489
|
-
intentDetectionEnabled: true
|
|
493
|
+
intentDetectionEnabled: true,
|
|
494
|
+
enhanceTriggersJSON: "[]"
|
|
490
495
|
))
|
|
491
496
|
// Command-shaped speech with a selection may become a rewrite — persist first.
|
|
492
497
|
#expect(!RecordingEngine.shouldPasteBeforePersistence(
|
|
493
498
|
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
494
499
|
transcript: "rewrite this to be more formal",
|
|
495
500
|
hasSelection: true,
|
|
496
|
-
intentDetectionEnabled: true
|
|
501
|
+
intentDetectionEnabled: true,
|
|
502
|
+
enhanceTriggersJSON: "[]"
|
|
497
503
|
))
|
|
498
504
|
// Question-shaped speech may become a conversation — persist first.
|
|
499
505
|
#expect(!RecordingEngine.shouldPasteBeforePersistence(
|
|
500
506
|
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
501
507
|
transcript: "what's the capital of France?",
|
|
502
508
|
hasSelection: false,
|
|
503
|
-
intentDetectionEnabled: true
|
|
509
|
+
intentDetectionEnabled: true,
|
|
510
|
+
enhanceTriggersJSON: "[]"
|
|
504
511
|
))
|
|
505
512
|
// Detection off restores pure dictation: always paste first when cleanup is off.
|
|
506
513
|
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
507
514
|
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
508
515
|
transcript: "what's the capital of France?",
|
|
509
516
|
hasSelection: false,
|
|
510
|
-
intentDetectionEnabled: false
|
|
517
|
+
intentDetectionEnabled: false,
|
|
518
|
+
enhanceTriggersJSON: "[]"
|
|
511
519
|
))
|
|
512
|
-
//
|
|
520
|
+
// `always` mode persists first whatever the transcript looks like. This used to read "any
|
|
521
|
+
// cleanup mode other than off", which this PR makes false: `auto` now pastes first when
|
|
522
|
+
// EnhancementScreen proves the helper cannot rewrite the text.
|
|
513
523
|
#expect(!RecordingEngine.shouldPasteBeforePersistence(
|
|
514
524
|
postProcessingMode: PostProcessingMode.always.rawValue,
|
|
515
525
|
transcript: "meet me at noon",
|
|
516
526
|
hasSelection: false,
|
|
517
|
-
intentDetectionEnabled: true
|
|
527
|
+
intentDetectionEnabled: true,
|
|
528
|
+
enhanceTriggersJSON: "[]"
|
|
518
529
|
))
|
|
519
530
|
}
|
|
520
531
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A throwaway `~` for one `RecordingEngine` under test.
|
|
4
|
+
///
|
|
5
|
+
/// `RecordingEngine` roots everything it owns at `<home>/.hasna/recordings`: the audio spool,
|
|
6
|
+
/// `config.json` (which holds the operator's live API keys), and `Recordings.log`. Its
|
|
7
|
+
/// `homePath` default is the real home, so before that seam existed every engine a test built
|
|
8
|
+
/// wrote there — on 2026-07-27 a suite run appended 11 fixture lines
|
|
9
|
+
/// (`target=com.example.editor pid=99999`, `accessibility=false`) to the log of a machine the
|
|
10
|
+
/// owner dictates on, corrupting the only record the recording investigations read, and
|
|
11
|
+
/// rewrote that machine's real `config.json`.
|
|
12
|
+
///
|
|
13
|
+
/// Same shape as `NativeAppDiagnosticsTests.makeHome()`, the existing precedent for a temp
|
|
14
|
+
/// home behind a `homePath:` parameter: a fresh `$TMPDIR` directory per call. It is left for
|
|
15
|
+
/// the OS to reap rather than torn down, because the callers are engine factories that hand
|
|
16
|
+
/// back only the engine and so have no scope to remove it from; `CLIRunnerTests` instead
|
|
17
|
+
/// passes the temp home it already creates and already deletes.
|
|
18
|
+
func makeIsolatedTestHome(_ label: String) -> String {
|
|
19
|
+
let url = FileManager.default.temporaryDirectory
|
|
20
|
+
.appendingPathComponent("recordings-\(label)-\(UUID().uuidString)")
|
|
21
|
+
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
22
|
+
return url.path
|
|
23
|
+
}
|
|
@@ -3,6 +3,12 @@ import Foundation
|
|
|
3
3
|
import RecordingsUpdateProtocol
|
|
4
4
|
|
|
5
5
|
enum ApplicationProcessQuiescence {
|
|
6
|
+
/// `<sys/proc_info.h>` defines `PROC_PIDPATHINFO_MAXSIZE` as `(4 * MAXPATHLEN)`. Swift's
|
|
7
|
+
/// ClangImporter imports the libproc *functions* but not compound macro expressions, so
|
|
8
|
+
/// the value is restated here. `proc_pidpath` rejects a buffer larger than this, so it
|
|
9
|
+
/// must track the header rather than being rounded up to a convenient power of two.
|
|
10
|
+
private static let processPathBufferSize = 4 * Int(MAXPATHLEN)
|
|
11
|
+
|
|
6
12
|
/// Reject activation while any executable loaded from the live bundle is still
|
|
7
13
|
/// running. The one authenticated update client servicing this XPC request is
|
|
8
14
|
/// exempt only when its kernel-reported executable path is the fixed client path.
|
|
@@ -20,7 +26,7 @@ enum ApplicationProcessQuiescence {
|
|
|
20
26
|
let authenticatedClientPath = RecordingsUpdateConstants.applicationPath + "/"
|
|
21
27
|
+ RecordingsUpdateConstants.updateClientRelativePath
|
|
22
28
|
for processIdentifier in processIdentifiers.prefix(Int(count)) where processIdentifier > 0 {
|
|
23
|
-
var pathBytes = [CChar](repeating: 0, count:
|
|
29
|
+
var pathBytes = [CChar](repeating: 0, count: Self.processPathBufferSize)
|
|
24
30
|
let pathLength = pathBytes.withUnsafeMutableBufferPointer { buffer in
|
|
25
31
|
proc_pidpath(
|
|
26
32
|
processIdentifier,
|
|
@@ -25,16 +25,16 @@ struct AtomicActivator {
|
|
|
25
25
|
let applicationPath = RecordingsUpdateConstants.applicationPath
|
|
26
26
|
let previousPath = stagedUpdate.directory + "/previous.app"
|
|
27
27
|
let failedCandidatePath = stagedUpdate.directory + "/failed-candidate.app"
|
|
28
|
-
let previousDigest = try digestIfPresent(applicationPath)
|
|
29
|
-
let candidateMode = try applicationMode(candidatePath)
|
|
30
|
-
let previousMode = previousDigest == nil ? nil : try applicationMode(applicationPath)
|
|
31
|
-
let candidateExecutableModes = try applicationExecutableModes(candidatePath)
|
|
28
|
+
let previousDigest = try Self.digestIfPresent(applicationPath)
|
|
29
|
+
let candidateMode = try Self.applicationMode(candidatePath)
|
|
30
|
+
let previousMode = previousDigest == nil ? nil : try Self.applicationMode(applicationPath)
|
|
31
|
+
let candidateExecutableModes = try Self.applicationExecutableModes(candidatePath)
|
|
32
32
|
let previousExecutableModes = previousDigest == nil
|
|
33
33
|
? nil
|
|
34
|
-
: try applicationExecutableModes(applicationPath)
|
|
35
|
-
guard try digestIfPresent(candidatePath) == payload.candidateTreeSHA256,
|
|
36
|
-
try digestIfPresent(previousPath) == nil,
|
|
37
|
-
try digestIfPresent(failedCandidatePath) == nil
|
|
34
|
+
: try Self.applicationExecutableModes(applicationPath)
|
|
35
|
+
guard try Self.digestIfPresent(candidatePath) == payload.candidateTreeSHA256,
|
|
36
|
+
try Self.digestIfPresent(previousPath) == nil,
|
|
37
|
+
try Self.digestIfPresent(failedCandidatePath) == nil
|
|
38
38
|
else {
|
|
39
39
|
throw AtomicActivationError.invalidPreparedState
|
|
40
40
|
}
|
|
@@ -83,7 +83,7 @@ struct AtomicActivator {
|
|
|
83
83
|
journal.cohortPackageSHA256 == payload.packageSHA256,
|
|
84
84
|
journal.candidateTreeSHA256 == payload.candidateTreeSHA256,
|
|
85
85
|
journal.minimumOSVersion == payload.minimumOSVersion,
|
|
86
|
-
journal.envelopePayloadSHA256 == envelopeDigest(payload)
|
|
86
|
+
journal.envelopePayloadSHA256 == (try envelopeDigest(payload))
|
|
87
87
|
else {
|
|
88
88
|
throw AtomicActivationError.recoveryRequired
|
|
89
89
|
}
|
|
@@ -171,7 +171,7 @@ struct AtomicActivator {
|
|
|
171
171
|
journal.phase = "launch-barrier-releasing"
|
|
172
172
|
try durableJournal.write(journal)
|
|
173
173
|
try namespace.releaseCommittedLaunchBarrier(journal: journal)
|
|
174
|
-
try Self.requireCommittedNamespaceState(journal)
|
|
174
|
+
try Self.requireCommittedNamespaceState(journal: journal)
|
|
175
175
|
journal.phase = "launch-barrier-released"
|
|
176
176
|
try durableJournal.write(journal)
|
|
177
177
|
journal.phase = "activated"
|
|
@@ -144,7 +144,7 @@ final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
|
|
|
144
144
|
envelope: envelope
|
|
145
145
|
)
|
|
146
146
|
do {
|
|
147
|
-
let result = try performStagedInstall(staged: staged, policy: policy)
|
|
147
|
+
let result = try performStagedInstall(staged: staged, peer: peer, policy: policy)
|
|
148
148
|
try cleanupIfTerminalOrUnjournaled(staged: staged, rootState: rootState)
|
|
149
149
|
return result
|
|
150
150
|
} catch {
|
|
@@ -166,6 +166,10 @@ final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
|
|
|
166
166
|
|
|
167
167
|
private static func performStagedInstall(
|
|
168
168
|
staged: StagedUpdate,
|
|
169
|
+
// The activation quiescence gate must exempt exactly the one authenticated client
|
|
170
|
+
// servicing this request, so the verified peer has to reach it rather than being
|
|
171
|
+
// re-derived here.
|
|
172
|
+
peer: AuthenticatedPeer,
|
|
169
173
|
policy: BrokerPolicy
|
|
170
174
|
) throws -> NSDictionary {
|
|
171
175
|
guard let envelopeData = staged.envelope.data,
|
|
@@ -149,11 +149,56 @@ enum RootTrustStore {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/// PRIVATE API, DELIBERATE — RISK ACCEPTED.
|
|
153
|
+
///
|
|
154
|
+
/// The peer's audit token is the only race-free identity for an XPC connection: it names the
|
|
155
|
+
/// exact process that opened the connection, whereas a PID can be reused between the time
|
|
156
|
+
/// the kernel reports it and the time we resolve its code signature. `NSXPCConnection`
|
|
157
|
+
/// exposes `-auditToken` in the runtime but declares it in no public header (the public
|
|
158
|
+
/// header offers only `processIdentifier`, `effectiveUserIdentifier` and
|
|
159
|
+
/// `auditSessionIdentifier`), so this declaration is the only contract we have and Apple may
|
|
160
|
+
/// change or remove it in any macOS update.
|
|
161
|
+
///
|
|
162
|
+
/// Two things keep that risk bounded, and both must stay:
|
|
163
|
+
/// 1. `authenticate` checks `responds(to:)` before casting, so a macOS that REMOVES the property
|
|
164
|
+
/// makes peer authentication throw rather than read a garbage struct. That is the limit of what
|
|
165
|
+
/// guard 1 proves: it establishes only that a selector of that name exists, and says nothing
|
|
166
|
+
/// about its return type, so a macOS that CHANGED the return type would pass it.
|
|
167
|
+
/// 2. `authenticate` cross-checks the token's PID and EUID against the *public*
|
|
168
|
+
/// `processIdentifier` and `effectiveUserIdentifier`. This is the guard that catches ABI drift,
|
|
169
|
+
/// including the changed-return-type case guard 1 cannot see: a layout that no longer decodes to
|
|
170
|
+
/// the same process disagrees with the public properties and is rejected, so the failure mode is
|
|
171
|
+
/// a refused peer, never a mis-identified one.
|
|
172
|
+
///
|
|
173
|
+
/// The `@objc` attribute below is load-bearing. Without it the protocol carries no Objective-C
|
|
174
|
+
/// witness and the `unsafeBitCast` still compiles, but the first authentication traps instead of
|
|
175
|
+
/// dispatching to the real getter. Pinned by native-updater-broker-contract.test.ts.
|
|
176
|
+
///
|
|
177
|
+
/// The supported long-term replacement is `-[NSXPCListener setConnectionCodeSigningRequirement:]`
|
|
178
|
+
/// (macOS 13+), which moves requirement enforcement into XPC itself. Migrating is tracked
|
|
179
|
+
/// separately because this code also reads signing flags and entitlements back off the
|
|
180
|
+
/// resolved `SecCode`, which that API does not provide.
|
|
181
|
+
@objc private protocol XPCConnectionAuditTokenProviding {
|
|
182
|
+
var auditToken: audit_token_t { get }
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
struct PeerIdentityPolicy {
|
|
153
186
|
let policy: BrokerPolicy
|
|
154
187
|
|
|
155
188
|
func authenticate(_ connection: NSXPCConnection) throws -> AuthenticatedPeer {
|
|
156
|
-
|
|
189
|
+
// See XPCConnectionAuditTokenProviding: fail closed if the private property is gone
|
|
190
|
+
// instead of reinterpreting whatever the cast would land on.
|
|
191
|
+
guard connection.responds(to: NSSelectorFromString("auditToken")) else {
|
|
192
|
+
throw BrokerSecurityError.peerAuditTokenUnavailable
|
|
193
|
+
}
|
|
194
|
+
var token = unsafeBitCast(connection, to: XPCConnectionAuditTokenProviding.self).auditToken
|
|
195
|
+
// The audit token is authoritative, but it must agree with the two public
|
|
196
|
+
// properties. Disagreement means the private ABI moved under us; refuse the peer.
|
|
197
|
+
guard audit_token_to_pid(token) == connection.processIdentifier,
|
|
198
|
+
audit_token_to_euid(token) == connection.effectiveUserIdentifier
|
|
199
|
+
else {
|
|
200
|
+
throw BrokerSecurityError.peerAuditTokenUnavailable
|
|
201
|
+
}
|
|
157
202
|
let tokenData = withUnsafeBytes(of: &token) { Data($0) }
|
|
158
203
|
let attributes = [kSecGuestAttributeAudit as String: tokenData] as CFDictionary
|
|
159
204
|
var guest: SecCode?
|
|
@@ -191,8 +236,13 @@ struct PeerIdentityPolicy {
|
|
|
191
236
|
}
|
|
192
237
|
|
|
193
238
|
var signingInformation: CFDictionary?
|
|
239
|
+
// `SecCodeCopySigningInformation` is typed against `SecStaticCode`. In C a
|
|
240
|
+
// `SecCodeRef` is accepted directly (a dynamic code is a static code plus running
|
|
241
|
+
// state) but Swift imports the two as unrelated CF types, so the relationship has
|
|
242
|
+
// to be restated. This is a representation cast only — no ownership transfer.
|
|
243
|
+
let staticGuest = unsafeBitCast(guest, to: SecStaticCode.self)
|
|
194
244
|
let informationStatus = SecCodeCopySigningInformation(
|
|
195
|
-
|
|
245
|
+
staticGuest,
|
|
196
246
|
SecCSFlags(rawValue: kSecCSSigningInformation | kSecCSRequirementInformation),
|
|
197
247
|
&signingInformation
|
|
198
248
|
)
|
|
@@ -229,7 +279,10 @@ struct PeerIdentityPolicy {
|
|
|
229
279
|
guard let flags = information[kSecCodeInfoFlags as String] as? NSNumber else {
|
|
230
280
|
return false
|
|
231
281
|
}
|
|
232
|
-
|
|
282
|
+
// `kSecCodeSignatureRuntime` is a member of the `CF_OPTIONS(uint32_t,
|
|
283
|
+
// SecCodeSignatureFlags)` set in CSCommon.h, so Swift exposes it as
|
|
284
|
+
// `SecCodeSignatureFlags.runtime` and not as a bare `kSec…` global.
|
|
285
|
+
return flags.uint32Value & SecCodeSignatureFlags.runtime.rawValue != 0
|
|
233
286
|
}
|
|
234
287
|
|
|
235
288
|
private static func hasNoDangerousDebugOrLoaderEntitlements(
|
|
@@ -259,6 +312,7 @@ enum BrokerSecurityError: Error, CustomStringConvertible {
|
|
|
259
312
|
case invalidPolicy(String)
|
|
260
313
|
case invalidTrustFile
|
|
261
314
|
case peerCodeUnavailable(OSStatus)
|
|
315
|
+
case peerAuditTokenUnavailable
|
|
262
316
|
case peerRejected(OSStatus)
|
|
263
317
|
case peerMetadataRejected
|
|
264
318
|
|
|
@@ -267,6 +321,8 @@ enum BrokerSecurityError: Error, CustomStringConvertible {
|
|
|
267
321
|
case let .invalidPolicy(reason): "Invalid root-owned broker policy: \(reason)"
|
|
268
322
|
case .invalidTrustFile: "A root-owned broker trust file is missing or unsafe"
|
|
269
323
|
case let .peerCodeUnavailable(status): "Could not resolve the XPC peer code identity (\(status))"
|
|
324
|
+
case .peerAuditTokenUnavailable:
|
|
325
|
+
"Could not read a trustworthy audit token for the XPC peer"
|
|
270
326
|
case let .peerRejected(status): "The XPC peer code identity was rejected (\(status))"
|
|
271
327
|
case .peerMetadataRejected: "The XPC peer signing metadata was rejected"
|
|
272
328
|
}
|
|
@@ -102,17 +102,36 @@ public protocol RecordingsUpdateXPCProtocol {
|
|
|
102
102
|
func queryStatus(withReply reply: @escaping (NSDictionary) -> Void)
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/// Builds the secure-coding allowlist that `NSXPCInterface` expects for one argument.
|
|
106
|
+
///
|
|
107
|
+
/// `-[NSXPCInterface setClasses:forSelector:argumentIndex:ofReply:]` takes an
|
|
108
|
+
/// `NSSet<Class>`, which Swift imports as `Set<AnyHashable>` because Objective-C class
|
|
109
|
+
/// objects do not conform to Swift's `Hashable`. A set literal of metatypes therefore
|
|
110
|
+
/// does not type-check, and the members must stay *class objects*: XPC reads each one
|
|
111
|
+
/// as a `Class`. Satisfying the type checker some other way is not a workaround —
|
|
112
|
+
/// `AnyHashable(ObjectIdentifier(cls))` compiles but yields a set in which no member is
|
|
113
|
+
/// a class, which crashes inside `setClasses`. Bridging an `NSSet` of the class objects
|
|
114
|
+
/// is the only shape that both compiles and preserves the intended allowlist.
|
|
115
|
+
private func xpcAllowedClasses(_ classes: [AnyClass]) -> Set<AnyHashable> {
|
|
116
|
+
guard let allowed = NSSet(array: classes) as? Set<AnyHashable> else {
|
|
117
|
+
// Fail closed rather than install a widened or empty allowlist on the
|
|
118
|
+
// privileged updater interface.
|
|
119
|
+
preconditionFailure("NSXPCInterface class allowlist failed to bridge from NSSet")
|
|
120
|
+
}
|
|
121
|
+
return allowed
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
/// XPC decoding is intentionally limited to retained file handles on request and
|
|
106
125
|
/// scalar dictionaries on reply. No path-bearing or arbitrary option object is accepted.
|
|
107
126
|
public func makeRecordingsUpdateXPCInterface() -> NSXPCInterface {
|
|
108
127
|
let interface = NSXPCInterface(with: RecordingsUpdateXPCProtocol.self)
|
|
109
128
|
let installSelector = NSSelectorFromString("installWithArchive:manifest:envelope:reply:")
|
|
110
129
|
let statusSelector = NSSelectorFromString("queryStatusWithReply:")
|
|
111
|
-
let fileHandleClasses
|
|
130
|
+
let fileHandleClasses = xpcAllowedClasses([FileHandle.self])
|
|
112
131
|
for index in 0..<3 {
|
|
113
132
|
interface.setClasses(fileHandleClasses, for: installSelector, argumentIndex: index, ofReply: false)
|
|
114
133
|
}
|
|
115
|
-
let replyClasses
|
|
134
|
+
let replyClasses = xpcAllowedClasses([NSDictionary.self, NSString.self, NSNumber.self])
|
|
116
135
|
interface.setClasses(replyClasses, for: installSelector, argumentIndex: 0, ofReply: true)
|
|
117
136
|
interface.setClasses(replyClasses, for: statusSelector, argumentIndex: 0, ofReply: true)
|
|
118
137
|
return interface
|