@hasna/recordings 0.4.0 → 0.5.0
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 +56 -6
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +28 -7
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/index.js +27 -6
- package/dist/mcp/index.js +27 -6
- package/dist/server/index.js +27 -6
- package/dist/storage.js +27 -6
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +27 -6
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/macos_artifact.ts +40 -33
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +115 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/build.sh +2 -1
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
|
@@ -689,18 +689,19 @@ private final class PCMStreamPipe: @unchecked Sendable {
|
|
|
689
689
|
guard !Task.isCancelled else { break }
|
|
690
690
|
guard !data.isEmpty else { continue }
|
|
691
691
|
recordedPCM.append(data)
|
|
692
|
+
// Providers need every admitted packet before a pause can settle.
|
|
693
|
+
// Only the legacy realtime client waits for network-sized chunks.
|
|
694
|
+
providerSession?.appendPCM(data)
|
|
692
695
|
pendingChunk.append(data)
|
|
693
696
|
|
|
694
697
|
while pendingChunk.count >= chunkSize {
|
|
695
698
|
let chunk = pendingChunk.prefixData(count: chunkSize)
|
|
696
|
-
providerSession?.appendPCM(chunk)
|
|
697
699
|
await client?.sendAudio(chunk)
|
|
698
700
|
pendingChunk.removeFirst(chunkSize)
|
|
699
701
|
}
|
|
700
702
|
}
|
|
701
703
|
|
|
702
704
|
if !Task.isCancelled && !pendingChunk.isEmpty {
|
|
703
|
-
providerSession?.appendPCM(pendingChunk)
|
|
704
705
|
await client?.sendAudio(pendingChunk)
|
|
705
706
|
}
|
|
706
707
|
return recordedPCM
|
|
@@ -939,6 +940,10 @@ public final class RecordingEngine: ObservableObject {
|
|
|
939
940
|
private var providerSession: (any RecordingTranscriptionSession)?
|
|
940
941
|
private var providerConfiguration: RecordingProviderSessionConfiguration?
|
|
941
942
|
private var providerCompletionTask: Task<Void, Never>?
|
|
943
|
+
/// Injection keeps persistence ordering testable without changing the public file contract.
|
|
944
|
+
var providerAudioWriter: @Sendable (Data, URL) throws -> Void = { pcm, url in
|
|
945
|
+
try RecordingEngine.writeWAV(pcmData: pcm, sampleRate: 24_000, channelCount: 1, bitsPerSample: 16, to: url)
|
|
946
|
+
}
|
|
942
947
|
|
|
943
948
|
private var nativeRecorder: PCMRecordingSource?
|
|
944
949
|
private var recordingTimer: Timer?
|
|
@@ -948,6 +953,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
948
953
|
private var fnKeyIsDown = false
|
|
949
954
|
private var targetAppBundleIdentifier: String?
|
|
950
955
|
private var targetAppPid: pid_t?
|
|
956
|
+
private var frozenPasteTargetsByGeneration: [UInt64: RecordingPasteTargetSelection] = [:]
|
|
951
957
|
private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
|
|
952
958
|
public var projectStore: ProjectStore?
|
|
953
959
|
/// The minimal app uses only global cleanup preferences from the legacy settings
|
|
@@ -980,6 +986,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
980
986
|
launchDate: app.launchDate
|
|
981
987
|
)
|
|
982
988
|
}
|
|
989
|
+
var pasteTargetApplicationLookup: (pid_t) -> PasteApplicationObservation? = {
|
|
990
|
+
NSRunningApplication(processIdentifier: $0).map(PasteApplicationObservation.init)
|
|
991
|
+
}
|
|
992
|
+
var pasteFallbackWriter: (String) -> Bool = { text in
|
|
993
|
+
RecordingEngine.writeClipboardPreservingOnFailure(text, to: .general)
|
|
994
|
+
}
|
|
983
995
|
var recorderFactory: (@escaping @Sendable (Data) -> Void) -> PCMRecordingSource = {
|
|
984
996
|
NativePCMRecorder(onPCM: $0)
|
|
985
997
|
}
|
|
@@ -990,12 +1002,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
990
1002
|
RecordingEngine.focusedWindowTitle(pid: $0)
|
|
991
1003
|
}
|
|
992
1004
|
var commandCLI: @Sendable (_ args: [String], _ home: String, _ timeout: TimeInterval) -> String = { args, home, ceiling in
|
|
993
|
-
// The caller
|
|
1005
|
+
// The caller supplies the remaining public budget after queue admission. CLIRunner's
|
|
994
1006
|
// total deadline (execution, termination grace, kill grace, pipe drain) sits a full
|
|
995
1007
|
// return margin below it: spawn setup, waitid poll granularity, capture shutdown,
|
|
996
1008
|
// and the hop back to the caller all run outside CLIRunner's clamped waits and must
|
|
997
1009
|
// fit inside the reserved margin.
|
|
998
1010
|
let cliDeadline = ceiling - RecordingEngine.commandRewriteReturnMargin
|
|
1011
|
+
guard cliDeadline > CLIRunner.wallClockCleanupReserve else {
|
|
1012
|
+
return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
|
|
1013
|
+
}
|
|
999
1014
|
return CLIRunner.run(args, home: home, timeout: cliDeadline, totalWallClockBudget: cliDeadline)
|
|
1000
1015
|
}
|
|
1001
1016
|
/// Resolves and revalidates the frozen rewrite target immediately before a rewrite:
|
|
@@ -1227,6 +1242,23 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1227
1242
|
/// *observable* rewrite time under the public ceiling even when the execution window,
|
|
1228
1243
|
/// termination grace, and pipe drain all run to exhaustion.
|
|
1229
1244
|
nonisolated static let commandRewriteReturnMargin: TimeInterval = 1
|
|
1245
|
+
|
|
1246
|
+
/// Create this operation synchronously before awaiting the blocking queue: creating
|
|
1247
|
+
/// its deadline inside the submitted closure would give delayed work a fresh budget.
|
|
1248
|
+
nonisolated static func makeCommandRewriteOperation(
|
|
1249
|
+
args: [String],
|
|
1250
|
+
home: String,
|
|
1251
|
+
runCLI: @escaping @Sendable ([String], String, TimeInterval) -> String,
|
|
1252
|
+
deadline: CLIRunner.WallClockDeadline = .init(after: commandRewriteTimeout)
|
|
1253
|
+
) -> @Sendable () -> String {
|
|
1254
|
+
return {
|
|
1255
|
+
let remaining = min(commandRewriteTimeout, deadline.remaining())
|
|
1256
|
+
guard remaining > commandRewriteReturnMargin + CLIRunner.wallClockCleanupReserve else {
|
|
1257
|
+
return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
|
|
1258
|
+
}
|
|
1259
|
+
return runCLI(args, home, remaining)
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1230
1262
|
/// Wait before each read-back of the target app's focused field. The window server
|
|
1231
1263
|
/// delivers the posted keystroke asynchronously and the app then does its own work, so a
|
|
1232
1264
|
/// read taken on the posting turn would report "unchanged" for a paste that is simply
|
|
@@ -1738,7 +1770,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1738
1770
|
|
|
1739
1771
|
// MARK: - Start Recording (Streaming)
|
|
1740
1772
|
|
|
1741
|
-
public func startRecording(trigger: RecordingTrigger = .manual
|
|
1773
|
+
public func startRecording(trigger: RecordingTrigger = .manual,
|
|
1774
|
+
pasteTarget: RecordingPasteTargetSelection = .frontmostApplication) {
|
|
1742
1775
|
guard Self.canBeginRecording(
|
|
1743
1776
|
isRecording: isRecording,
|
|
1744
1777
|
isTranscribing: isTranscribing,
|
|
@@ -1755,11 +1788,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1755
1788
|
}
|
|
1756
1789
|
log("startRecording trigger=\(trigger) microphoneStatus=\(microphoneAuthorization().rawValue) accessibility=\(accessibilityTrustCheck())")
|
|
1757
1790
|
recordingGeneration &+= 1
|
|
1758
|
-
if pasteTargetProcessIdentityByGeneration.count >= 32 {
|
|
1791
|
+
if pasteTargetProcessIdentityByGeneration.count >= 32 || frozenPasteTargetsByGeneration.count >= 32 {
|
|
1759
1792
|
let oldestRetainedGeneration = recordingGeneration > 16 ? recordingGeneration - 16 : 0
|
|
1760
1793
|
pasteTargetProcessIdentityByGeneration = pasteTargetProcessIdentityByGeneration.filter {
|
|
1761
1794
|
$0.key >= oldestRetainedGeneration
|
|
1762
1795
|
}
|
|
1796
|
+
frozenPasteTargetsByGeneration = frozenPasteTargetsByGeneration.filter { $0.key >= oldestRetainedGeneration }
|
|
1763
1797
|
}
|
|
1764
1798
|
activeTrigger = trigger
|
|
1765
1799
|
keyboardShortcutIsDown = trigger == .keyboardShortcut
|
|
@@ -1778,7 +1812,19 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1778
1812
|
setBlockedReason(nil, for: .delivery)
|
|
1779
1813
|
|
|
1780
1814
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
1781
|
-
let frontmostApp
|
|
1815
|
+
let frontmostApp: FrontmostAppSnapshot?
|
|
1816
|
+
switch pasteTarget {
|
|
1817
|
+
case .frontmostApplication:
|
|
1818
|
+
frontmostApp = frontmostAppSnapshot()
|
|
1819
|
+
case .frozen(let target):
|
|
1820
|
+
// Keep explicit nil distinct from omission, including when a previously
|
|
1821
|
+
// observed target terminated between the host's snapshot and this call.
|
|
1822
|
+
frozenPasteTargetsByGeneration[recordingGeneration] = pasteTarget
|
|
1823
|
+
if let target, target.processIdentifier != myPID,
|
|
1824
|
+
let app = pasteTargetApplicationLookup(target.processIdentifier), target.matches(app) {
|
|
1825
|
+
frontmostApp = FrontmostAppSnapshot(pid: target.processIdentifier, bundleIdentifier: target.bundleIdentifier, launchDate: target.launchDate)
|
|
1826
|
+
} else { frontmostApp = nil }
|
|
1827
|
+
}
|
|
1782
1828
|
let isOwnApp = frontmostApp?.pid == myPID
|
|
1783
1829
|
targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
|
|
1784
1830
|
targetAppPid = isOwnApp ? nil : frontmostApp?.pid
|
|
@@ -1798,8 +1844,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1798
1844
|
// The selection is still frozen for every recording (not only an exposed "command
|
|
1799
1845
|
// mode"), so a later command decision can only ever act on the exact text and
|
|
1800
1846
|
// element that were selected when the user started speaking. The Accessibility IPC
|
|
1801
|
-
// that reads it runs on a
|
|
1802
|
-
// microphone
|
|
1847
|
+
// that reads it runs on a blocking-work queue, concurrently with recorder start:
|
|
1848
|
+
// neither the microphone nor a cooperative worker waits on a beachballing app. The MainActor stays
|
|
1803
1849
|
// free to process the key-up that stops the recording. Skipped entirely when intent
|
|
1804
1850
|
// detection is off — no command route exists to consume it.
|
|
1805
1851
|
let shouldCaptureSelection = Self.shouldCaptureSelection(
|
|
@@ -1812,12 +1858,14 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1812
1858
|
let windowTitleLookup = focusedWindowTitleLookup
|
|
1813
1859
|
let windowTitlePid = frontmostApp?.pid
|
|
1814
1860
|
let axSnapshotTask = Task.detached(priority: .userInitiated) { () -> RecordingStartAXSnapshot in
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1861
|
+
await BlockingOperation.run {
|
|
1862
|
+
let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
|
|
1863
|
+
let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
|
|
1864
|
+
return RecordingStartAXSnapshot(
|
|
1865
|
+
selectionToken: selectionToken,
|
|
1866
|
+
focusedWindowTitle: focusedWindowTitle
|
|
1867
|
+
)
|
|
1868
|
+
}
|
|
1821
1869
|
}
|
|
1822
1870
|
|
|
1823
1871
|
// Project auto-selection and the processing configuration resolve with the
|
|
@@ -2367,14 +2415,22 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2367
2415
|
let pcm = await pipe?.finish() ?? Data()
|
|
2368
2416
|
guard !Task.isCancelled, let self,
|
|
2369
2417
|
self.recordingGeneration == pipelineGeneration else { return }
|
|
2418
|
+
var timings = [pipelineTrace.message(stage: "pcm_drain_complete", detail: "pcm_bytes=\(pcm.count)")]
|
|
2419
|
+
// Capture timestamps now, but batch their I/O after completion. Instrumentation
|
|
2420
|
+
// must not add a log-file write before the early network commit or WAV write.
|
|
2421
|
+
defer { self.log(timings.joined(separator: "\n")) }
|
|
2370
2422
|
do {
|
|
2371
2423
|
guard !pcm.isEmpty, let audioPath else { throw RecordingProviderError.noAudio }
|
|
2372
2424
|
let audioURL = URL(fileURLWithPath: audioPath)
|
|
2425
|
+
session.inputEnded()
|
|
2426
|
+
timings.append(pipelineTrace.message(stage: "provider_input_ended"))
|
|
2427
|
+
let writeAudio = self.providerAudioWriter
|
|
2373
2428
|
try await Task.detached(priority: .userInitiated) {
|
|
2374
|
-
try
|
|
2429
|
+
try writeAudio(pcm, audioURL)
|
|
2375
2430
|
}.value
|
|
2376
2431
|
try Task.checkCancellation()
|
|
2377
2432
|
guard self.recordingGeneration == pipelineGeneration else { return }
|
|
2433
|
+
timings.append(pipelineTrace.message(stage: "wav_write_complete"))
|
|
2378
2434
|
let duration = Double(pcm.count) / 48_000
|
|
2379
2435
|
self.recordingDuration = duration
|
|
2380
2436
|
let result = try await session.finish(RecordingTranscriptionRequest(
|
|
@@ -2383,6 +2439,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2383
2439
|
))
|
|
2384
2440
|
try Task.checkCancellation()
|
|
2385
2441
|
guard self.recordingGeneration == pipelineGeneration else { return }
|
|
2442
|
+
timings.append(pipelineTrace.message(stage: "provider_finish_complete"))
|
|
2386
2443
|
let rawText = result.rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2387
2444
|
let processed = result.processedText?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2388
2445
|
let text = (processed?.isEmpty == false ? processed : nil) ?? rawText
|
|
@@ -4067,10 +4124,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4067
4124
|
activeProjectId: canonicalProjectId,
|
|
4068
4125
|
processingConfiguration: processingConfiguration
|
|
4069
4126
|
)
|
|
4070
|
-
let
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4127
|
+
let rewriteOperation = Self.makeCommandRewriteOperation(
|
|
4128
|
+
args: rewriteArguments,
|
|
4129
|
+
home: homePath,
|
|
4130
|
+
runCLI: self.commandCLI
|
|
4131
|
+
)
|
|
4132
|
+
let result = await BlockingOperation.run(rewriteOperation)
|
|
4074
4133
|
if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
|
|
4075
4134
|
self.isTranscribing = false
|
|
4076
4135
|
self.liveTranscriptionText = ""
|
|
@@ -4272,13 +4331,21 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4272
4331
|
deliveryCompleted?()
|
|
4273
4332
|
return
|
|
4274
4333
|
}
|
|
4275
|
-
let
|
|
4334
|
+
if let generation = pipelineGeneration, frozenPasteTargetsByGeneration[generation] != nil,
|
|
4335
|
+
pasteTargetProcessIdentityByGeneration[generation] == nil {
|
|
4336
|
+
completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
|
|
4337
|
+
pipelineGeneration: pipelineGeneration)
|
|
4338
|
+
deliveryCompleted?()
|
|
4339
|
+
return
|
|
4340
|
+
}
|
|
4276
4341
|
var previousClipboard: ClipboardSnapshot?
|
|
4277
4342
|
|
|
4278
4343
|
let accessibility = protectedOperationTrust()
|
|
4279
4344
|
guard accessibility.trusted else {
|
|
4280
4345
|
let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
|
|
4281
|
-
let copied = shouldCopy &&
|
|
4346
|
+
let copied = shouldCopy && pasteFallbackWriter(text)
|
|
4347
|
+
appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
|
|
4348
|
+
pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
|
|
4282
4349
|
log("paste blocked by accessibility permission")
|
|
4283
4350
|
let message = if deliveryKind == .commandRewrite {
|
|
4284
4351
|
"Paste cancelled because Accessibility permission changed"
|
|
@@ -4306,18 +4373,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4306
4373
|
)
|
|
4307
4374
|
|
|
4308
4375
|
guard let app = targetApp else {
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
log("paste target app not found")
|
|
4312
|
-
updateDeliveryStatus(
|
|
4313
|
-
deliveryKind == .commandRewrite
|
|
4314
|
-
? "Paste cancelled because the target app is unavailable"
|
|
4315
|
-
: copied
|
|
4316
|
-
? "Copied — no target app found"
|
|
4317
|
-
: "Transcription ready, but the clipboard could not be updated",
|
|
4318
|
-
kind: .failure,
|
|
4319
|
-
pipelineGeneration: pipelineGeneration
|
|
4320
|
-
)
|
|
4376
|
+
completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
|
|
4377
|
+
pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
|
|
4321
4378
|
deliveryCompleted?()
|
|
4322
4379
|
return
|
|
4323
4380
|
}
|
|
@@ -4840,15 +4897,37 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4840
4897
|
setBlockedReason(nil, for: .pressConsumed)
|
|
4841
4898
|
}
|
|
4842
4899
|
|
|
4900
|
+
private func appendUndeliveredPaste(text: String, copied: Bool, captureID: String?,
|
|
4901
|
+
pipelineGeneration: UInt64?, fallbackBundle: String? = nil) {
|
|
4902
|
+
let selection = pipelineGeneration.flatMap { frozenPasteTargetsByGeneration[$0] }
|
|
4903
|
+
let target: RecordingPasteTarget? = if case .frozen(let value) = selection { value } else { nil }
|
|
4904
|
+
recentPastes.insert(RecentPaste(text: text, bundleIdentifier: target?.bundleIdentifier ?? fallbackBundle,
|
|
4905
|
+
appName: target?.applicationName ?? fallbackBundle ?? "No target app",
|
|
4906
|
+
location: copied ? "Clipboard only" : "", status: copied ? "Copied; paste not delivered" : "Paste not delivered",
|
|
4907
|
+
verified: false, captureID: captureID, deliveryStatus: .notDelivered), at: 0)
|
|
4908
|
+
if recentPastes.count > 50 { recentPastes.removeLast() }
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
private func completeUnavailablePaste(_ text: String, deliveryKind: PasteDeliveryKind, captureID: String?,
|
|
4912
|
+
pipelineGeneration: UInt64?, fallbackBundle: String? = nil) {
|
|
4913
|
+
let copied = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind) && pasteFallbackWriter(text)
|
|
4914
|
+
appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
|
|
4915
|
+
pipelineGeneration: pipelineGeneration, fallbackBundle: fallbackBundle)
|
|
4916
|
+
log("paste target app not found")
|
|
4917
|
+
updateDeliveryStatus(deliveryKind == .commandRewrite
|
|
4918
|
+
? "Paste cancelled because the target app is unavailable"
|
|
4919
|
+
: copied ? "Copied — no target app found" : "Transcription ready, but the clipboard could not be updated",
|
|
4920
|
+
kind: .failure, pipelineGeneration: pipelineGeneration)
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4843
4923
|
private func selectedRunningPasteTarget(
|
|
4844
4924
|
targetAppBundleIdentifier: String?,
|
|
4845
4925
|
targetAppPid: pid_t?,
|
|
4846
4926
|
frontmostPid: pid_t?,
|
|
4847
4927
|
pipelineGeneration: UInt64?
|
|
4848
4928
|
) -> NSRunningApplication? {
|
|
4849
|
-
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
4850
4929
|
let runningApps = NSWorkspace.shared.runningApplications
|
|
4851
|
-
let candidates = runningApps.map {
|
|
4930
|
+
let candidates = runningApps.filter { !$0.isTerminated }.map {
|
|
4852
4931
|
PasteTargetCandidate(
|
|
4853
4932
|
pid: $0.processIdentifier,
|
|
4854
4933
|
bundleIdentifier: $0.bundleIdentifier,
|
|
@@ -4856,23 +4935,30 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4856
4935
|
launchDate: $0.launchDate
|
|
4857
4936
|
)
|
|
4858
4937
|
}
|
|
4859
|
-
let
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
let selectedTarget = Self.selectPasteTarget(
|
|
4863
|
-
candidates: candidates,
|
|
4864
|
-
currentPid: myPID,
|
|
4865
|
-
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
4866
|
-
targetPid: targetAppPid,
|
|
4867
|
-
frontmostPid: frontmostPid,
|
|
4868
|
-
requiredProcessIdentity: requiredProcessIdentity,
|
|
4869
|
-
requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
|
|
4938
|
+
let selectedTarget = resolvePasteTarget(
|
|
4939
|
+
candidates: candidates, targetBundleIdentifier: targetAppBundleIdentifier,
|
|
4940
|
+
targetPid: targetAppPid, frontmostPid: frontmostPid, pipelineGeneration: pipelineGeneration
|
|
4870
4941
|
)
|
|
4871
4942
|
return selectedTarget.flatMap { selected in
|
|
4872
4943
|
runningApps.first { $0.processIdentifier == selected.pid }
|
|
4873
4944
|
}
|
|
4874
4945
|
}
|
|
4875
4946
|
|
|
4947
|
+
func resolvePasteTarget(candidates: [PasteTargetCandidate], targetBundleIdentifier: String?,
|
|
4948
|
+
targetPid: pid_t?, frontmostPid: pid_t?, pipelineGeneration: UInt64?) -> PasteTargetCandidate? {
|
|
4949
|
+
let frozen = pipelineGeneration.flatMap { frozenPasteTargetsByGeneration[$0] }
|
|
4950
|
+
let identity = pipelineGeneration.flatMap { pasteTargetProcessIdentityByGeneration[$0] }
|
|
4951
|
+
if frozen != nil {
|
|
4952
|
+
// No bundle-only or foreground fallback for an explicitly frozen capture.
|
|
4953
|
+
guard let identity, targetPid == identity.pid,
|
|
4954
|
+
targetBundleIdentifier == identity.bundleIdentifier else { return nil }
|
|
4955
|
+
}
|
|
4956
|
+
return Self.selectPasteTarget(candidates: frozen == nil ? candidates : candidates.filter(\.isRegularApp),
|
|
4957
|
+
currentPid: ProcessInfo.processInfo.processIdentifier, targetBundleIdentifier: targetBundleIdentifier,
|
|
4958
|
+
targetPid: targetPid, frontmostPid: frontmostPid, requiredProcessIdentity: identity,
|
|
4959
|
+
requiresProcessIdentity: frozen != nil || (pipelineGeneration != nil && targetPid != nil))
|
|
4960
|
+
}
|
|
4961
|
+
|
|
4876
4962
|
nonisolated static func selectPasteTarget(
|
|
4877
4963
|
candidates: [PasteTargetCandidate],
|
|
4878
4964
|
currentPid: pid_t,
|
|
@@ -5031,12 +5117,15 @@ enum CLIRunner: Sendable {
|
|
|
5031
5117
|
|
|
5032
5118
|
enum ExecutionError: Error, LocalizedError, Equatable {
|
|
5033
5119
|
case timedOut(executable: String, seconds: TimeInterval)
|
|
5120
|
+
case deadlineExhausted
|
|
5034
5121
|
case captureFailed(operation: CaptureOperation, code: Int32)
|
|
5035
5122
|
|
|
5036
5123
|
var errorDescription: String? {
|
|
5037
5124
|
switch self {
|
|
5038
5125
|
case let .timedOut(executable, seconds):
|
|
5039
5126
|
return "Command timed out after \(seconds.formatted()) seconds: \(executable)"
|
|
5127
|
+
case .deadlineExhausted:
|
|
5128
|
+
return "Command timed out: insufficient time remains to start safely."
|
|
5040
5129
|
case let .captureFailed(operation, code):
|
|
5041
5130
|
return "Failed to capture command output while \(operation.description): \(String(cString: strerror(code)))"
|
|
5042
5131
|
}
|
|
@@ -5065,27 +5154,76 @@ enum CLIRunner: Sendable {
|
|
|
5065
5154
|
_ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
|
|
5066
5155
|
) throws -> Int32
|
|
5067
5156
|
|
|
5157
|
+
/// An explicit monotonic deadline can cross queue and preparation boundaries without
|
|
5158
|
+
/// resetting the budget. The clock is injectable for deterministic boundary tests.
|
|
5159
|
+
struct WallClockDeadline: Sendable {
|
|
5160
|
+
private let expiresAt: UInt64
|
|
5161
|
+
private let now: @Sendable () -> UInt64
|
|
5162
|
+
|
|
5163
|
+
init(
|
|
5164
|
+
after seconds: TimeInterval,
|
|
5165
|
+
now: @escaping @Sendable () -> UInt64 = { DispatchTime.now().uptimeNanoseconds }
|
|
5166
|
+
) {
|
|
5167
|
+
precondition(seconds.isFinite && seconds >= 0)
|
|
5168
|
+
let startedAt = now()
|
|
5169
|
+
let maximumDelay = min(UInt64(Int64.max), UInt64.max - startedAt)
|
|
5170
|
+
let requested = seconds * 1_000_000_000
|
|
5171
|
+
// Truncate fractional nanoseconds: rounding up could extend the configured
|
|
5172
|
+
// budget and reject its own deadline at a subsequent handoff validation.
|
|
5173
|
+
let delay = requested >= Double(maximumDelay)
|
|
5174
|
+
? maximumDelay : UInt64(requested)
|
|
5175
|
+
expiresAt = startedAt + delay
|
|
5176
|
+
self.now = now
|
|
5177
|
+
}
|
|
5178
|
+
|
|
5179
|
+
func remaining(reserving reserve: TimeInterval = 0) -> TimeInterval {
|
|
5180
|
+
let current = now()
|
|
5181
|
+
guard expiresAt > current else { return 0 }
|
|
5182
|
+
return max(0, Double(expiresAt - current) / 1_000_000_000 - reserve)
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
|
|
5186
|
+
private static func requireStartBudget(_ deadline: WallClockDeadline?) throws {
|
|
5187
|
+
if let deadline, deadline.remaining() <= wallClockCleanupReserve {
|
|
5188
|
+
throw ExecutionError.deadlineExhausted
|
|
5189
|
+
}
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5068
5192
|
static func run(
|
|
5069
5193
|
_ args: [String],
|
|
5070
5194
|
home: String,
|
|
5071
5195
|
timeout: TimeInterval = 120,
|
|
5072
5196
|
totalWallClockBudget: TimeInterval? = nil,
|
|
5073
|
-
environment suppliedEnvironment: [String: String]? = nil
|
|
5197
|
+
environment suppliedEnvironment: [String: String]? = nil,
|
|
5198
|
+
wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
|
|
5199
|
+
environmentProvider: (() throws -> [String: String])? = nil
|
|
5074
5200
|
) -> String {
|
|
5075
|
-
let
|
|
5076
|
-
|
|
5201
|
+
if let totalWallClockBudget {
|
|
5202
|
+
precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
|
|
5203
|
+
}
|
|
5204
|
+
precondition(suppliedDeadline == nil || totalWallClockBudget != nil)
|
|
5205
|
+
let deadline = suppliedDeadline ?? totalWallClockBudget.map { WallClockDeadline(after: $0) }
|
|
5206
|
+
if let suppliedDeadline, let totalWallClockBudget {
|
|
5207
|
+
precondition(suppliedDeadline.remaining() <= totalWallClockBudget)
|
|
5208
|
+
}
|
|
5077
5209
|
do {
|
|
5078
|
-
|
|
5210
|
+
try requireStartBudget(deadline)
|
|
5211
|
+
let command = resolveCommand(home: home)
|
|
5212
|
+
let arguments = command.argumentsPrefix + args
|
|
5213
|
+
try requireStartBudget(deadline)
|
|
5214
|
+
let environment = try suppliedEnvironment ?? environmentProvider?() ?? ServiceAPIConfiguration.childEnvironment(
|
|
5079
5215
|
base: OpenAIAPIKeyStore.childEnvironment(base: ProcessInfo.processInfo.environment.merging([
|
|
5080
5216
|
"PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
5081
5217
|
]) { _, new in new }, homePath: home)
|
|
5082
5218
|
)
|
|
5219
|
+
try requireStartBudget(deadline)
|
|
5083
5220
|
let output = try runExecutable(
|
|
5084
5221
|
command.executable,
|
|
5085
5222
|
arguments: arguments,
|
|
5086
5223
|
environment: environment,
|
|
5087
5224
|
executionTimeout: timeout,
|
|
5088
|
-
totalWallClockBudget: totalWallClockBudget
|
|
5225
|
+
totalWallClockBudget: totalWallClockBudget,
|
|
5226
|
+
wallClockDeadline: deadline
|
|
5089
5227
|
)
|
|
5090
5228
|
if output.terminationStatus != 0 {
|
|
5091
5229
|
let details = output.stderr.isEmpty ? output.stdout : output.stderr
|
|
@@ -5133,6 +5271,7 @@ enum CLIRunner: Sendable {
|
|
|
5133
5271
|
forceKillGracePeriod: TimeInterval = 1,
|
|
5134
5272
|
pipeDrainTimeout: TimeInterval = 2,
|
|
5135
5273
|
totalWallClockBudget: TimeInterval? = nil,
|
|
5274
|
+
wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
|
|
5136
5275
|
beforeExecutionDeadline: (() -> Void)? = nil,
|
|
5137
5276
|
lifecycleObserver: ((ProcessLifecycleEvent) -> Void)? = nil,
|
|
5138
5277
|
leaderReaper: LeaderReaper? = nil,
|
|
@@ -5146,23 +5285,24 @@ enum CLIRunner: Sendable {
|
|
|
5146
5285
|
precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
|
|
5147
5286
|
}
|
|
5148
5287
|
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5288
|
+
precondition(suppliedDeadline == nil || totalWallClockBudget != nil)
|
|
5289
|
+
if let suppliedDeadline, let totalWallClockBudget {
|
|
5290
|
+
precondition(suppliedDeadline.remaining() <= totalWallClockBudget)
|
|
5291
|
+
}
|
|
5292
|
+
// A caller that prepared the command supplies its original deadline. Direct
|
|
5293
|
+
// executable callers begin here; neither path resets a previously spent budget.
|
|
5294
|
+
let wallClockDeadline = suppliedDeadline ?? totalWallClockBudget.map { WallClockDeadline(after: $0) }
|
|
5152
5295
|
func clampedToWallClockBudget(
|
|
5153
5296
|
_ phaseTimeout: TimeInterval,
|
|
5154
5297
|
reserving reserve: TimeInterval = 0
|
|
5155
5298
|
) -> TimeInterval {
|
|
5156
5299
|
guard let wallClockDeadline else { return phaseTimeout }
|
|
5157
|
-
|
|
5158
|
-
let remaining = wallClockDeadline > now
|
|
5159
|
-
? Double(wallClockDeadline - now) / 1_000_000_000 - reserve
|
|
5160
|
-
: 0
|
|
5161
|
-
return max(0, min(phaseTimeout, remaining))
|
|
5300
|
+
return min(phaseTimeout, wallClockDeadline.remaining(reserving: reserve))
|
|
5162
5301
|
}
|
|
5163
5302
|
let contractualExecutionTimeout = totalWallClockBudget
|
|
5164
5303
|
.map { min(executionTimeout, $0 - wallClockCleanupReserve) } ?? executionTimeout
|
|
5165
5304
|
|
|
5305
|
+
try requireStartBudget(wallClockDeadline)
|
|
5166
5306
|
let stdoutReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
|
|
5167
5307
|
let stderrReader: PipeCaptureReader
|
|
5168
5308
|
do {
|
|
@@ -5176,6 +5316,7 @@ enum CLIRunner: Sendable {
|
|
|
5176
5316
|
|
|
5177
5317
|
let processIdentifier: pid_t
|
|
5178
5318
|
do {
|
|
5319
|
+
try requireStartBudget(wallClockDeadline)
|
|
5179
5320
|
processIdentifier = try spawnProcessGroup(
|
|
5180
5321
|
executable,
|
|
5181
5322
|
arguments: arguments,
|
|
@@ -5355,6 +5496,30 @@ enum CLIRunner: Sendable {
|
|
|
5355
5496
|
)
|
|
5356
5497
|
}
|
|
5357
5498
|
|
|
5499
|
+
// CLOEXEC_DEFAULT also closes stdin unless it is explicitly inherited. Keep
|
|
5500
|
+
// the existing stdin behavior, including an already closed/CLOEXEC stream;
|
|
5501
|
+
// a capture pipe that reused fd 0 must still be closed by the actions above.
|
|
5502
|
+
if !inheritedDescriptors.contains(STDIN_FILENO) {
|
|
5503
|
+
var inputFlags: Int32
|
|
5504
|
+
repeat {
|
|
5505
|
+
inputFlags = Darwin.fcntl(STDIN_FILENO, F_GETFD)
|
|
5506
|
+
} while inputFlags == -1 && errno == EINTR
|
|
5507
|
+
let inputError = errno
|
|
5508
|
+
if inputFlags == -1 && inputError != EBADF {
|
|
5509
|
+
throw NSError(
|
|
5510
|
+
domain: NSPOSIXErrorDomain,
|
|
5511
|
+
code: Int(inputError),
|
|
5512
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to inspect command standard input"]
|
|
5513
|
+
)
|
|
5514
|
+
}
|
|
5515
|
+
if inputFlags >= 0 && inputFlags & FD_CLOEXEC == 0 {
|
|
5516
|
+
try checkPOSIX(
|
|
5517
|
+
posix_spawn_file_actions_addinherit_np(&fileActions, STDIN_FILENO),
|
|
5518
|
+
operation: "inherit command standard input"
|
|
5519
|
+
)
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5358
5523
|
var attributes: posix_spawnattr_t?
|
|
5359
5524
|
try checkPOSIX(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes")
|
|
5360
5525
|
defer { posix_spawnattr_destroy(&attributes) }
|
|
@@ -5371,7 +5536,10 @@ enum CLIRunner: Sendable {
|
|
|
5371
5536
|
posix_spawnattr_setsigmask(&attributes, &unblockedSignals),
|
|
5372
5537
|
operation: "unblock command signals"
|
|
5373
5538
|
)
|
|
5374
|
-
|
|
5539
|
+
// A different reader may still be between pipe() and FD_CLOEXEC setup.
|
|
5540
|
+
// Inherit only our explicit standard streams, never that unrelated pipe.
|
|
5541
|
+
let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF
|
|
5542
|
+
| POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT
|
|
5375
5543
|
try checkPOSIX(
|
|
5376
5544
|
posix_spawnattr_setflags(&attributes, Int16(spawnFlags)),
|
|
5377
5545
|
operation: "configure command process group"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// An observed app process, not a bundle-ID request to launch or find another instance.
|
|
5
|
+
public struct RecordingPasteTarget: Equatable, Sendable {
|
|
6
|
+
public let processIdentifier: pid_t
|
|
7
|
+
public let bundleIdentifier: String
|
|
8
|
+
public let launchDate: Date
|
|
9
|
+
public let applicationName: String
|
|
10
|
+
|
|
11
|
+
init?(observation: PasteApplicationObservation, currentPID: pid_t) {
|
|
12
|
+
guard observation.pid > 0, observation.pid != currentPID,
|
|
13
|
+
observation.isRegular, !observation.isTerminated,
|
|
14
|
+
let bundle = observation.bundleIdentifier, !bundle.isEmpty,
|
|
15
|
+
let launchDate = observation.launchDate else { return nil }
|
|
16
|
+
processIdentifier = observation.pid
|
|
17
|
+
bundleIdentifier = bundle
|
|
18
|
+
self.launchDate = launchDate
|
|
19
|
+
applicationName = observation.name ?? "Application"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func matches(_ observation: PasteApplicationObservation) -> Bool {
|
|
23
|
+
observation.pid == processIdentifier && observation.bundleIdentifier == bundleIdentifier
|
|
24
|
+
&& observation.launchDate == launchDate && observation.isRegular && !observation.isTerminated
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Omission preserves the recorder's existing frontmost-app behavior. A frozen nil
|
|
29
|
+
/// explicitly means no destination; it never adopts another app at delivery time.
|
|
30
|
+
public enum RecordingPasteTargetSelection: Equatable, Sendable {
|
|
31
|
+
case frontmostApplication
|
|
32
|
+
case frozen(RecordingPasteTarget?)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
struct PasteApplicationObservation: Sendable {
|
|
36
|
+
var pid: pid_t
|
|
37
|
+
var bundleIdentifier: String?
|
|
38
|
+
var launchDate: Date?
|
|
39
|
+
var name: String?
|
|
40
|
+
var isRegular: Bool
|
|
41
|
+
var isTerminated: Bool
|
|
42
|
+
|
|
43
|
+
init(_ app: NSRunningApplication) {
|
|
44
|
+
pid = app.processIdentifier; bundleIdentifier = app.bundleIdentifier
|
|
45
|
+
launchDate = app.launchDate; name = app.localizedName
|
|
46
|
+
isRegular = app.activationPolicy == .regular; isTerminated = app.isTerminated
|
|
47
|
+
}
|
|
48
|
+
init(pid: pid_t, bundleIdentifier: String?, launchDate: Date?, name: String? = nil,
|
|
49
|
+
isRegular: Bool = true, isTerminated: Bool = false) {
|
|
50
|
+
self.pid = pid; self.bundleIdentifier = bundleIdentifier; self.launchDate = launchDate
|
|
51
|
+
self.name = name; self.isRegular = isRegular; self.isTerminated = isTerminated
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Keep one tracker for the host application's lifetime, before presenting its UI.
|
|
56
|
+
/// Observes workspace activation only; never installs input handlers, queries AX,
|
|
57
|
+
/// activates apps, reads their documents, or accesses the clipboard.
|
|
58
|
+
@MainActor public final class RecordingPasteTargetTracker {
|
|
59
|
+
private let currentPID: pid_t
|
|
60
|
+
private let frontmost: () -> PasteApplicationObservation?
|
|
61
|
+
private let lookup: (pid_t) -> PasteApplicationObservation?
|
|
62
|
+
private var lastExternal: RecordingPasteTarget?
|
|
63
|
+
private var observers: [NSObjectProtocol] = []
|
|
64
|
+
private var center: NotificationCenter?
|
|
65
|
+
|
|
66
|
+
public convenience init() {
|
|
67
|
+
self.init(currentPID: ProcessInfo.processInfo.processIdentifier,
|
|
68
|
+
frontmost: { NSWorkspace.shared.frontmostApplication.map(PasteApplicationObservation.init) },
|
|
69
|
+
lookup: { NSRunningApplication(processIdentifier: $0).map(PasteApplicationObservation.init) })
|
|
70
|
+
let center = NSWorkspace.shared.notificationCenter
|
|
71
|
+
self.center = center
|
|
72
|
+
observers.append(center.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { [weak self] notification in
|
|
73
|
+
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
|
74
|
+
let observation = PasteApplicationObservation(app)
|
|
75
|
+
MainActor.assumeIsolated { self?.observe(observation) }
|
|
76
|
+
})
|
|
77
|
+
observers.append(center.addObserver(forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main) { [weak self] notification in
|
|
78
|
+
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
|
79
|
+
let observation = PasteApplicationObservation(app)
|
|
80
|
+
MainActor.assumeIsolated { self?.terminated(observation) }
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
init(currentPID: pid_t, frontmost: @escaping () -> PasteApplicationObservation?,
|
|
85
|
+
lookup: @escaping (pid_t) -> PasteApplicationObservation?) {
|
|
86
|
+
self.currentPID = currentPID; self.frontmost = frontmost; self.lookup = lookup
|
|
87
|
+
if let app = frontmost() { observe(app) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
isolated deinit { for observer in observers { center?.removeObserver(observer) } }
|
|
91
|
+
|
|
92
|
+
func observe(_ app: PasteApplicationObservation) {
|
|
93
|
+
guard let target = RecordingPasteTarget(observation: app, currentPID: currentPID) else { return }
|
|
94
|
+
lastExternal = target
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func terminated(_ app: PasteApplicationObservation) {
|
|
98
|
+
guard let target = lastExternal, app.pid == target.processIdentifier,
|
|
99
|
+
app.bundleIdentifier == target.bundleIdentifier, app.launchDate == target.launchDate else { return }
|
|
100
|
+
lastExternal = nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Revalidate now, then pass this value to startRecording as `.frozen(value)`.
|
|
104
|
+
/// A terminated or replaced process clears the remembered destination; older
|
|
105
|
+
/// apps are not searched as a substitute.
|
|
106
|
+
public func snapshot() -> RecordingPasteTarget? {
|
|
107
|
+
if let app = frontmost() { observe(app) }
|
|
108
|
+
guard let target = lastExternal else { return nil }
|
|
109
|
+
guard let live = lookup(target.processIdentifier), target.matches(live) else {
|
|
110
|
+
lastExternal = nil; return nil
|
|
111
|
+
}
|
|
112
|
+
return target
|
|
113
|
+
}
|
|
114
|
+
}
|