@hasna/recordings 0.3.13 → 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 +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- 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/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- 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/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- 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 +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.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 +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- 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/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- 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 +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- 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
- package/src/native/Recordings/App/SidebarView.swift +0 -212
|
@@ -16,19 +16,23 @@ extension KeyboardShortcuts.Name {
|
|
|
16
16
|
|
|
17
17
|
public struct TranscriptionResult: Identifiable, Sendable {
|
|
18
18
|
public let id = UUID()
|
|
19
|
-
let rawText: String
|
|
20
|
-
let processedText: String?
|
|
21
|
-
let timestamp: Date
|
|
22
|
-
let projectId: String?
|
|
23
|
-
let projectName: String?
|
|
19
|
+
public let rawText: String
|
|
20
|
+
public let processedText: String?
|
|
21
|
+
public let timestamp: Date
|
|
22
|
+
public let projectId: String?
|
|
23
|
+
public let projectName: String?
|
|
24
|
+
public let captureID: String?
|
|
25
|
+
public let audioURL: URL?
|
|
24
26
|
public var displayText: String { processedText ?? rawText }
|
|
25
27
|
|
|
26
|
-
init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?) {
|
|
28
|
+
public init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?, captureID: String? = nil, audioURL: URL? = nil) {
|
|
27
29
|
self.rawText = rawText
|
|
28
30
|
self.processedText = processedText
|
|
29
31
|
self.timestamp = timestamp
|
|
30
32
|
self.projectId = projectId
|
|
31
33
|
self.projectName = projectName
|
|
34
|
+
self.captureID = captureID
|
|
35
|
+
self.audioURL = audioURL
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -95,7 +99,7 @@ struct FrontmostAppSnapshot: Equatable, Sendable {
|
|
|
95
99
|
|
|
96
100
|
/// The one capability `RecordingEngine` needs from an audio recorder; lets tests run the
|
|
97
101
|
/// production start path without microphone hardware or TCC grants.
|
|
98
|
-
protocol PCMRecordingSource: AnyObject {
|
|
102
|
+
protocol PCMRecordingSource: AnyObject, Sendable {
|
|
99
103
|
func start() throws
|
|
100
104
|
func stop()
|
|
101
105
|
}
|
|
@@ -671,7 +675,7 @@ private final class PCMStreamPipe: @unchecked Sendable {
|
|
|
671
675
|
private let continuation: AsyncStream<Data>.Continuation
|
|
672
676
|
private let processor: Task<Data, Never>
|
|
673
677
|
|
|
674
|
-
init(chunkSize: Int, client: RealtimeTranscriptionClient?) {
|
|
678
|
+
init(chunkSize: Int, client: RealtimeTranscriptionClient?, providerSession: (any RecordingTranscriptionSession)? = nil) {
|
|
675
679
|
var streamContinuation: AsyncStream<Data>.Continuation!
|
|
676
680
|
let stream = AsyncStream<Data>(bufferingPolicy: .unbounded) { continuation in
|
|
677
681
|
streamContinuation = continuation
|
|
@@ -682,17 +686,22 @@ private final class PCMStreamPipe: @unchecked Sendable {
|
|
|
682
686
|
var pendingChunk = Data()
|
|
683
687
|
|
|
684
688
|
for await data in stream {
|
|
689
|
+
guard !Task.isCancelled else { break }
|
|
685
690
|
guard !data.isEmpty else { continue }
|
|
686
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)
|
|
687
695
|
pendingChunk.append(data)
|
|
688
696
|
|
|
689
697
|
while pendingChunk.count >= chunkSize {
|
|
690
|
-
|
|
698
|
+
let chunk = pendingChunk.prefixData(count: chunkSize)
|
|
699
|
+
await client?.sendAudio(chunk)
|
|
691
700
|
pendingChunk.removeFirst(chunkSize)
|
|
692
701
|
}
|
|
693
702
|
}
|
|
694
703
|
|
|
695
|
-
if !pendingChunk.isEmpty {
|
|
704
|
+
if !Task.isCancelled && !pendingChunk.isEmpty {
|
|
696
705
|
await client?.sendAudio(pendingChunk)
|
|
697
706
|
}
|
|
698
707
|
return recordedPCM
|
|
@@ -737,7 +746,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
737
746
|
@Published public private(set) var isWarmingUpCapture = false
|
|
738
747
|
@Published public var useFnKey: Bool = false {
|
|
739
748
|
didSet {
|
|
740
|
-
|
|
749
|
+
preferences.set(useFnKey, forKey: "useFnKey")
|
|
741
750
|
updateFnMonitor()
|
|
742
751
|
refreshTriggerDiagnostics()
|
|
743
752
|
}
|
|
@@ -854,9 +863,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
854
863
|
private var deliveryBlockedReasonGeneration: UInt64?
|
|
855
864
|
/// Advanced fallback policy (Settings only): when off, every recording is dictated
|
|
856
865
|
/// literally and the classifier is never consulted.
|
|
857
|
-
@Published public var intentDetectionEnabled: Bool =
|
|
866
|
+
@Published public var intentDetectionEnabled: Bool = false {
|
|
858
867
|
didSet {
|
|
859
|
-
|
|
868
|
+
preferences.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
|
|
860
869
|
}
|
|
861
870
|
}
|
|
862
871
|
/// Typed Record-page state; views render idle/listening/finalizing/processing/ready/error
|
|
@@ -874,13 +883,68 @@ public final class RecordingEngine: ObservableObject {
|
|
|
874
883
|
@Published public var isTranscribing = false
|
|
875
884
|
@Published public var recordingDuration: TimeInterval = 0
|
|
876
885
|
@Published public var liveTranscriptionText = ""
|
|
886
|
+
@Published public private(set) var isPaused = false
|
|
887
|
+
@Published public private(set) var audioLevel: Double = 0
|
|
888
|
+
@Published public private(set) var recentPastes: [RecentPaste] = []
|
|
889
|
+
@Published public private(set) var latestAudioPath: String?
|
|
890
|
+
@Published public var autoPasteEnabled = true {
|
|
891
|
+
didSet {
|
|
892
|
+
if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
|
|
893
|
+
preferences.set(autoPasteEnabled, forKey: "recordingsAutoPaste")
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
private var captureMonitor = CaptureMonitor()
|
|
898
|
+
|
|
899
|
+
public func togglePause() {
|
|
900
|
+
guard isRecording else { return }
|
|
901
|
+
isPaused.toggle()
|
|
902
|
+
captureMonitor.setPaused(isPaused)
|
|
903
|
+
statusMessage = isPaused ? "Paused" : "Recording…"
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
public func clearRecentPastes() { recentPastes.removeAll() }
|
|
907
|
+
|
|
908
|
+
#if DEBUG
|
|
909
|
+
/// Explicitly isolated design review only; does not open a microphone or connection.
|
|
910
|
+
public func configureVisualPreview() {
|
|
911
|
+
isRecording = true
|
|
912
|
+
recordingDuration = 12
|
|
913
|
+
audioLevel = 0.65
|
|
914
|
+
liveTranscriptionText = "Here are the key takeaways from today’s meeting…"
|
|
915
|
+
let samples = [
|
|
916
|
+
("com.apple.MobileSMS", "To: James", "Schedule a review for tomorrow at 2 PM…"),
|
|
917
|
+
("com.apple.Notes", "New Note", "Here are the key takeaways from today’s…"),
|
|
918
|
+
("com.tinyspeck.slackmacgap", "#product", "Can you confirm we’re still on for Friday?"),
|
|
919
|
+
("com.apple.mail", "New message", "Sharing the updated timeline and next steps."),
|
|
920
|
+
("notion.id", "Page", "Write a short summary of the discussion…")
|
|
921
|
+
]
|
|
922
|
+
recentPastes = zip(samples, ["Messages", "Notes", "Slack", "Mail", "Notion"]).map { sample, name in RecentPaste(text: sample.2, bundleIdentifier: sample.0, appName: name, location: sample.1, status: "Pasted", verified: true) }
|
|
923
|
+
}
|
|
924
|
+
#endif
|
|
925
|
+
|
|
877
926
|
@Published public var transcriptionLanguage = OpenAIAPIKeyStore.defaultLanguage {
|
|
878
927
|
didSet {
|
|
879
|
-
|
|
880
|
-
|
|
928
|
+
preferences.set(transcriptionLanguage, forKey: "recordingsLanguage")
|
|
929
|
+
if !usesIsolatedProvider {
|
|
930
|
+
try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
|
|
931
|
+
}
|
|
881
932
|
}
|
|
882
933
|
}
|
|
883
934
|
|
|
935
|
+
private let preferences: UserDefaults
|
|
936
|
+
private let preferencesSuiteName: String?
|
|
937
|
+
private let installsGlobalHandlers: Bool
|
|
938
|
+
private let transcriptionProvider: (any RecordingTranscriptionProvider)?
|
|
939
|
+
private var usesIsolatedProvider: Bool { transcriptionProvider != nil }
|
|
940
|
+
private var providerSession: (any RecordingTranscriptionSession)?
|
|
941
|
+
private var providerConfiguration: RecordingProviderSessionConfiguration?
|
|
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
|
+
}
|
|
947
|
+
|
|
884
948
|
private var nativeRecorder: PCMRecordingSource?
|
|
885
949
|
private var recordingTimer: Timer?
|
|
886
950
|
private var activeTrigger: RecordingTrigger?
|
|
@@ -889,10 +953,21 @@ public final class RecordingEngine: ObservableObject {
|
|
|
889
953
|
private var fnKeyIsDown = false
|
|
890
954
|
private var targetAppBundleIdentifier: String?
|
|
891
955
|
private var targetAppPid: pid_t?
|
|
956
|
+
private var frozenPasteTargetsByGeneration: [UInt64: RecordingPasteTargetSelection] = [:]
|
|
892
957
|
private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
|
|
893
958
|
public var projectStore: ProjectStore?
|
|
959
|
+
/// The minimal app uses only global cleanup preferences from the legacy settings
|
|
960
|
+
/// file. Keeping this separate prevents old active projects from tagging new captures.
|
|
961
|
+
public var globalRecordingPreferences: ProjectStore?
|
|
894
962
|
public var voiceShortcuts: VoiceShortcuts?
|
|
895
963
|
|
|
964
|
+
var recordingCleanupPreferences: (prompt: String, mode: String) {
|
|
965
|
+
if let settings = globalRecordingPreferences?.settings {
|
|
966
|
+
return (settings.globalSystemPrompt, (PostProcessingMode(rawValue: settings.postProcessingMode) ?? .auto).rawValue)
|
|
967
|
+
}
|
|
968
|
+
return (projectStore?.effectiveSystemPrompt ?? "", projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue)
|
|
969
|
+
}
|
|
970
|
+
|
|
896
971
|
// MARK: - Injectable boundaries
|
|
897
972
|
// Production defaults perform the real I/O; tests replace them to drive the production
|
|
898
973
|
// start/delivery paths without microphone, Accessibility, network, or CLI access.
|
|
@@ -911,6 +986,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
911
986
|
launchDate: app.launchDate
|
|
912
987
|
)
|
|
913
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
|
+
}
|
|
914
995
|
var recorderFactory: (@escaping @Sendable (Data) -> Void) -> PCMRecordingSource = {
|
|
915
996
|
NativePCMRecorder(onPCM: $0)
|
|
916
997
|
}
|
|
@@ -921,12 +1002,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
921
1002
|
RecordingEngine.focusedWindowTitle(pid: $0)
|
|
922
1003
|
}
|
|
923
1004
|
var commandCLI: @Sendable (_ args: [String], _ home: String, _ timeout: TimeInterval) -> String = { args, home, ceiling in
|
|
924
|
-
// The caller
|
|
1005
|
+
// The caller supplies the remaining public budget after queue admission. CLIRunner's
|
|
925
1006
|
// total deadline (execution, termination grace, kill grace, pipe drain) sits a full
|
|
926
1007
|
// return margin below it: spawn setup, waitid poll granularity, capture shutdown,
|
|
927
1008
|
// and the hop back to the caller all run outside CLIRunner's clamped waits and must
|
|
928
1009
|
// fit inside the reserved margin.
|
|
929
1010
|
let cliDeadline = ceiling - RecordingEngine.commandRewriteReturnMargin
|
|
1011
|
+
guard cliDeadline > CLIRunner.wallClockCleanupReserve else {
|
|
1012
|
+
return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
|
|
1013
|
+
}
|
|
930
1014
|
return CLIRunner.run(args, home: home, timeout: cliDeadline, totalWallClockBudget: cliDeadline)
|
|
931
1015
|
}
|
|
932
1016
|
/// Resolves and revalidates the frozen rewrite target immediately before a rewrite:
|
|
@@ -1158,6 +1242,23 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1158
1242
|
/// *observable* rewrite time under the public ceiling even when the execution window,
|
|
1159
1243
|
/// termination grace, and pipe drain all run to exhaustion.
|
|
1160
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
|
+
}
|
|
1161
1262
|
/// Wait before each read-back of the target app's focused field. The window server
|
|
1162
1263
|
/// delivers the posted keystroke asynchronously and the app then does its own work, so a
|
|
1163
1264
|
/// read taken on the posting turn would report "unchanged" for a paste that is simply
|
|
@@ -1188,15 +1289,37 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1188
1289
|
let home: String
|
|
1189
1290
|
private var audioDir: String { "\(home)/.hasna/recordings/audio" }
|
|
1190
1291
|
|
|
1191
|
-
public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
|
|
1292
|
+
public convenience init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path, installsGlobalHandlers: Bool = true) {
|
|
1293
|
+
self.init(homePath: homePath, preferences: .standard, preferencesSuiteName: nil, installsGlobalHandlers: installsGlobalHandlers, transcriptionProvider: nil)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/// Reuses native capture and paste with isolated preferences and an explicit provider.
|
|
1297
|
+
/// No global handlers, legacy keys, environment routing or helper CLI are consulted.
|
|
1298
|
+
public convenience init(configuration: RecordingEngineConfiguration, transcriptionProvider: any RecordingTranscriptionProvider) {
|
|
1299
|
+
// A validated non-empty suite name is supported by Foundation on macOS.
|
|
1300
|
+
let preferences = UserDefaults(suiteName: configuration.preferencesSuiteName)!
|
|
1301
|
+
self.init(homePath: configuration.isolatedHomePath, preferences: preferences, preferencesSuiteName: configuration.preferencesSuiteName, installsGlobalHandlers: false, transcriptionProvider: transcriptionProvider)
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private init(homePath: String, preferences: UserDefaults, preferencesSuiteName: String?, installsGlobalHandlers: Bool, transcriptionProvider: (any RecordingTranscriptionProvider)?) {
|
|
1192
1305
|
home = homePath
|
|
1306
|
+
self.preferences = preferences
|
|
1307
|
+
self.preferencesSuiteName = preferencesSuiteName
|
|
1308
|
+
self.installsGlobalHandlers = installsGlobalHandlers
|
|
1309
|
+
self.transcriptionProvider = transcriptionProvider
|
|
1193
1310
|
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
1194
1311
|
log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
|
|
1195
1312
|
|
|
1196
1313
|
// Load preferences
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1314
|
+
if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
|
|
1315
|
+
autoPasteEnabled = storedPreference("recordingsAutoPaste") as? Bool ?? true
|
|
1316
|
+
}
|
|
1317
|
+
intentDetectionEnabled = storedPreference("intentDetectionEnabled") as? Bool ?? false
|
|
1318
|
+
transcriptionLanguage = usesIsolatedProvider
|
|
1319
|
+
? (storedPreference("recordingsLanguage") as? String ?? "en")
|
|
1320
|
+
: OpenAIAPIKeyStore.loadLanguage(homePath: home)
|
|
1321
|
+
useFnKey = storedPreference("useFnKey") as? Bool ?? false
|
|
1322
|
+
guard installsGlobalHandlers else { statusMessage = "Ready"; return }
|
|
1200
1323
|
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
1201
1324
|
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
1202
1325
|
}
|
|
@@ -1268,6 +1391,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1268
1391
|
updateStatus()
|
|
1269
1392
|
}
|
|
1270
1393
|
|
|
1394
|
+
private func storedPreference(_ key: String) -> Any? {
|
|
1395
|
+
if let preferencesSuiteName {
|
|
1396
|
+
// UserDefaults.object also consults process/global domains. A separate app's
|
|
1397
|
+
// engine reads only its explicitly named persistent domain.
|
|
1398
|
+
return preferences.persistentDomain(forName: preferencesSuiteName)?[key]
|
|
1399
|
+
}
|
|
1400
|
+
return preferences.object(forKey: key)
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1271
1403
|
public var microphonePermissionLabel: String {
|
|
1272
1404
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
1273
1405
|
case .authorized:
|
|
@@ -1331,6 +1463,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1331
1463
|
/// was indistinguishable from a working one. Log the resolved binding so "is the
|
|
1332
1464
|
/// trigger armed, and to what" is answerable from the log alone.
|
|
1333
1465
|
public func logResolvedTrigger() {
|
|
1466
|
+
guard installsGlobalHandlers else { return }
|
|
1334
1467
|
let stored = KeyboardShortcuts.getShortcut(for: .toggleRecording)
|
|
1335
1468
|
let bound = stored
|
|
1336
1469
|
.map { "carbonKeyCode=\($0.carbonKeyCode) carbonModifiers=\($0.carbonModifiers)" }
|
|
@@ -1406,6 +1539,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1406
1539
|
}
|
|
1407
1540
|
|
|
1408
1541
|
private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
|
|
1542
|
+
guard installsGlobalHandlers else { return }
|
|
1409
1543
|
// Decided as a local first, then handed to the single writer once. Assigning the
|
|
1410
1544
|
// published property from each branch is how the per-source erasure bug got in.
|
|
1411
1545
|
var reason: String?
|
|
@@ -1512,6 +1646,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1512
1646
|
/// Re-evaluate every trigger's health, push it to the UI, and record it. The one entry
|
|
1513
1647
|
/// point callers should use after anything changes a binding.
|
|
1514
1648
|
public func refreshTriggerDiagnostics() {
|
|
1649
|
+
guard installsGlobalHandlers else { updateStatus(); return }
|
|
1515
1650
|
refreshHotkeyDiagnostics()
|
|
1516
1651
|
updateStatus()
|
|
1517
1652
|
logResolvedTrigger()
|
|
@@ -1635,7 +1770,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1635
1770
|
|
|
1636
1771
|
// MARK: - Start Recording (Streaming)
|
|
1637
1772
|
|
|
1638
|
-
public func startRecording(trigger: RecordingTrigger = .manual
|
|
1773
|
+
public func startRecording(trigger: RecordingTrigger = .manual,
|
|
1774
|
+
pasteTarget: RecordingPasteTargetSelection = .frontmostApplication) {
|
|
1639
1775
|
guard Self.canBeginRecording(
|
|
1640
1776
|
isRecording: isRecording,
|
|
1641
1777
|
isTranscribing: isTranscribing,
|
|
@@ -1652,11 +1788,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1652
1788
|
}
|
|
1653
1789
|
log("startRecording trigger=\(trigger) microphoneStatus=\(microphoneAuthorization().rawValue) accessibility=\(accessibilityTrustCheck())")
|
|
1654
1790
|
recordingGeneration &+= 1
|
|
1655
|
-
if pasteTargetProcessIdentityByGeneration.count >= 32 {
|
|
1791
|
+
if pasteTargetProcessIdentityByGeneration.count >= 32 || frozenPasteTargetsByGeneration.count >= 32 {
|
|
1656
1792
|
let oldestRetainedGeneration = recordingGeneration > 16 ? recordingGeneration - 16 : 0
|
|
1657
1793
|
pasteTargetProcessIdentityByGeneration = pasteTargetProcessIdentityByGeneration.filter {
|
|
1658
1794
|
$0.key >= oldestRetainedGeneration
|
|
1659
1795
|
}
|
|
1796
|
+
frozenPasteTargetsByGeneration = frozenPasteTargetsByGeneration.filter { $0.key >= oldestRetainedGeneration }
|
|
1660
1797
|
}
|
|
1661
1798
|
activeTrigger = trigger
|
|
1662
1799
|
keyboardShortcutIsDown = trigger == .keyboardShortcut
|
|
@@ -1675,7 +1812,19 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1675
1812
|
setBlockedReason(nil, for: .delivery)
|
|
1676
1813
|
|
|
1677
1814
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
1678
|
-
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
|
+
}
|
|
1679
1828
|
let isOwnApp = frontmostApp?.pid == myPID
|
|
1680
1829
|
targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
|
|
1681
1830
|
targetAppPid = isOwnApp ? nil : frontmostApp?.pid
|
|
@@ -1695,26 +1844,28 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1695
1844
|
// The selection is still frozen for every recording (not only an exposed "command
|
|
1696
1845
|
// mode"), so a later command decision can only ever act on the exact text and
|
|
1697
1846
|
// element that were selected when the user started speaking. The Accessibility IPC
|
|
1698
|
-
// that reads it runs on a
|
|
1699
|
-
// 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
|
|
1700
1849
|
// free to process the key-up that stops the recording. Skipped entirely when intent
|
|
1701
1850
|
// detection is off — no command route exists to consume it.
|
|
1702
1851
|
let shouldCaptureSelection = Self.shouldCaptureSelection(
|
|
1703
1852
|
targetPid: targetAppPid,
|
|
1704
1853
|
accessibilityTrusted: accessibilityTrustCheck(),
|
|
1705
|
-
intentDetectionEnabled: intentDetectionEnabled
|
|
1854
|
+
intentDetectionEnabled: !usesIsolatedProvider && intentDetectionEnabled
|
|
1706
1855
|
)
|
|
1707
1856
|
let capturePid = targetAppPid
|
|
1708
1857
|
let captureSelection = selectionCapture
|
|
1709
1858
|
let windowTitleLookup = focusedWindowTitleLookup
|
|
1710
1859
|
let windowTitlePid = frontmostApp?.pid
|
|
1711
1860
|
let axSnapshotTask = Task.detached(priority: .userInitiated) { () -> RecordingStartAXSnapshot in
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
+
}
|
|
1718
1869
|
}
|
|
1719
1870
|
|
|
1720
1871
|
// Project auto-selection and the processing configuration resolve with the
|
|
@@ -1722,9 +1873,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1722
1873
|
// recording pipeline awaits this context only after the recorder has stopped.
|
|
1723
1874
|
let generation = recordingGeneration
|
|
1724
1875
|
let projectStore = projectStore
|
|
1876
|
+
let cleanupPreferences = recordingCleanupPreferences
|
|
1725
1877
|
let targetBundleIdentifierForProjects = targetAppBundleIdentifier
|
|
1726
1878
|
let transcriptionLanguageAtStart = transcriptionLanguage
|
|
1727
|
-
let intentDetectionEnabledAtStart = intentDetectionEnabled
|
|
1879
|
+
let intentDetectionEnabledAtStart = !usesIsolatedProvider && intentDetectionEnabled
|
|
1880
|
+
let usesIsolatedProvider = usesIsolatedProvider
|
|
1728
1881
|
let homePath = home
|
|
1729
1882
|
let startContext = Task { @MainActor [weak self] () -> RecordingStartResolvedContext in
|
|
1730
1883
|
let axSnapshot = await axSnapshotTask.value
|
|
@@ -1748,7 +1901,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1748
1901
|
self.log("project synchronization degraded; continuing capture: \(warning)")
|
|
1749
1902
|
}
|
|
1750
1903
|
}
|
|
1751
|
-
let modelSelection =
|
|
1904
|
+
let modelSelection = usesIsolatedProvider
|
|
1905
|
+
? ProcessingModelSelection(transcriptionPrompt: "", transcriptionModel: "", transcriberModel: "", enhancementModel: "", intentModel: "", enhanceTriggersJSON: "[]", keywordTransformsJSON: "{}")
|
|
1906
|
+
: OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
|
|
1752
1907
|
return RecordingStartResolvedContext(
|
|
1753
1908
|
selectionToken: axSnapshot.selectionToken,
|
|
1754
1909
|
canonicalProjectId: projectStore?.activeCanonicalProjectIdForRecording,
|
|
@@ -1756,8 +1911,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1756
1911
|
activeProjectName: projectStore?.activeProject?.name,
|
|
1757
1912
|
processing: RecordingProcessingConfiguration(
|
|
1758
1913
|
transcriptionPrompt: modelSelection.transcriptionPrompt,
|
|
1759
|
-
transcriberPrompt:
|
|
1760
|
-
postProcessingMode:
|
|
1914
|
+
transcriberPrompt: cleanupPreferences.prompt,
|
|
1915
|
+
postProcessingMode: cleanupPreferences.mode,
|
|
1761
1916
|
transcriptionLanguage: transcriptionLanguageAtStart,
|
|
1762
1917
|
transcriptionModel: modelSelection.transcriptionModel,
|
|
1763
1918
|
transcriberModel: modelSelection.transcriberModel,
|
|
@@ -1945,7 +2100,26 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1945
2100
|
}
|
|
1946
2101
|
|
|
1947
2102
|
private func startNativeRecording(startContext: Task<RecordingStartResolvedContext, Never>) {
|
|
1948
|
-
let apiKey = openAIAPIKeyProvider()
|
|
2103
|
+
let apiKey = usesIsolatedProvider ? "" : openAIAPIKeyProvider()
|
|
2104
|
+
if let transcriptionProvider {
|
|
2105
|
+
let generation = recordingGeneration
|
|
2106
|
+
let configuration = RecordingProviderSessionConfiguration(captureID: UUID().uuidString, language: transcriptionLanguage)
|
|
2107
|
+
do {
|
|
2108
|
+
providerSession = try transcriptionProvider.makeSession(configuration: configuration) { [weak self] text in
|
|
2109
|
+
Task { @MainActor [weak self] in
|
|
2110
|
+
guard let self, self.recordingGeneration == generation,
|
|
2111
|
+
self.captureIsActive || self.isTranscribing else { return }
|
|
2112
|
+
self.liveTranscriptionText = text
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
providerConfiguration = configuration
|
|
2116
|
+
} catch {
|
|
2117
|
+
resetRecordingIntent()
|
|
2118
|
+
statusMessage = error.localizedDescription
|
|
2119
|
+
flowPhase = .failed(statusMessage)
|
|
2120
|
+
return
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
1949
2123
|
let captureConfiguration = RecordingCaptureConfiguration(
|
|
1950
2124
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1951
2125
|
targetAppPid: targetAppPid,
|
|
@@ -1964,7 +2138,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1964
2138
|
: RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
1965
2139
|
realtimeClient = client
|
|
1966
2140
|
|
|
1967
|
-
let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
|
|
2141
|
+
let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client, providerSession: providerSession)
|
|
1968
2142
|
pcmStreamPipe = streamPipe
|
|
1969
2143
|
let homePath = home
|
|
1970
2144
|
let captureGeneration = recordingGeneration
|
|
@@ -1972,7 +2146,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1972
2146
|
self?.confirmCaptureIsLive(generation: generation)
|
|
1973
2147
|
}
|
|
1974
2148
|
let firstChunkLogged = LockedFlag()
|
|
2149
|
+
let monitor = CaptureMonitor()
|
|
2150
|
+
captureMonitor = monitor
|
|
2151
|
+
isPaused = false
|
|
2152
|
+
audioLevel = 0
|
|
1975
2153
|
let recorder = recorderFactory { data in
|
|
2154
|
+
guard monitor.admit(data) else { return }
|
|
1976
2155
|
if firstChunkLogged.take() {
|
|
1977
2156
|
NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
|
|
1978
2157
|
// The first sample is the only honest signal that this recording exists.
|
|
@@ -2004,10 +2183,14 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2004
2183
|
|
|
2005
2184
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
2006
2185
|
Task { @MainActor [weak self] in
|
|
2007
|
-
self
|
|
2186
|
+
guard let self else { return }
|
|
2187
|
+
let sample = monitor.snapshot()
|
|
2188
|
+
self.recordingDuration = sample.duration
|
|
2189
|
+
self.audioLevel = sample.level
|
|
2008
2190
|
}
|
|
2009
2191
|
}
|
|
2010
2192
|
} catch {
|
|
2193
|
+
cancelProviderSession()
|
|
2011
2194
|
log("native recorder failed error=\(error.localizedDescription)")
|
|
2012
2195
|
// Unreachable today — nothing between `recorder.start()` and `isWarmingUpCapture`
|
|
2013
2196
|
// can throw — but a warming flag left set here wedges the engine permanently: the
|
|
@@ -2049,6 +2232,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2049
2232
|
private func abandonWarmingCapture(reason: String, alert: RecordingAttemptAlert?) {
|
|
2050
2233
|
guard isWarmingUpCapture else { return }
|
|
2051
2234
|
log("capture abandoned before first audio reason=\(reason)")
|
|
2235
|
+
cancelProviderSession()
|
|
2052
2236
|
// Supersede the attempt so every completion still bound to it — a queued first-chunk
|
|
2053
2237
|
// confirmation, the resolved start context — is stale and cannot apply.
|
|
2054
2238
|
recordingGeneration &+= 1
|
|
@@ -2154,6 +2338,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2154
2338
|
// MARK: - Cancel (discard without transcribing)
|
|
2155
2339
|
|
|
2156
2340
|
public func cancelRecording() {
|
|
2341
|
+
if usesIsolatedProvider && isTranscribing {
|
|
2342
|
+
pipelineDeliveryGate.abandonPipeline(recordingGeneration)
|
|
2343
|
+
recordingGeneration &+= 1
|
|
2344
|
+
cancelProviderSession()
|
|
2345
|
+
isTranscribing = false
|
|
2346
|
+
liveTranscriptionText = ""
|
|
2347
|
+
recordedPCM.removeAll(keepingCapacity: true)
|
|
2348
|
+
activeAudioPath = nil
|
|
2349
|
+
resetRecordingIntent()
|
|
2350
|
+
updateStatus()
|
|
2351
|
+
return
|
|
2352
|
+
}
|
|
2157
2353
|
// Discard during warm-up: identical teardown, but the user asked for it, so the glyph
|
|
2158
2354
|
// stays quiet.
|
|
2159
2355
|
if isWarmingUpCapture {
|
|
@@ -2162,6 +2358,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2162
2358
|
}
|
|
2163
2359
|
guard isRecording else { return }
|
|
2164
2360
|
log("cancelRecording")
|
|
2361
|
+
cancelProviderSession()
|
|
2165
2362
|
|
|
2166
2363
|
recordingTimer?.invalidate()
|
|
2167
2364
|
recordingTimer = nil
|
|
@@ -2178,6 +2375,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2178
2375
|
pcmStreamPipe = nil
|
|
2179
2376
|
|
|
2180
2377
|
isRecording = false
|
|
2378
|
+
isPaused = false
|
|
2379
|
+
audioLevel = 0
|
|
2380
|
+
recordingDuration = 0
|
|
2181
2381
|
isTranscribing = false
|
|
2182
2382
|
liveTranscriptionText = ""
|
|
2183
2383
|
recordedPCM.removeAll(keepingCapacity: true)
|
|
@@ -2191,6 +2391,98 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2191
2391
|
|
|
2192
2392
|
// MARK: - Stop & Transcribe
|
|
2193
2393
|
|
|
2394
|
+
private func cancelProviderSession() {
|
|
2395
|
+
providerCompletionTask?.cancel()
|
|
2396
|
+
providerCompletionTask = nil
|
|
2397
|
+
providerSession?.cancel()
|
|
2398
|
+
providerSession = nil
|
|
2399
|
+
providerConfiguration = nil
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/// The provider path shares capture, WAV writing and verified paste delivery with the
|
|
2403
|
+
/// legacy recorder. It never enters the legacy CLI, credential or intent-model paths.
|
|
2404
|
+
private func stopWithProvider(
|
|
2405
|
+
recorder: PCMRecordingSource?, pipe: PCMStreamPipe?,
|
|
2406
|
+
session: any RecordingTranscriptionSession,
|
|
2407
|
+
configuration: RecordingProviderSessionConfiguration, audioPath: String?,
|
|
2408
|
+
targetAppBundleIdentifier: String?, targetAppPid: pid_t?,
|
|
2409
|
+
pipelineGeneration: UInt64, pipelineTrace: RecordingPipelineTrace
|
|
2410
|
+
) {
|
|
2411
|
+
providerCompletionTask = Task { [weak self] in
|
|
2412
|
+
// Shutdown and drain belong to this capture even if the user cancels and starts
|
|
2413
|
+
// another one before the native input tap has stopped.
|
|
2414
|
+
await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
|
|
2415
|
+
let pcm = await pipe?.finish() ?? Data()
|
|
2416
|
+
guard !Task.isCancelled, let self,
|
|
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")) }
|
|
2422
|
+
do {
|
|
2423
|
+
guard !pcm.isEmpty, let audioPath else { throw RecordingProviderError.noAudio }
|
|
2424
|
+
let audioURL = URL(fileURLWithPath: audioPath)
|
|
2425
|
+
session.inputEnded()
|
|
2426
|
+
timings.append(pipelineTrace.message(stage: "provider_input_ended"))
|
|
2427
|
+
let writeAudio = self.providerAudioWriter
|
|
2428
|
+
try await Task.detached(priority: .userInitiated) {
|
|
2429
|
+
try writeAudio(pcm, audioURL)
|
|
2430
|
+
}.value
|
|
2431
|
+
try Task.checkCancellation()
|
|
2432
|
+
guard self.recordingGeneration == pipelineGeneration else { return }
|
|
2433
|
+
timings.append(pipelineTrace.message(stage: "wav_write_complete"))
|
|
2434
|
+
let duration = Double(pcm.count) / 48_000
|
|
2435
|
+
self.recordingDuration = duration
|
|
2436
|
+
let result = try await session.finish(RecordingTranscriptionRequest(
|
|
2437
|
+
captureID: configuration.captureID, audioURL: audioURL,
|
|
2438
|
+
duration: duration, language: configuration.language
|
|
2439
|
+
))
|
|
2440
|
+
try Task.checkCancellation()
|
|
2441
|
+
guard self.recordingGeneration == pipelineGeneration else { return }
|
|
2442
|
+
timings.append(pipelineTrace.message(stage: "provider_finish_complete"))
|
|
2443
|
+
let rawText = result.rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2444
|
+
let processed = result.processedText?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2445
|
+
let text = (processed?.isEmpty == false ? processed : nil) ?? rawText
|
|
2446
|
+
guard !text.isEmpty else { throw RecordingProviderError.emptyTranscript }
|
|
2447
|
+
self.isTranscribing = false
|
|
2448
|
+
self.liveTranscriptionText = ""
|
|
2449
|
+
self.activeAudioPath = nil
|
|
2450
|
+
self.providerSession = nil
|
|
2451
|
+
self.providerConfiguration = nil
|
|
2452
|
+
self.providerCompletionTask = nil
|
|
2453
|
+
// Publication precedes delivery. Consumers may persist asynchronously without
|
|
2454
|
+
// delaying paste; this is an in-memory result, not a persistence receipt.
|
|
2455
|
+
self.recentTranscriptions.insert(TranscriptionResult(
|
|
2456
|
+
rawText: rawText, processedText: processed?.isEmpty == false ? processed : nil,
|
|
2457
|
+
timestamp: Date(), projectId: nil, projectName: nil,
|
|
2458
|
+
captureID: configuration.captureID, audioURL: audioURL
|
|
2459
|
+
), at: 0)
|
|
2460
|
+
if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
|
|
2461
|
+
guard self.pipelineDeliveryGate.claimDelivery(for: pipelineGeneration) else { return }
|
|
2462
|
+
self.pasteIntoFrontApp(
|
|
2463
|
+
text, targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2464
|
+
targetAppPid: targetAppPid, restoreClipboard: true,
|
|
2465
|
+
deliveryKind: .ordinaryDictation, captureID: configuration.captureID, pipelineTrace: pipelineTrace,
|
|
2466
|
+
pipelineGeneration: pipelineGeneration, deliveryCompleted: nil
|
|
2467
|
+
)
|
|
2468
|
+
} catch {
|
|
2469
|
+
guard self.recordingGeneration == pipelineGeneration, !Task.isCancelled else { return }
|
|
2470
|
+
session.cancel()
|
|
2471
|
+
self.providerSession = nil
|
|
2472
|
+
self.providerConfiguration = nil
|
|
2473
|
+
self.providerCompletionTask = nil
|
|
2474
|
+
self.activeAudioPath = nil
|
|
2475
|
+
self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
|
|
2476
|
+
// Do not log provider errors: third-party error text can contain a token or
|
|
2477
|
+
// request URL. The host UI receives the localized description for diagnosis.
|
|
2478
|
+
self.isTranscribing = false
|
|
2479
|
+
self.liveTranscriptionText = ""
|
|
2480
|
+
self.statusMessage = error.localizedDescription
|
|
2481
|
+
self.flowPhase = .failed(self.statusMessage)
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2194
2486
|
public func stopAndTranscribe() {
|
|
2195
2487
|
// Stop during the warm-up window has nothing to transcribe — the microphone was opened
|
|
2196
2488
|
// but has not delivered a sample. Abandon visibly instead of spending a transcription
|
|
@@ -2208,12 +2500,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2208
2500
|
|
|
2209
2501
|
let recorder = nativeRecorder
|
|
2210
2502
|
nativeRecorder = nil
|
|
2211
|
-
recorder?.stop()
|
|
2212
2503
|
|
|
2213
2504
|
isRecording = false
|
|
2214
2505
|
isTranscribing = true
|
|
2215
2506
|
|
|
2216
2507
|
guard let captureConfiguration = activeCaptureConfiguration else {
|
|
2508
|
+
recorder?.stop()
|
|
2217
2509
|
realtimeClient?.stop()
|
|
2218
2510
|
realtimeClient = nil
|
|
2219
2511
|
streamingTask?.cancel()
|
|
@@ -2230,6 +2522,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2230
2522
|
let targetAppBundleIdentifier = captureConfiguration.targetAppBundleIdentifier
|
|
2231
2523
|
let targetAppPid = captureConfiguration.targetAppPid
|
|
2232
2524
|
let audioPath = activeAudioPath
|
|
2525
|
+
latestAudioPath = audioPath
|
|
2526
|
+
isPaused = false
|
|
2527
|
+
audioLevel = 0
|
|
2233
2528
|
let pcmStreamPipe = pcmStreamPipe
|
|
2234
2529
|
let client = realtimeClient
|
|
2235
2530
|
let pipelineGeneration = recordingGeneration
|
|
@@ -2239,9 +2534,23 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2239
2534
|
resetRecordingIntent()
|
|
2240
2535
|
self.pcmStreamPipe = nil
|
|
2241
2536
|
|
|
2537
|
+
if let providerSession, let providerConfiguration {
|
|
2538
|
+
stopWithProvider(
|
|
2539
|
+
recorder: recorder, pipe: pcmStreamPipe, session: providerSession,
|
|
2540
|
+
configuration: providerConfiguration, audioPath: audioPath,
|
|
2541
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid,
|
|
2542
|
+
pipelineGeneration: pipelineGeneration, pipelineTrace: pipelineTrace
|
|
2543
|
+
)
|
|
2544
|
+
return
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2242
2547
|
Task {
|
|
2548
|
+
// AVAudioEngine shutdown can block for hundreds of milliseconds. Keep
|
|
2549
|
+
// receiving realtime text and repainting while capture drains completely.
|
|
2550
|
+
await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
|
|
2243
2551
|
if let pcmStreamPipe {
|
|
2244
2552
|
self.recordedPCM = await pcmStreamPipe.finish()
|
|
2553
|
+
self.recordingDuration = Double(self.recordedPCM.count) / 48_000
|
|
2245
2554
|
}
|
|
2246
2555
|
self.log(pipelineTrace.message(
|
|
2247
2556
|
stage: "pcm_drain_complete",
|
|
@@ -3815,10 +4124,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3815
4124
|
activeProjectId: canonicalProjectId,
|
|
3816
4125
|
processingConfiguration: processingConfiguration
|
|
3817
4126
|
)
|
|
3818
|
-
let
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
4127
|
+
let rewriteOperation = Self.makeCommandRewriteOperation(
|
|
4128
|
+
args: rewriteArguments,
|
|
4129
|
+
home: homePath,
|
|
4130
|
+
runCLI: self.commandCLI
|
|
4131
|
+
)
|
|
4132
|
+
let result = await BlockingOperation.run(rewriteOperation)
|
|
3822
4133
|
if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
|
|
3823
4134
|
self.isTranscribing = false
|
|
3824
4135
|
self.liveTranscriptionText = ""
|
|
@@ -3969,7 +4280,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3969
4280
|
_ text: String,
|
|
3970
4281
|
targetAppBundleIdentifier: String? = nil,
|
|
3971
4282
|
targetAppPid: pid_t? = nil,
|
|
3972
|
-
restoreClipboard: Bool = false
|
|
4283
|
+
restoreClipboard: Bool = false,
|
|
4284
|
+
captureID: String? = nil
|
|
3973
4285
|
) {
|
|
3974
4286
|
pasteIntoFrontApp(
|
|
3975
4287
|
text,
|
|
@@ -3977,6 +4289,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3977
4289
|
targetAppPid: targetAppPid,
|
|
3978
4290
|
restoreClipboard: restoreClipboard,
|
|
3979
4291
|
deliveryKind: .manualPaste,
|
|
4292
|
+
captureID: captureID,
|
|
3980
4293
|
pipelineTrace: nil,
|
|
3981
4294
|
pipelineGeneration: nil,
|
|
3982
4295
|
deliveryCompleted: nil
|
|
@@ -3989,6 +4302,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3989
4302
|
targetAppPid: pid_t? = nil,
|
|
3990
4303
|
restoreClipboard: Bool = false,
|
|
3991
4304
|
deliveryKind: PasteDeliveryKind,
|
|
4305
|
+
captureID: String? = nil,
|
|
3992
4306
|
selectionToken: AccessibilitySelectionToken? = nil,
|
|
3993
4307
|
pipelineTrace: RecordingPipelineTrace?,
|
|
3994
4308
|
pipelineGeneration: UInt64?,
|
|
@@ -4007,18 +4321,31 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4007
4321
|
deliveryCompleted?()
|
|
4008
4322
|
return
|
|
4009
4323
|
}
|
|
4324
|
+
if !autoPasteEnabled && deliveryKind != .manualPaste {
|
|
4325
|
+
updateDeliveryStatus("Transcript ready — auto-paste is off", kind: .success, pipelineGeneration: pipelineGeneration)
|
|
4326
|
+
deliveryCompleted?()
|
|
4327
|
+
return
|
|
4328
|
+
}
|
|
4010
4329
|
if let pasteInterceptorForTesting {
|
|
4011
4330
|
pasteInterceptorForTesting(text, deliveryKind, pipelineGeneration)
|
|
4012
4331
|
deliveryCompleted?()
|
|
4013
4332
|
return
|
|
4014
4333
|
}
|
|
4015
|
-
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
|
+
}
|
|
4016
4341
|
var previousClipboard: ClipboardSnapshot?
|
|
4017
4342
|
|
|
4018
4343
|
let accessibility = protectedOperationTrust()
|
|
4019
4344
|
guard accessibility.trusted else {
|
|
4020
4345
|
let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
|
|
4021
|
-
let copied = shouldCopy &&
|
|
4346
|
+
let copied = shouldCopy && pasteFallbackWriter(text)
|
|
4347
|
+
appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
|
|
4348
|
+
pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
|
|
4022
4349
|
log("paste blocked by accessibility permission")
|
|
4023
4350
|
let message = if deliveryKind == .commandRewrite {
|
|
4024
4351
|
"Paste cancelled because Accessibility permission changed"
|
|
@@ -4046,18 +4373,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4046
4373
|
)
|
|
4047
4374
|
|
|
4048
4375
|
guard let app = targetApp else {
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
log("paste target app not found")
|
|
4052
|
-
updateDeliveryStatus(
|
|
4053
|
-
deliveryKind == .commandRewrite
|
|
4054
|
-
? "Paste cancelled because the target app is unavailable"
|
|
4055
|
-
: copied
|
|
4056
|
-
? "Copied — no target app found"
|
|
4057
|
-
: "Transcription ready, but the clipboard could not be updated",
|
|
4058
|
-
kind: .failure,
|
|
4059
|
-
pipelineGeneration: pipelineGeneration
|
|
4060
|
-
)
|
|
4376
|
+
completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
|
|
4377
|
+
pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
|
|
4061
4378
|
deliveryCompleted?()
|
|
4062
4379
|
return
|
|
4063
4380
|
}
|
|
@@ -4176,6 +4493,14 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4176
4493
|
detail: "chars=\(transaction.text.count)"
|
|
4177
4494
|
))
|
|
4178
4495
|
}
|
|
4496
|
+
let verified = Self.pasteTraceStage(for: outcome) == "paste_delivery_confirmed"
|
|
4497
|
+
let deliveryStatus = Self.recentPasteDeliveryStatus(for: outcome)
|
|
4498
|
+
self.recentPastes.insert(RecentPaste(
|
|
4499
|
+
text: transaction.text, bundleIdentifier: app.bundleIdentifier, appName: app.localizedName ?? "Application",
|
|
4500
|
+
location: "Focused field", status: verified ? "Pasted" : "Unconfirmed", verified: verified,
|
|
4501
|
+
captureID: captureID, deliveryStatus: deliveryStatus
|
|
4502
|
+
), at: 0)
|
|
4503
|
+
if self.recentPastes.count > 50 { self.recentPastes.removeLast() }
|
|
4179
4504
|
deliveryCompleted?()
|
|
4180
4505
|
let message = switch outcome {
|
|
4181
4506
|
case .pasted: "Pasted (\(transaction.text.count) chars)"
|
|
@@ -4521,6 +4846,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4521
4846
|
/// Pipeline-timing stage name. `paste_posted` used to be emitted for every posted
|
|
4522
4847
|
/// keystroke, which made the timing trace read like a delivery record; the three delivery
|
|
4523
4848
|
/// verdicts are now distinct stages.
|
|
4849
|
+
nonisolated static func recentPasteDeliveryStatus(for outcome: PasteDeliveryOutcome) -> RecentPasteDeliveryStatus {
|
|
4850
|
+
switch outcome {
|
|
4851
|
+
case .pasted: .confirmed
|
|
4852
|
+
case .deliveredUnverified: .unconfirmed
|
|
4853
|
+
case .deliveryNotObserved, .secureInputActive, .targetUnavailable,
|
|
4854
|
+
.clipboardOwnershipLost, .clipboardWriteFailed, .eventPostFailed: .notDelivered
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4524
4858
|
nonisolated static func pasteTraceStage(for outcome: PasteDeliveryOutcome) -> String {
|
|
4525
4859
|
switch outcome {
|
|
4526
4860
|
case .pasted: "paste_delivery_confirmed"
|
|
@@ -4563,15 +4897,37 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4563
4897
|
setBlockedReason(nil, for: .pressConsumed)
|
|
4564
4898
|
}
|
|
4565
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
|
+
|
|
4566
4923
|
private func selectedRunningPasteTarget(
|
|
4567
4924
|
targetAppBundleIdentifier: String?,
|
|
4568
4925
|
targetAppPid: pid_t?,
|
|
4569
4926
|
frontmostPid: pid_t?,
|
|
4570
4927
|
pipelineGeneration: UInt64?
|
|
4571
4928
|
) -> NSRunningApplication? {
|
|
4572
|
-
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
4573
4929
|
let runningApps = NSWorkspace.shared.runningApplications
|
|
4574
|
-
let candidates = runningApps.map {
|
|
4930
|
+
let candidates = runningApps.filter { !$0.isTerminated }.map {
|
|
4575
4931
|
PasteTargetCandidate(
|
|
4576
4932
|
pid: $0.processIdentifier,
|
|
4577
4933
|
bundleIdentifier: $0.bundleIdentifier,
|
|
@@ -4579,23 +4935,30 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4579
4935
|
launchDate: $0.launchDate
|
|
4580
4936
|
)
|
|
4581
4937
|
}
|
|
4582
|
-
let
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
let selectedTarget = Self.selectPasteTarget(
|
|
4586
|
-
candidates: candidates,
|
|
4587
|
-
currentPid: myPID,
|
|
4588
|
-
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
4589
|
-
targetPid: targetAppPid,
|
|
4590
|
-
frontmostPid: frontmostPid,
|
|
4591
|
-
requiredProcessIdentity: requiredProcessIdentity,
|
|
4592
|
-
requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
|
|
4938
|
+
let selectedTarget = resolvePasteTarget(
|
|
4939
|
+
candidates: candidates, targetBundleIdentifier: targetAppBundleIdentifier,
|
|
4940
|
+
targetPid: targetAppPid, frontmostPid: frontmostPid, pipelineGeneration: pipelineGeneration
|
|
4593
4941
|
)
|
|
4594
4942
|
return selectedTarget.flatMap { selected in
|
|
4595
4943
|
runningApps.first { $0.processIdentifier == selected.pid }
|
|
4596
4944
|
}
|
|
4597
4945
|
}
|
|
4598
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
|
+
|
|
4599
4962
|
nonisolated static func selectPasteTarget(
|
|
4600
4963
|
candidates: [PasteTargetCandidate],
|
|
4601
4964
|
currentPid: pid_t,
|
|
@@ -4754,12 +5117,15 @@ enum CLIRunner: Sendable {
|
|
|
4754
5117
|
|
|
4755
5118
|
enum ExecutionError: Error, LocalizedError, Equatable {
|
|
4756
5119
|
case timedOut(executable: String, seconds: TimeInterval)
|
|
5120
|
+
case deadlineExhausted
|
|
4757
5121
|
case captureFailed(operation: CaptureOperation, code: Int32)
|
|
4758
5122
|
|
|
4759
5123
|
var errorDescription: String? {
|
|
4760
5124
|
switch self {
|
|
4761
5125
|
case let .timedOut(executable, seconds):
|
|
4762
5126
|
return "Command timed out after \(seconds.formatted()) seconds: \(executable)"
|
|
5127
|
+
case .deadlineExhausted:
|
|
5128
|
+
return "Command timed out: insufficient time remains to start safely."
|
|
4763
5129
|
case let .captureFailed(operation, code):
|
|
4764
5130
|
return "Failed to capture command output while \(operation.description): \(String(cString: strerror(code)))"
|
|
4765
5131
|
}
|
|
@@ -4788,24 +5154,76 @@ enum CLIRunner: Sendable {
|
|
|
4788
5154
|
_ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
|
|
4789
5155
|
) throws -> Int32
|
|
4790
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
|
+
|
|
4791
5192
|
static func run(
|
|
4792
5193
|
_ args: [String],
|
|
4793
5194
|
home: String,
|
|
4794
5195
|
timeout: TimeInterval = 120,
|
|
4795
|
-
totalWallClockBudget: TimeInterval? = nil
|
|
5196
|
+
totalWallClockBudget: TimeInterval? = nil,
|
|
5197
|
+
environment suppliedEnvironment: [String: String]? = nil,
|
|
5198
|
+
wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
|
|
5199
|
+
environmentProvider: (() throws -> [String: String])? = nil
|
|
4796
5200
|
) -> String {
|
|
4797
|
-
let
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
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
|
+
}
|
|
4802
5209
|
do {
|
|
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(
|
|
5215
|
+
base: OpenAIAPIKeyStore.childEnvironment(base: ProcessInfo.processInfo.environment.merging([
|
|
5216
|
+
"PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
5217
|
+
]) { _, new in new }, homePath: home)
|
|
5218
|
+
)
|
|
5219
|
+
try requireStartBudget(deadline)
|
|
4803
5220
|
let output = try runExecutable(
|
|
4804
5221
|
command.executable,
|
|
4805
5222
|
arguments: arguments,
|
|
4806
5223
|
environment: environment,
|
|
4807
5224
|
executionTimeout: timeout,
|
|
4808
|
-
totalWallClockBudget: totalWallClockBudget
|
|
5225
|
+
totalWallClockBudget: totalWallClockBudget,
|
|
5226
|
+
wallClockDeadline: deadline
|
|
4809
5227
|
)
|
|
4810
5228
|
if output.terminationStatus != 0 {
|
|
4811
5229
|
let details = output.stderr.isEmpty ? output.stdout : output.stderr
|
|
@@ -4853,6 +5271,7 @@ enum CLIRunner: Sendable {
|
|
|
4853
5271
|
forceKillGracePeriod: TimeInterval = 1,
|
|
4854
5272
|
pipeDrainTimeout: TimeInterval = 2,
|
|
4855
5273
|
totalWallClockBudget: TimeInterval? = nil,
|
|
5274
|
+
wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
|
|
4856
5275
|
beforeExecutionDeadline: (() -> Void)? = nil,
|
|
4857
5276
|
lifecycleObserver: ((ProcessLifecycleEvent) -> Void)? = nil,
|
|
4858
5277
|
leaderReaper: LeaderReaper? = nil,
|
|
@@ -4866,23 +5285,24 @@ enum CLIRunner: Sendable {
|
|
|
4866
5285
|
precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
|
|
4867
5286
|
}
|
|
4868
5287
|
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
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) }
|
|
4872
5295
|
func clampedToWallClockBudget(
|
|
4873
5296
|
_ phaseTimeout: TimeInterval,
|
|
4874
5297
|
reserving reserve: TimeInterval = 0
|
|
4875
5298
|
) -> TimeInterval {
|
|
4876
5299
|
guard let wallClockDeadline else { return phaseTimeout }
|
|
4877
|
-
|
|
4878
|
-
let remaining = wallClockDeadline > now
|
|
4879
|
-
? Double(wallClockDeadline - now) / 1_000_000_000 - reserve
|
|
4880
|
-
: 0
|
|
4881
|
-
return max(0, min(phaseTimeout, remaining))
|
|
5300
|
+
return min(phaseTimeout, wallClockDeadline.remaining(reserving: reserve))
|
|
4882
5301
|
}
|
|
4883
5302
|
let contractualExecutionTimeout = totalWallClockBudget
|
|
4884
5303
|
.map { min(executionTimeout, $0 - wallClockCleanupReserve) } ?? executionTimeout
|
|
4885
5304
|
|
|
5305
|
+
try requireStartBudget(wallClockDeadline)
|
|
4886
5306
|
let stdoutReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
|
|
4887
5307
|
let stderrReader: PipeCaptureReader
|
|
4888
5308
|
do {
|
|
@@ -4896,6 +5316,7 @@ enum CLIRunner: Sendable {
|
|
|
4896
5316
|
|
|
4897
5317
|
let processIdentifier: pid_t
|
|
4898
5318
|
do {
|
|
5319
|
+
try requireStartBudget(wallClockDeadline)
|
|
4899
5320
|
processIdentifier = try spawnProcessGroup(
|
|
4900
5321
|
executable,
|
|
4901
5322
|
arguments: arguments,
|
|
@@ -5075,6 +5496,30 @@ enum CLIRunner: Sendable {
|
|
|
5075
5496
|
)
|
|
5076
5497
|
}
|
|
5077
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
|
+
|
|
5078
5523
|
var attributes: posix_spawnattr_t?
|
|
5079
5524
|
try checkPOSIX(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes")
|
|
5080
5525
|
defer { posix_spawnattr_destroy(&attributes) }
|
|
@@ -5091,7 +5536,10 @@ enum CLIRunner: Sendable {
|
|
|
5091
5536
|
posix_spawnattr_setsigmask(&attributes, &unblockedSignals),
|
|
5092
5537
|
operation: "unblock command signals"
|
|
5093
5538
|
)
|
|
5094
|
-
|
|
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
|
|
5095
5543
|
try checkPOSIX(
|
|
5096
5544
|
posix_spawnattr_setflags(&attributes, Int16(spawnFlags)),
|
|
5097
5545
|
operation: "configure command process group"
|
|
@@ -5611,7 +6059,7 @@ enum CLIRunner: Sendable {
|
|
|
5611
6059
|
}
|
|
5612
6060
|
}
|
|
5613
6061
|
|
|
5614
|
-
static func parseError(_ output: String) -> String? {
|
|
6062
|
+
static func parseError(_ output: String, serviceAPI: Bool = false) -> String? {
|
|
5615
6063
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
5616
6064
|
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
5617
6065
|
let message = NativeErrorSanitizer.sanitize(
|
|
@@ -5620,10 +6068,12 @@ enum CLIRunner: Sendable {
|
|
|
5620
6068
|
let lowercased = message.lowercased()
|
|
5621
6069
|
if lowercased.contains("401") || lowercased.contains("incorrect api key")
|
|
5622
6070
|
|| lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
|
|
6071
|
+
if serviceAPI { return "Recordings API authentication failed — check the API connection in Settings" }
|
|
5623
6072
|
return "OpenAI API key invalid or expired — update it in Recordings Settings"
|
|
5624
6073
|
}
|
|
5625
6074
|
if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
|
|
5626
6075
|
|| lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
|
|
6076
|
+
if serviceAPI { return "Recordings API request limit reached — try again later" }
|
|
5627
6077
|
return "OpenAI quota exceeded — check the OpenAI account billing"
|
|
5628
6078
|
}
|
|
5629
6079
|
if message.contains("OpenAI API key not configured") {
|