@hasna/recordings 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +349 -69
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +59 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1919 -98
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1973 -236
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
|
@@ -1,47 +1,494 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
@preconcurrency import ApplicationServices
|
|
3
|
+
import Darwin
|
|
3
4
|
import SwiftUI
|
|
4
5
|
@preconcurrency import KeyboardShortcuts
|
|
5
6
|
|
|
6
7
|
// MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
|
|
7
8
|
|
|
8
9
|
extension KeyboardShortcuts.Name {
|
|
9
|
-
@MainActor static let toggleRecording = Self("toggleRecording", default: .init(.f5))
|
|
10
|
+
@MainActor public static let toggleRecording = Self("toggleRecording", default: .init(.f5))
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
// MARK: -
|
|
13
|
+
// MARK: - Transcription Result
|
|
14
|
+
|
|
15
|
+
public struct TranscriptionResult: Identifiable, Sendable {
|
|
16
|
+
public let id = UUID()
|
|
17
|
+
let rawText: String
|
|
18
|
+
let processedText: String?
|
|
19
|
+
let timestamp: Date
|
|
20
|
+
let projectId: String?
|
|
21
|
+
let projectName: String?
|
|
22
|
+
public var displayText: String { processedText ?? rawText }
|
|
23
|
+
|
|
24
|
+
init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?) {
|
|
25
|
+
self.rawText = rawText
|
|
26
|
+
self.processedText = processedText
|
|
27
|
+
self.timestamp = timestamp
|
|
28
|
+
self.projectId = projectId
|
|
29
|
+
self.projectName = projectName
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
struct RealtimeFastPathSaveResult: Sendable {
|
|
34
|
+
let text: String?
|
|
35
|
+
let error: String?
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
enum FallbackCompletionAction: Equatable, Sendable {
|
|
39
|
+
case deliver(String)
|
|
40
|
+
case fail(String)
|
|
41
|
+
case backgroundRecovered
|
|
42
|
+
case backgroundFailed(String)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
struct RecordingProcessingConfiguration: Equatable, Sendable {
|
|
46
|
+
let transcriptionPrompt: String
|
|
47
|
+
let transcriberPrompt: String
|
|
48
|
+
let postProcessingMode: String
|
|
49
|
+
let transcriptionLanguage: String
|
|
50
|
+
let transcriptionModel: String
|
|
51
|
+
let transcriberModel: String
|
|
52
|
+
let enhancementModel: String
|
|
53
|
+
let intentModel: String
|
|
54
|
+
let intentDetectionEnabled: Bool
|
|
55
|
+
let enhanceTriggersJSON: String
|
|
56
|
+
let keywordTransformsJSON: String
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Accessibility state frozen near recording start. Captured on a detached task so the
|
|
60
|
+
/// recorder never waits on Accessibility IPC to a possibly-unresponsive target app.
|
|
61
|
+
struct RecordingStartAXSnapshot: Sendable {
|
|
62
|
+
let selectionToken: AccessibilitySelectionToken?
|
|
63
|
+
let focusedWindowTitle: String?
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Everything about the recording that is only known once the start-time Accessibility
|
|
67
|
+
/// snapshot and project auto-selection have resolved. Bound to one recording generation via
|
|
68
|
+
/// the task stored in `RecordingCaptureConfiguration`.
|
|
69
|
+
struct RecordingStartResolvedContext: Sendable {
|
|
70
|
+
let selectionToken: AccessibilitySelectionToken?
|
|
71
|
+
let canonicalProjectId: String?
|
|
72
|
+
let displayProjectId: String?
|
|
73
|
+
let activeProjectName: String?
|
|
74
|
+
let processing: RecordingProcessingConfiguration
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
struct RecordingCaptureConfiguration: Sendable {
|
|
78
|
+
let targetAppBundleIdentifier: String?
|
|
79
|
+
let targetAppPid: pid_t?
|
|
80
|
+
/// Resolves the frozen selection token, project binding, and processing configuration.
|
|
81
|
+
/// Started at recording start and awaited only after the recorder has stopped, so
|
|
82
|
+
/// capture latency can never delay microphone start.
|
|
83
|
+
let startContext: Task<RecordingStartResolvedContext, Never>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// The frontmost-application identity `startRecording` freezes. Abstracted from
|
|
87
|
+
/// `NSWorkspace` so production-path tests can drive recording starts headlessly.
|
|
88
|
+
struct FrontmostAppSnapshot: Equatable, Sendable {
|
|
89
|
+
let pid: pid_t
|
|
90
|
+
let bundleIdentifier: String?
|
|
91
|
+
let launchDate: Date?
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// The one capability `RecordingEngine` needs from an audio recorder; lets tests run the
|
|
95
|
+
/// production start path without microphone hardware or TCC grants.
|
|
96
|
+
protocol PCMRecordingSource: AnyObject {
|
|
97
|
+
func start() throws
|
|
98
|
+
func stop()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
extension NativePCMRecorder: PCMRecordingSource {}
|
|
102
|
+
|
|
103
|
+
struct AccessibilitySelectionIdentity<Element: Equatable & Sendable>: Equatable, Sendable {
|
|
104
|
+
let element: Element
|
|
105
|
+
let window: Element
|
|
106
|
+
let documentIdentifier: String
|
|
107
|
+
let rangeLocation: Int
|
|
108
|
+
let rangeLength: Int
|
|
109
|
+
let selectedText: String
|
|
110
|
+
|
|
111
|
+
func matches(
|
|
112
|
+
element currentElement: Element,
|
|
113
|
+
window currentWindow: Element,
|
|
114
|
+
documentIdentifier currentDocumentIdentifier: String,
|
|
115
|
+
rangeLocation currentRangeLocation: Int,
|
|
116
|
+
rangeLength currentRangeLength: Int,
|
|
117
|
+
selectedText currentSelectedText: String
|
|
118
|
+
) -> Bool {
|
|
119
|
+
element == currentElement
|
|
120
|
+
&& window == currentWindow
|
|
121
|
+
&& documentIdentifier == currentDocumentIdentifier
|
|
122
|
+
&& rangeLocation == currentRangeLocation
|
|
123
|
+
&& rangeLength == currentRangeLength
|
|
124
|
+
&& selectedText == currentSelectedText
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private struct AXElementIdentity: Equatable, @unchecked Sendable {
|
|
129
|
+
let element: AXUIElement
|
|
13
130
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
131
|
+
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
132
|
+
let element = lhs.element
|
|
133
|
+
let currentElement = rhs.element
|
|
134
|
+
return CFEqual(element, currentElement)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Captured off the MainActor (Accessibility calls are Mach IPC and thread-safe); the token
|
|
139
|
+
/// itself is immutable after capture, so later MainActor revalidation reads are safe.
|
|
140
|
+
final class AccessibilitySelectionToken: @unchecked Sendable {
|
|
141
|
+
private let identity: AccessibilitySelectionIdentity<AXElementIdentity>
|
|
142
|
+
|
|
143
|
+
var selectedText: String { identity.selectedText }
|
|
144
|
+
|
|
145
|
+
private init(
|
|
146
|
+
element: AXUIElement,
|
|
147
|
+
window: AXUIElement,
|
|
148
|
+
documentIdentifier: String,
|
|
149
|
+
range: CFRange,
|
|
150
|
+
selectedText: String
|
|
151
|
+
) {
|
|
152
|
+
identity = AccessibilitySelectionIdentity(
|
|
153
|
+
element: AXElementIdentity(element: element),
|
|
154
|
+
window: AXElementIdentity(element: window),
|
|
155
|
+
documentIdentifier: documentIdentifier,
|
|
156
|
+
rangeLocation: range.location,
|
|
157
|
+
rangeLength: range.length,
|
|
158
|
+
selectedText: selectedText
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Cap for each Accessibility IPC round trip during capture. Capture runs off the
|
|
163
|
+
/// recorder-start path, but revalidation still happens synchronously before a paste or
|
|
164
|
+
/// rewrite, so a beachballing target app must never stall behind the multi-second
|
|
165
|
+
/// system default.
|
|
166
|
+
static let captureMessagingTimeout: Float = 0.25
|
|
167
|
+
|
|
168
|
+
#if DEBUG
|
|
169
|
+
/// Test-only token whose AX elements point at this process; revalidation against a real
|
|
170
|
+
/// target fails closed, which is exactly what delivery tests need to observe.
|
|
171
|
+
static func unsafeTestToken(selectedText: String) -> AccessibilitySelectionToken {
|
|
172
|
+
let element = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier)
|
|
173
|
+
return AccessibilitySelectionToken(
|
|
174
|
+
element: element,
|
|
175
|
+
window: element,
|
|
176
|
+
documentIdentifier: "document:test",
|
|
177
|
+
range: CFRange(location: 0, length: (selectedText as NSString).length),
|
|
178
|
+
selectedText: selectedText
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
#endif
|
|
182
|
+
|
|
183
|
+
static func capture(for pid: pid_t) -> AccessibilitySelectionToken? {
|
|
184
|
+
let application = AXUIElementCreateApplication(pid)
|
|
185
|
+
AXUIElementSetMessagingTimeout(application, captureMessagingTimeout)
|
|
186
|
+
var focusedElementRef: CFTypeRef?
|
|
187
|
+
guard AXUIElementCopyAttributeValue(
|
|
188
|
+
application,
|
|
189
|
+
kAXFocusedUIElementAttribute as CFString,
|
|
190
|
+
&focusedElementRef
|
|
191
|
+
) == .success,
|
|
192
|
+
let focusedElementRef,
|
|
193
|
+
CFGetTypeID(focusedElementRef) == AXUIElementGetTypeID() else { return nil }
|
|
194
|
+
let focusedElement = focusedElementRef as! AXUIElement
|
|
195
|
+
AXUIElementSetMessagingTimeout(focusedElement, captureMessagingTimeout)
|
|
196
|
+
|
|
197
|
+
var focusedWindowRef: CFTypeRef?
|
|
198
|
+
guard AXUIElementCopyAttributeValue(
|
|
199
|
+
application,
|
|
200
|
+
kAXFocusedWindowAttribute as CFString,
|
|
201
|
+
&focusedWindowRef
|
|
202
|
+
) == .success,
|
|
203
|
+
let focusedWindowRef,
|
|
204
|
+
CFGetTypeID(focusedWindowRef) == AXUIElementGetTypeID() else { return nil }
|
|
205
|
+
let focusedWindow = focusedWindowRef as! AXUIElement
|
|
206
|
+
AXUIElementSetMessagingTimeout(focusedWindow, captureMessagingTimeout)
|
|
207
|
+
|
|
208
|
+
let documentIdentifier = stringAttribute(
|
|
209
|
+
kAXDocumentAttribute as CFString,
|
|
210
|
+
on: focusedElement
|
|
211
|
+
) ?? stringAttribute(
|
|
212
|
+
kAXDocumentAttribute as CFString,
|
|
213
|
+
on: focusedWindow
|
|
214
|
+
)
|
|
215
|
+
guard let contextIdentifier = RecordingEngine.stableAccessibilityContextIdentifier(
|
|
216
|
+
documentIdentifier: documentIdentifier,
|
|
217
|
+
elementIdentifier: stringAttribute(kAXIdentifierAttribute as CFString, on: focusedElement)
|
|
218
|
+
) else { return nil }
|
|
219
|
+
|
|
220
|
+
guard let selectedRange = selectedRange(for: focusedElement),
|
|
221
|
+
let selectedText = selectedText(for: focusedElement, range: selectedRange) else {
|
|
222
|
+
return nil
|
|
223
|
+
}
|
|
224
|
+
return AccessibilitySelectionToken(
|
|
225
|
+
element: focusedElement,
|
|
226
|
+
window: focusedWindow,
|
|
227
|
+
documentIdentifier: contextIdentifier,
|
|
228
|
+
range: selectedRange,
|
|
229
|
+
selectedText: selectedText
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func matchesCurrentSelection(for pid: pid_t) -> Bool {
|
|
234
|
+
guard let current = Self.capture(for: pid) else { return false }
|
|
235
|
+
return identity.matches(
|
|
236
|
+
element: current.identity.element,
|
|
237
|
+
window: current.identity.window,
|
|
238
|
+
documentIdentifier: current.identity.documentIdentifier,
|
|
239
|
+
rangeLocation: current.identity.rangeLocation,
|
|
240
|
+
rangeLength: current.identity.rangeLength,
|
|
241
|
+
selectedText: current.identity.selectedText
|
|
242
|
+
)
|
|
243
|
+
}
|
|
18
244
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
case .dictation: return "text.bubble.fill"
|
|
24
|
-
case .command: return "wand.and.stars"
|
|
245
|
+
private static func stringAttribute(_ attribute: CFString, on element: AXUIElement) -> String? {
|
|
246
|
+
var valueRef: CFTypeRef?
|
|
247
|
+
guard AXUIElementCopyAttributeValue(element, attribute, &valueRef) == .success else {
|
|
248
|
+
return nil
|
|
25
249
|
}
|
|
250
|
+
return valueRef as? String
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private static func selectedRange(for element: AXUIElement) -> CFRange? {
|
|
254
|
+
var rangeRef: CFTypeRef?
|
|
255
|
+
guard AXUIElementCopyAttributeValue(
|
|
256
|
+
element,
|
|
257
|
+
kAXSelectedTextRangeAttribute as CFString,
|
|
258
|
+
&rangeRef
|
|
259
|
+
) == .success,
|
|
260
|
+
let rangeRef,
|
|
261
|
+
CFGetTypeID(rangeRef) == AXValueGetTypeID() else { return nil }
|
|
262
|
+
|
|
263
|
+
let rangeValue = rangeRef as! AXValue
|
|
264
|
+
var selectedRange = CFRange()
|
|
265
|
+
guard AXValueGetType(rangeValue) == .cfRange,
|
|
266
|
+
AXValueGetValue(rangeValue, .cfRange, &selectedRange),
|
|
267
|
+
selectedRange.location >= 0,
|
|
268
|
+
selectedRange.length > 0 else { return nil }
|
|
269
|
+
return selectedRange
|
|
26
270
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
271
|
+
|
|
272
|
+
private static func selectedText(for element: AXUIElement, range: CFRange) -> String? {
|
|
273
|
+
var selectedTextRef: CFTypeRef?
|
|
274
|
+
if AXUIElementCopyAttributeValue(
|
|
275
|
+
element,
|
|
276
|
+
kAXSelectedTextAttribute as CFString,
|
|
277
|
+
&selectedTextRef
|
|
278
|
+
) == .success,
|
|
279
|
+
let selectedText = selectedTextRef as? String {
|
|
280
|
+
return (selectedText as NSString).length == range.length ? selectedText : nil
|
|
32
281
|
}
|
|
282
|
+
|
|
283
|
+
var valueRef: CFTypeRef?
|
|
284
|
+
guard AXUIElementCopyAttributeValue(
|
|
285
|
+
element,
|
|
286
|
+
kAXValueAttribute as CFString,
|
|
287
|
+
&valueRef
|
|
288
|
+
) == .success,
|
|
289
|
+
let value = valueRef as? String else { return nil }
|
|
290
|
+
let valueLength = (value as NSString).length
|
|
291
|
+
guard range.location <= valueLength,
|
|
292
|
+
range.length <= valueLength - range.location else { return nil }
|
|
293
|
+
return (value as NSString).substring(
|
|
294
|
+
with: NSRange(location: range.location, length: range.length)
|
|
295
|
+
)
|
|
33
296
|
}
|
|
34
297
|
}
|
|
35
298
|
|
|
36
|
-
|
|
299
|
+
struct PasteDeliveryTransaction: Equatable, Sendable {
|
|
300
|
+
let id: UUID
|
|
301
|
+
let text: String
|
|
302
|
+
let generation: UInt64?
|
|
303
|
+
}
|
|
37
304
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
305
|
+
enum PasteDeliveryOutcome: Equatable, Sendable {
|
|
306
|
+
case pasted
|
|
307
|
+
case targetUnavailable
|
|
308
|
+
case clipboardOwnershipLost
|
|
309
|
+
case clipboardWriteFailed
|
|
310
|
+
case eventPostFailed
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
struct PasteboardWriteResult: Equatable, Sendable {
|
|
314
|
+
let verified: Bool
|
|
315
|
+
let ownershipChangeCount: Int
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Outcome of revalidating the frozen rewrite target immediately before a rewrite runs.
|
|
319
|
+
/// Anything but a live, matching selection fails the rewrite closed.
|
|
320
|
+
enum RewriteTargetResolution: Equatable, Sendable {
|
|
321
|
+
case selection(String)
|
|
322
|
+
case targetAppMissing
|
|
323
|
+
case selectionUnavailable
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@MainActor
|
|
327
|
+
final class PasteTransactionCoordinator {
|
|
328
|
+
private enum State: Equatable {
|
|
329
|
+
case idle
|
|
330
|
+
case scheduled(UUID)
|
|
331
|
+
case settling(UUID)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
typealias ScheduledOperation = @MainActor @Sendable () -> Void
|
|
335
|
+
typealias Scheduler = @MainActor @Sendable (TimeInterval, @escaping ScheduledOperation) -> Void
|
|
336
|
+
typealias PayloadWriter = @MainActor @Sendable (String) -> PasteboardWriteResult
|
|
337
|
+
typealias PastePoster = @MainActor @Sendable () -> Bool
|
|
338
|
+
typealias WriteObserver = @MainActor @Sendable (PasteboardWriteResult) -> Void
|
|
339
|
+
typealias Completion = @MainActor @Sendable (PasteDeliveryTransaction, PasteDeliveryOutcome) -> Void
|
|
340
|
+
typealias Settlement = @MainActor @Sendable (PasteDeliveryTransaction, PasteDeliveryOutcome) -> Void
|
|
341
|
+
|
|
342
|
+
private let schedule: Scheduler
|
|
343
|
+
private let writeAndVerify: PayloadWriter
|
|
344
|
+
private let postPaste: PastePoster
|
|
345
|
+
/// Fires immediately before `hasPendingTransaction` changes value. The settlement hop
|
|
346
|
+
/// back to idle runs on its own scheduled turn with no other state write, so an owner
|
|
347
|
+
/// deriving gates from this coordinator (e.g. `canStartRecording`) must publish here or
|
|
348
|
+
/// its observers never recompute after settlement.
|
|
349
|
+
var pendingTransactionWillChange: (@MainActor () -> Void)?
|
|
350
|
+
private var state: State = .idle {
|
|
351
|
+
willSet {
|
|
352
|
+
if (newValue == .idle) != (state == .idle) {
|
|
353
|
+
pendingTransactionWillChange?()
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
init(
|
|
359
|
+
schedule: @escaping Scheduler,
|
|
360
|
+
writeAndVerify: @escaping PayloadWriter,
|
|
361
|
+
postPaste: @escaping PastePoster
|
|
362
|
+
) {
|
|
363
|
+
self.schedule = schedule
|
|
364
|
+
self.writeAndVerify = writeAndVerify
|
|
365
|
+
self.postPaste = postPaste
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
var hasPendingTransaction: Bool {
|
|
369
|
+
state != .idle
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
@discardableResult
|
|
373
|
+
func submit(
|
|
374
|
+
text: String,
|
|
375
|
+
generation: UInt64?,
|
|
376
|
+
delay: TimeInterval,
|
|
377
|
+
settlementDelay: TimeInterval = 0,
|
|
378
|
+
targetIsReady: @escaping @MainActor @Sendable () -> Bool = { true },
|
|
379
|
+
payloadIsReady: @escaping @MainActor @Sendable () -> Bool = { true },
|
|
380
|
+
prepare: @escaping ScheduledOperation = {},
|
|
381
|
+
writeAttempted: @escaping WriteObserver = { _ in },
|
|
382
|
+
completion: @escaping Completion,
|
|
383
|
+
settlement: @escaping Settlement = { _, _ in }
|
|
384
|
+
) -> Bool {
|
|
385
|
+
guard state == .idle else { return false }
|
|
386
|
+
let transaction = PasteDeliveryTransaction(id: UUID(), text: text, generation: generation)
|
|
387
|
+
state = .scheduled(transaction.id)
|
|
388
|
+
schedule(delay) { [weak self] in
|
|
389
|
+
guard let self, self.state == .scheduled(transaction.id) else { return }
|
|
390
|
+
self.state = .settling(transaction.id)
|
|
391
|
+
guard targetIsReady() else {
|
|
392
|
+
settlement(transaction, .targetUnavailable)
|
|
393
|
+
self.state = .idle
|
|
394
|
+
completion(transaction, .targetUnavailable)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
prepare()
|
|
398
|
+
guard targetIsReady() else {
|
|
399
|
+
settlement(transaction, .targetUnavailable)
|
|
400
|
+
self.state = .idle
|
|
401
|
+
completion(transaction, .targetUnavailable)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
let writeResult = self.writeAndVerify(transaction.text)
|
|
405
|
+
writeAttempted(writeResult)
|
|
406
|
+
guard writeResult.verified else {
|
|
407
|
+
settlement(transaction, .clipboardWriteFailed)
|
|
408
|
+
self.state = .idle
|
|
409
|
+
completion(transaction, .clipboardWriteFailed)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
guard targetIsReady() else {
|
|
413
|
+
settlement(transaction, .targetUnavailable)
|
|
414
|
+
self.state = .idle
|
|
415
|
+
completion(transaction, .targetUnavailable)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
guard payloadIsReady() else {
|
|
419
|
+
settlement(transaction, .clipboardOwnershipLost)
|
|
420
|
+
self.state = .idle
|
|
421
|
+
completion(transaction, .clipboardOwnershipLost)
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
guard self.postPaste() else {
|
|
425
|
+
settlement(transaction, .eventPostFailed)
|
|
426
|
+
self.state = .idle
|
|
427
|
+
completion(transaction, .eventPostFailed)
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
completion(transaction, .pasted)
|
|
431
|
+
guard settlementDelay > 0 else {
|
|
432
|
+
settlement(transaction, .pasted)
|
|
433
|
+
self.state = .idle
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
self.schedule(settlementDelay) { [weak self] in
|
|
437
|
+
guard let self, self.state == .settling(transaction.id) else { return }
|
|
438
|
+
settlement(transaction, .pasted)
|
|
439
|
+
self.state = .idle
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return true
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
struct PipelineDeliveryGate: Sendable {
|
|
447
|
+
private var pendingGenerations = Set<UInt64>()
|
|
448
|
+
private var highestClaimedGeneration: UInt64?
|
|
449
|
+
|
|
450
|
+
mutating func registerPipeline(_ generation: UInt64) {
|
|
451
|
+
pendingGenerations.insert(generation)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
mutating func abandonPipeline(_ generation: UInt64) {
|
|
455
|
+
pendingGenerations.remove(generation)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
mutating func claimDelivery(for generation: UInt64) -> Bool {
|
|
459
|
+
if pendingGenerations.remove(generation) != nil {
|
|
460
|
+
highestClaimedGeneration = max(highestClaimedGeneration ?? generation, generation)
|
|
461
|
+
return true
|
|
462
|
+
}
|
|
463
|
+
if let highestClaimedGeneration, generation <= highestClaimedGeneration {
|
|
464
|
+
return false
|
|
465
|
+
}
|
|
466
|
+
highestClaimedGeneration = generation
|
|
467
|
+
return true
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
func shouldApplyStatus(
|
|
471
|
+
deliveryGeneration: UInt64,
|
|
472
|
+
currentGeneration: UInt64,
|
|
473
|
+
isRecording: Bool,
|
|
474
|
+
isTranscribing: Bool
|
|
475
|
+
) -> Bool {
|
|
476
|
+
deliveryGeneration == currentGeneration && !isRecording && !isTranscribing
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
struct RecordingPipelineTrace: Sendable {
|
|
481
|
+
let id = UUID().uuidString
|
|
482
|
+
let startedUptimeMilliseconds = UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
|
|
483
|
+
|
|
484
|
+
func message(stage: String, detail: String = "") -> String {
|
|
485
|
+
let nowMilliseconds = UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
|
|
486
|
+
let elapsedMilliseconds = nowMilliseconds >= startedUptimeMilliseconds
|
|
487
|
+
? nowMilliseconds - startedUptimeMilliseconds
|
|
488
|
+
: 0
|
|
489
|
+
let suffix = detail.isEmpty ? "" : " \(detail)"
|
|
490
|
+
return "pipeline_timing pipeline_id=\(id) stage=\(stage) elapsed_ms=\(elapsedMilliseconds)\(suffix)"
|
|
491
|
+
}
|
|
45
492
|
}
|
|
46
493
|
|
|
47
494
|
public enum RecordingTrigger: Equatable, Sendable {
|
|
@@ -50,10 +497,65 @@ public enum RecordingTrigger: Equatable, Sendable {
|
|
|
50
497
|
case keyboardShortcut
|
|
51
498
|
}
|
|
52
499
|
|
|
500
|
+
struct MicrophonePermissionStartGate {
|
|
501
|
+
private(set) var activeRequestID: UUID?
|
|
502
|
+
|
|
503
|
+
var isAwaitingResponse: Bool {
|
|
504
|
+
activeRequestID != nil
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
mutating func reserve(requestID: UUID = UUID()) -> UUID? {
|
|
508
|
+
guard activeRequestID == nil else { return nil }
|
|
509
|
+
activeRequestID = requestID
|
|
510
|
+
return requestID
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
mutating func consumeResponse(for requestID: UUID) -> Bool {
|
|
514
|
+
guard activeRequestID == requestID else { return false }
|
|
515
|
+
activeRequestID = nil
|
|
516
|
+
return true
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
mutating func cancel() {
|
|
520
|
+
activeRequestID = nil
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
53
524
|
struct PasteTargetCandidate: Equatable, Sendable {
|
|
54
525
|
let pid: pid_t
|
|
55
526
|
let bundleIdentifier: String?
|
|
56
527
|
let isRegularApp: Bool
|
|
528
|
+
let launchDate: Date?
|
|
529
|
+
|
|
530
|
+
init(
|
|
531
|
+
pid: pid_t,
|
|
532
|
+
bundleIdentifier: String?,
|
|
533
|
+
isRegularApp: Bool,
|
|
534
|
+
launchDate: Date? = nil
|
|
535
|
+
) {
|
|
536
|
+
self.pid = pid
|
|
537
|
+
self.bundleIdentifier = bundleIdentifier
|
|
538
|
+
self.isRegularApp = isRegularApp
|
|
539
|
+
self.launchDate = launchDate
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
struct PasteTargetProcessIdentity: Equatable, Sendable {
|
|
544
|
+
let pid: pid_t
|
|
545
|
+
let bundleIdentifier: String
|
|
546
|
+
let launchDate: Date
|
|
547
|
+
|
|
548
|
+
func matches(_ candidate: PasteTargetCandidate) -> Bool {
|
|
549
|
+
candidate.pid == pid
|
|
550
|
+
&& candidate.bundleIdentifier == bundleIdentifier
|
|
551
|
+
&& candidate.launchDate == launchDate
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
enum PasteDeliveryKind: Equatable, Sendable {
|
|
556
|
+
case ordinaryDictation
|
|
557
|
+
case commandRewrite
|
|
558
|
+
case manualPaste
|
|
57
559
|
}
|
|
58
560
|
|
|
59
561
|
private final class PCMStreamPipe: @unchecked Sendable {
|
|
@@ -114,12 +616,6 @@ private extension Data {
|
|
|
114
616
|
@MainActor
|
|
115
617
|
public final class RecordingEngine: ObservableObject {
|
|
116
618
|
@Published public var isRecording = false
|
|
117
|
-
@Published public var mode: RecordingMode = .pushToTalk {
|
|
118
|
-
didSet {
|
|
119
|
-
UserDefaults.standard.set(mode.rawValue, forKey: "recordingMode")
|
|
120
|
-
updateStatus()
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
619
|
@Published public var useFnKey: Bool = false {
|
|
124
620
|
didSet {
|
|
125
621
|
UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
|
|
@@ -127,8 +623,24 @@ public final class RecordingEngine: ObservableObject {
|
|
|
127
623
|
updateStatus()
|
|
128
624
|
}
|
|
129
625
|
}
|
|
130
|
-
|
|
626
|
+
/// Advanced fallback policy (Settings only): when off, every recording is dictated
|
|
627
|
+
/// literally and the classifier is never consulted.
|
|
628
|
+
@Published public var intentDetectionEnabled: Bool = true {
|
|
629
|
+
didSet {
|
|
630
|
+
UserDefaults.standard.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/// Typed Record-page state; views render idle/listening/finalizing/processing/ready/error
|
|
634
|
+
/// from this instead of parsing `statusMessage`.
|
|
635
|
+
@Published public private(set) var flowPhase: RecordingFlowPhase = .idle
|
|
636
|
+
/// Latest conversational answer. Cleared whenever a new recording starts so a stale reply
|
|
637
|
+
/// can never be attributed to a later recording.
|
|
638
|
+
@Published public private(set) var conversationReply: ConversationReply?
|
|
131
639
|
@Published public var recentTranscriptions: [TranscriptionResult] = []
|
|
640
|
+
/// Advances only after the CLI confirms that a recording has been persisted. The app
|
|
641
|
+
/// store observes this independently of the Record pane so asynchronous saves and
|
|
642
|
+
/// background recovery refresh the Library even after that pane has been unmounted.
|
|
643
|
+
@Published public private(set) var persistedRecordingRevision: UInt64 = 0
|
|
132
644
|
@Published public var statusMessage = "Starting..."
|
|
133
645
|
@Published public var isTranscribing = false
|
|
134
646
|
@Published public var recordingDuration: TimeInterval = 0
|
|
@@ -140,16 +652,80 @@ public final class RecordingEngine: ObservableObject {
|
|
|
140
652
|
}
|
|
141
653
|
}
|
|
142
654
|
|
|
143
|
-
private var nativeRecorder:
|
|
655
|
+
private var nativeRecorder: PCMRecordingSource?
|
|
144
656
|
private var recordingTimer: Timer?
|
|
145
657
|
private var activeTrigger: RecordingTrigger?
|
|
658
|
+
private var microphonePermissionStartGate = MicrophonePermissionStartGate()
|
|
146
659
|
private var keyboardShortcutIsDown = false
|
|
147
660
|
private var fnKeyIsDown = false
|
|
148
661
|
private var targetAppBundleIdentifier: String?
|
|
149
662
|
private var targetAppPid: pid_t?
|
|
663
|
+
private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
|
|
150
664
|
public var projectStore: ProjectStore?
|
|
151
665
|
public var voiceShortcuts: VoiceShortcuts?
|
|
152
666
|
|
|
667
|
+
// MARK: - Injectable boundaries
|
|
668
|
+
// Production defaults perform the real I/O; tests replace them to drive the production
|
|
669
|
+
// start/delivery paths without microphone, Accessibility, network, or CLI access.
|
|
670
|
+
var microphoneAuthorization: () -> AVAuthorizationStatus = {
|
|
671
|
+
AVCaptureDevice.authorizationStatus(for: .audio)
|
|
672
|
+
}
|
|
673
|
+
var accessibilityTrustCheck: @Sendable () -> Bool = { AXIsProcessTrusted() }
|
|
674
|
+
lazy var protectedOperationTrust: () -> AccessibilityTrustResult = { [accessibilityPromptGate] in
|
|
675
|
+
accessibilityPromptGate.trustForProtectedOperation()
|
|
676
|
+
}
|
|
677
|
+
var frontmostAppSnapshot: () -> FrontmostAppSnapshot? = {
|
|
678
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
|
679
|
+
return FrontmostAppSnapshot(
|
|
680
|
+
pid: app.processIdentifier,
|
|
681
|
+
bundleIdentifier: app.bundleIdentifier,
|
|
682
|
+
launchDate: app.launchDate
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
var recorderFactory: (@escaping @Sendable (Data) -> Void) -> PCMRecordingSource = {
|
|
686
|
+
NativePCMRecorder(onPCM: $0)
|
|
687
|
+
}
|
|
688
|
+
var selectionCapture: @Sendable (pid_t) -> AccessibilitySelectionToken? = {
|
|
689
|
+
AccessibilitySelectionToken.capture(for: $0)
|
|
690
|
+
}
|
|
691
|
+
var focusedWindowTitleLookup: @Sendable (pid_t) -> String? = {
|
|
692
|
+
RecordingEngine.focusedWindowTitle(pid: $0)
|
|
693
|
+
}
|
|
694
|
+
var commandCLI: @Sendable (_ args: [String], _ home: String, _ timeout: TimeInterval) -> String = { args, home, ceiling in
|
|
695
|
+
// The caller's timeout is the public ceiling on *observable* wall time. CLIRunner's
|
|
696
|
+
// total deadline (execution, termination grace, kill grace, pipe drain) sits a full
|
|
697
|
+
// return margin below it: spawn setup, waitid poll granularity, capture shutdown,
|
|
698
|
+
// and the hop back to the caller all run outside CLIRunner's clamped waits and must
|
|
699
|
+
// fit inside the reserved margin.
|
|
700
|
+
let cliDeadline = ceiling - RecordingEngine.commandRewriteReturnMargin
|
|
701
|
+
return CLIRunner.run(args, home: home, timeout: cliDeadline, totalWallClockBudget: cliDeadline)
|
|
702
|
+
}
|
|
703
|
+
/// Resolves and revalidates the frozen rewrite target immediately before a rewrite:
|
|
704
|
+
/// re-finds the recorded target app, activates it, waits for focus to settle, and
|
|
705
|
+
/// re-reads the frozen Accessibility selection. Production performs real NSWorkspace/AX
|
|
706
|
+
/// I/O; tests replace it to drive the rewrite pipeline (Rewriting busy state, CLI
|
|
707
|
+
/// budget, cancellation, staleness) headless.
|
|
708
|
+
lazy var rewriteSelectionResolver: @MainActor (
|
|
709
|
+
_ targetAppBundleIdentifier: String?,
|
|
710
|
+
_ targetAppPid: pid_t?,
|
|
711
|
+
_ selectionToken: AccessibilitySelectionToken?,
|
|
712
|
+
_ pipelineGeneration: UInt64?
|
|
713
|
+
) async -> RewriteTargetResolution = { [weak self] bundleIdentifier, pid, token, generation in
|
|
714
|
+
guard let self else { return .targetAppMissing }
|
|
715
|
+
return await self.resolveRewriteSelection(
|
|
716
|
+
targetAppBundleIdentifier: bundleIdentifier,
|
|
717
|
+
targetAppPid: pid,
|
|
718
|
+
selectionToken: token,
|
|
719
|
+
pipelineGeneration: generation
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
lazy var openAIAPIKeyProvider: () -> String = { [home] in
|
|
723
|
+
OpenAIAPIKeyStore.load(homePath: home)
|
|
724
|
+
}
|
|
725
|
+
/// Test-only delivery tap. When set, a routed paste stops at this boundary — everything
|
|
726
|
+
/// up to it (routing, payload selection, generation guards) is the production path.
|
|
727
|
+
var pasteInterceptorForTesting: (@MainActor (_ text: String, _ deliveryKind: PasteDeliveryKind, _ pipelineGeneration: UInt64?) -> Void)?
|
|
728
|
+
|
|
153
729
|
// Real-time streaming
|
|
154
730
|
private var realtimeClient: RealtimeTranscriptionClient?
|
|
155
731
|
private var streamingTask: Task<Void, Never>?
|
|
@@ -157,10 +733,98 @@ public final class RecordingEngine: ObservableObject {
|
|
|
157
733
|
private var streamingText = ""
|
|
158
734
|
private var recordedPCM = Data()
|
|
159
735
|
private var activeAudioPath: String?
|
|
160
|
-
private
|
|
736
|
+
private let accessibilityPromptGate = AccessibilityPromptGate.processShared
|
|
737
|
+
private(set) var recordingGeneration: UInt64 = 0
|
|
738
|
+
private var activeCaptureConfiguration: RecordingCaptureConfiguration?
|
|
739
|
+
var pipelineDeliveryGate = PipelineDeliveryGate()
|
|
740
|
+
/// Generation whose intent delivery (Deciding/Answering/Rewriting) is in flight, or nil.
|
|
741
|
+
/// Scoped to the generation so a stale completion can never clear a newer pending state.
|
|
742
|
+
private var intentDeliveryPendingGeneration: UInt64?
|
|
743
|
+
lazy var intentClassifier = SpeechIntentClassifier(
|
|
744
|
+
apiKeyProvider: { [home] in OpenAIAPIKeyStore.load(homePath: home) }
|
|
745
|
+
)
|
|
746
|
+
private lazy var pasteTransactionCoordinator = makePasteTransactionCoordinator(
|
|
747
|
+
schedule: { delay, operation in
|
|
748
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
749
|
+
MainActor.assumeIsolated { operation() }
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
writeAndVerify: { text in
|
|
753
|
+
let pasteboard = NSPasteboard.general
|
|
754
|
+
return RecordingEngine.writeClipboardAttempt(text, to: pasteboard)
|
|
755
|
+
},
|
|
756
|
+
postPaste: {
|
|
757
|
+
let source = CGEventSource(stateID: .hidSystemState)
|
|
758
|
+
guard let down = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true),
|
|
759
|
+
let up = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) else {
|
|
760
|
+
return false
|
|
761
|
+
}
|
|
762
|
+
down.flags = .maskCommand
|
|
763
|
+
up.flags = .maskCommand
|
|
764
|
+
down.post(tap: .cgSessionEventTap)
|
|
765
|
+
up.post(tap: .cgSessionEventTap)
|
|
766
|
+
return true
|
|
767
|
+
}
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
/// Every coordinator the engine owns must publish its idle transitions:
|
|
771
|
+
/// `canStartRecording` derives from coordinator state, and settlement back to idle is
|
|
772
|
+
/// otherwise invisible to observers — the menu bar would stay busy with Start disabled
|
|
773
|
+
/// until some unrelated published change.
|
|
774
|
+
private func makePasteTransactionCoordinator(
|
|
775
|
+
schedule: @escaping PasteTransactionCoordinator.Scheduler,
|
|
776
|
+
writeAndVerify: @escaping PasteTransactionCoordinator.PayloadWriter,
|
|
777
|
+
postPaste: @escaping PasteTransactionCoordinator.PastePoster
|
|
778
|
+
) -> PasteTransactionCoordinator {
|
|
779
|
+
let coordinator = PasteTransactionCoordinator(
|
|
780
|
+
schedule: schedule,
|
|
781
|
+
writeAndVerify: writeAndVerify,
|
|
782
|
+
postPaste: postPaste
|
|
783
|
+
)
|
|
784
|
+
coordinator.pendingTransactionWillChange = { [weak self] in
|
|
785
|
+
self?.objectWillChange.send()
|
|
786
|
+
}
|
|
787
|
+
return coordinator
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
#if DEBUG
|
|
791
|
+
/// Test-only: swaps in a coordinator with injected I/O while keeping the production
|
|
792
|
+
/// observation wiring, so settlement observability can be driven deterministically.
|
|
793
|
+
@discardableResult
|
|
794
|
+
func installPasteCoordinatorForTesting(
|
|
795
|
+
schedule: @escaping PasteTransactionCoordinator.Scheduler,
|
|
796
|
+
writeAndVerify: @escaping PasteTransactionCoordinator.PayloadWriter,
|
|
797
|
+
postPaste: @escaping PasteTransactionCoordinator.PastePoster
|
|
798
|
+
) -> PasteTransactionCoordinator {
|
|
799
|
+
let coordinator = makePasteTransactionCoordinator(
|
|
800
|
+
schedule: schedule,
|
|
801
|
+
writeAndVerify: writeAndVerify,
|
|
802
|
+
postPaste: postPaste
|
|
803
|
+
)
|
|
804
|
+
pasteTransactionCoordinator = coordinator
|
|
805
|
+
return coordinator
|
|
806
|
+
}
|
|
807
|
+
#endif
|
|
161
808
|
|
|
162
|
-
private nonisolated static let
|
|
809
|
+
private nonisolated static let realtimePeriodicCommitIntervalMilliseconds: UInt64 = 900
|
|
163
810
|
private nonisolated static let realtimeFinishTimeoutMilliseconds: UInt64 = 700
|
|
811
|
+
/// Hard wall-clock budget for the rewrite helper (CLI spawn + one model call), covering
|
|
812
|
+
/// execution *and* CLIRunner's termination grace, kill grace, and pipe drain — not just
|
|
813
|
+
/// the child execution deadline. The user is waiting with recording blocked, so this
|
|
814
|
+
/// matches the interactive answer ceiling (`SpeechIntentClassifier.conversationTimeout`)
|
|
815
|
+
/// — never the generic 120 s CLI ceiling; cancellation stays available the whole time.
|
|
816
|
+
/// The `commandCLI` seam hands CLIRunner `commandRewriteTimeout` minus
|
|
817
|
+
/// `commandRewriteReturnMargin` so the runner's own deadline keeping plus the return
|
|
818
|
+
/// path stays inside this ceiling.
|
|
819
|
+
nonisolated static let commandRewriteTimeout: TimeInterval = 10
|
|
820
|
+
/// Wall-clock margin reserved out of `commandRewriteTimeout` before it becomes
|
|
821
|
+
/// CLIRunner's total deadline. CLIRunner clamps every wait to that deadline but still
|
|
822
|
+
/// pays small unclamped costs around them — spawn setup, waitid poll granularity (each
|
|
823
|
+
/// bounded wait can oversleep one 10 ms poll), synchronous capture shutdown, and the
|
|
824
|
+
/// detached-task hop back to the MainActor. Reserving a full second keeps the
|
|
825
|
+
/// *observable* rewrite time under the public ceiling even when the execution window,
|
|
826
|
+
/// termination grace, and pipe drain all run to exhaustion.
|
|
827
|
+
nonisolated static let commandRewriteReturnMargin: TimeInterval = 1
|
|
164
828
|
|
|
165
829
|
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
166
830
|
private let fnMonitor = FnKeyMonitor()
|
|
@@ -169,21 +833,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
169
833
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
170
834
|
private var audioDir: String { "\(home)/.hasna/recordings/audio" }
|
|
171
835
|
|
|
172
|
-
// MARK: - OpenAI API Key
|
|
173
|
-
|
|
174
|
-
private var openAIAPIKey: String {
|
|
175
|
-
OpenAIAPIKeyStore.load(homePath: home)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
836
|
public init() {
|
|
179
837
|
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
180
838
|
log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
|
|
181
839
|
|
|
182
840
|
// Load preferences
|
|
183
|
-
|
|
184
|
-
let parsedMode = RecordingMode(rawValue: savedMode) {
|
|
185
|
-
mode = parsedMode
|
|
186
|
-
}
|
|
841
|
+
intentDetectionEnabled = UserDefaults.standard.object(forKey: "intentDetectionEnabled") as? Bool ?? true
|
|
187
842
|
transcriptionLanguage = OpenAIAPIKeyStore.loadLanguage(homePath: home)
|
|
188
843
|
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
|
|
189
844
|
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
@@ -195,7 +850,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
195
850
|
Task { @MainActor [weak self] in
|
|
196
851
|
guard let self else { return }
|
|
197
852
|
self.fnKeyIsDown = true
|
|
198
|
-
guard self.useFnKey, Self.canBeginRecording(
|
|
853
|
+
guard self.useFnKey, Self.canBeginRecording(
|
|
854
|
+
isRecording: self.isRecording,
|
|
855
|
+
isTranscribing: self.isTranscribing,
|
|
856
|
+
isAwaitingMicrophonePermission: self.microphonePermissionStartGate.isAwaitingResponse,
|
|
857
|
+
isDeliveryPending: self.deliveryIsPending
|
|
858
|
+
) else { return }
|
|
199
859
|
self.startRecording(trigger: .fnKey)
|
|
200
860
|
}
|
|
201
861
|
}
|
|
@@ -213,13 +873,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
213
873
|
self.stopAndTranscribe()
|
|
214
874
|
}
|
|
215
875
|
}
|
|
216
|
-
updateFnMonitor()
|
|
876
|
+
updateFnMonitor(allowAutomaticPrompt: false)
|
|
217
877
|
|
|
218
878
|
KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
|
|
219
879
|
Task { @MainActor [weak self] in
|
|
220
880
|
guard let self, !self.keyboardShortcutIsDown else { return }
|
|
221
881
|
self.keyboardShortcutIsDown = true
|
|
222
|
-
guard Self.canBeginRecording(
|
|
882
|
+
guard Self.canBeginRecording(
|
|
883
|
+
isRecording: self.isRecording,
|
|
884
|
+
isTranscribing: self.isTranscribing,
|
|
885
|
+
isAwaitingMicrophonePermission: self.microphonePermissionStartGate.isAwaitingResponse,
|
|
886
|
+
isDeliveryPending: self.deliveryIsPending
|
|
887
|
+
) else { return }
|
|
223
888
|
self.startRecording(trigger: .keyboardShortcut)
|
|
224
889
|
}
|
|
225
890
|
}
|
|
@@ -285,9 +950,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
285
950
|
}
|
|
286
951
|
|
|
287
952
|
public func requestAccessibilityPermission() {
|
|
288
|
-
let
|
|
289
|
-
log("requestAccessibilityPermission trusted=\(trusted)")
|
|
290
|
-
statusMessage = trusted
|
|
953
|
+
let result = accessibilityPromptGate.requestExplicitly()
|
|
954
|
+
log("requestAccessibilityPermission trusted=\(result.trusted)")
|
|
955
|
+
statusMessage = result.trusted
|
|
291
956
|
? "Accessibility allowed"
|
|
292
957
|
: "Enable Accessibility permission for Recordings to paste"
|
|
293
958
|
objectWillChange.send()
|
|
@@ -307,11 +972,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
307
972
|
}
|
|
308
973
|
}
|
|
309
974
|
|
|
310
|
-
private func updateFnMonitor() {
|
|
975
|
+
private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
|
|
311
976
|
if useFnKey {
|
|
312
977
|
let ok = fnMonitor.start()
|
|
313
978
|
log("fn monitor start ok=\(ok)")
|
|
314
979
|
if !ok {
|
|
980
|
+
if allowAutomaticPrompt {
|
|
981
|
+
let result = accessibilityPromptGate.trustForProtectedOperation()
|
|
982
|
+
log("fn monitor accessibility trusted=\(result.trusted) prompted=\(result.didPrompt)")
|
|
983
|
+
}
|
|
315
984
|
statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
|
|
316
985
|
}
|
|
317
986
|
} else {
|
|
@@ -320,8 +989,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
320
989
|
}
|
|
321
990
|
|
|
322
991
|
public func updateStatus() {
|
|
323
|
-
if isRecording || isTranscribing { return }
|
|
992
|
+
if isRecording || isTranscribing || deliveryIsPending { return }
|
|
324
993
|
statusMessage = "Ready"
|
|
994
|
+
flowPhase = .idle
|
|
325
995
|
}
|
|
326
996
|
|
|
327
997
|
// MARK: - Toggle
|
|
@@ -333,40 +1003,141 @@ public final class RecordingEngine: ObservableObject {
|
|
|
333
1003
|
// MARK: - Start Recording (Streaming)
|
|
334
1004
|
|
|
335
1005
|
public func startRecording(trigger: RecordingTrigger = .manual) {
|
|
336
|
-
guard Self.canBeginRecording(
|
|
1006
|
+
guard Self.canBeginRecording(
|
|
1007
|
+
isRecording: isRecording,
|
|
1008
|
+
isTranscribing: isTranscribing,
|
|
1009
|
+
isAwaitingMicrophonePermission: microphonePermissionStartGate.isAwaitingResponse,
|
|
1010
|
+
isDeliveryPending: deliveryIsPending
|
|
1011
|
+
) else {
|
|
337
1012
|
if isTranscribing {
|
|
338
1013
|
statusMessage = "Finish transcribing before recording again"
|
|
1014
|
+
} else if deliveryIsPending {
|
|
1015
|
+
statusMessage = "Still delivering the last recording"
|
|
339
1016
|
}
|
|
340
1017
|
return
|
|
341
1018
|
}
|
|
342
|
-
log("startRecording trigger=\(trigger) microphoneStatus=\(
|
|
1019
|
+
log("startRecording trigger=\(trigger) microphoneStatus=\(microphoneAuthorization().rawValue) accessibility=\(accessibilityTrustCheck())")
|
|
1020
|
+
recordingGeneration &+= 1
|
|
1021
|
+
if pasteTargetProcessIdentityByGeneration.count >= 32 {
|
|
1022
|
+
let oldestRetainedGeneration = recordingGeneration > 16 ? recordingGeneration - 16 : 0
|
|
1023
|
+
pasteTargetProcessIdentityByGeneration = pasteTargetProcessIdentityByGeneration.filter {
|
|
1024
|
+
$0.key >= oldestRetainedGeneration
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
343
1027
|
activeTrigger = trigger
|
|
344
1028
|
keyboardShortcutIsDown = trigger == .keyboardShortcut
|
|
1029
|
+
conversationReply = nil
|
|
345
1030
|
|
|
346
1031
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
347
|
-
let frontmostApp =
|
|
348
|
-
let isOwnApp = frontmostApp?.
|
|
1032
|
+
let frontmostApp = frontmostAppSnapshot()
|
|
1033
|
+
let isOwnApp = frontmostApp?.pid == myPID
|
|
349
1034
|
targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
|
|
350
|
-
targetAppPid = isOwnApp ? nil : frontmostApp?.
|
|
1035
|
+
targetAppPid = isOwnApp ? nil : frontmostApp?.pid
|
|
1036
|
+
if !isOwnApp,
|
|
1037
|
+
let pid = frontmostApp?.pid,
|
|
1038
|
+
let bundleIdentifier = frontmostApp?.bundleIdentifier,
|
|
1039
|
+
let launchDate = frontmostApp?.launchDate {
|
|
1040
|
+
pasteTargetProcessIdentityByGeneration[recordingGeneration] = PasteTargetProcessIdentity(
|
|
1041
|
+
pid: pid,
|
|
1042
|
+
bundleIdentifier: bundleIdentifier,
|
|
1043
|
+
launchDate: launchDate
|
|
1044
|
+
)
|
|
1045
|
+
} else {
|
|
1046
|
+
pasteTargetProcessIdentityByGeneration[recordingGeneration] = nil
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// The selection is still frozen for every recording (not only an exposed "command
|
|
1050
|
+
// mode"), so a later command decision can only ever act on the exact text and
|
|
1051
|
+
// element that were selected when the user started speaking. The Accessibility IPC
|
|
1052
|
+
// that reads it runs on a detached task, concurrently with recorder start: the
|
|
1053
|
+
// microphone must never wait on a beachballing target app, and the MainActor stays
|
|
1054
|
+
// free to process the key-up that stops the recording. Skipped entirely when intent
|
|
1055
|
+
// detection is off — no command route exists to consume it.
|
|
1056
|
+
let shouldCaptureSelection = Self.shouldCaptureSelection(
|
|
1057
|
+
targetPid: targetAppPid,
|
|
1058
|
+
accessibilityTrusted: accessibilityTrustCheck(),
|
|
1059
|
+
intentDetectionEnabled: intentDetectionEnabled
|
|
1060
|
+
)
|
|
1061
|
+
let capturePid = targetAppPid
|
|
1062
|
+
let captureSelection = selectionCapture
|
|
1063
|
+
let windowTitleLookup = focusedWindowTitleLookup
|
|
1064
|
+
let windowTitlePid = frontmostApp?.pid
|
|
1065
|
+
let axSnapshotTask = Task.detached(priority: .userInitiated) { () -> RecordingStartAXSnapshot in
|
|
1066
|
+
let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
|
|
1067
|
+
let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
|
|
1068
|
+
return RecordingStartAXSnapshot(
|
|
1069
|
+
selectionToken: selectionToken,
|
|
1070
|
+
focusedWindowTitle: focusedWindowTitle
|
|
1071
|
+
)
|
|
1072
|
+
}
|
|
351
1073
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
1074
|
+
// Project auto-selection and the processing configuration resolve with the
|
|
1075
|
+
// snapshot, still once per recording start and frozen for this generation; the
|
|
1076
|
+
// recording pipeline awaits this context only after the recorder has stopped.
|
|
1077
|
+
let generation = recordingGeneration
|
|
1078
|
+
let projectStore = projectStore
|
|
1079
|
+
let targetBundleIdentifierForProjects = targetAppBundleIdentifier
|
|
1080
|
+
let transcriptionLanguageAtStart = transcriptionLanguage
|
|
1081
|
+
let intentDetectionEnabledAtStart = intentDetectionEnabled
|
|
1082
|
+
let homePath = home
|
|
1083
|
+
let startContext = Task { @MainActor [weak self] () -> RecordingStartResolvedContext in
|
|
1084
|
+
let axSnapshot = await axSnapshotTask.value
|
|
1085
|
+
if let self, generation == self.recordingGeneration, let store = projectStore {
|
|
1086
|
+
let projects = store.settings.projects
|
|
1087
|
+
let detected = ProjectStore.matchProject(
|
|
1088
|
+
windowTitle: axSnapshot.focusedWindowTitle,
|
|
1089
|
+
bundleId: targetBundleIdentifierForProjects,
|
|
1090
|
+
projects: projects
|
|
1091
|
+
)
|
|
1092
|
+
if let detected,
|
|
1093
|
+
detected.id != store.settings.activeProjectId,
|
|
1094
|
+
store.canMutateProjects {
|
|
1095
|
+
do {
|
|
1096
|
+
try store.setActive(detected.id)
|
|
1097
|
+
} catch {
|
|
1098
|
+
self.log("project auto-selection failed; continuing capture with the last active project: \(error.localizedDescription)")
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if let warning = store.synchronizationError ?? store.persistenceError {
|
|
1102
|
+
self.log("project synchronization degraded; continuing capture: \(warning)")
|
|
1103
|
+
}
|
|
358
1104
|
}
|
|
1105
|
+
let modelSelection = OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
|
|
1106
|
+
return RecordingStartResolvedContext(
|
|
1107
|
+
selectionToken: axSnapshot.selectionToken,
|
|
1108
|
+
canonicalProjectId: projectStore?.activeCanonicalProjectIdForRecording,
|
|
1109
|
+
displayProjectId: projectStore?.settings.activeProjectId,
|
|
1110
|
+
activeProjectName: projectStore?.activeProject?.name,
|
|
1111
|
+
processing: RecordingProcessingConfiguration(
|
|
1112
|
+
transcriptionPrompt: modelSelection.transcriptionPrompt,
|
|
1113
|
+
transcriberPrompt: projectStore?.effectiveSystemPrompt ?? "",
|
|
1114
|
+
postProcessingMode: projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue,
|
|
1115
|
+
transcriptionLanguage: transcriptionLanguageAtStart,
|
|
1116
|
+
transcriptionModel: modelSelection.transcriptionModel,
|
|
1117
|
+
transcriberModel: modelSelection.transcriberModel,
|
|
1118
|
+
enhancementModel: modelSelection.enhancementModel,
|
|
1119
|
+
intentModel: modelSelection.intentModel,
|
|
1120
|
+
intentDetectionEnabled: intentDetectionEnabledAtStart,
|
|
1121
|
+
enhanceTriggersJSON: modelSelection.enhanceTriggersJSON,
|
|
1122
|
+
keywordTransformsJSON: modelSelection.keywordTransformsJSON
|
|
1123
|
+
)
|
|
1124
|
+
)
|
|
359
1125
|
}
|
|
360
1126
|
|
|
361
|
-
switch
|
|
1127
|
+
switch microphoneAuthorization() {
|
|
362
1128
|
case .authorized:
|
|
363
|
-
startNativeRecording()
|
|
1129
|
+
startNativeRecording(startContext: startContext)
|
|
364
1130
|
case .notDetermined:
|
|
1131
|
+
guard let requestID = microphonePermissionStartGate.reserve() else { return }
|
|
365
1132
|
statusMessage = "Allow microphone access to record"
|
|
366
1133
|
log("requesting microphone access before recording")
|
|
367
1134
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
|
368
1135
|
Task { @MainActor [weak self] in
|
|
369
1136
|
guard let self else { return }
|
|
1137
|
+
guard self.microphonePermissionStartGate.consumeResponse(for: requestID) else {
|
|
1138
|
+
self.log("ignoring stale microphone permission response")
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
370
1141
|
self.log("microphone access response granted=\(granted)")
|
|
371
1142
|
if granted {
|
|
372
1143
|
guard self.shouldContinueStarting(trigger: trigger) else {
|
|
@@ -375,25 +1146,122 @@ public final class RecordingEngine: ObservableObject {
|
|
|
375
1146
|
self.updateStatus()
|
|
376
1147
|
return
|
|
377
1148
|
}
|
|
378
|
-
self.startNativeRecording()
|
|
1149
|
+
self.startNativeRecording(startContext: startContext)
|
|
379
1150
|
} else {
|
|
380
1151
|
self.resetRecordingIntent()
|
|
381
1152
|
self.statusMessage = "Microphone permission denied"
|
|
1153
|
+
self.flowPhase = .failed(self.statusMessage)
|
|
382
1154
|
}
|
|
383
1155
|
}
|
|
384
1156
|
}
|
|
385
1157
|
case .denied, .restricted:
|
|
386
1158
|
resetRecordingIntent()
|
|
387
|
-
log("microphone permission blocked status=\(
|
|
1159
|
+
log("microphone permission blocked status=\(microphoneAuthorization().rawValue)")
|
|
388
1160
|
statusMessage = "Enable Microphone permission for Recordings in System Settings"
|
|
1161
|
+
flowPhase = .failed(statusMessage)
|
|
389
1162
|
@unknown default:
|
|
390
1163
|
resetRecordingIntent()
|
|
391
1164
|
statusMessage = "Microphone permission unavailable"
|
|
1165
|
+
flowPhase = .failed(statusMessage)
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
nonisolated static func canBeginRecording(
|
|
1170
|
+
isRecording: Bool,
|
|
1171
|
+
isTranscribing: Bool,
|
|
1172
|
+
isAwaitingMicrophonePermission: Bool = false,
|
|
1173
|
+
isDeliveryPending: Bool = false
|
|
1174
|
+
) -> Bool {
|
|
1175
|
+
!isRecording && !isTranscribing && !isAwaitingMicrophonePermission && !isDeliveryPending
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
nonisolated static func shouldCaptureSelection(
|
|
1179
|
+
targetPid: pid_t?,
|
|
1180
|
+
accessibilityTrusted: Bool,
|
|
1181
|
+
intentDetectionEnabled: Bool
|
|
1182
|
+
) -> Bool {
|
|
1183
|
+
targetPid != nil && accessibilityTrusted && intentDetectionEnabled
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
nonisolated static func recordingStatus(
|
|
1187
|
+
trigger: RecordingTrigger
|
|
1188
|
+
) -> String {
|
|
1189
|
+
switch trigger {
|
|
1190
|
+
case .manual: "Recording — click Stop when finished"
|
|
1191
|
+
case .fnKey, .keyboardShortcut: "Recording — release to stop"
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private var deliveryIsPending: Bool {
|
|
1196
|
+
intentDeliveryPendingGeneration != nil || pasteTransactionCoordinator.hasPendingTransaction
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private func beginIntentDelivery(for generation: UInt64) {
|
|
1200
|
+
intentDeliveryPendingGeneration = generation
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private func endIntentDelivery(for generation: UInt64) {
|
|
1204
|
+
if intentDeliveryPendingGeneration == generation {
|
|
1205
|
+
intentDeliveryPendingGeneration = nil
|
|
392
1206
|
}
|
|
393
1207
|
}
|
|
394
1208
|
|
|
395
|
-
|
|
396
|
-
|
|
1209
|
+
/// Truthful start availability for UI surfaces. Mirrors exactly the gate
|
|
1210
|
+
/// `startRecording` applies, so a menu bar or button can never present Start while the
|
|
1211
|
+
/// engine would reject it.
|
|
1212
|
+
public var canStartRecording: Bool {
|
|
1213
|
+
Self.canBeginRecording(
|
|
1214
|
+
isRecording: isRecording,
|
|
1215
|
+
isTranscribing: isTranscribing,
|
|
1216
|
+
isAwaitingMicrophonePermission: microphonePermissionStartGate.isAwaitingResponse,
|
|
1217
|
+
isDeliveryPending: deliveryIsPending
|
|
1218
|
+
)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/// Whether an in-flight Deciding/Answering/Rewriting delivery can be cancelled. Once a
|
|
1222
|
+
/// paste transaction is submitted the remaining window is sub-second and has its own
|
|
1223
|
+
/// target/clipboard safety rails, so cancellation stops being offered.
|
|
1224
|
+
public var canCancelIntentDelivery: Bool {
|
|
1225
|
+
intentDeliveryPendingGeneration != nil && !pasteTransactionCoordinator.hasPendingTransaction
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/// Cancels the pending intent delivery. Every phase that can be pending here —
|
|
1229
|
+
/// Deciding, Answering, Rewriting — inserted the transcript into Recent before the
|
|
1230
|
+
/// phase began (and the recording was already persisted to the library), so cancelling
|
|
1231
|
+
/// only abandons the delivery: "transcript saved to Recent" is literally true. Bumping
|
|
1232
|
+
/// the generation makes every in-flight completion stale, and every completion path
|
|
1233
|
+
/// re-checks the generation before touching state, the clipboard, or the target app —
|
|
1234
|
+
/// a cancelled decision, answer, or rewrite can never land later.
|
|
1235
|
+
public func cancelIntentProcessing() {
|
|
1236
|
+
guard canCancelIntentDelivery else { return }
|
|
1237
|
+
log("intent delivery cancelled by user generation=\(recordingGeneration)")
|
|
1238
|
+
recordingGeneration &+= 1
|
|
1239
|
+
intentDeliveryPendingGeneration = nil
|
|
1240
|
+
isTranscribing = false
|
|
1241
|
+
liveTranscriptionText = ""
|
|
1242
|
+
statusMessage = "Cancelled — transcript saved to Recent"
|
|
1243
|
+
flowPhase = .idle
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
#if DEBUG
|
|
1247
|
+
/// Test-only: advances and registers a pipeline generation the way a recording
|
|
1248
|
+
/// start/stop pair would, so delivery tests can drive `finishWithText` repeatedly.
|
|
1249
|
+
func beginPipelineForTesting() -> UInt64 {
|
|
1250
|
+
recordingGeneration &+= 1
|
|
1251
|
+
pipelineDeliveryGate.registerPipeline(recordingGeneration)
|
|
1252
|
+
return recordingGeneration
|
|
1253
|
+
}
|
|
1254
|
+
#endif
|
|
1255
|
+
|
|
1256
|
+
/// Single staleness rule for generation-bound deliveries: anything bound to a
|
|
1257
|
+
/// superseded generation — or arriving mid-recording — is abandoned.
|
|
1258
|
+
nonisolated static func shouldAbandonDelivery(
|
|
1259
|
+
pipelineGeneration: UInt64?,
|
|
1260
|
+
currentGeneration: UInt64,
|
|
1261
|
+
isRecording: Bool
|
|
1262
|
+
) -> Bool {
|
|
1263
|
+
guard let pipelineGeneration else { return false }
|
|
1264
|
+
return isRecording || pipelineGeneration != currentGeneration
|
|
397
1265
|
}
|
|
398
1266
|
|
|
399
1267
|
nonisolated static func shouldContinueStartingAfterPermission(
|
|
@@ -419,11 +1287,20 @@ public final class RecordingEngine: ObservableObject {
|
|
|
419
1287
|
)
|
|
420
1288
|
}
|
|
421
1289
|
|
|
422
|
-
private func startNativeRecording() {
|
|
423
|
-
let apiKey =
|
|
1290
|
+
private func startNativeRecording(startContext: Task<RecordingStartResolvedContext, Never>) {
|
|
1291
|
+
let apiKey = openAIAPIKeyProvider()
|
|
1292
|
+
let captureConfiguration = RecordingCaptureConfiguration(
|
|
1293
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1294
|
+
targetAppPid: targetAppPid,
|
|
1295
|
+
startContext: startContext
|
|
1296
|
+
)
|
|
1297
|
+
activeCaptureConfiguration = captureConfiguration
|
|
424
1298
|
log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
|
|
425
1299
|
if !apiKey.isEmpty {
|
|
426
|
-
startRealtimeStreaming(
|
|
1300
|
+
startRealtimeStreaming(
|
|
1301
|
+
apiKey: apiKey,
|
|
1302
|
+
transcriptionLanguage: transcriptionLanguage
|
|
1303
|
+
)
|
|
427
1304
|
}
|
|
428
1305
|
|
|
429
1306
|
let client = realtimeClient
|
|
@@ -431,7 +1308,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
431
1308
|
pcmStreamPipe = streamPipe
|
|
432
1309
|
let homePath = home
|
|
433
1310
|
let firstChunkLogged = LockedFlag()
|
|
434
|
-
let recorder =
|
|
1311
|
+
let recorder = recorderFactory { data in
|
|
435
1312
|
if firstChunkLogged.take() {
|
|
436
1313
|
NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
|
|
437
1314
|
}
|
|
@@ -449,11 +1326,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
449
1326
|
recordedPCM.removeAll(keepingCapacity: true)
|
|
450
1327
|
activeAudioPath = "\(audioDir)/recording-\(Self.timestampForFilename()).wav"
|
|
451
1328
|
let trigger = activeTrigger ?? .manual
|
|
452
|
-
statusMessage =
|
|
453
|
-
|
|
454
|
-
case (_, .manual): "Recording — click Stop when finished"
|
|
455
|
-
case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
|
|
456
|
-
}
|
|
1329
|
+
statusMessage = Self.recordingStatus(trigger: trigger)
|
|
1330
|
+
flowPhase = .listening
|
|
457
1331
|
|
|
458
1332
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
459
1333
|
Task { @MainActor [weak self] in
|
|
@@ -468,14 +1342,16 @@ public final class RecordingEngine: ObservableObject {
|
|
|
468
1342
|
streamingTask = nil
|
|
469
1343
|
pcmStreamPipe?.cancel()
|
|
470
1344
|
pcmStreamPipe = nil
|
|
1345
|
+
activeCaptureConfiguration = nil
|
|
471
1346
|
resetRecordingIntent()
|
|
472
1347
|
statusMessage = "Failed: \(error.localizedDescription)"
|
|
1348
|
+
flowPhase = .failed(statusMessage)
|
|
473
1349
|
}
|
|
474
1350
|
}
|
|
475
1351
|
|
|
476
1352
|
// MARK: - Real-time Streaming
|
|
477
1353
|
|
|
478
|
-
private func startRealtimeStreaming(apiKey: String) {
|
|
1354
|
+
private func startRealtimeStreaming(apiKey: String, transcriptionLanguage: String) {
|
|
479
1355
|
let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
480
1356
|
realtimeClient = client
|
|
481
1357
|
let language = OpenAIAPIKeyStore.apiLanguageHint(for: transcriptionLanguage)
|
|
@@ -485,15 +1361,19 @@ public final class RecordingEngine: ObservableObject {
|
|
|
485
1361
|
await client.startStreaming(language: language)
|
|
486
1362
|
self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
|
|
487
1363
|
|
|
488
|
-
var lastPeriodicCommitAt
|
|
1364
|
+
var lastPeriodicCommitAt: UInt64?
|
|
489
1365
|
|
|
490
1366
|
// Receive deltas
|
|
491
1367
|
while client.isStreaming {
|
|
492
1368
|
try? await Task.sleep(for: .milliseconds(100))
|
|
493
|
-
|
|
494
|
-
|
|
1369
|
+
let now = Self.monotonicMilliseconds()
|
|
1370
|
+
let periodicCommitIsDue = Self.realtimePeriodicCommitIsDue(
|
|
1371
|
+
nowMilliseconds: now,
|
|
1372
|
+
lastCommitMilliseconds: lastPeriodicCommitAt
|
|
1373
|
+
)
|
|
1374
|
+
if self.isRecording, periodicCommitIsDue {
|
|
495
1375
|
if await client.commitInput(reason: "periodic") {
|
|
496
|
-
lastPeriodicCommitAt =
|
|
1376
|
+
lastPeriodicCommitAt = now
|
|
497
1377
|
}
|
|
498
1378
|
}
|
|
499
1379
|
let text = client.accumulatedText
|
|
@@ -516,11 +1396,43 @@ public final class RecordingEngine: ObservableObject {
|
|
|
516
1396
|
}
|
|
517
1397
|
}
|
|
518
1398
|
|
|
1399
|
+
// MARK: - Cancel (discard without transcribing)
|
|
1400
|
+
|
|
1401
|
+
public func cancelRecording() {
|
|
1402
|
+
guard isRecording else { return }
|
|
1403
|
+
log("cancelRecording")
|
|
1404
|
+
|
|
1405
|
+
recordingTimer?.invalidate()
|
|
1406
|
+
recordingTimer = nil
|
|
1407
|
+
|
|
1408
|
+
let recorder = nativeRecorder
|
|
1409
|
+
nativeRecorder = nil
|
|
1410
|
+
recorder?.stop()
|
|
1411
|
+
|
|
1412
|
+
realtimeClient?.stop()
|
|
1413
|
+
realtimeClient = nil
|
|
1414
|
+
streamingTask?.cancel()
|
|
1415
|
+
streamingTask = nil
|
|
1416
|
+
pcmStreamPipe?.cancel()
|
|
1417
|
+
pcmStreamPipe = nil
|
|
1418
|
+
|
|
1419
|
+
isRecording = false
|
|
1420
|
+
isTranscribing = false
|
|
1421
|
+
liveTranscriptionText = ""
|
|
1422
|
+
recordedPCM.removeAll(keepingCapacity: true)
|
|
1423
|
+
activeAudioPath = nil
|
|
1424
|
+
activeCaptureConfiguration = nil
|
|
1425
|
+
resetRecordingIntent()
|
|
1426
|
+
statusMessage = "Ready"
|
|
1427
|
+
flowPhase = .idle
|
|
1428
|
+
}
|
|
1429
|
+
|
|
519
1430
|
// MARK: - Stop & Transcribe
|
|
520
1431
|
|
|
521
1432
|
public func stopAndTranscribe() {
|
|
522
1433
|
guard isRecording else { return }
|
|
523
|
-
|
|
1434
|
+
let pipelineTrace = RecordingPipelineTrace()
|
|
1435
|
+
log(pipelineTrace.message(stage: "release"))
|
|
524
1436
|
|
|
525
1437
|
recordingTimer?.invalidate()
|
|
526
1438
|
recordingTimer = nil
|
|
@@ -532,14 +1444,29 @@ public final class RecordingEngine: ObservableObject {
|
|
|
532
1444
|
isRecording = false
|
|
533
1445
|
isTranscribing = true
|
|
534
1446
|
|
|
535
|
-
let
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1447
|
+
guard let captureConfiguration = activeCaptureConfiguration else {
|
|
1448
|
+
realtimeClient?.stop()
|
|
1449
|
+
realtimeClient = nil
|
|
1450
|
+
streamingTask?.cancel()
|
|
1451
|
+
streamingTask = nil
|
|
1452
|
+
pcmStreamPipe?.cancel()
|
|
1453
|
+
pcmStreamPipe = nil
|
|
1454
|
+
activeAudioPath = nil
|
|
1455
|
+
recordedPCM.removeAll(keepingCapacity: true)
|
|
1456
|
+
resetRecordingIntent()
|
|
1457
|
+
finish("Recording configuration unavailable")
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
activeCaptureConfiguration = nil
|
|
1461
|
+
let targetAppBundleIdentifier = captureConfiguration.targetAppBundleIdentifier
|
|
1462
|
+
let targetAppPid = captureConfiguration.targetAppPid
|
|
540
1463
|
let audioPath = activeAudioPath
|
|
541
1464
|
let pcmStreamPipe = pcmStreamPipe
|
|
542
1465
|
let client = realtimeClient
|
|
1466
|
+
let pipelineGeneration = recordingGeneration
|
|
1467
|
+
pipelineDeliveryGate.registerPipeline(pipelineGeneration)
|
|
1468
|
+
statusMessage = "Transcribing..."
|
|
1469
|
+
flowPhase = .finalizing
|
|
543
1470
|
resetRecordingIntent()
|
|
544
1471
|
self.pcmStreamPipe = nil
|
|
545
1472
|
|
|
@@ -547,44 +1474,225 @@ public final class RecordingEngine: ObservableObject {
|
|
|
547
1474
|
if let pcmStreamPipe {
|
|
548
1475
|
self.recordedPCM = await pcmStreamPipe.finish()
|
|
549
1476
|
}
|
|
550
|
-
self.log(
|
|
551
|
-
|
|
552
|
-
|
|
1477
|
+
self.log(pipelineTrace.message(
|
|
1478
|
+
stage: "pcm_drain_complete",
|
|
1479
|
+
detail: "pcm_bytes=\(self.recordedPCM.count)"
|
|
1480
|
+
))
|
|
1481
|
+
|
|
1482
|
+
let streamingResult = await client?.finish(
|
|
1483
|
+
timeoutMilliseconds: Self.realtimeFinishTimeoutMilliseconds,
|
|
1484
|
+
pipelineID: pipelineTrace.id,
|
|
1485
|
+
pipelineStartedUptimeMilliseconds: pipelineTrace.startedUptimeMilliseconds
|
|
1486
|
+
)
|
|
1487
|
+
?? RealtimeFinishResult(text: "", settled: false, error: nil)
|
|
1488
|
+
self.log(pipelineTrace.message(
|
|
1489
|
+
stage: "realtime_finish_complete",
|
|
1490
|
+
detail: "settled=\(streamingResult.settled) chars=\(streamingResult.text.count)"
|
|
1491
|
+
))
|
|
1492
|
+
|
|
1493
|
+
// The start context was captured concurrently at recording start; by the time
|
|
1494
|
+
// the realtime transcript has settled it is resolved in all but pathological
|
|
1495
|
+
// cases, and its Accessibility reads are bounded either way.
|
|
1496
|
+
let startContext = await captureConfiguration.startContext.value
|
|
1497
|
+
let selectionToken = startContext.selectionToken
|
|
1498
|
+
let activeProjectId = startContext.displayProjectId
|
|
1499
|
+
let canonicalProjectId = startContext.canonicalProjectId
|
|
1500
|
+
let activeProjectName = startContext.activeProjectName
|
|
1501
|
+
let processingConfiguration = startContext.processing
|
|
1502
|
+
let postProcessingMode = processingConfiguration.postProcessingMode
|
|
1503
|
+
let busyLabel = Self.shouldLabelRewriting(
|
|
1504
|
+
postProcessingMode: postProcessingMode
|
|
1505
|
+
) ? "Rewriting..." : "Transcribing..."
|
|
553
1506
|
|
|
554
1507
|
self.realtimeClient = nil
|
|
555
1508
|
self.streamingTask?.cancel()
|
|
556
1509
|
self.streamingTask = nil
|
|
1510
|
+
if self.isTranscribing {
|
|
1511
|
+
self.statusMessage = busyLabel
|
|
1512
|
+
self.flowPhase = .processing(busyLabel)
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if let error = streamingResult.error {
|
|
1516
|
+
self.log("realtime finish reported error=\(error)")
|
|
1517
|
+
}
|
|
557
1518
|
|
|
558
|
-
let realtimeText = Self.normalizedRealtimeTranscript(streamingResult)
|
|
1519
|
+
let realtimeText = Self.normalizedRealtimeTranscript(streamingResult.text)
|
|
1520
|
+
let safeRealtimeFallbackText = Self.settledRealtimeFallbackTranscript(
|
|
1521
|
+
finishResult: streamingResult,
|
|
1522
|
+
pcmByteCount: self.recordedPCM.count,
|
|
1523
|
+
language: processingConfiguration.transcriptionLanguage
|
|
1524
|
+
)
|
|
1525
|
+
let realtimeFastPathText = Self.settledRealtimeFastPathTranscript(
|
|
1526
|
+
finishResult: streamingResult,
|
|
1527
|
+
pcmByteCount: self.recordedPCM.count,
|
|
1528
|
+
language: processingConfiguration.transcriptionLanguage
|
|
1529
|
+
)
|
|
559
1530
|
|
|
560
1531
|
self.liveTranscriptionText = ""
|
|
561
1532
|
|
|
562
|
-
if let
|
|
1533
|
+
if let realtimeFastPathText {
|
|
1534
|
+
let pcmData = self.recordedPCM
|
|
1535
|
+
let durationMs = Int(self.recordingDuration * 1_000)
|
|
1536
|
+
let language = OpenAIAPIKeyStore.apiLanguageHint(for: processingConfiguration.transcriptionLanguage)
|
|
1537
|
+
let homePath = self.home
|
|
1538
|
+
self.log(pipelineTrace.message(
|
|
1539
|
+
stage: "realtime_fast_path_ready",
|
|
1540
|
+
detail: "chars=\(realtimeFastPathText.count) pcm_bytes=\(pcmData.count)"
|
|
1541
|
+
))
|
|
1542
|
+
let persist: @Sendable () async -> RealtimeFastPathSaveResult = {
|
|
1543
|
+
await Self.saveRealtimeTranscript(
|
|
1544
|
+
text: realtimeFastPathText,
|
|
1545
|
+
audioPath: audioPath,
|
|
1546
|
+
pcmData: pcmData,
|
|
1547
|
+
durationMs: durationMs,
|
|
1548
|
+
activeProjectId: canonicalProjectId,
|
|
1549
|
+
processingConfiguration: processingConfiguration,
|
|
1550
|
+
language: language,
|
|
1551
|
+
recordingId: pipelineTrace.id,
|
|
1552
|
+
homePath: homePath,
|
|
1553
|
+
pipelineTrace: pipelineTrace
|
|
1554
|
+
)
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
if Self.shouldPasteBeforePersistence(
|
|
1558
|
+
postProcessingMode: postProcessingMode,
|
|
1559
|
+
transcript: realtimeFastPathText,
|
|
1560
|
+
hasSelection: selectionToken != nil,
|
|
1561
|
+
intentDetectionEnabled: processingConfiguration.intentDetectionEnabled
|
|
1562
|
+
) {
|
|
1563
|
+
self.isTranscribing = false
|
|
1564
|
+
_ = Self.deliverRealtimeBeforePersistence(
|
|
1565
|
+
text: realtimeFastPathText,
|
|
1566
|
+
persist: persist,
|
|
1567
|
+
deliver: { text in
|
|
1568
|
+
await withCheckedContinuation { continuation in
|
|
1569
|
+
self.finishWithText(
|
|
1570
|
+
text,
|
|
1571
|
+
rawTranscript: text,
|
|
1572
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1573
|
+
targetAppPid: targetAppPid,
|
|
1574
|
+
selectionToken: selectionToken,
|
|
1575
|
+
canonicalProjectId: canonicalProjectId,
|
|
1576
|
+
activeProjectId: activeProjectId,
|
|
1577
|
+
activeProjectName: activeProjectName,
|
|
1578
|
+
processingConfiguration: processingConfiguration,
|
|
1579
|
+
pipelineTrace: pipelineTrace,
|
|
1580
|
+
pipelineGeneration: pipelineGeneration,
|
|
1581
|
+
deliveryCompleted: { continuation.resume() }
|
|
1582
|
+
)
|
|
1583
|
+
}
|
|
1584
|
+
},
|
|
1585
|
+
persistenceCompleted: { result in
|
|
1586
|
+
self.recordPersistenceCompletion(savedText: result.text)
|
|
1587
|
+
if result.text == nil {
|
|
1588
|
+
self.recoverAsyncPersistenceFailure(
|
|
1589
|
+
error: result.error ?? "Realtime save returned no recording",
|
|
1590
|
+
audioPath: audioPath,
|
|
1591
|
+
pcmData: pcmData,
|
|
1592
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1593
|
+
targetAppPid: targetAppPid,
|
|
1594
|
+
selectionToken: selectionToken,
|
|
1595
|
+
canonicalProjectId: canonicalProjectId,
|
|
1596
|
+
displayProjectId: activeProjectId,
|
|
1597
|
+
activeProjectName: activeProjectName,
|
|
1598
|
+
processingConfiguration: processingConfiguration,
|
|
1599
|
+
pipelineTrace: pipelineTrace,
|
|
1600
|
+
pipelineGeneration: pipelineGeneration
|
|
1601
|
+
)
|
|
1602
|
+
} else {
|
|
1603
|
+
self.log(pipelineTrace.message(stage: "async_persistence_complete"))
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
)
|
|
1607
|
+
self.activeAudioPath = nil
|
|
1608
|
+
self.recordedPCM.removeAll(keepingCapacity: true)
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
let saveResult = await persist()
|
|
1613
|
+
self.recordPersistenceCompletion(savedText: saveResult.text)
|
|
1614
|
+
guard let savedText = saveResult.text else {
|
|
1615
|
+
self.log("realtime fast-path save failed error=\(saveResult.error ?? "unknown")")
|
|
1616
|
+
if let audioPath, FileManager.default.fileExists(atPath: audioPath) || self.writeCapturedWAV(to: audioPath) {
|
|
1617
|
+
self.fallbackTranscribe(
|
|
1618
|
+
audioPath: audioPath,
|
|
1619
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1620
|
+
targetAppPid: targetAppPid,
|
|
1621
|
+
selectionToken: selectionToken,
|
|
1622
|
+
canonicalProjectId: canonicalProjectId,
|
|
1623
|
+
displayProjectId: activeProjectId,
|
|
1624
|
+
activeProjectName: activeProjectName,
|
|
1625
|
+
processingConfiguration: processingConfiguration,
|
|
1626
|
+
realtimeText: safeRealtimeFallbackText,
|
|
1627
|
+
pipelineTrace: pipelineTrace,
|
|
1628
|
+
pipelineGeneration: pipelineGeneration
|
|
1629
|
+
)
|
|
1630
|
+
} else {
|
|
1631
|
+
self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
|
|
1632
|
+
self.finish(saveResult.error ?? "Failed to save transcription")
|
|
1633
|
+
}
|
|
1634
|
+
self.activeAudioPath = nil
|
|
1635
|
+
self.recordedPCM.removeAll(keepingCapacity: true)
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
self.isTranscribing = false
|
|
1639
|
+
self.finishWithText(
|
|
1640
|
+
savedText,
|
|
1641
|
+
rawTranscript: realtimeFastPathText,
|
|
1642
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1643
|
+
targetAppPid: targetAppPid,
|
|
1644
|
+
selectionToken: selectionToken,
|
|
1645
|
+
canonicalProjectId: canonicalProjectId,
|
|
1646
|
+
activeProjectId: activeProjectId,
|
|
1647
|
+
activeProjectName: activeProjectName,
|
|
1648
|
+
processingConfiguration: processingConfiguration,
|
|
1649
|
+
pipelineTrace: pipelineTrace,
|
|
1650
|
+
pipelineGeneration: pipelineGeneration
|
|
1651
|
+
)
|
|
1652
|
+
} else if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
1653
|
+
self.log(pipelineTrace.message(stage: "wav_write_complete", detail: "path=\(audioPath)"))
|
|
1654
|
+
if realtimeText != nil, !streamingResult.settled {
|
|
1655
|
+
self.log("realtime fast path skipped because final transcript did not settle")
|
|
1656
|
+
}
|
|
563
1657
|
self.log("transcribing captured full audio with quality model audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
|
|
564
1658
|
self.fallbackTranscribe(
|
|
565
1659
|
audioPath: audioPath,
|
|
566
|
-
curMode: curMode,
|
|
567
1660
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
568
1661
|
targetAppPid: targetAppPid,
|
|
569
|
-
|
|
1662
|
+
selectionToken: selectionToken,
|
|
1663
|
+
canonicalProjectId: canonicalProjectId,
|
|
1664
|
+
displayProjectId: activeProjectId,
|
|
570
1665
|
activeProjectName: activeProjectName,
|
|
571
|
-
|
|
1666
|
+
processingConfiguration: processingConfiguration,
|
|
1667
|
+
realtimeText: safeRealtimeFallbackText,
|
|
1668
|
+
pipelineTrace: pipelineTrace,
|
|
1669
|
+
pipelineGeneration: pipelineGeneration
|
|
572
1670
|
)
|
|
573
1671
|
} else {
|
|
574
|
-
let resolved = Self.resolveFinalTranscript(
|
|
1672
|
+
let resolved = Self.resolveFinalTranscript(
|
|
1673
|
+
cliText: nil,
|
|
1674
|
+
cliError: "No audio captured",
|
|
1675
|
+
realtimeText: safeRealtimeFallbackText
|
|
1676
|
+
)
|
|
575
1677
|
if let text = resolved.text {
|
|
576
1678
|
self.log("no audio file written; using realtime transcript chars=\(text.count)")
|
|
577
1679
|
self.isTranscribing = false
|
|
578
1680
|
self.finishWithText(
|
|
579
1681
|
text,
|
|
580
|
-
|
|
1682
|
+
rawTranscript: text,
|
|
581
1683
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
582
1684
|
targetAppPid: targetAppPid,
|
|
1685
|
+
selectionToken: selectionToken,
|
|
1686
|
+
canonicalProjectId: canonicalProjectId,
|
|
583
1687
|
activeProjectId: activeProjectId,
|
|
584
|
-
activeProjectName: activeProjectName
|
|
1688
|
+
activeProjectName: activeProjectName,
|
|
1689
|
+
processingConfiguration: processingConfiguration,
|
|
1690
|
+
pipelineTrace: pipelineTrace,
|
|
1691
|
+
pipelineGeneration: pipelineGeneration
|
|
585
1692
|
)
|
|
586
1693
|
} else {
|
|
587
1694
|
self.log("no audio captured")
|
|
1695
|
+
self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
|
|
588
1696
|
self.finish(resolved.failureStatus ?? "No audio captured")
|
|
589
1697
|
}
|
|
590
1698
|
}
|
|
@@ -607,6 +1715,75 @@ public final class RecordingEngine: ObservableObject {
|
|
|
607
1715
|
return trimmed.isEmpty ? nil : trimmed
|
|
608
1716
|
}
|
|
609
1717
|
|
|
1718
|
+
public nonisolated static func settledRealtimeFastPathTranscript(
|
|
1719
|
+
finishResult: RealtimeFinishResult,
|
|
1720
|
+
pcmByteCount: Int,
|
|
1721
|
+
language: String
|
|
1722
|
+
) -> String? {
|
|
1723
|
+
guard let text = settledRealtimeFallbackTranscript(
|
|
1724
|
+
finishResult: finishResult,
|
|
1725
|
+
pcmByteCount: pcmByteCount,
|
|
1726
|
+
language: language
|
|
1727
|
+
) else { return nil }
|
|
1728
|
+
return text
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
public nonisolated static func settledRealtimeFallbackTranscript(
|
|
1732
|
+
finishResult: RealtimeFinishResult,
|
|
1733
|
+
pcmByteCount: Int,
|
|
1734
|
+
language: String
|
|
1735
|
+
) -> String? {
|
|
1736
|
+
guard finishResult.settled, finishResult.error == nil else { return nil }
|
|
1737
|
+
guard let text = safeRealtimeFallbackTranscript(
|
|
1738
|
+
realtimeText: finishResult.text,
|
|
1739
|
+
language: language
|
|
1740
|
+
) else { return nil }
|
|
1741
|
+
return shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount) ? nil : text
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/// Delivery may only run ahead of persistence for transcripts the local screen already
|
|
1745
|
+
/// decided are plain dictation: the paste is near-instant, so persistence is deferred by
|
|
1746
|
+
/// milliseconds. Command/conversation-shaped transcripts persist first — their delivery
|
|
1747
|
+
/// can block on the classifier, the assistant, or the rewrite CLI, and the recording
|
|
1748
|
+
/// must already be durable by then.
|
|
1749
|
+
nonisolated static func shouldPasteBeforePersistence(
|
|
1750
|
+
postProcessingMode: String,
|
|
1751
|
+
transcript: String,
|
|
1752
|
+
hasSelection: Bool,
|
|
1753
|
+
intentDetectionEnabled: Bool
|
|
1754
|
+
) -> Bool {
|
|
1755
|
+
guard PostProcessingMode(rawValue: postProcessingMode) == .off else { return false }
|
|
1756
|
+
guard intentDetectionEnabled else { return true }
|
|
1757
|
+
return IntentScreen.screen(text: transcript, hasSelection: hasSelection)?.intent == .dictate
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
nonisolated static func shouldLabelRewriting(
|
|
1761
|
+
postProcessingMode: String
|
|
1762
|
+
) -> Bool {
|
|
1763
|
+
PostProcessingMode(rawValue: postProcessingMode) != .off
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
@MainActor
|
|
1767
|
+
static func deliverRealtimeBeforePersistence(
|
|
1768
|
+
text: String,
|
|
1769
|
+
persist: @escaping @Sendable () async -> RealtimeFastPathSaveResult,
|
|
1770
|
+
deliver: @escaping @MainActor @Sendable (String) async -> Void,
|
|
1771
|
+
persistenceCompleted: @escaping @MainActor @Sendable (RealtimeFastPathSaveResult) -> Void
|
|
1772
|
+
) -> Task<Void, Never> {
|
|
1773
|
+
return Task {
|
|
1774
|
+
await deliver(text)
|
|
1775
|
+
let result = await persist()
|
|
1776
|
+
persistenceCompleted(result)
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/// Publishes a monotonic completion event only for confirmed persistence. A failed
|
|
1781
|
+
/// helper result must not make the Library appear current before recovery succeeds.
|
|
1782
|
+
func recordPersistenceCompletion(savedText: String?) {
|
|
1783
|
+
guard savedText != nil else { return }
|
|
1784
|
+
persistedRecordingRevision &+= 1
|
|
1785
|
+
}
|
|
1786
|
+
|
|
610
1787
|
public nonisolated static func shouldUseRealtimeFastPath(
|
|
611
1788
|
realtimeText: String?,
|
|
612
1789
|
pcmByteCount: Int,
|
|
@@ -624,30 +1801,49 @@ public final class RecordingEngine: ObservableObject {
|
|
|
624
1801
|
pcmByteCount: Int,
|
|
625
1802
|
language: String = "en"
|
|
626
1803
|
) -> String? {
|
|
627
|
-
guard let text =
|
|
628
|
-
guard isSafeRealtimeFastPathText(rawText: realtimeText ?? "", cleanedText: text, language: language) else {
|
|
629
|
-
return nil
|
|
630
|
-
}
|
|
1804
|
+
guard let text = safeRealtimeFallbackTranscript(realtimeText: realtimeText, language: language) else { return nil }
|
|
631
1805
|
return shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount) ? nil : text
|
|
632
1806
|
}
|
|
633
1807
|
|
|
1808
|
+
public nonisolated static func safeRealtimeFallbackTranscript(
|
|
1809
|
+
realtimeText: String?,
|
|
1810
|
+
language: String = "en"
|
|
1811
|
+
) -> String? {
|
|
1812
|
+
guard let text = normalizedRealtimeTranscript(realtimeText) else { return nil }
|
|
1813
|
+
guard isSafeRealtimeFastPathText(
|
|
1814
|
+
rawText: realtimeText ?? "",
|
|
1815
|
+
cleanedText: text,
|
|
1816
|
+
language: language
|
|
1817
|
+
) else { return nil }
|
|
1818
|
+
return text
|
|
1819
|
+
}
|
|
1820
|
+
|
|
634
1821
|
public nonisolated static func isSafeRealtimeFastPathText(rawText: String, cleanedText: String, language: String) -> Bool {
|
|
635
1822
|
guard !cleanedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
|
636
1823
|
let languageHint = OpenAIAPIKeyStore.apiLanguageHint(for: language)
|
|
637
|
-
guard languageHint == "en" else { return true }
|
|
638
1824
|
|
|
639
1825
|
let rawTrimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
640
1826
|
let rawNormalized = normalizedTranscriptText(rawTrimmed)
|
|
641
1827
|
let cleanedNormalized = normalizedTranscriptText(cleanedText)
|
|
642
1828
|
guard !cleanedNormalized.isEmpty else { return false }
|
|
1829
|
+
if languageHint == "en" {
|
|
1830
|
+
guard cjkLetterCount(in: cleanedNormalized) == 0 else { return false }
|
|
1831
|
+
}
|
|
643
1832
|
guard rawNormalized != cleanedNormalized else { return true }
|
|
644
1833
|
guard cjkLetterCount(in: cleanedNormalized) == 0 else { return false }
|
|
645
1834
|
|
|
646
|
-
let cleanedWords = canonicalTranscriptWords(cleanedNormalized
|
|
1835
|
+
let cleanedWords = canonicalTranscriptWords(cleanedNormalized)
|
|
647
1836
|
guard !cleanedWords.isEmpty else { return false }
|
|
648
1837
|
|
|
649
|
-
|
|
650
|
-
|
|
1838
|
+
// CJK fragments are known realtime transport artifacts, but repeated words and
|
|
1839
|
+
// fillers may be intentional speech. The fast path is only safe when cleanup
|
|
1840
|
+
// preserves every lexical token; otherwise the whole WAV is transcribed.
|
|
1841
|
+
let rawWords = languageHint == "en"
|
|
1842
|
+
? canonicalTranscriptWordsPreservingSpeechTokens(rawNormalized)
|
|
1843
|
+
: canonicalTranscriptWords(rawNormalized)
|
|
1844
|
+
guard cleanedWords == rawWords else { return false }
|
|
1845
|
+
|
|
1846
|
+
guard languageHint == "en" else { return true }
|
|
651
1847
|
|
|
652
1848
|
let rawCJKCount = cjkLetterCount(in: rawNormalized)
|
|
653
1849
|
if rawCJKCount > 0 {
|
|
@@ -656,8 +1852,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
656
1852
|
guard rawCJKCount <= max(6, cleanedLatinCount / 3) else { return false }
|
|
657
1853
|
}
|
|
658
1854
|
|
|
659
|
-
|
|
660
|
-
return removedWordCount <= max(8, cleanedWords.count + 3)
|
|
1855
|
+
return true
|
|
661
1856
|
}
|
|
662
1857
|
|
|
663
1858
|
public nonisolated static func wasRealtimeTranscriptRepaired(rawText: String, cleanedText: String) -> Bool {
|
|
@@ -691,17 +1886,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
691
1886
|
|
|
692
1887
|
private nonisolated static func removeStandaloneRealtimeArtifacts(from text: String) -> String {
|
|
693
1888
|
let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
|
|
694
|
-
let englishDominant = latinLetterCount(in: text) >= max(12, cjkLetterCount(in: text) * 3)
|
|
695
1889
|
let words = text.split(separator: " ").compactMap { rawWord -> String? in
|
|
696
1890
|
let normalized = rawWord
|
|
697
1891
|
.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
698
1892
|
.lowercased()
|
|
699
1893
|
return artifactTokens.contains(normalized) ? nil : String(rawWord)
|
|
700
|
-
}.compactMap { word -> String? in
|
|
701
|
-
guard englishDominant else { return word }
|
|
702
|
-
let normalized = word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
703
|
-
guard !normalized.isEmpty else { return word }
|
|
704
|
-
return isMostlyCJKArtifact(normalized) ? nil : word
|
|
705
1894
|
}
|
|
706
1895
|
return words.joined(separator: " ")
|
|
707
1896
|
}
|
|
@@ -718,41 +1907,32 @@ public final class RecordingEngine: ObservableObject {
|
|
|
718
1907
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
719
1908
|
}
|
|
720
1909
|
|
|
721
|
-
private nonisolated static func canonicalTranscriptWords(_ text: String
|
|
1910
|
+
private nonisolated static func canonicalTranscriptWords(_ text: String) -> [String] {
|
|
722
1911
|
text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).compactMap { rawWord in
|
|
723
|
-
|
|
1912
|
+
let normalized = String(rawWord)
|
|
724
1913
|
.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
725
1914
|
.lowercased()
|
|
726
|
-
if droppingArtifacts {
|
|
727
|
-
normalized = normalized.unicodeScalars.reduce(into: "") { output, scalar in
|
|
728
|
-
if !isCJKScalar(scalar) {
|
|
729
|
-
output.unicodeScalars.append(scalar)
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
1915
|
guard !normalized.isEmpty else { return nil }
|
|
734
|
-
if droppingArtifacts, isStandaloneRealtimeArtifact(normalized) {
|
|
735
|
-
return nil
|
|
736
|
-
}
|
|
737
1916
|
return normalized
|
|
738
1917
|
}
|
|
739
1918
|
}
|
|
740
1919
|
|
|
741
|
-
private nonisolated static func
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if
|
|
747
|
-
return
|
|
1920
|
+
private nonisolated static func canonicalTranscriptWordsPreservingSpeechTokens(_ text: String) -> [String] {
|
|
1921
|
+
text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).compactMap { rawWord in
|
|
1922
|
+
var normalized = String(rawWord)
|
|
1923
|
+
.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
1924
|
+
.lowercased()
|
|
1925
|
+
if normalized == "어" || normalized == "음" {
|
|
1926
|
+
return nil
|
|
1927
|
+
}
|
|
1928
|
+
if normalized.hasSuffix("어") {
|
|
1929
|
+
let withoutSuffix = String(normalized.dropLast())
|
|
1930
|
+
if latinLetterCount(in: withoutSuffix) > 0 {
|
|
1931
|
+
normalized = withoutSuffix
|
|
1932
|
+
}
|
|
748
1933
|
}
|
|
1934
|
+
return normalized.isEmpty ? nil : normalized
|
|
749
1935
|
}
|
|
750
|
-
return false
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
private nonisolated static func isStandaloneRealtimeArtifact(_ word: String) -> Bool {
|
|
754
|
-
let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
|
|
755
|
-
return artifactTokens.contains(word) || isMostlyCJKArtifact(word)
|
|
756
1936
|
}
|
|
757
1937
|
|
|
758
1938
|
private nonisolated static func collapseAdjacentDuplicateWords(in text: String) -> String {
|
|
@@ -800,13 +1980,6 @@ public final class RecordingEngine: ObservableObject {
|
|
|
800
1980
|
word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).lowercased()
|
|
801
1981
|
}
|
|
802
1982
|
|
|
803
|
-
private nonisolated static func isMostlyCJKArtifact(_ word: String) -> Bool {
|
|
804
|
-
let cjkCount = word.unicodeScalars.filter(isCJKScalar).count
|
|
805
|
-
guard cjkCount > 0 else { return false }
|
|
806
|
-
let letterCount = word.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
|
|
807
|
-
return cjkCount >= max(1, letterCount - cjkCount)
|
|
808
|
-
}
|
|
809
|
-
|
|
810
1983
|
private nonisolated static func latinLetterCount(in text: String) -> Int {
|
|
811
1984
|
text.unicodeScalars.filter { scalar in
|
|
812
1985
|
(65...90).contains(Int(scalar.value)) || (97...122).contains(Int(scalar.value))
|
|
@@ -830,29 +2003,288 @@ public final class RecordingEngine: ObservableObject {
|
|
|
830
2003
|
}
|
|
831
2004
|
}
|
|
832
2005
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
2006
|
+
func finishWithText(
|
|
2007
|
+
_ text: String,
|
|
2008
|
+
rawTranscript: String,
|
|
2009
|
+
targetAppBundleIdentifier: String?,
|
|
2010
|
+
targetAppPid: pid_t?,
|
|
2011
|
+
selectionToken: AccessibilitySelectionToken?,
|
|
2012
|
+
canonicalProjectId: String?,
|
|
2013
|
+
activeProjectId: String?,
|
|
2014
|
+
activeProjectName: String?,
|
|
2015
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2016
|
+
pipelineTrace: RecordingPipelineTrace? = nil,
|
|
2017
|
+
pipelineGeneration: UInt64? = nil,
|
|
2018
|
+
deliveryCompleted: (@MainActor @Sendable () -> Void)? = nil
|
|
2019
|
+
) {
|
|
2020
|
+
log("finishWithText chars=\(text.count) rawChars=\(rawTranscript.count)")
|
|
2021
|
+
if let pipelineGeneration,
|
|
2022
|
+
!pipelineDeliveryGate.claimDelivery(for: pipelineGeneration) {
|
|
2023
|
+
log("duplicate delivery suppressed pipeline_generation=\(pipelineGeneration)")
|
|
2024
|
+
deliveryCompleted?()
|
|
2025
|
+
return
|
|
2026
|
+
}
|
|
2027
|
+
// A delivery whose recording has been superseded (or that would land mid-recording)
|
|
2028
|
+
// is abandoned outright: the transcript is persisted to the library, and nothing may
|
|
2029
|
+
// paste into whatever the user is doing now.
|
|
2030
|
+
if isRecording || Self.shouldAbandonDelivery(
|
|
2031
|
+
pipelineGeneration: pipelineGeneration,
|
|
2032
|
+
currentGeneration: recordingGeneration,
|
|
2033
|
+
isRecording: isRecording
|
|
2034
|
+
) {
|
|
2035
|
+
log("delivery abandoned for superseded recording pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
|
|
2036
|
+
deliveryCompleted?()
|
|
837
2037
|
return
|
|
838
2038
|
}
|
|
839
2039
|
|
|
840
|
-
let
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
2040
|
+
let execute: @MainActor @Sendable (RoutedSpeechAction, IntentDecisionOrigin, Bool) -> Void = { [weak self] action, origin, transcriptRetainedInRecent in
|
|
2041
|
+
guard let self else {
|
|
2042
|
+
deliveryCompleted?()
|
|
2043
|
+
return
|
|
2044
|
+
}
|
|
2045
|
+
self.executeRoutedAction(
|
|
2046
|
+
action,
|
|
2047
|
+
origin: origin,
|
|
2048
|
+
transcriptRetainedInRecent: transcriptRetainedInRecent,
|
|
2049
|
+
text: text,
|
|
2050
|
+
rawTranscript: rawTranscript,
|
|
2051
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2052
|
+
targetAppPid: targetAppPid,
|
|
2053
|
+
selectionToken: selectionToken,
|
|
2054
|
+
canonicalProjectId: canonicalProjectId,
|
|
2055
|
+
activeProjectId: activeProjectId,
|
|
2056
|
+
activeProjectName: activeProjectName,
|
|
2057
|
+
processingConfiguration: processingConfiguration,
|
|
2058
|
+
pipelineTrace: pipelineTrace,
|
|
2059
|
+
pipelineGeneration: pipelineGeneration,
|
|
2060
|
+
deliveryCompleted: deliveryCompleted
|
|
2061
|
+
)
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// Voice shortcuts are explicit user-configured expansions and take precedence over
|
|
2065
|
+
// intent inference, exactly as they preceded routing before this flow existed.
|
|
2066
|
+
if let shortcutText = voiceShortcuts?.match(rawTranscript) {
|
|
2067
|
+
log("voice shortcut matched — pasting shortcut content")
|
|
2068
|
+
pasteIntoFrontApp(
|
|
2069
|
+
shortcutText,
|
|
2070
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2071
|
+
targetAppPid: targetAppPid,
|
|
2072
|
+
restoreClipboard: true,
|
|
2073
|
+
deliveryKind: .ordinaryDictation,
|
|
2074
|
+
pipelineTrace: pipelineTrace,
|
|
2075
|
+
pipelineGeneration: pipelineGeneration,
|
|
2076
|
+
deliveryCompleted: deliveryCompleted
|
|
2077
|
+
)
|
|
2078
|
+
insertRecentTranscription(
|
|
2079
|
+
rawText: rawTranscript,
|
|
846
2080
|
processedText: shortcutText,
|
|
847
|
-
timestamp: Date(),
|
|
848
2081
|
projectId: activeProjectId,
|
|
849
2082
|
projectName: activeProjectName
|
|
2083
|
+
)
|
|
2084
|
+
return
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Intent is always decided on the raw transcript — never on post-processed output,
|
|
2088
|
+
// which the enhancement pipeline may have rewritten.
|
|
2089
|
+
let routingContext = IntentRoutingContext(
|
|
2090
|
+
detectionEnabled: processingConfiguration.intentDetectionEnabled,
|
|
2091
|
+
hasSelection: selectionToken != nil,
|
|
2092
|
+
accessibilityTrusted: accessibilityTrustCheck()
|
|
2093
|
+
)
|
|
2094
|
+
if !routingContext.detectionEnabled {
|
|
2095
|
+
execute(IntentRouter.route(decision: nil, context: routingContext), .localScreen, false)
|
|
2096
|
+
return
|
|
2097
|
+
}
|
|
2098
|
+
if let localDecision = IntentScreen.screen(text: rawTranscript, hasSelection: routingContext.hasSelection) {
|
|
2099
|
+
log("intent decided locally intent=\(localDecision.intent.rawValue) reason=\(localDecision.reason)")
|
|
2100
|
+
execute(
|
|
2101
|
+
IntentRouter.route(decision: localDecision, context: routingContext),
|
|
2102
|
+
.localScreen,
|
|
2103
|
+
false
|
|
2104
|
+
)
|
|
2105
|
+
return
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// Consult the classifier. New recordings are blocked while the decision is pending,
|
|
2109
|
+
// and the generation is re-checked afterwards so a stale (or user-cancelled)
|
|
2110
|
+
// decision can never act on a later recording.
|
|
2111
|
+
// The transcript enters Recent before the pending phase begins: cancelling while
|
|
2112
|
+
// Deciding (or any later phase) promises "transcript saved to Recent", so it must
|
|
2113
|
+
// already be there.
|
|
2114
|
+
insertRecentTranscription(
|
|
2115
|
+
rawText: rawTranscript,
|
|
2116
|
+
processedText: nil,
|
|
2117
|
+
projectId: activeProjectId,
|
|
2118
|
+
projectName: activeProjectName
|
|
2119
|
+
)
|
|
2120
|
+
let deliveryGeneration = pipelineGeneration ?? recordingGeneration
|
|
2121
|
+
beginIntentDelivery(for: deliveryGeneration)
|
|
2122
|
+
updateDeliveryStatus("Deciding...", kind: .progress, pipelineGeneration: pipelineGeneration)
|
|
2123
|
+
if let pipelineTrace { log(pipelineTrace.message(stage: "intent_classification_started")) }
|
|
2124
|
+
let classifier = intentClassifier
|
|
2125
|
+
let intentModel = processingConfiguration.intentModel
|
|
2126
|
+
let hasSelection = routingContext.hasSelection
|
|
2127
|
+
let trustCheck = accessibilityTrustCheck
|
|
2128
|
+
Task { [weak self] in
|
|
2129
|
+
let outcome = await classifier.classify(
|
|
2130
|
+
transcript: rawTranscript,
|
|
2131
|
+
hasSelection: hasSelection,
|
|
2132
|
+
model: intentModel
|
|
2133
|
+
)
|
|
2134
|
+
guard let self else {
|
|
2135
|
+
deliveryCompleted?()
|
|
2136
|
+
return
|
|
2137
|
+
}
|
|
2138
|
+
self.endIntentDelivery(for: deliveryGeneration)
|
|
2139
|
+
guard deliveryGeneration == self.recordingGeneration, !self.isRecording else {
|
|
2140
|
+
self.log("stale intent decision abandoned pipeline_generation=\(deliveryGeneration)")
|
|
2141
|
+
deliveryCompleted?()
|
|
2142
|
+
return
|
|
2143
|
+
}
|
|
2144
|
+
let decision: IntentDecision?
|
|
2145
|
+
switch outcome {
|
|
2146
|
+
case .decision(let classified):
|
|
2147
|
+
decision = classified
|
|
2148
|
+
self.log("intent classified intent=\(classified.intent.rawValue) confidence=\(classified.confidence) reason=\(classified.reason)")
|
|
2149
|
+
case .unavailable(let message):
|
|
2150
|
+
decision = nil
|
|
2151
|
+
self.log("intent classifier unavailable — failing closed to dictation: \(message)")
|
|
2152
|
+
}
|
|
2153
|
+
if let pipelineTrace { self.log(pipelineTrace.message(stage: "intent_classification_complete")) }
|
|
2154
|
+
let action = IntentRouter.route(
|
|
2155
|
+
decision: decision,
|
|
2156
|
+
context: IntentRoutingContext(
|
|
2157
|
+
detectionEnabled: true,
|
|
2158
|
+
hasSelection: hasSelection,
|
|
2159
|
+
accessibilityTrusted: trustCheck()
|
|
2160
|
+
)
|
|
2161
|
+
)
|
|
2162
|
+
execute(action, .classifier, true)
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
private func executeRoutedAction(
|
|
2167
|
+
_ action: RoutedSpeechAction,
|
|
2168
|
+
origin: IntentDecisionOrigin,
|
|
2169
|
+
transcriptRetainedInRecent: Bool,
|
|
2170
|
+
text: String,
|
|
2171
|
+
rawTranscript: String,
|
|
2172
|
+
targetAppBundleIdentifier: String?,
|
|
2173
|
+
targetAppPid: pid_t?,
|
|
2174
|
+
selectionToken: AccessibilitySelectionToken?,
|
|
2175
|
+
canonicalProjectId: String?,
|
|
2176
|
+
activeProjectId: String?,
|
|
2177
|
+
activeProjectName: String?,
|
|
2178
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2179
|
+
pipelineTrace: RecordingPipelineTrace?,
|
|
2180
|
+
pipelineGeneration: UInt64?,
|
|
2181
|
+
deliveryCompleted: (@MainActor @Sendable () -> Void)?
|
|
2182
|
+
) {
|
|
2183
|
+
switch action {
|
|
2184
|
+
case .paste(let reason, let literalRawTranscript):
|
|
2185
|
+
log("intent route=paste origin=\(origin.rawValue) literal=\(literalRawTranscript) reason=\(reason)")
|
|
2186
|
+
let output = literalRawTranscript ? rawTranscript : text
|
|
2187
|
+
pasteIntoFrontApp(
|
|
2188
|
+
output,
|
|
2189
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2190
|
+
targetAppPid: targetAppPid,
|
|
2191
|
+
restoreClipboard: true,
|
|
2192
|
+
deliveryKind: .ordinaryDictation,
|
|
2193
|
+
pipelineTrace: pipelineTrace,
|
|
2194
|
+
pipelineGeneration: pipelineGeneration,
|
|
2195
|
+
deliveryCompleted: deliveryCompleted
|
|
2196
|
+
)
|
|
2197
|
+
if transcriptRetainedInRecent {
|
|
2198
|
+
attachProcessedTextToRecentTranscription(
|
|
2199
|
+
rawText: rawTranscript,
|
|
2200
|
+
processedText: output == rawTranscript ? nil : output
|
|
2201
|
+
)
|
|
2202
|
+
} else {
|
|
2203
|
+
insertRecentTranscription(
|
|
2204
|
+
rawText: rawTranscript,
|
|
2205
|
+
processedText: output == rawTranscript ? nil : output,
|
|
2206
|
+
projectId: activeProjectId,
|
|
2207
|
+
projectName: activeProjectName
|
|
2208
|
+
)
|
|
2209
|
+
}
|
|
2210
|
+
case .rewriteSelection(let reason):
|
|
2211
|
+
log("intent route=rewriteSelection origin=\(origin.rawValue) reason=\(reason)")
|
|
2212
|
+
// Retention before processing: the Rewriting phase can be cancelled (or fail),
|
|
2213
|
+
// and the Cancel affordance promises the transcript stays in Recent.
|
|
2214
|
+
if !transcriptRetainedInRecent {
|
|
2215
|
+
insertRecentTranscription(
|
|
2216
|
+
rawText: rawTranscript,
|
|
2217
|
+
processedText: nil,
|
|
2218
|
+
projectId: activeProjectId,
|
|
2219
|
+
projectName: activeProjectName
|
|
2220
|
+
)
|
|
2221
|
+
}
|
|
2222
|
+
runCommandMode(
|
|
2223
|
+
instruction: rawTranscript,
|
|
2224
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2225
|
+
targetAppPid: targetAppPid,
|
|
2226
|
+
selectionToken: selectionToken,
|
|
2227
|
+
canonicalProjectId: canonicalProjectId,
|
|
2228
|
+
processingConfiguration: processingConfiguration,
|
|
2229
|
+
pipelineTrace: pipelineTrace,
|
|
2230
|
+
pipelineGeneration: pipelineGeneration,
|
|
2231
|
+
deliveryCompleted: deliveryCompleted
|
|
2232
|
+
)
|
|
2233
|
+
case .answerConversation(let reason):
|
|
2234
|
+
log("intent route=answerConversation origin=\(origin.rawValue) reason=\(reason)")
|
|
2235
|
+
if !transcriptRetainedInRecent {
|
|
2236
|
+
insertRecentTranscription(
|
|
2237
|
+
rawText: rawTranscript,
|
|
2238
|
+
processedText: nil,
|
|
2239
|
+
projectId: activeProjectId,
|
|
2240
|
+
projectName: activeProjectName
|
|
2241
|
+
)
|
|
2242
|
+
}
|
|
2243
|
+
runConversationMode(
|
|
2244
|
+
question: rawTranscript,
|
|
2245
|
+
processingConfiguration: processingConfiguration,
|
|
2246
|
+
pipelineTrace: pipelineTrace,
|
|
2247
|
+
pipelineGeneration: pipelineGeneration,
|
|
2248
|
+
deliveryCompleted: deliveryCompleted
|
|
2249
|
+
)
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
private func insertRecentTranscription(
|
|
2254
|
+
rawText: String,
|
|
2255
|
+
processedText: String?,
|
|
2256
|
+
projectId: String?,
|
|
2257
|
+
projectName: String?
|
|
2258
|
+
) {
|
|
2259
|
+
recentTranscriptions.insert(
|
|
2260
|
+
TranscriptionResult(
|
|
2261
|
+
rawText: rawText,
|
|
2262
|
+
processedText: processedText,
|
|
2263
|
+
timestamp: Date(),
|
|
2264
|
+
projectId: projectId,
|
|
2265
|
+
projectName: projectName
|
|
850
2266
|
),
|
|
851
2267
|
at: 0
|
|
852
2268
|
)
|
|
853
2269
|
if recentTranscriptions.count > 20 { recentTranscriptions.removeLast() }
|
|
854
2270
|
}
|
|
855
2271
|
|
|
2272
|
+
/// Backfills the processed text onto a transcript that entered Recent when its pending
|
|
2273
|
+
/// phase began, so the entry shows exactly what was pasted. The original timestamp is
|
|
2274
|
+
/// preserved.
|
|
2275
|
+
private func attachProcessedTextToRecentTranscription(rawText: String, processedText: String?) {
|
|
2276
|
+
guard let processedText,
|
|
2277
|
+
let index = recentTranscriptions.firstIndex(where: { $0.rawText == rawText }) else { return }
|
|
2278
|
+
let existing = recentTranscriptions[index]
|
|
2279
|
+
recentTranscriptions[index] = TranscriptionResult(
|
|
2280
|
+
rawText: existing.rawText,
|
|
2281
|
+
processedText: processedText,
|
|
2282
|
+
timestamp: existing.timestamp,
|
|
2283
|
+
projectId: existing.projectId,
|
|
2284
|
+
projectName: existing.projectName
|
|
2285
|
+
)
|
|
2286
|
+
}
|
|
2287
|
+
|
|
856
2288
|
private func writeCapturedWAV(to path: String) -> Bool {
|
|
857
2289
|
guard !recordedPCM.isEmpty else { return false }
|
|
858
2290
|
do {
|
|
@@ -872,21 +2304,97 @@ public final class RecordingEngine: ObservableObject {
|
|
|
872
2304
|
}
|
|
873
2305
|
}
|
|
874
2306
|
|
|
875
|
-
private nonisolated static func
|
|
876
|
-
|
|
2307
|
+
private nonisolated static func saveRealtimeTranscript(
|
|
2308
|
+
text: String,
|
|
2309
|
+
audioPath: String?,
|
|
2310
|
+
pcmData: Data,
|
|
2311
|
+
durationMs: Int,
|
|
2312
|
+
activeProjectId: String?,
|
|
2313
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2314
|
+
language: String,
|
|
2315
|
+
recordingId: String,
|
|
2316
|
+
homePath: String,
|
|
2317
|
+
pipelineTrace: RecordingPipelineTrace
|
|
2318
|
+
) async -> RealtimeFastPathSaveResult {
|
|
2319
|
+
await Task.detached(priority: .utility) {
|
|
877
2320
|
do {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
2321
|
+
var savedAudioPath: String?
|
|
2322
|
+
if let audioPath, !pcmData.isEmpty {
|
|
2323
|
+
NativeAppLog.write(
|
|
2324
|
+
pipelineTrace.message(stage: "wav_write_started", detail: "pcm_bytes=\(pcmData.count)"),
|
|
2325
|
+
homePath: homePath
|
|
2326
|
+
)
|
|
2327
|
+
try Self.writeWAV(
|
|
2328
|
+
pcmData: pcmData,
|
|
2329
|
+
sampleRate: 24_000,
|
|
2330
|
+
channelCount: 1,
|
|
2331
|
+
bitsPerSample: 16,
|
|
2332
|
+
to: URL(fileURLWithPath: audioPath)
|
|
2333
|
+
)
|
|
2334
|
+
savedAudioPath = audioPath
|
|
2335
|
+
NativeAppLog.write(
|
|
2336
|
+
pipelineTrace.message(stage: "wav_write_complete", detail: "path=\(audioPath) pcm_bytes=\(pcmData.count)"),
|
|
2337
|
+
homePath: homePath
|
|
2338
|
+
)
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
let textFile = try Self.writeTemporaryTranscript(text: text, homePath: homePath)
|
|
2342
|
+
defer { try? FileManager.default.removeItem(atPath: textFile) }
|
|
2343
|
+
|
|
2344
|
+
let args = saveTextCLIArgs(
|
|
2345
|
+
textFile: textFile,
|
|
2346
|
+
audioPath: savedAudioPath,
|
|
2347
|
+
activeProjectId: activeProjectId,
|
|
2348
|
+
transcriberPrompt: processingConfiguration.transcriberPrompt,
|
|
2349
|
+
postProcessingMode: processingConfiguration.postProcessingMode,
|
|
2350
|
+
language: language,
|
|
2351
|
+
transcriptionModel: processingConfiguration.transcriptionModel,
|
|
2352
|
+
transcriberModel: processingConfiguration.transcriberModel,
|
|
2353
|
+
enhancementModel: processingConfiguration.enhancementModel,
|
|
2354
|
+
enhanceTriggersJSON: processingConfiguration.enhanceTriggersJSON,
|
|
2355
|
+
keywordTransformsJSON: processingConfiguration.keywordTransformsJSON,
|
|
2356
|
+
recordingId: recordingId,
|
|
2357
|
+
durationMs: durationMs,
|
|
2358
|
+
source: "realtime_fast_path",
|
|
2359
|
+
modelUsed: RealtimeTranscriptionClient.transcriptionModelID
|
|
2360
|
+
)
|
|
2361
|
+
NativeAppLog.write(
|
|
2362
|
+
pipelineTrace.message(stage: "helper_started", detail: "operation=save_text"),
|
|
2363
|
+
homePath: homePath
|
|
2364
|
+
)
|
|
2365
|
+
let output = CLIRunner.run(args, home: homePath)
|
|
2366
|
+
if let error = CLIRunner.parseError(output) {
|
|
2367
|
+
NativeAppLog.write(
|
|
2368
|
+
pipelineTrace.message(stage: "helper_processing_store_failed", detail: "error=\(NativeErrorSanitizer.sanitize(error))"),
|
|
2369
|
+
homePath: homePath
|
|
2370
|
+
)
|
|
2371
|
+
return RealtimeFastPathSaveResult(text: nil, error: error)
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
NativeAppLog.write(
|
|
2375
|
+
pipelineTrace.message(stage: "helper_processing_store_complete"),
|
|
2376
|
+
homePath: homePath
|
|
884
2377
|
)
|
|
885
|
-
|
|
2378
|
+
return RealtimeFastPathSaveResult(text: CLIRunner.parseJSON(output) ?? text, error: nil)
|
|
886
2379
|
} catch {
|
|
887
|
-
NativeAppLog.write(
|
|
2380
|
+
NativeAppLog.write(
|
|
2381
|
+
pipelineTrace.message(stage: "persistence_failed", detail: "error=\(NativeErrorSanitizer.sanitize(error.localizedDescription))"),
|
|
2382
|
+
homePath: homePath
|
|
2383
|
+
)
|
|
2384
|
+
return RealtimeFastPathSaveResult(text: nil, error: error.localizedDescription)
|
|
888
2385
|
}
|
|
889
|
-
}
|
|
2386
|
+
}.value
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
private nonisolated static func writeTemporaryTranscript(text: String, homePath: String) throws -> String {
|
|
2390
|
+
let dir = URL(fileURLWithPath: homePath)
|
|
2391
|
+
.appendingPathComponent(".hasna", isDirectory: true)
|
|
2392
|
+
.appendingPathComponent("recordings", isDirectory: true)
|
|
2393
|
+
.appendingPathComponent("tmp", isDirectory: true)
|
|
2394
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
2395
|
+
let url = dir.appendingPathComponent("realtime-\(UUID().uuidString).txt")
|
|
2396
|
+
try text.write(to: url, atomically: true, encoding: .utf8)
|
|
2397
|
+
return url.path
|
|
890
2398
|
}
|
|
891
2399
|
|
|
892
2400
|
private nonisolated static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
|
|
@@ -938,226 +2446,1154 @@ public final class RecordingEngine: ObservableObject {
|
|
|
938
2446
|
|
|
939
2447
|
// MARK: - Fallback Transcription
|
|
940
2448
|
|
|
941
|
-
private func
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
2449
|
+
private func recoverAsyncPersistenceFailure(
|
|
2450
|
+
error: String,
|
|
2451
|
+
audioPath: String?,
|
|
2452
|
+
pcmData: Data,
|
|
2453
|
+
targetAppBundleIdentifier: String?,
|
|
2454
|
+
targetAppPid: pid_t?,
|
|
2455
|
+
selectionToken: AccessibilitySelectionToken?,
|
|
2456
|
+
canonicalProjectId: String?,
|
|
2457
|
+
displayProjectId: String?,
|
|
2458
|
+
activeProjectName: String?,
|
|
2459
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2460
|
+
pipelineTrace: RecordingPipelineTrace,
|
|
2461
|
+
pipelineGeneration: UInt64
|
|
2462
|
+
) {
|
|
2463
|
+
let sanitizedError = NativeErrorSanitizer.sanitize(error)
|
|
2464
|
+
log(pipelineTrace.message(
|
|
2465
|
+
stage: "async_persistence_failed",
|
|
2466
|
+
detail: "error=\(sanitizedError)"
|
|
2467
|
+
))
|
|
2468
|
+
updateBackgroundRecoveryStatus(
|
|
2469
|
+
"Pasted; recovering recording...",
|
|
2470
|
+
kind: .success,
|
|
2471
|
+
pipelineGeneration: pipelineGeneration
|
|
2472
|
+
)
|
|
946
2473
|
|
|
947
2474
|
Task.detached {
|
|
948
|
-
let
|
|
949
|
-
|
|
950
|
-
|
|
2475
|
+
let recoveryAudioPath = Self.ensureBackgroundRecoveryAudio(
|
|
2476
|
+
audioPath: audioPath,
|
|
2477
|
+
pcmData: pcmData
|
|
2478
|
+
)
|
|
951
2479
|
|
|
952
2480
|
await MainActor.run {
|
|
953
|
-
|
|
954
|
-
self.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
let resolved = Self.resolveFinalTranscript(cliText: cliText, cliError: cliError, realtimeText: realtimeText)
|
|
960
|
-
guard let text = resolved.text else {
|
|
961
|
-
self.finish(resolved.failureStatus ?? "Transcription failed")
|
|
2481
|
+
guard let recoveryAudioPath else {
|
|
2482
|
+
self.updateBackgroundRecoveryStatus(
|
|
2483
|
+
"Pasted, but recording could not be saved: \(sanitizedError)",
|
|
2484
|
+
kind: .failure,
|
|
2485
|
+
pipelineGeneration: pipelineGeneration
|
|
2486
|
+
)
|
|
962
2487
|
return
|
|
963
2488
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
self.log("using realtime transcript fallback chars=\(text.count)")
|
|
967
|
-
} else {
|
|
968
|
-
self.log("cli transcription succeeded chars=\(text.count)")
|
|
969
|
-
}
|
|
970
|
-
self.isTranscribing = false
|
|
971
|
-
self.finishWithText(
|
|
972
|
-
text,
|
|
973
|
-
curMode: curMode,
|
|
2489
|
+
self.fallbackTranscribe(
|
|
2490
|
+
audioPath: recoveryAudioPath,
|
|
974
2491
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
975
2492
|
targetAppPid: targetAppPid,
|
|
976
|
-
|
|
977
|
-
|
|
2493
|
+
selectionToken: selectionToken,
|
|
2494
|
+
canonicalProjectId: canonicalProjectId,
|
|
2495
|
+
displayProjectId: displayProjectId,
|
|
2496
|
+
activeProjectName: activeProjectName,
|
|
2497
|
+
processingConfiguration: processingConfiguration,
|
|
2498
|
+
realtimeText: nil,
|
|
2499
|
+
pipelineTrace: pipelineTrace,
|
|
2500
|
+
deliverResult: false,
|
|
2501
|
+
backgroundRecoveryGeneration: pipelineGeneration
|
|
978
2502
|
)
|
|
979
2503
|
}
|
|
980
2504
|
}
|
|
981
2505
|
}
|
|
982
2506
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
2507
|
+
nonisolated static func ensureBackgroundRecoveryAudio(
|
|
2508
|
+
audioPath: String?,
|
|
2509
|
+
pcmData: Data
|
|
2510
|
+
) -> String? {
|
|
2511
|
+
guard let audioPath else { return nil }
|
|
2512
|
+
if FileManager.default.fileExists(atPath: audioPath) {
|
|
2513
|
+
return audioPath
|
|
2514
|
+
}
|
|
2515
|
+
guard !pcmData.isEmpty else { return nil }
|
|
2516
|
+
do {
|
|
2517
|
+
try writeWAV(
|
|
2518
|
+
pcmData: pcmData,
|
|
2519
|
+
sampleRate: 24_000,
|
|
2520
|
+
channelCount: 1,
|
|
2521
|
+
bitsPerSample: 16,
|
|
2522
|
+
to: URL(fileURLWithPath: audioPath)
|
|
2523
|
+
)
|
|
2524
|
+
return audioPath
|
|
2525
|
+
} catch {
|
|
2526
|
+
return nil
|
|
2527
|
+
}
|
|
994
2528
|
}
|
|
995
2529
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
2530
|
+
nonisolated static func shouldApplyBackgroundRecoveryStatus(
|
|
2531
|
+
recoveryGeneration: UInt64,
|
|
2532
|
+
currentGeneration: UInt64,
|
|
2533
|
+
isRecording: Bool,
|
|
2534
|
+
isTranscribing: Bool
|
|
2535
|
+
) -> Bool {
|
|
2536
|
+
recoveryGeneration == currentGeneration && !isRecording && !isTranscribing
|
|
1002
2537
|
}
|
|
1003
2538
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
)
|
|
1017
|
-
guard let targetApp else {
|
|
1018
|
-
log("command mode target app not found")
|
|
1019
|
-
statusMessage = "No target app found"
|
|
2539
|
+
private func updateBackgroundRecoveryStatus(
|
|
2540
|
+
_ message: String,
|
|
2541
|
+
kind: DeliveryStatusKind,
|
|
2542
|
+
pipelineGeneration: UInt64
|
|
2543
|
+
) {
|
|
2544
|
+
guard Self.shouldApplyBackgroundRecoveryStatus(
|
|
2545
|
+
recoveryGeneration: pipelineGeneration,
|
|
2546
|
+
currentGeneration: recordingGeneration,
|
|
2547
|
+
isRecording: isRecording,
|
|
2548
|
+
isTranscribing: isTranscribing
|
|
2549
|
+
) else {
|
|
2550
|
+
log("background recovery status suppressed for superseded pipeline generation=\(pipelineGeneration)")
|
|
1020
2551
|
return
|
|
1021
2552
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
}
|
|
2553
|
+
statusMessage = message
|
|
2554
|
+
flowPhase = Self.flowPhase(forDeliveryStatus: message, kind: kind)
|
|
2555
|
+
}
|
|
1026
2556
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
2557
|
+
nonisolated static func fallbackCompletionAction(
|
|
2558
|
+
cliText: String?,
|
|
2559
|
+
cliError: String?,
|
|
2560
|
+
realtimeText: String?,
|
|
2561
|
+
deliverResult: Bool
|
|
2562
|
+
) -> FallbackCompletionAction {
|
|
2563
|
+
let resolved = resolveFinalTranscript(
|
|
2564
|
+
cliText: cliText,
|
|
2565
|
+
cliError: cliError,
|
|
2566
|
+
realtimeText: realtimeText
|
|
2567
|
+
)
|
|
2568
|
+
guard let text = resolved.text else {
|
|
2569
|
+
let failure = resolved.failureStatus ?? "Transcription failed"
|
|
2570
|
+
return deliverResult ? .fail(failure) : .backgroundFailed(failure)
|
|
1030
2571
|
}
|
|
2572
|
+
return deliverResult ? .deliver(text) : .backgroundRecovered
|
|
2573
|
+
}
|
|
1031
2574
|
|
|
2575
|
+
private func fallbackTranscribe(
|
|
2576
|
+
audioPath: String,
|
|
2577
|
+
targetAppBundleIdentifier: String?,
|
|
2578
|
+
targetAppPid: pid_t?,
|
|
2579
|
+
selectionToken: AccessibilitySelectionToken?,
|
|
2580
|
+
canonicalProjectId: String?,
|
|
2581
|
+
displayProjectId: String?,
|
|
2582
|
+
activeProjectName: String?,
|
|
2583
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2584
|
+
realtimeText: String? = nil,
|
|
2585
|
+
pipelineTrace: RecordingPipelineTrace? = nil,
|
|
2586
|
+
pipelineGeneration: UInt64? = nil,
|
|
2587
|
+
deliverResult: Bool = true,
|
|
2588
|
+
backgroundRecoveryGeneration: UInt64? = nil
|
|
2589
|
+
) {
|
|
1032
2590
|
let homePath = home
|
|
1033
|
-
Task {
|
|
1034
|
-
try? await Task.sleep(for: .milliseconds(Int((copyDelay + 0.25) * 1_000)))
|
|
1035
|
-
let selected = NSPasteboard.general.string(forType: .string) ?? ""
|
|
1036
|
-
guard !selected.isEmpty else {
|
|
1037
|
-
statusMessage = "No text selected"
|
|
1038
|
-
return
|
|
1039
|
-
}
|
|
1040
|
-
statusMessage = "Rewriting..."
|
|
1041
|
-
isTranscribing = true
|
|
1042
2591
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
2592
|
+
if deliverResult {
|
|
2593
|
+
isTranscribing = true
|
|
2594
|
+
statusMessage = Self.shouldLabelRewriting(
|
|
2595
|
+
postProcessingMode: processingConfiguration.postProcessingMode
|
|
2596
|
+
) ? "Rewriting..." : "Transcribing..."
|
|
2597
|
+
flowPhase = .processing(statusMessage)
|
|
2598
|
+
} else {
|
|
2599
|
+
if let backgroundRecoveryGeneration {
|
|
2600
|
+
updateBackgroundRecoveryStatus(
|
|
2601
|
+
"Pasted; recovering recording...",
|
|
2602
|
+
kind: .success,
|
|
2603
|
+
pipelineGeneration: backgroundRecoveryGeneration
|
|
2604
|
+
)
|
|
1055
2605
|
}
|
|
1056
2606
|
}
|
|
1057
|
-
}
|
|
1058
2607
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
2608
|
+
// Only a proven canonical Store id may be persisted. The local display id remains
|
|
2609
|
+
// available to recent-transcript UI even when synchronization is degraded.
|
|
2610
|
+
let transcribeArgs = Self.transcribeCLIArgs(
|
|
2611
|
+
audioPath: audioPath,
|
|
2612
|
+
activeProjectId: canonicalProjectId,
|
|
2613
|
+
transcriberPrompt: processingConfiguration.transcriberPrompt,
|
|
2614
|
+
postProcessingMode: processingConfiguration.postProcessingMode,
|
|
2615
|
+
language: processingConfiguration.transcriptionLanguage,
|
|
2616
|
+
transcriptionPrompt: processingConfiguration.transcriptionPrompt,
|
|
2617
|
+
transcriptionModel: processingConfiguration.transcriptionModel,
|
|
2618
|
+
transcriberModel: processingConfiguration.transcriberModel,
|
|
2619
|
+
enhancementModel: processingConfiguration.enhancementModel,
|
|
2620
|
+
enhanceTriggersJSON: processingConfiguration.enhanceTriggersJSON,
|
|
2621
|
+
keywordTransformsJSON: processingConfiguration.keywordTransformsJSON,
|
|
2622
|
+
recordingId: pipelineTrace?.id
|
|
2623
|
+
)
|
|
1068
2624
|
|
|
1069
|
-
|
|
2625
|
+
Task.detached {
|
|
2626
|
+
if let pipelineTrace {
|
|
2627
|
+
NativeAppLog.write(
|
|
2628
|
+
pipelineTrace.message(stage: "helper_started", detail: "operation=batch_transcribe"),
|
|
2629
|
+
homePath: homePath
|
|
2630
|
+
)
|
|
2631
|
+
}
|
|
2632
|
+
let output = CLIRunner.run(transcribeArgs, home: homePath)
|
|
2633
|
+
let cliError = CLIRunner.parseError(output)
|
|
2634
|
+
let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
|
|
2635
|
+
let cliRawText = cliError == nil ? CLIRunner.parseRawTranscript(output) : nil
|
|
1070
2636
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
2637
|
+
await MainActor.run {
|
|
2638
|
+
if let pipelineTrace {
|
|
2639
|
+
self.log(pipelineTrace.message(
|
|
2640
|
+
stage: cliError == nil ? "helper_processing_store_complete" : "helper_processing_store_failed"
|
|
2641
|
+
))
|
|
2642
|
+
}
|
|
2643
|
+
if let cliError {
|
|
2644
|
+
self.log("cli transcription failed error=\(cliError)")
|
|
2645
|
+
} else if cliText == nil {
|
|
2646
|
+
self.log("cli transcription empty output=\(output.prefix(160))")
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
if cliText == nil {
|
|
2650
|
+
self.log("using realtime transcript fallback chars=\(realtimeText?.count ?? 0)")
|
|
2651
|
+
} else {
|
|
2652
|
+
self.log("cli transcription succeeded chars=\(cliText?.count ?? 0)")
|
|
2653
|
+
}
|
|
2654
|
+
self.recordPersistenceCompletion(savedText: cliError == nil ? cliText : nil)
|
|
2655
|
+
switch Self.fallbackCompletionAction(
|
|
2656
|
+
cliText: cliText,
|
|
2657
|
+
cliError: cliError,
|
|
2658
|
+
realtimeText: realtimeText,
|
|
2659
|
+
deliverResult: deliverResult
|
|
2660
|
+
) {
|
|
2661
|
+
case .deliver(let text):
|
|
2662
|
+
self.isTranscribing = false
|
|
2663
|
+
self.finishWithText(
|
|
2664
|
+
text,
|
|
2665
|
+
rawTranscript: cliRawText ?? realtimeText ?? text,
|
|
2666
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2667
|
+
targetAppPid: targetAppPid,
|
|
2668
|
+
selectionToken: selectionToken,
|
|
2669
|
+
canonicalProjectId: canonicalProjectId,
|
|
2670
|
+
activeProjectId: displayProjectId,
|
|
2671
|
+
activeProjectName: activeProjectName,
|
|
2672
|
+
processingConfiguration: processingConfiguration,
|
|
2673
|
+
pipelineTrace: pipelineTrace,
|
|
2674
|
+
pipelineGeneration: pipelineGeneration
|
|
2675
|
+
)
|
|
2676
|
+
case .fail(let failure):
|
|
2677
|
+
if let pipelineGeneration {
|
|
2678
|
+
self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
|
|
2679
|
+
}
|
|
2680
|
+
self.finish(failure)
|
|
2681
|
+
case .backgroundRecovered:
|
|
2682
|
+
if let pipelineTrace {
|
|
2683
|
+
self.log(pipelineTrace.message(stage: "async_persistence_recovered"))
|
|
2684
|
+
}
|
|
2685
|
+
if let backgroundRecoveryGeneration {
|
|
2686
|
+
self.updateBackgroundRecoveryStatus(
|
|
2687
|
+
"Pasted and saved",
|
|
2688
|
+
kind: .success,
|
|
2689
|
+
pipelineGeneration: backgroundRecoveryGeneration
|
|
2690
|
+
)
|
|
2691
|
+
}
|
|
2692
|
+
case .backgroundFailed(let failure):
|
|
2693
|
+
if let backgroundRecoveryGeneration {
|
|
2694
|
+
self.updateBackgroundRecoveryStatus(
|
|
2695
|
+
"Pasted, but recording could not be saved: \(failure)",
|
|
2696
|
+
kind: .failure,
|
|
2697
|
+
pipelineGeneration: backgroundRecoveryGeneration
|
|
2698
|
+
)
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
private func mostRecentAudioFile() -> String? {
|
|
2706
|
+
let files = (try? FileManager.default.contentsOfDirectory(atPath: audioDir)) ?? []
|
|
2707
|
+
let wavFiles = files.filter { $0.hasSuffix(".wav") }.sorted().reversed()
|
|
2708
|
+
return wavFiles.first.map { "\(audioDir)/\($0)" }
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
nonisolated static func transcribeCLIArgs(
|
|
2712
|
+
audioPath: String,
|
|
2713
|
+
activeProjectId: String?,
|
|
2714
|
+
transcriberPrompt: String,
|
|
2715
|
+
postProcessingMode: String,
|
|
2716
|
+
language: String = "auto",
|
|
2717
|
+
transcriptionPrompt: String? = nil,
|
|
2718
|
+
transcriptionModel: String? = nil,
|
|
2719
|
+
transcriberModel: String? = nil,
|
|
2720
|
+
enhancementModel: String? = nil,
|
|
2721
|
+
enhanceTriggersJSON: String? = nil,
|
|
2722
|
+
keywordTransformsJSON: String? = nil,
|
|
2723
|
+
recordingId: String? = nil
|
|
2724
|
+
) -> [String] {
|
|
2725
|
+
var args = ["--json"]
|
|
2726
|
+
if let activeProjectId, !activeProjectId.isEmpty {
|
|
2727
|
+
args += ["--project", activeProjectId]
|
|
2728
|
+
}
|
|
2729
|
+
args += ["transcribe", audioPath]
|
|
2730
|
+
|
|
2731
|
+
let languageHint = OpenAIAPIKeyStore.apiLanguageHint(for: language)
|
|
2732
|
+
if !languageHint.isEmpty {
|
|
2733
|
+
args += ["--language", languageHint]
|
|
2734
|
+
}
|
|
2735
|
+
if let recordingId, !recordingId.isEmpty {
|
|
2736
|
+
args += ["--recording-id", recordingId]
|
|
2737
|
+
}
|
|
2738
|
+
if let transcriptionPrompt, !transcriptionPrompt.isEmpty {
|
|
2739
|
+
args += ["--prompt", transcriptionPrompt]
|
|
2740
|
+
}
|
|
2741
|
+
if let transcriptionModel, !transcriptionModel.isEmpty {
|
|
2742
|
+
args += ["--transcription-model", transcriptionModel]
|
|
2743
|
+
}
|
|
2744
|
+
if let transcriberModel, !transcriberModel.isEmpty {
|
|
2745
|
+
args += ["--transcriber-model", transcriberModel]
|
|
2746
|
+
}
|
|
2747
|
+
if let enhancementModel, !enhancementModel.isEmpty {
|
|
2748
|
+
args += ["--enhancement-model", enhancementModel]
|
|
2749
|
+
}
|
|
2750
|
+
if let enhanceTriggersJSON, !enhanceTriggersJSON.isEmpty {
|
|
2751
|
+
args += ["--enhance-triggers-json", enhanceTriggersJSON]
|
|
2752
|
+
}
|
|
2753
|
+
if let keywordTransformsJSON, !keywordTransformsJSON.isEmpty {
|
|
2754
|
+
args += ["--keyword-transforms-json", keywordTransformsJSON]
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
let mode = PostProcessingMode(rawValue: postProcessingMode)?.rawValue ?? PostProcessingMode.auto.rawValue
|
|
2758
|
+
args += ["--post-processing", mode]
|
|
2759
|
+
|
|
2760
|
+
let prompt = transcriberPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2761
|
+
if !prompt.isEmpty {
|
|
2762
|
+
args += ["--transcriber-prompt", prompt]
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
return args
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
nonisolated static func saveTextCLIArgs(
|
|
2769
|
+
textFile: String,
|
|
2770
|
+
audioPath: String?,
|
|
2771
|
+
activeProjectId: String?,
|
|
2772
|
+
transcriberPrompt: String,
|
|
2773
|
+
postProcessingMode: String,
|
|
2774
|
+
language: String,
|
|
2775
|
+
transcriptionModel: String? = nil,
|
|
2776
|
+
transcriberModel: String? = nil,
|
|
2777
|
+
enhancementModel: String? = nil,
|
|
2778
|
+
enhanceTriggersJSON: String? = nil,
|
|
2779
|
+
keywordTransformsJSON: String? = nil,
|
|
2780
|
+
recordingId: String? = nil,
|
|
2781
|
+
durationMs: Int,
|
|
2782
|
+
source: String,
|
|
2783
|
+
modelUsed: String
|
|
2784
|
+
) -> [String] {
|
|
2785
|
+
var args = ["--json"]
|
|
2786
|
+
if let activeProjectId, !activeProjectId.isEmpty {
|
|
2787
|
+
args += ["--project", activeProjectId]
|
|
2788
|
+
}
|
|
2789
|
+
args += [
|
|
2790
|
+
"save-text",
|
|
2791
|
+
"--text-file", textFile,
|
|
2792
|
+
"--source", source,
|
|
2793
|
+
"--model-used", modelUsed,
|
|
2794
|
+
"--post-processing", PostProcessingMode(rawValue: postProcessingMode)?.rawValue ?? PostProcessingMode.auto.rawValue,
|
|
2795
|
+
]
|
|
2796
|
+
if let audioPath, !audioPath.isEmpty {
|
|
2797
|
+
args += ["--audio-path", audioPath]
|
|
2798
|
+
}
|
|
2799
|
+
if durationMs > 0 {
|
|
2800
|
+
args += ["--duration-ms", String(durationMs)]
|
|
2801
|
+
}
|
|
2802
|
+
if !language.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
2803
|
+
args += ["--language", language]
|
|
2804
|
+
}
|
|
2805
|
+
if let recordingId, !recordingId.isEmpty {
|
|
2806
|
+
args += ["--recording-id", recordingId]
|
|
2807
|
+
}
|
|
2808
|
+
if let transcriptionModel, !transcriptionModel.isEmpty {
|
|
2809
|
+
args += ["--transcription-model", transcriptionModel]
|
|
2810
|
+
}
|
|
2811
|
+
if let transcriberModel, !transcriberModel.isEmpty {
|
|
2812
|
+
args += ["--transcriber-model", transcriberModel]
|
|
2813
|
+
}
|
|
2814
|
+
if let enhancementModel, !enhancementModel.isEmpty {
|
|
2815
|
+
args += ["--enhancement-model", enhancementModel]
|
|
2816
|
+
}
|
|
2817
|
+
if let enhanceTriggersJSON, !enhanceTriggersJSON.isEmpty {
|
|
2818
|
+
args += ["--enhance-triggers-json", enhanceTriggersJSON]
|
|
2819
|
+
}
|
|
2820
|
+
if let keywordTransformsJSON, !keywordTransformsJSON.isEmpty {
|
|
2821
|
+
args += ["--keyword-transforms-json", keywordTransformsJSON]
|
|
2822
|
+
}
|
|
2823
|
+
let prompt = transcriberPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2824
|
+
if !prompt.isEmpty {
|
|
2825
|
+
args += ["--transcriber-prompt", prompt]
|
|
2826
|
+
}
|
|
2827
|
+
return args
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
nonisolated static func rewriteCLIArgs(
|
|
2831
|
+
selectedText: String,
|
|
2832
|
+
instruction: String,
|
|
2833
|
+
activeProjectId: String?,
|
|
2834
|
+
processingConfiguration: RecordingProcessingConfiguration
|
|
2835
|
+
) -> [String] {
|
|
2836
|
+
var args: [String] = []
|
|
2837
|
+
if let activeProjectId, !activeProjectId.isEmpty {
|
|
2838
|
+
args += ["--project", activeProjectId]
|
|
2839
|
+
}
|
|
2840
|
+
args += [
|
|
2841
|
+
"rewrite",
|
|
2842
|
+
"--instruction", instruction,
|
|
2843
|
+
"--post-processing", processingConfiguration.postProcessingMode,
|
|
2844
|
+
"--language", processingConfiguration.transcriptionLanguage,
|
|
2845
|
+
"--prompt", processingConfiguration.transcriptionPrompt,
|
|
2846
|
+
"--transcriber-prompt", processingConfiguration.transcriberPrompt,
|
|
2847
|
+
"--transcription-model", processingConfiguration.transcriptionModel,
|
|
2848
|
+
"--transcriber-model", processingConfiguration.transcriberModel,
|
|
2849
|
+
"--enhancement-model", processingConfiguration.enhancementModel,
|
|
2850
|
+
"--enhance-triggers-json", processingConfiguration.enhanceTriggersJSON,
|
|
2851
|
+
"--keyword-transforms-json", processingConfiguration.keywordTransformsJSON,
|
|
2852
|
+
"--", selectedText,
|
|
2853
|
+
]
|
|
2854
|
+
return args
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
private func finish(_ msg: String) {
|
|
2858
|
+
log("finish status=\(msg)")
|
|
2859
|
+
isTranscribing = false
|
|
2860
|
+
liveTranscriptionText = ""
|
|
2861
|
+
statusMessage = msg
|
|
2862
|
+
flowPhase = .failed(msg)
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
private func resetRecordingIntent() {
|
|
2866
|
+
activeTrigger = nil
|
|
2867
|
+
microphonePermissionStartGate.cancel()
|
|
2868
|
+
keyboardShortcutIsDown = false
|
|
2869
|
+
fnKeyIsDown = false
|
|
2870
|
+
targetAppBundleIdentifier = nil
|
|
2871
|
+
targetAppPid = nil
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
// MARK: - Conversation
|
|
2875
|
+
|
|
2876
|
+
private func runConversationMode(
|
|
2877
|
+
question: String,
|
|
2878
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2879
|
+
pipelineTrace: RecordingPipelineTrace?,
|
|
2880
|
+
pipelineGeneration: UInt64?,
|
|
2881
|
+
deliveryCompleted: (@MainActor @Sendable () -> Void)?
|
|
2882
|
+
) {
|
|
2883
|
+
let deliveryGeneration = pipelineGeneration ?? recordingGeneration
|
|
2884
|
+
beginIntentDelivery(for: deliveryGeneration)
|
|
2885
|
+
updateDeliveryStatus("Answering...", kind: .progress, pipelineGeneration: pipelineGeneration)
|
|
2886
|
+
if let pipelineTrace { log(pipelineTrace.message(stage: "conversation_started")) }
|
|
2887
|
+
let classifier = intentClassifier
|
|
2888
|
+
let model = processingConfiguration.intentModel
|
|
2889
|
+
Task { [weak self] in
|
|
2890
|
+
let outcome = await classifier.answer(question: question, model: model)
|
|
2891
|
+
guard let self else {
|
|
2892
|
+
deliveryCompleted?()
|
|
2893
|
+
return
|
|
2894
|
+
}
|
|
2895
|
+
self.endIntentDelivery(for: deliveryGeneration)
|
|
2896
|
+
if let pipelineTrace { self.log(pipelineTrace.message(stage: "conversation_complete")) }
|
|
2897
|
+
// The conversation route never touches the clipboard: the reply card has an
|
|
2898
|
+
// explicit Copy affordance, and clobbering whatever the user had copied would be
|
|
2899
|
+
// an irreversible side effect of a possibly-misclassified recording.
|
|
2900
|
+
switch outcome {
|
|
2901
|
+
case .answer(let answer):
|
|
2902
|
+
if Self.shouldApplyConversationReply(
|
|
2903
|
+
replyGeneration: pipelineGeneration,
|
|
2904
|
+
currentGeneration: self.recordingGeneration,
|
|
2905
|
+
isRecording: self.isRecording
|
|
2906
|
+
) {
|
|
2907
|
+
self.conversationReply = ConversationReply(question: question, answer: answer)
|
|
2908
|
+
self.updateDeliveryStatus("Answered", kind: .success, pipelineGeneration: pipelineGeneration)
|
|
2909
|
+
} else {
|
|
2910
|
+
self.log("stale conversation reply dropped pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
|
|
2911
|
+
}
|
|
2912
|
+
case .unavailable(let message):
|
|
2913
|
+
// The delayed answer failed; never auto-paste this late. Fail closed to the
|
|
2914
|
+
// preview path: the transcript is persisted and stays in Recent.
|
|
2915
|
+
self.log("conversation unavailable: \(message)")
|
|
2916
|
+
self.updateDeliveryStatus(
|
|
2917
|
+
"Couldn't answer — transcript saved to Recent",
|
|
2918
|
+
kind: .failure,
|
|
2919
|
+
pipelineGeneration: pipelineGeneration
|
|
2920
|
+
)
|
|
2921
|
+
}
|
|
2922
|
+
deliveryCompleted?()
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
nonisolated static func shouldApplyConversationReply(
|
|
2927
|
+
replyGeneration: UInt64?,
|
|
2928
|
+
currentGeneration: UInt64,
|
|
2929
|
+
isRecording: Bool
|
|
2930
|
+
) -> Bool {
|
|
2931
|
+
guard !isRecording else { return false }
|
|
2932
|
+
// No generation means the reply cannot be proven current — fail closed.
|
|
2933
|
+
guard let replyGeneration else { return false }
|
|
2934
|
+
return replyGeneration == currentGeneration
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
// MARK: - Command Mode
|
|
2938
|
+
|
|
2939
|
+
private func runCommandMode(
|
|
2940
|
+
instruction: String,
|
|
2941
|
+
targetAppBundleIdentifier: String?,
|
|
2942
|
+
targetAppPid: pid_t?,
|
|
2943
|
+
selectionToken: AccessibilitySelectionToken?,
|
|
2944
|
+
canonicalProjectId: String?,
|
|
2945
|
+
processingConfiguration: RecordingProcessingConfiguration,
|
|
2946
|
+
pipelineTrace: RecordingPipelineTrace?,
|
|
2947
|
+
pipelineGeneration: UInt64?,
|
|
2948
|
+
deliveryCompleted: (@MainActor @Sendable () -> Void)?
|
|
2949
|
+
) {
|
|
2950
|
+
let deliveryGeneration = pipelineGeneration ?? recordingGeneration
|
|
2951
|
+
beginIntentDelivery(for: deliveryGeneration)
|
|
2952
|
+
let finishCommandDelivery: @MainActor @Sendable () -> Void = { [weak self] in
|
|
2953
|
+
self?.endIntentDelivery(for: deliveryGeneration)
|
|
2954
|
+
deliveryCompleted?()
|
|
2955
|
+
}
|
|
2956
|
+
guard protectedOperationTrust().trusted else {
|
|
2957
|
+
log("command mode blocked by accessibility permission")
|
|
2958
|
+
updateDeliveryStatus(
|
|
2959
|
+
"Enable Accessibility permission for Recordings to rewrite selected text",
|
|
2960
|
+
kind: .failure,
|
|
2961
|
+
pipelineGeneration: pipelineGeneration
|
|
2962
|
+
)
|
|
2963
|
+
finishCommandDelivery()
|
|
2964
|
+
return
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
let homePath = home
|
|
2968
|
+
let resolveTarget = rewriteSelectionResolver
|
|
2969
|
+
Task { @MainActor in
|
|
2970
|
+
let resolution = await resolveTarget(
|
|
2971
|
+
targetAppBundleIdentifier,
|
|
2972
|
+
targetAppPid,
|
|
2973
|
+
selectionToken,
|
|
2974
|
+
pipelineGeneration
|
|
2975
|
+
)
|
|
2976
|
+
let selected: String
|
|
2977
|
+
switch resolution {
|
|
2978
|
+
case .targetAppMissing:
|
|
2979
|
+
self.log("command mode target app not found")
|
|
2980
|
+
self.updateDeliveryStatus("No target app found", kind: .failure, pipelineGeneration: pipelineGeneration)
|
|
2981
|
+
finishCommandDelivery()
|
|
2982
|
+
return
|
|
2983
|
+
case .selectionUnavailable:
|
|
2984
|
+
self.updateDeliveryStatus("No text selected", kind: .failure, pipelineGeneration: pipelineGeneration)
|
|
2985
|
+
finishCommandDelivery()
|
|
2986
|
+
return
|
|
2987
|
+
case .selection(let validated):
|
|
2988
|
+
selected = validated
|
|
2989
|
+
}
|
|
2990
|
+
// A cancellation (or newer recording) during target resolution makes the whole
|
|
2991
|
+
// rewrite stale — never spawn the CLI for a delivery that can only be abandoned.
|
|
2992
|
+
guard !Self.shouldAbandonDelivery(
|
|
2993
|
+
pipelineGeneration: pipelineGeneration,
|
|
2994
|
+
currentGeneration: self.recordingGeneration,
|
|
2995
|
+
isRecording: self.isRecording
|
|
2996
|
+
) else {
|
|
2997
|
+
self.log("stale rewrite abandoned before CLI pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
|
|
2998
|
+
finishCommandDelivery()
|
|
2999
|
+
return
|
|
3000
|
+
}
|
|
3001
|
+
if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
|
|
3002
|
+
self.statusMessage = "Rewriting..."
|
|
3003
|
+
self.flowPhase = .processing("Rewriting...")
|
|
3004
|
+
self.isTranscribing = true
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
let rewriteArguments = Self.rewriteCLIArgs(
|
|
3008
|
+
selectedText: selected,
|
|
3009
|
+
instruction: instruction,
|
|
3010
|
+
activeProjectId: canonicalProjectId,
|
|
3011
|
+
processingConfiguration: processingConfiguration
|
|
3012
|
+
)
|
|
3013
|
+
let runCLI = self.commandCLI
|
|
3014
|
+
let result = await Task.detached {
|
|
3015
|
+
runCLI(rewriteArguments, homePath, Self.commandRewriteTimeout)
|
|
3016
|
+
}.value
|
|
3017
|
+
if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
|
|
3018
|
+
self.isTranscribing = false
|
|
3019
|
+
self.liveTranscriptionText = ""
|
|
3020
|
+
}
|
|
3021
|
+
// A rewrite finishing after the user cancelled (or after a newer recording
|
|
3022
|
+
// superseded it) must never paste, even if the frozen selection still matches.
|
|
3023
|
+
guard !Self.shouldAbandonDelivery(
|
|
3024
|
+
pipelineGeneration: pipelineGeneration,
|
|
3025
|
+
currentGeneration: self.recordingGeneration,
|
|
3026
|
+
isRecording: self.isRecording
|
|
3027
|
+
) else {
|
|
3028
|
+
self.log("stale rewrite abandoned pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
|
|
3029
|
+
finishCommandDelivery()
|
|
3030
|
+
return
|
|
3031
|
+
}
|
|
3032
|
+
if CLIRunner.parseError(result) == nil, !result.isEmpty {
|
|
3033
|
+
self.pasteIntoFrontApp(
|
|
3034
|
+
result,
|
|
3035
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
3036
|
+
targetAppPid: targetAppPid,
|
|
3037
|
+
restoreClipboard: true,
|
|
3038
|
+
deliveryKind: .commandRewrite,
|
|
3039
|
+
selectionToken: selectionToken,
|
|
3040
|
+
pipelineTrace: pipelineTrace,
|
|
3041
|
+
pipelineGeneration: pipelineGeneration,
|
|
3042
|
+
deliveryCompleted: finishCommandDelivery
|
|
3043
|
+
)
|
|
3044
|
+
} else {
|
|
3045
|
+
self.updateDeliveryStatus(
|
|
3046
|
+
CLIRunner.parseError(result) ?? "Rewrite failed",
|
|
3047
|
+
kind: .failure,
|
|
3048
|
+
pipelineGeneration: pipelineGeneration
|
|
3049
|
+
)
|
|
3050
|
+
finishCommandDelivery()
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/// Production body of `rewriteSelectionResolver`: real NSWorkspace/AX I/O. The frozen
|
|
3056
|
+
/// target app is re-found and activated, focus settles, and the frozen selection is
|
|
3057
|
+
/// revalidated element-for-element before any rewrite may run.
|
|
3058
|
+
private func resolveRewriteSelection(
|
|
3059
|
+
targetAppBundleIdentifier: String?,
|
|
3060
|
+
targetAppPid: pid_t?,
|
|
3061
|
+
selectionToken: AccessibilitySelectionToken?,
|
|
3062
|
+
pipelineGeneration: UInt64?
|
|
3063
|
+
) async -> RewriteTargetResolution {
|
|
3064
|
+
let requiredProcessIdentity = pipelineGeneration.flatMap {
|
|
3065
|
+
pasteTargetProcessIdentityByGeneration[$0]
|
|
3066
|
+
}
|
|
3067
|
+
let targetApp = selectedRunningPasteTarget(
|
|
3068
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
3069
|
+
targetAppPid: targetAppPid,
|
|
3070
|
+
frontmostPid: NSWorkspace.shared.frontmostApplication?.processIdentifier,
|
|
3071
|
+
pipelineGeneration: pipelineGeneration
|
|
3072
|
+
)
|
|
3073
|
+
guard let targetApp else {
|
|
3074
|
+
return .targetAppMissing
|
|
3075
|
+
}
|
|
3076
|
+
let alreadyFrontmost = targetApp.processIdentifier == NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
3077
|
+
if !alreadyFrontmost {
|
|
3078
|
+
targetApp.activate()
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
let focusDelay: TimeInterval = alreadyFrontmost ? 0.05 : 0.35
|
|
3082
|
+
try? await Task.sleep(for: .milliseconds(Int(focusDelay * 1_000)))
|
|
3083
|
+
let frontmostBeforeRead = NSWorkspace.shared.frontmostApplication
|
|
3084
|
+
guard Self.pasteTargetIsReady(
|
|
3085
|
+
expectedPid: targetApp.processIdentifier,
|
|
3086
|
+
expectedBundleIdentifier: targetApp.bundleIdentifier,
|
|
3087
|
+
frontmostPid: frontmostBeforeRead?.processIdentifier,
|
|
3088
|
+
frontmostBundleIdentifier: frontmostBeforeRead?.bundleIdentifier,
|
|
3089
|
+
accessibilityTrusted: AXIsProcessTrusted(),
|
|
3090
|
+
expectedLaunchDate: requiredProcessIdentity?.launchDate,
|
|
3091
|
+
frontmostLaunchDate: frontmostBeforeRead?.launchDate,
|
|
3092
|
+
requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
|
|
3093
|
+
) else {
|
|
3094
|
+
return .selectionUnavailable
|
|
3095
|
+
}
|
|
3096
|
+
let frontmostAfterRead = NSWorkspace.shared.frontmostApplication
|
|
3097
|
+
let selected = Self.validAccessibilitySelection(
|
|
3098
|
+
selectionToken?.selectedText,
|
|
3099
|
+
targetStillFrontmost: Self.pasteTargetIsReady(
|
|
3100
|
+
expectedPid: targetApp.processIdentifier,
|
|
3101
|
+
expectedBundleIdentifier: targetApp.bundleIdentifier,
|
|
3102
|
+
frontmostPid: frontmostAfterRead?.processIdentifier,
|
|
3103
|
+
frontmostBundleIdentifier: frontmostAfterRead?.bundleIdentifier,
|
|
3104
|
+
accessibilityTrusted: AXIsProcessTrusted(),
|
|
3105
|
+
expectedLaunchDate: requiredProcessIdentity?.launchDate,
|
|
3106
|
+
frontmostLaunchDate: frontmostAfterRead?.launchDate,
|
|
3107
|
+
requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
|
|
3108
|
+
)
|
|
3109
|
+
)
|
|
3110
|
+
guard let selected,
|
|
3111
|
+
selectionToken?.matchesCurrentSelection(
|
|
3112
|
+
for: targetApp.processIdentifier
|
|
3113
|
+
) == true else {
|
|
3114
|
+
return .selectionUnavailable
|
|
3115
|
+
}
|
|
3116
|
+
return .selection(selected)
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
nonisolated static func validAccessibilitySelection(
|
|
3120
|
+
_ candidate: String?,
|
|
3121
|
+
targetStillFrontmost: Bool
|
|
3122
|
+
) -> String? {
|
|
3123
|
+
let text = candidate ?? ""
|
|
3124
|
+
guard targetStillFrontmost,
|
|
3125
|
+
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
3126
|
+
return text
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
private func canOwnBusyState(pipelineGeneration: UInt64?) -> Bool {
|
|
3130
|
+
guard let pipelineGeneration else { return true }
|
|
3131
|
+
return pipelineGeneration == recordingGeneration && !isRecording
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
// MARK: - Window Title (Accessibility API)
|
|
3135
|
+
|
|
3136
|
+
/// Runs off the MainActor in the recording-start snapshot; every IPC round trip is
|
|
3137
|
+
/// bounded so an unresponsive app delays project detection, never recording.
|
|
3138
|
+
private nonisolated static func focusedWindowTitle(pid: pid_t?) -> String? {
|
|
3139
|
+
guard let pid else { return nil }
|
|
3140
|
+
let app = AXUIElementCreateApplication(pid)
|
|
3141
|
+
AXUIElementSetMessagingTimeout(app, AccessibilitySelectionToken.captureMessagingTimeout)
|
|
3142
|
+
var windowRef: CFTypeRef?
|
|
3143
|
+
guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
|
|
3144
|
+
let window = windowRef,
|
|
3145
|
+
CFGetTypeID(window) == AXUIElementGetTypeID() else { return nil }
|
|
3146
|
+
let windowElement = window as! AXUIElement
|
|
3147
|
+
AXUIElementSetMessagingTimeout(windowElement, AccessibilitySelectionToken.captureMessagingTimeout)
|
|
3148
|
+
var titleRef: CFTypeRef?
|
|
3149
|
+
guard AXUIElementCopyAttributeValue(windowElement, kAXTitleAttribute as CFString, &titleRef) == .success,
|
|
3150
|
+
let title = titleRef as? String else { return nil }
|
|
1080
3151
|
return title
|
|
1081
3152
|
}
|
|
1082
3153
|
|
|
1083
3154
|
// MARK: - Paste
|
|
1084
3155
|
|
|
1085
|
-
|
|
1086
|
-
|
|
3156
|
+
/// Copies text without pasting, preserving the previous clipboard if the write fails.
|
|
3157
|
+
/// Used by explicit "Copy" affordances in the UI.
|
|
3158
|
+
@discardableResult
|
|
3159
|
+
public func copyToClipboard(_ text: String) -> Bool {
|
|
3160
|
+
Self.writeClipboardPreservingOnFailure(text, to: .general)
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
public func pasteIntoFrontApp(
|
|
3164
|
+
_ text: String,
|
|
3165
|
+
targetAppBundleIdentifier: String? = nil,
|
|
3166
|
+
targetAppPid: pid_t? = nil,
|
|
3167
|
+
restoreClipboard: Bool = false
|
|
3168
|
+
) {
|
|
3169
|
+
pasteIntoFrontApp(
|
|
3170
|
+
text,
|
|
3171
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
3172
|
+
targetAppPid: targetAppPid,
|
|
3173
|
+
restoreClipboard: restoreClipboard,
|
|
3174
|
+
deliveryKind: .manualPaste,
|
|
3175
|
+
pipelineTrace: nil,
|
|
3176
|
+
pipelineGeneration: nil,
|
|
3177
|
+
deliveryCompleted: nil
|
|
3178
|
+
)
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
private func pasteIntoFrontApp(
|
|
3182
|
+
_ text: String,
|
|
3183
|
+
targetAppBundleIdentifier: String? = nil,
|
|
3184
|
+
targetAppPid: pid_t? = nil,
|
|
3185
|
+
restoreClipboard: Bool = false,
|
|
3186
|
+
deliveryKind: PasteDeliveryKind,
|
|
3187
|
+
selectionToken: AccessibilitySelectionToken? = nil,
|
|
3188
|
+
pipelineTrace: RecordingPipelineTrace?,
|
|
3189
|
+
pipelineGeneration: UInt64?,
|
|
3190
|
+
deliveryCompleted: (@MainActor @Sendable () -> Void)?
|
|
3191
|
+
) {
|
|
3192
|
+
if let pipelineTrace { log(pipelineTrace.message(stage: "paste_requested", detail: "chars=\(text.count)")) }
|
|
3193
|
+
log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(accessibilityTrustCheck())")
|
|
3194
|
+
// A paste bound to a superseded generation (a cancelled or replaced recording) is
|
|
3195
|
+
// abandoned before it can touch the clipboard or the target app.
|
|
3196
|
+
if Self.shouldAbandonDelivery(
|
|
3197
|
+
pipelineGeneration: pipelineGeneration,
|
|
3198
|
+
currentGeneration: recordingGeneration,
|
|
3199
|
+
isRecording: isRecording
|
|
3200
|
+
) {
|
|
3201
|
+
log("paste abandoned for superseded recording pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
|
|
3202
|
+
deliveryCompleted?()
|
|
3203
|
+
return
|
|
3204
|
+
}
|
|
3205
|
+
if let pasteInterceptorForTesting {
|
|
3206
|
+
pasteInterceptorForTesting(text, deliveryKind, pipelineGeneration)
|
|
3207
|
+
deliveryCompleted?()
|
|
3208
|
+
return
|
|
3209
|
+
}
|
|
1087
3210
|
let pb = NSPasteboard.general
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
log("paste blocked by accessibility permission
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
3211
|
+
var previousClipboard: ClipboardSnapshot?
|
|
3212
|
+
|
|
3213
|
+
let accessibility = protectedOperationTrust()
|
|
3214
|
+
guard accessibility.trusted else {
|
|
3215
|
+
let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
|
|
3216
|
+
let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
|
|
3217
|
+
log("paste blocked by accessibility permission")
|
|
3218
|
+
let message = if deliveryKind == .commandRewrite {
|
|
3219
|
+
"Paste cancelled because Accessibility permission changed"
|
|
3220
|
+
} else if !copied {
|
|
3221
|
+
"Transcription ready, but the clipboard could not be updated"
|
|
3222
|
+
} else if accessibility.didPrompt {
|
|
3223
|
+
"Copied — approve Accessibility for this Recordings app"
|
|
3224
|
+
} else {
|
|
3225
|
+
"Copied — waiting for Accessibility approval"
|
|
3226
|
+
}
|
|
3227
|
+
updateDeliveryStatus(message, kind: .failure, pipelineGeneration: pipelineGeneration)
|
|
3228
|
+
deliveryCompleted?()
|
|
1098
3229
|
return
|
|
1099
3230
|
}
|
|
1100
3231
|
|
|
1101
3232
|
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
3233
|
+
let requiredProcessIdentity = pipelineGeneration.flatMap {
|
|
3234
|
+
pasteTargetProcessIdentityByGeneration[$0]
|
|
3235
|
+
}
|
|
1102
3236
|
let targetApp = selectedRunningPasteTarget(
|
|
1103
3237
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1104
3238
|
targetAppPid: targetAppPid,
|
|
1105
|
-
frontmostPid: frontmostPid
|
|
3239
|
+
frontmostPid: frontmostPid,
|
|
3240
|
+
pipelineGeneration: pipelineGeneration
|
|
1106
3241
|
)
|
|
1107
3242
|
|
|
1108
3243
|
guard let app = targetApp else {
|
|
3244
|
+
let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
|
|
3245
|
+
let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
|
|
1109
3246
|
log("paste target app not found")
|
|
1110
|
-
|
|
3247
|
+
updateDeliveryStatus(
|
|
3248
|
+
deliveryKind == .commandRewrite
|
|
3249
|
+
? "Paste cancelled because the target app is unavailable"
|
|
3250
|
+
: copied
|
|
3251
|
+
? "Copied — no target app found"
|
|
3252
|
+
: "Transcription ready, but the clipboard could not be updated",
|
|
3253
|
+
kind: .failure,
|
|
3254
|
+
pipelineGeneration: pipelineGeneration
|
|
3255
|
+
)
|
|
3256
|
+
deliveryCompleted?()
|
|
1111
3257
|
return
|
|
1112
3258
|
}
|
|
1113
3259
|
|
|
1114
3260
|
// Activate the exact app that owned focus when recording started, then paste after focus settles.
|
|
1115
3261
|
let alreadyFrontmost = app.processIdentifier == frontmostPid
|
|
1116
3262
|
if !alreadyFrontmost {
|
|
1117
|
-
app.activate(
|
|
3263
|
+
app.activate()
|
|
1118
3264
|
}
|
|
1119
3265
|
|
|
1120
3266
|
let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
3267
|
+
var ownedPasteboardChangeCount: Int?
|
|
3268
|
+
var clipboardOwnershipWasLost = false
|
|
3269
|
+
updateDeliveryStatus("Pasting...", kind: .progress, pipelineGeneration: pipelineGeneration)
|
|
3270
|
+
let accepted = pasteTransactionCoordinator.submit(
|
|
3271
|
+
text: text,
|
|
3272
|
+
generation: pipelineGeneration,
|
|
3273
|
+
delay: pasteDelay,
|
|
3274
|
+
settlementDelay: restoreClipboard ? 0.6 : 0,
|
|
3275
|
+
targetIsReady: {
|
|
3276
|
+
let frontmost = NSWorkspace.shared.frontmostApplication
|
|
3277
|
+
let appIsReady = Self.pasteTargetIsReady(
|
|
3278
|
+
expectedPid: app.processIdentifier,
|
|
3279
|
+
expectedBundleIdentifier: app.bundleIdentifier,
|
|
3280
|
+
frontmostPid: frontmost?.processIdentifier,
|
|
3281
|
+
frontmostBundleIdentifier: frontmost?.bundleIdentifier,
|
|
3282
|
+
accessibilityTrusted: AXIsProcessTrusted(),
|
|
3283
|
+
expectedLaunchDate: requiredProcessIdentity?.launchDate,
|
|
3284
|
+
frontmostLaunchDate: frontmost?.launchDate,
|
|
3285
|
+
requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
|
|
3286
|
+
)
|
|
3287
|
+
guard appIsReady else { return false }
|
|
3288
|
+
return selectionToken?.matchesCurrentSelection(for: app.processIdentifier) ?? true
|
|
3289
|
+
},
|
|
3290
|
+
payloadIsReady: {
|
|
3291
|
+
guard let ownedPasteboardChangeCount else { return false }
|
|
3292
|
+
return Self.clipboardStillOwned(
|
|
3293
|
+
NSPasteboard.general,
|
|
3294
|
+
text: text,
|
|
3295
|
+
changeCount: ownedPasteboardChangeCount
|
|
3296
|
+
)
|
|
3297
|
+
},
|
|
3298
|
+
prepare: {
|
|
3299
|
+
if restoreClipboard {
|
|
3300
|
+
previousClipboard = ClipboardSnapshot(pasteboard: .general)
|
|
1140
3301
|
}
|
|
3302
|
+
},
|
|
3303
|
+
writeAttempted: { result in
|
|
3304
|
+
ownedPasteboardChangeCount = result.ownershipChangeCount
|
|
3305
|
+
}
|
|
3306
|
+
) { transaction, outcome in
|
|
3307
|
+
let posted = outcome == .pasted
|
|
3308
|
+
let accessibilityTrusted = AXIsProcessTrusted()
|
|
3309
|
+
let completedTranscriptAlreadyOnClipboard = outcome == .targetUnavailable
|
|
3310
|
+
&& !restoreClipboard
|
|
3311
|
+
&& (ownedPasteboardChangeCount.map {
|
|
3312
|
+
Self.clipboardStillOwned(.general, text: transaction.text, changeCount: $0)
|
|
3313
|
+
} ?? false)
|
|
3314
|
+
let shouldCopyAfterFailure = Self.shouldCopyAfterPasteFailure(
|
|
3315
|
+
outcome: outcome,
|
|
3316
|
+
deliveryKind: deliveryKind,
|
|
3317
|
+
accessibilityTrusted: accessibilityTrusted,
|
|
3318
|
+
clipboardOwnershipWasLost: clipboardOwnershipWasLost,
|
|
3319
|
+
completedTranscriptAlreadyOnClipboard: completedTranscriptAlreadyOnClipboard
|
|
3320
|
+
)
|
|
3321
|
+
let copiedAfterFailure = shouldCopyAfterFailure
|
|
3322
|
+
&& Self.writeClipboardPreservingOnFailure(transaction.text, to: .general)
|
|
3323
|
+
self.log("paste outcome=\(outcome) target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost) transaction=\(transaction.id)")
|
|
3324
|
+
if let pipelineTrace {
|
|
3325
|
+
self.log(pipelineTrace.message(
|
|
3326
|
+
stage: posted ? "paste_posted" : "paste_failed",
|
|
3327
|
+
detail: "chars=\(transaction.text.count)"
|
|
3328
|
+
))
|
|
3329
|
+
}
|
|
3330
|
+
deliveryCompleted?()
|
|
3331
|
+
let message = switch outcome {
|
|
3332
|
+
case .pasted: "Pasted (\(transaction.text.count) chars)"
|
|
3333
|
+
case .targetUnavailable: Self.targetUnavailableDeliveryStatus(
|
|
3334
|
+
deliveryKind: deliveryKind,
|
|
3335
|
+
accessibilityTrusted: accessibilityTrusted,
|
|
3336
|
+
clipboardOwnershipWasLost: clipboardOwnershipWasLost,
|
|
3337
|
+
completedTranscriptAlreadyOnClipboard: completedTranscriptAlreadyOnClipboard,
|
|
3338
|
+
fallbackWriteRequested: shouldCopyAfterFailure,
|
|
3339
|
+
fallbackWriteSucceeded: copiedAfterFailure
|
|
3340
|
+
)
|
|
3341
|
+
case .clipboardOwnershipLost: "Paste cancelled because the clipboard changed"
|
|
3342
|
+
case .clipboardWriteFailed: "Paste failed because the clipboard could not be updated"
|
|
3343
|
+
case .eventPostFailed: restoreClipboard
|
|
3344
|
+
? "Paste failed because the paste event could not be posted"
|
|
3345
|
+
: "Copied, but paste event could not be posted"
|
|
3346
|
+
}
|
|
3347
|
+
self.updateDeliveryStatus(
|
|
3348
|
+
message,
|
|
3349
|
+
kind: posted ? .success : .failure,
|
|
3350
|
+
pipelineGeneration: transaction.generation
|
|
3351
|
+
)
|
|
3352
|
+
} settlement: { transaction, outcome in
|
|
3353
|
+
let pasteboard = NSPasteboard.general
|
|
3354
|
+
let stillOwnsChangeCount = ownedPasteboardChangeCount.map {
|
|
3355
|
+
pasteboard.changeCount == $0
|
|
3356
|
+
} ?? false
|
|
3357
|
+
let stillOwnsPayload = ownedPasteboardChangeCount.map {
|
|
3358
|
+
Self.clipboardStillOwned(pasteboard, text: transaction.text, changeCount: $0)
|
|
3359
|
+
} ?? false
|
|
3360
|
+
if Self.clipboardOwnershipWasLostAfterPasteFailure(
|
|
3361
|
+
outcome: outcome,
|
|
3362
|
+
hasOwnershipToken: ownedPasteboardChangeCount != nil,
|
|
3363
|
+
stillOwnsPayload: stillOwnsPayload
|
|
3364
|
+
) {
|
|
3365
|
+
clipboardOwnershipWasLost = true
|
|
3366
|
+
}
|
|
3367
|
+
guard let previousClipboard else { return }
|
|
3368
|
+
let shouldRestore = switch outcome {
|
|
3369
|
+
case .clipboardWriteFailed:
|
|
3370
|
+
stillOwnsChangeCount
|
|
3371
|
+
case .targetUnavailable, .clipboardOwnershipLost, .eventPostFailed, .pasted:
|
|
3372
|
+
stillOwnsPayload
|
|
1141
3373
|
}
|
|
3374
|
+
if shouldRestore {
|
|
3375
|
+
previousClipboard.restore(to: pasteboard)
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
guard accepted else {
|
|
3379
|
+
log("paste transaction rejected because another delivery is pending")
|
|
3380
|
+
updateDeliveryStatus(
|
|
3381
|
+
"Finish the previous paste before trying again",
|
|
3382
|
+
kind: .failure,
|
|
3383
|
+
pipelineGeneration: pipelineGeneration
|
|
3384
|
+
)
|
|
3385
|
+
deliveryCompleted?()
|
|
3386
|
+
return
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
nonisolated static func clipboardOwnershipWasLostAfterPasteFailure(
|
|
3391
|
+
outcome: PasteDeliveryOutcome,
|
|
3392
|
+
hasOwnershipToken: Bool,
|
|
3393
|
+
stillOwnsPayload: Bool
|
|
3394
|
+
) -> Bool {
|
|
3395
|
+
outcome == .targetUnavailable && hasOwnershipToken && !stillOwnsPayload
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
@discardableResult
|
|
3399
|
+
private nonisolated static func writeClipboard(_ text: String, to pasteboard: NSPasteboard) -> Bool {
|
|
3400
|
+
writeClipboardAttempt(text, to: pasteboard).verified
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
@discardableResult
|
|
3404
|
+
nonisolated static func writeClipboardPreservingOnFailure(
|
|
3405
|
+
_ text: String,
|
|
3406
|
+
to pasteboard: NSPasteboard
|
|
3407
|
+
) -> Bool {
|
|
3408
|
+
let previousClipboard = ClipboardSnapshot(pasteboard: pasteboard)
|
|
3409
|
+
let result = writeClipboardAttempt(text, to: pasteboard)
|
|
3410
|
+
guard !result.verified else { return true }
|
|
3411
|
+
if pasteboard.changeCount == result.ownershipChangeCount {
|
|
3412
|
+
previousClipboard.restore(to: pasteboard)
|
|
3413
|
+
}
|
|
3414
|
+
return false
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
nonisolated static func writeClipboardAttempt(
|
|
3418
|
+
_ text: String,
|
|
3419
|
+
to pasteboard: NSPasteboard
|
|
3420
|
+
) -> PasteboardWriteResult {
|
|
3421
|
+
let clearedChangeCount = pasteboard.clearContents()
|
|
3422
|
+
guard pasteboard.setString(text, forType: .string) else {
|
|
3423
|
+
return PasteboardWriteResult(
|
|
3424
|
+
verified: false,
|
|
3425
|
+
ownershipChangeCount: clearedChangeCount
|
|
3426
|
+
)
|
|
1142
3427
|
}
|
|
3428
|
+
let writtenChangeCount = pasteboard.changeCount
|
|
3429
|
+
let storedText = pasteboard.string(forType: .string)
|
|
3430
|
+
return PasteboardWriteResult(
|
|
3431
|
+
verified: pasteboard.changeCount == writtenChangeCount && storedText == text,
|
|
3432
|
+
ownershipChangeCount: writtenChangeCount
|
|
3433
|
+
)
|
|
1143
3434
|
}
|
|
1144
3435
|
|
|
1145
|
-
|
|
3436
|
+
nonisolated static func clipboardStillOwned(
|
|
3437
|
+
_ pasteboard: NSPasteboard,
|
|
3438
|
+
text: String,
|
|
3439
|
+
changeCount: Int
|
|
3440
|
+
) -> Bool {
|
|
3441
|
+
pasteboard.changeCount == changeCount && pasteboard.string(forType: .string) == text
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
nonisolated static func pasteTargetIsReady(
|
|
3445
|
+
expectedPid: pid_t,
|
|
3446
|
+
expectedBundleIdentifier: String?,
|
|
3447
|
+
frontmostPid: pid_t?,
|
|
3448
|
+
frontmostBundleIdentifier: String?,
|
|
3449
|
+
accessibilityTrusted: Bool,
|
|
3450
|
+
expectedLaunchDate: Date? = nil,
|
|
3451
|
+
frontmostLaunchDate: Date? = nil,
|
|
3452
|
+
requiresProcessIdentity: Bool = false
|
|
3453
|
+
) -> Bool {
|
|
3454
|
+
let processIdentityMatches = if requiresProcessIdentity {
|
|
3455
|
+
expectedLaunchDate != nil && frontmostLaunchDate == expectedLaunchDate
|
|
3456
|
+
} else {
|
|
3457
|
+
expectedLaunchDate == nil || frontmostLaunchDate == expectedLaunchDate
|
|
3458
|
+
}
|
|
3459
|
+
return accessibilityTrusted
|
|
3460
|
+
&& frontmostPid == expectedPid
|
|
3461
|
+
&& frontmostBundleIdentifier == expectedBundleIdentifier
|
|
3462
|
+
&& processIdentityMatches
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
nonisolated static func shouldCopyPasteFallback(deliveryKind: PasteDeliveryKind) -> Bool {
|
|
3466
|
+
deliveryKind != .commandRewrite
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
nonisolated static func shouldCopyAfterPasteFailure(
|
|
3470
|
+
outcome: PasteDeliveryOutcome,
|
|
3471
|
+
deliveryKind: PasteDeliveryKind,
|
|
3472
|
+
accessibilityTrusted: Bool,
|
|
3473
|
+
clipboardOwnershipWasLost: Bool = false,
|
|
3474
|
+
completedTranscriptAlreadyOnClipboard: Bool = false
|
|
3475
|
+
) -> Bool {
|
|
3476
|
+
outcome == .targetUnavailable
|
|
3477
|
+
&& !accessibilityTrusted
|
|
3478
|
+
&& !clipboardOwnershipWasLost
|
|
3479
|
+
&& !completedTranscriptAlreadyOnClipboard
|
|
3480
|
+
&& shouldCopyPasteFallback(deliveryKind: deliveryKind)
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
nonisolated static func targetUnavailableDeliveryStatus(
|
|
3484
|
+
deliveryKind: PasteDeliveryKind,
|
|
3485
|
+
accessibilityTrusted: Bool,
|
|
3486
|
+
clipboardOwnershipWasLost: Bool,
|
|
3487
|
+
completedTranscriptAlreadyOnClipboard: Bool,
|
|
3488
|
+
fallbackWriteRequested: Bool,
|
|
3489
|
+
fallbackWriteSucceeded: Bool
|
|
3490
|
+
) -> String {
|
|
3491
|
+
if fallbackWriteSucceeded {
|
|
3492
|
+
return "Copied — Accessibility permission changed"
|
|
3493
|
+
}
|
|
3494
|
+
if completedTranscriptAlreadyOnClipboard {
|
|
3495
|
+
return accessibilityTrusted
|
|
3496
|
+
? "Copied — target app lost focus"
|
|
3497
|
+
: "Copied — Accessibility permission changed"
|
|
3498
|
+
}
|
|
3499
|
+
if clipboardOwnershipWasLost {
|
|
3500
|
+
return "Paste cancelled because the clipboard changed"
|
|
3501
|
+
}
|
|
3502
|
+
if !accessibilityTrusted && deliveryKind == .commandRewrite {
|
|
3503
|
+
return "Paste cancelled because Accessibility permission changed"
|
|
3504
|
+
}
|
|
3505
|
+
if fallbackWriteRequested {
|
|
3506
|
+
return "Transcription ready, but the clipboard could not be updated"
|
|
3507
|
+
}
|
|
3508
|
+
return "Paste cancelled because the target app lost focus"
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
nonisolated static func stableAccessibilityDocumentIdentifier(_ candidate: String?) -> String? {
|
|
3512
|
+
guard let candidate,
|
|
3513
|
+
!candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
3514
|
+
return nil
|
|
3515
|
+
}
|
|
3516
|
+
return candidate
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
nonisolated static func stableAccessibilityContextIdentifier(
|
|
3520
|
+
documentIdentifier: String?,
|
|
3521
|
+
elementIdentifier: String?
|
|
3522
|
+
) -> String? {
|
|
3523
|
+
if let documentIdentifier = stableAccessibilityDocumentIdentifier(documentIdentifier) {
|
|
3524
|
+
return "document:\(documentIdentifier)"
|
|
3525
|
+
}
|
|
3526
|
+
// AXIdentifier identifies a control, not the document shown in it. Editors
|
|
3527
|
+
// commonly reuse one control and window across tabs, so fail closed without
|
|
3528
|
+
// an independently document-specific AX identity.
|
|
3529
|
+
_ = elementIdentifier
|
|
3530
|
+
return nil
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
enum DeliveryStatusKind: Equatable, Sendable {
|
|
3534
|
+
case progress
|
|
3535
|
+
case success
|
|
3536
|
+
case failure
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
nonisolated static func flowPhase(
|
|
3540
|
+
forDeliveryStatus message: String,
|
|
3541
|
+
kind: DeliveryStatusKind
|
|
3542
|
+
) -> RecordingFlowPhase {
|
|
3543
|
+
switch kind {
|
|
3544
|
+
case .progress: .processing(message)
|
|
3545
|
+
case .success: .ready(message)
|
|
3546
|
+
case .failure: .failed(message)
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
private func updateDeliveryStatus(
|
|
3551
|
+
_ message: String,
|
|
3552
|
+
kind: DeliveryStatusKind,
|
|
3553
|
+
pipelineGeneration: UInt64?
|
|
3554
|
+
) {
|
|
3555
|
+
if let pipelineGeneration {
|
|
3556
|
+
guard pipelineDeliveryGate.shouldApplyStatus(
|
|
3557
|
+
deliveryGeneration: pipelineGeneration,
|
|
3558
|
+
currentGeneration: recordingGeneration,
|
|
3559
|
+
isRecording: isRecording,
|
|
3560
|
+
isTranscribing: isTranscribing
|
|
3561
|
+
) else {
|
|
3562
|
+
log("delivery status suppressed for superseded pipeline generation=\(pipelineGeneration)")
|
|
3563
|
+
return
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
statusMessage = message
|
|
3567
|
+
flowPhase = Self.flowPhase(forDeliveryStatus: message, kind: kind)
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
private func selectedRunningPasteTarget(
|
|
3571
|
+
targetAppBundleIdentifier: String?,
|
|
3572
|
+
targetAppPid: pid_t?,
|
|
3573
|
+
frontmostPid: pid_t?,
|
|
3574
|
+
pipelineGeneration: UInt64?
|
|
3575
|
+
) -> NSRunningApplication? {
|
|
1146
3576
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
1147
3577
|
let runningApps = NSWorkspace.shared.runningApplications
|
|
1148
3578
|
let candidates = runningApps.map {
|
|
1149
3579
|
PasteTargetCandidate(
|
|
1150
3580
|
pid: $0.processIdentifier,
|
|
1151
3581
|
bundleIdentifier: $0.bundleIdentifier,
|
|
1152
|
-
isRegularApp: $0.activationPolicy == .regular
|
|
3582
|
+
isRegularApp: $0.activationPolicy == .regular,
|
|
3583
|
+
launchDate: $0.launchDate
|
|
1153
3584
|
)
|
|
1154
3585
|
}
|
|
3586
|
+
let requiredProcessIdentity = pipelineGeneration.flatMap {
|
|
3587
|
+
pasteTargetProcessIdentityByGeneration[$0]
|
|
3588
|
+
}
|
|
1155
3589
|
let selectedTarget = Self.selectPasteTarget(
|
|
1156
3590
|
candidates: candidates,
|
|
1157
3591
|
currentPid: myPID,
|
|
1158
3592
|
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
1159
3593
|
targetPid: targetAppPid,
|
|
1160
|
-
frontmostPid: frontmostPid
|
|
3594
|
+
frontmostPid: frontmostPid,
|
|
3595
|
+
requiredProcessIdentity: requiredProcessIdentity,
|
|
3596
|
+
requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
|
|
1161
3597
|
)
|
|
1162
3598
|
return selectedTarget.flatMap { selected in
|
|
1163
3599
|
runningApps.first { $0.processIdentifier == selected.pid }
|
|
@@ -1169,21 +3605,55 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1169
3605
|
currentPid: pid_t,
|
|
1170
3606
|
targetBundleIdentifier: String?,
|
|
1171
3607
|
targetPid: pid_t?,
|
|
1172
|
-
frontmostPid: pid_t? = nil
|
|
3608
|
+
frontmostPid: pid_t? = nil,
|
|
3609
|
+
requiredProcessIdentity: PasteTargetProcessIdentity? = nil,
|
|
3610
|
+
requiresProcessIdentity: Bool = false
|
|
1173
3611
|
) -> PasteTargetCandidate? {
|
|
1174
|
-
|
|
1175
|
-
guard let
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
3612
|
+
if let targetPid {
|
|
3613
|
+
guard let targetBundleIdentifier else { return nil }
|
|
3614
|
+
let selected = candidates.first {
|
|
3615
|
+
$0.pid == targetPid
|
|
3616
|
+
&& $0.pid != currentPid
|
|
3617
|
+
&& $0.bundleIdentifier == targetBundleIdentifier
|
|
3618
|
+
}
|
|
3619
|
+
guard let selected else { return nil }
|
|
3620
|
+
if requiresProcessIdentity {
|
|
3621
|
+
guard let requiredProcessIdentity,
|
|
3622
|
+
requiredProcessIdentity.pid == targetPid,
|
|
3623
|
+
requiredProcessIdentity.bundleIdentifier == targetBundleIdentifier,
|
|
3624
|
+
requiredProcessIdentity.matches(selected) else { return nil }
|
|
3625
|
+
} else if let requiredProcessIdentity,
|
|
3626
|
+
!requiredProcessIdentity.matches(selected) {
|
|
3627
|
+
return nil
|
|
3628
|
+
}
|
|
3629
|
+
return selected
|
|
3630
|
+
}
|
|
3631
|
+
if let targetBundleIdentifier {
|
|
3632
|
+
return candidates.first {
|
|
3633
|
+
$0.pid != currentPid
|
|
3634
|
+
&& $0.isRegularApp
|
|
3635
|
+
&& $0.bundleIdentifier == targetBundleIdentifier
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
return candidates.first {
|
|
1182
3639
|
guard let frontmostPid else { return false }
|
|
1183
3640
|
return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
|
|
1184
3641
|
}
|
|
1185
3642
|
}
|
|
1186
3643
|
|
|
3644
|
+
private nonisolated static func monotonicMilliseconds() -> UInt64 {
|
|
3645
|
+
UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
nonisolated static func realtimePeriodicCommitIsDue(
|
|
3649
|
+
nowMilliseconds: UInt64,
|
|
3650
|
+
lastCommitMilliseconds: UInt64?
|
|
3651
|
+
) -> Bool {
|
|
3652
|
+
guard let lastCommitMilliseconds else { return true }
|
|
3653
|
+
guard nowMilliseconds >= lastCommitMilliseconds else { return false }
|
|
3654
|
+
return nowMilliseconds - lastCommitMilliseconds >= realtimePeriodicCommitIntervalMilliseconds
|
|
3655
|
+
}
|
|
3656
|
+
|
|
1187
3657
|
nonisolated static func resolveFinalTranscript(
|
|
1188
3658
|
cliText: String?,
|
|
1189
3659
|
cliError: String?,
|
|
@@ -1198,28 +3668,6 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1198
3668
|
return (nil, cliError ?? "Empty transcription")
|
|
1199
3669
|
}
|
|
1200
3670
|
|
|
1201
|
-
private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
|
|
1202
|
-
if AXIsProcessTrusted() {
|
|
1203
|
-
return true
|
|
1204
|
-
}
|
|
1205
|
-
if !prompt {
|
|
1206
|
-
return false
|
|
1207
|
-
}
|
|
1208
|
-
return AXIsProcessTrustedWithOptions(
|
|
1209
|
-
["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
|
|
1210
|
-
)
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
private func shouldPromptAccessibility() -> Bool {
|
|
1214
|
-
let now = Date()
|
|
1215
|
-
if let lastAccessibilityPromptAt,
|
|
1216
|
-
now.timeIntervalSince(lastAccessibilityPromptAt) < 20 {
|
|
1217
|
-
return false
|
|
1218
|
-
}
|
|
1219
|
-
lastAccessibilityPromptAt = now
|
|
1220
|
-
return true
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
3671
|
private func log(_ message: String) {
|
|
1224
3672
|
NativeAppLog.write(message, homePath: home)
|
|
1225
3673
|
}
|
|
@@ -1241,7 +3689,7 @@ private final class LockedFlag: @unchecked Sendable {
|
|
|
1241
3689
|
private struct ClipboardSnapshot {
|
|
1242
3690
|
private let items: [[NSPasteboard.PasteboardType: Data]]
|
|
1243
3691
|
|
|
1244
|
-
init
|
|
3692
|
+
init(pasteboard: NSPasteboard) {
|
|
1245
3693
|
let capturedItems = pasteboard.pasteboardItems?.compactMap { item -> [NSPasteboard.PasteboardType: Data]? in
|
|
1246
3694
|
let dataByType = item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { result, type in
|
|
1247
3695
|
if let data = item.data(forType: type) {
|
|
@@ -1250,7 +3698,6 @@ private struct ClipboardSnapshot {
|
|
|
1250
3698
|
}
|
|
1251
3699
|
return dataByType.isEmpty ? nil : dataByType
|
|
1252
3700
|
} ?? []
|
|
1253
|
-
guard !capturedItems.isEmpty else { return nil }
|
|
1254
3701
|
items = capturedItems
|
|
1255
3702
|
}
|
|
1256
3703
|
|
|
@@ -1269,43 +3716,911 @@ private struct ClipboardSnapshot {
|
|
|
1269
3716
|
|
|
1270
3717
|
// MARK: - CLI Runner
|
|
1271
3718
|
|
|
3719
|
+
private final class ProcessDataCapture: @unchecked Sendable {
|
|
3720
|
+
private let lock = NSLock()
|
|
3721
|
+
private var storage = Data()
|
|
3722
|
+
private var finished = false
|
|
3723
|
+
|
|
3724
|
+
func append(_ data: Data) {
|
|
3725
|
+
lock.lock()
|
|
3726
|
+
defer { lock.unlock() }
|
|
3727
|
+
guard !finished else { return }
|
|
3728
|
+
storage.append(data)
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
func finish() -> Bool {
|
|
3732
|
+
lock.lock()
|
|
3733
|
+
defer { lock.unlock() }
|
|
3734
|
+
guard !finished else { return false }
|
|
3735
|
+
finished = true
|
|
3736
|
+
return true
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
var data: Data {
|
|
3740
|
+
lock.lock()
|
|
3741
|
+
defer { lock.unlock() }
|
|
3742
|
+
return storage
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
|
|
1272
3746
|
enum CLIRunner: Sendable {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
3747
|
+
enum CaptureOperation: Sendable {
|
|
3748
|
+
case read
|
|
3749
|
+
case poll
|
|
3750
|
+
|
|
3751
|
+
fileprivate var description: String {
|
|
3752
|
+
switch self {
|
|
3753
|
+
case .read: "reading"
|
|
3754
|
+
case .poll: "waiting for"
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
enum ExecutionError: Error, LocalizedError, Equatable {
|
|
3760
|
+
case timedOut(executable: String, seconds: TimeInterval)
|
|
3761
|
+
case captureFailed(operation: CaptureOperation, code: Int32)
|
|
3762
|
+
|
|
3763
|
+
var errorDescription: String? {
|
|
3764
|
+
switch self {
|
|
3765
|
+
case let .timedOut(executable, seconds):
|
|
3766
|
+
return "Command timed out after \(seconds.formatted()) seconds: \(executable)"
|
|
3767
|
+
case let .captureFailed(operation, code):
|
|
3768
|
+
return "Failed to capture command output while \(operation.description): \(String(cString: strerror(code)))"
|
|
3769
|
+
}
|
|
1284
3770
|
}
|
|
1285
|
-
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
struct Command: Sendable {
|
|
3774
|
+
let executable: String
|
|
3775
|
+
let argumentsPrefix: [String]
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
struct ProcessOutput: Sendable {
|
|
3779
|
+
let stdout: String
|
|
3780
|
+
let stderr: String
|
|
3781
|
+
let terminationStatus: Int32
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
enum ProcessLifecycleEvent: Equatable, Sendable {
|
|
3785
|
+
case leaderExitObserved
|
|
3786
|
+
case processGroupSignaled(Int32)
|
|
3787
|
+
case leaderReaped(Int32)
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
typealias LeaderReaper = (
|
|
3791
|
+
_ processIdentifier: pid_t,
|
|
3792
|
+
_ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
|
|
3793
|
+
) throws -> Int32
|
|
3794
|
+
|
|
3795
|
+
static func run(
|
|
3796
|
+
_ args: [String],
|
|
3797
|
+
home: String,
|
|
3798
|
+
timeout: TimeInterval = 120,
|
|
3799
|
+
totalWallClockBudget: TimeInterval? = nil
|
|
3800
|
+
) -> String {
|
|
3801
|
+
let command = resolveCommand(home: home)
|
|
3802
|
+
let arguments = command.argumentsPrefix + args
|
|
3803
|
+
let environment = ProcessInfo.processInfo.environment.merging([
|
|
1286
3804
|
"PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
1287
3805
|
]) { _, new in new }
|
|
1288
|
-
proc.standardOutput = outPipe
|
|
1289
|
-
proc.standardError = errPipe
|
|
1290
3806
|
do {
|
|
1291
|
-
try
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
3807
|
+
let output = try runExecutable(
|
|
3808
|
+
command.executable,
|
|
3809
|
+
arguments: arguments,
|
|
3810
|
+
environment: environment,
|
|
3811
|
+
executionTimeout: timeout,
|
|
3812
|
+
totalWallClockBudget: totalWallClockBudget
|
|
3813
|
+
)
|
|
3814
|
+
if output.terminationStatus != 0 {
|
|
3815
|
+
let details = output.stderr.isEmpty ? output.stdout : output.stderr
|
|
3816
|
+
return "ERROR: \(NativeErrorSanitizer.sanitize(details.trimmingCharacters(in: .whitespacesAndNewlines)))"
|
|
3817
|
+
}
|
|
3818
|
+
return output.stdout.isEmpty
|
|
3819
|
+
? NativeErrorSanitizer.sanitize(output.stderr)
|
|
3820
|
+
: output.stdout
|
|
1300
3821
|
} catch {
|
|
1301
|
-
return "ERROR: \(error.localizedDescription)"
|
|
3822
|
+
return "ERROR: \(NativeErrorSanitizer.sanitize(error.localizedDescription))"
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
static func resolveCommand(
|
|
3827
|
+
home: String,
|
|
3828
|
+
bundleURL: URL = Bundle.main.bundleURL,
|
|
3829
|
+
fileManager: FileManager = .default
|
|
3830
|
+
) -> Command {
|
|
3831
|
+
let bundled = bundleURL.appendingPathComponent("Contents/Helpers/recordings")
|
|
3832
|
+
let isPackagedApp = bundleURL.pathExtension.caseInsensitiveCompare("app") == .orderedSame
|
|
3833
|
+
if isPackagedApp || fileManager.fileExists(atPath: bundled.path) {
|
|
3834
|
+
return Command(executable: bundled.path, argumentsPrefix: [])
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
// SwiftPM development and test runs do not have an app bundle. Retain an
|
|
3838
|
+
// explicit local fallback there; packaged apps exclusively use their helper.
|
|
3839
|
+
let userCLI = "\(home)/.bun/bin/recordings"
|
|
3840
|
+
if fileManager.fileExists(atPath: userCLI) {
|
|
3841
|
+
return Command(executable: userCLI, argumentsPrefix: [])
|
|
3842
|
+
}
|
|
3843
|
+
return Command(executable: "/usr/bin/env", argumentsPrefix: ["recordings"])
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
/// Wall-clock time reserved out of the execution window when `totalWallClockBudget` is
|
|
3847
|
+
/// set, so termination grace, kill grace, and pipe drain land inside the budget with
|
|
3848
|
+
/// scheduling margin to spare.
|
|
3849
|
+
static let wallClockCleanupReserve: TimeInterval = 1
|
|
3850
|
+
|
|
3851
|
+
static func runExecutable(
|
|
3852
|
+
_ executable: String,
|
|
3853
|
+
arguments: [String],
|
|
3854
|
+
environment: [String: String]? = nil,
|
|
3855
|
+
executionTimeout: TimeInterval = 120,
|
|
3856
|
+
terminationGracePeriod: TimeInterval = 0.5,
|
|
3857
|
+
forceKillGracePeriod: TimeInterval = 1,
|
|
3858
|
+
pipeDrainTimeout: TimeInterval = 2,
|
|
3859
|
+
totalWallClockBudget: TimeInterval? = nil,
|
|
3860
|
+
beforeExecutionDeadline: (() -> Void)? = nil,
|
|
3861
|
+
lifecycleObserver: ((ProcessLifecycleEvent) -> Void)? = nil,
|
|
3862
|
+
leaderReaper: LeaderReaper? = nil,
|
|
3863
|
+
captureSystemCalls: PipeCaptureReader.SystemCalls = .live
|
|
3864
|
+
) throws -> ProcessOutput {
|
|
3865
|
+
precondition(executionTimeout.isFinite && executionTimeout > 0)
|
|
3866
|
+
precondition(terminationGracePeriod.isFinite && terminationGracePeriod >= 0)
|
|
3867
|
+
precondition(forceKillGracePeriod.isFinite && forceKillGracePeriod >= 0)
|
|
3868
|
+
precondition(pipeDrainTimeout.isFinite && pipeDrainTimeout >= 0)
|
|
3869
|
+
if let totalWallClockBudget {
|
|
3870
|
+
precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
// The budget clock starts before the spawn so setup latency cannot extend the
|
|
3874
|
+
// observable wall time. Every wait below is clamped to what is left of it.
|
|
3875
|
+
let wallClockDeadline = totalWallClockBudget.map { monotonicUptimeDeadline(after: $0) }
|
|
3876
|
+
func clampedToWallClockBudget(
|
|
3877
|
+
_ phaseTimeout: TimeInterval,
|
|
3878
|
+
reserving reserve: TimeInterval = 0
|
|
3879
|
+
) -> TimeInterval {
|
|
3880
|
+
guard let wallClockDeadline else { return phaseTimeout }
|
|
3881
|
+
let now = DispatchTime.now().uptimeNanoseconds
|
|
3882
|
+
let remaining = wallClockDeadline > now
|
|
3883
|
+
? Double(wallClockDeadline - now) / 1_000_000_000 - reserve
|
|
3884
|
+
: 0
|
|
3885
|
+
return max(0, min(phaseTimeout, remaining))
|
|
3886
|
+
}
|
|
3887
|
+
let contractualExecutionTimeout = totalWallClockBudget
|
|
3888
|
+
.map { min(executionTimeout, $0 - wallClockCleanupReserve) } ?? executionTimeout
|
|
3889
|
+
|
|
3890
|
+
let stdoutReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
|
|
3891
|
+
let stderrReader: PipeCaptureReader
|
|
3892
|
+
do {
|
|
3893
|
+
stderrReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
|
|
3894
|
+
} catch {
|
|
3895
|
+
stdoutReader.closeWriteDescriptor()
|
|
3896
|
+
_ = finishCaptures([stdoutReader], pipeDrainTimeout: 0)
|
|
3897
|
+
throw error
|
|
3898
|
+
}
|
|
3899
|
+
let captureReaders = [stdoutReader, stderrReader]
|
|
3900
|
+
|
|
3901
|
+
let processIdentifier: pid_t
|
|
3902
|
+
do {
|
|
3903
|
+
processIdentifier = try spawnProcessGroup(
|
|
3904
|
+
executable,
|
|
3905
|
+
arguments: arguments,
|
|
3906
|
+
environment: environment,
|
|
3907
|
+
stdoutDescriptor: stdoutReader.writeDescriptor,
|
|
3908
|
+
stderrDescriptor: stderrReader.writeDescriptor,
|
|
3909
|
+
stdoutReadDescriptor: stdoutReader.readDescriptor,
|
|
3910
|
+
stderrReadDescriptor: stderrReader.readDescriptor
|
|
3911
|
+
)
|
|
3912
|
+
} catch {
|
|
3913
|
+
stdoutReader.closeWriteDescriptor()
|
|
3914
|
+
stderrReader.closeWriteDescriptor()
|
|
3915
|
+
_ = finishCaptures(captureReaders, pipeDrainTimeout: 0)
|
|
3916
|
+
throw error
|
|
3917
|
+
}
|
|
3918
|
+
stdoutReader.closeWriteDescriptor()
|
|
3919
|
+
stderrReader.closeWriteDescriptor()
|
|
3920
|
+
|
|
3921
|
+
beforeExecutionDeadline?()
|
|
3922
|
+
let leaderExitWasObserved: Bool
|
|
3923
|
+
let didTimeOut: Bool
|
|
3924
|
+
do {
|
|
3925
|
+
leaderExitWasObserved = try waitForUnreapedLeaderExit(
|
|
3926
|
+
processIdentifier,
|
|
3927
|
+
timeout: clampedToWallClockBudget(executionTimeout, reserving: wallClockCleanupReserve),
|
|
3928
|
+
lifecycleObserver: lifecycleObserver
|
|
3929
|
+
)
|
|
3930
|
+
didTimeOut = !leaderExitWasObserved
|
|
3931
|
+
} catch {
|
|
3932
|
+
signalProcessGroup(processIdentifier, signal: SIGKILL, lifecycleObserver: lifecycleObserver)
|
|
3933
|
+
reapLeaderInBackground(processIdentifier)
|
|
3934
|
+
_ = finishCaptures(
|
|
3935
|
+
captureReaders,
|
|
3936
|
+
pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
|
|
3937
|
+
)
|
|
3938
|
+
throw error
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
// Keep the direct child unreaped until every group-directed signal has
|
|
3942
|
+
// been sent. Its zombie reserves the process-group identifier, so a PID
|
|
3943
|
+
// reuse cannot redirect cleanup to an unrelated process group.
|
|
3944
|
+
signalProcessGroup(processIdentifier, signal: SIGTERM, lifecycleObserver: lifecycleObserver)
|
|
3945
|
+
var confirmedExit = leaderExitWasObserved
|
|
3946
|
+
if didTimeOut {
|
|
3947
|
+
do {
|
|
3948
|
+
confirmedExit = try waitForUnreapedLeaderExit(
|
|
3949
|
+
processIdentifier,
|
|
3950
|
+
timeout: clampedToWallClockBudget(terminationGracePeriod),
|
|
3951
|
+
lifecycleObserver: lifecycleObserver
|
|
3952
|
+
)
|
|
3953
|
+
} catch {
|
|
3954
|
+
signalProcessGroup(processIdentifier, signal: SIGKILL, lifecycleObserver: lifecycleObserver)
|
|
3955
|
+
reapLeaderInBackground(processIdentifier)
|
|
3956
|
+
_ = finishCaptures(
|
|
3957
|
+
captureReaders,
|
|
3958
|
+
pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
|
|
3959
|
+
)
|
|
3960
|
+
throw error
|
|
3961
|
+
}
|
|
3962
|
+
} else {
|
|
3963
|
+
let drainDeadline = monotonicDispatchDeadline(
|
|
3964
|
+
after: clampedToWallClockBudget(terminationGracePeriod)
|
|
3965
|
+
)
|
|
3966
|
+
for reader in captureReaders {
|
|
3967
|
+
reader.waitUntilExited(deadline: drainDeadline)
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
signalProcessGroup(processIdentifier, signal: SIGKILL, lifecycleObserver: lifecycleObserver)
|
|
3971
|
+
if !confirmedExit {
|
|
3972
|
+
do {
|
|
3973
|
+
confirmedExit = try waitForUnreapedLeaderExit(
|
|
3974
|
+
processIdentifier,
|
|
3975
|
+
timeout: clampedToWallClockBudget(forceKillGracePeriod),
|
|
3976
|
+
lifecycleObserver: lifecycleObserver
|
|
3977
|
+
)
|
|
3978
|
+
} catch {
|
|
3979
|
+
reapLeaderInBackground(processIdentifier)
|
|
3980
|
+
_ = finishCaptures(
|
|
3981
|
+
captureReaders,
|
|
3982
|
+
pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
|
|
3983
|
+
)
|
|
3984
|
+
throw error
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
let terminationStatus: Int32?
|
|
3988
|
+
if confirmedExit {
|
|
3989
|
+
do {
|
|
3990
|
+
terminationStatus = try leaderReaper?(
|
|
3991
|
+
processIdentifier,
|
|
3992
|
+
lifecycleObserver
|
|
3993
|
+
) ?? reapLeader(
|
|
3994
|
+
processIdentifier,
|
|
3995
|
+
lifecycleObserver: lifecycleObserver
|
|
3996
|
+
)
|
|
3997
|
+
} catch {
|
|
3998
|
+
reapLeaderInBackground(processIdentifier)
|
|
3999
|
+
_ = finishCaptures(
|
|
4000
|
+
captureReaders,
|
|
4001
|
+
pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
|
|
4002
|
+
)
|
|
4003
|
+
throw error
|
|
4004
|
+
}
|
|
4005
|
+
} else {
|
|
4006
|
+
reapLeaderInBackground(processIdentifier)
|
|
4007
|
+
terminationStatus = nil
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
let captureError = finishCaptures(
|
|
4011
|
+
captureReaders,
|
|
4012
|
+
pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
|
|
4013
|
+
)
|
|
4014
|
+
|
|
4015
|
+
if didTimeOut {
|
|
4016
|
+
throw ExecutionError.timedOut(executable: executable, seconds: contractualExecutionTimeout)
|
|
4017
|
+
}
|
|
4018
|
+
if let captureError {
|
|
4019
|
+
throw captureError
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
// Both readers are joined by now, so these snapshots can never observe a
|
|
4023
|
+
// truncated mid-append state.
|
|
4024
|
+
return ProcessOutput(
|
|
4025
|
+
stdout: String(decoding: stdoutReader.data, as: UTF8.self),
|
|
4026
|
+
stderr: String(decoding: stderrReader.data, as: UTF8.self),
|
|
4027
|
+
terminationStatus: terminationStatus ?? 1
|
|
4028
|
+
)
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
private static func spawnProcessGroup(
|
|
4032
|
+
_ executable: String,
|
|
4033
|
+
arguments: [String],
|
|
4034
|
+
environment: [String: String]?,
|
|
4035
|
+
stdoutDescriptor: Int32,
|
|
4036
|
+
stderrDescriptor: Int32,
|
|
4037
|
+
stdoutReadDescriptor: Int32,
|
|
4038
|
+
stderrReadDescriptor: Int32
|
|
4039
|
+
) throws -> pid_t {
|
|
4040
|
+
var duplicatedDescriptors: [Int32] = []
|
|
4041
|
+
defer {
|
|
4042
|
+
for descriptor in duplicatedDescriptors {
|
|
4043
|
+
Darwin.close(descriptor)
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
let childStdoutDescriptor = try nonStandardDescriptor(
|
|
4047
|
+
stdoutDescriptor,
|
|
4048
|
+
duplicates: &duplicatedDescriptors
|
|
4049
|
+
)
|
|
4050
|
+
let childStderrDescriptor = try nonStandardDescriptor(
|
|
4051
|
+
stderrDescriptor,
|
|
4052
|
+
duplicates: &duplicatedDescriptors
|
|
4053
|
+
)
|
|
4054
|
+
|
|
4055
|
+
var fileActions: posix_spawn_file_actions_t?
|
|
4056
|
+
try checkPOSIX(posix_spawn_file_actions_init(&fileActions), operation: "initialize spawn file actions")
|
|
4057
|
+
defer { posix_spawn_file_actions_destroy(&fileActions) }
|
|
4058
|
+
|
|
4059
|
+
try checkPOSIX(
|
|
4060
|
+
posix_spawn_file_actions_adddup2(&fileActions, childStdoutDescriptor, STDOUT_FILENO),
|
|
4061
|
+
operation: "configure command stdout"
|
|
4062
|
+
)
|
|
4063
|
+
try checkPOSIX(
|
|
4064
|
+
posix_spawn_file_actions_adddup2(&fileActions, childStderrDescriptor, STDERR_FILENO),
|
|
4065
|
+
operation: "configure command stderr"
|
|
4066
|
+
)
|
|
4067
|
+
let inheritedDescriptors = Set([
|
|
4068
|
+
stdoutReadDescriptor,
|
|
4069
|
+
stderrReadDescriptor,
|
|
4070
|
+
stdoutDescriptor,
|
|
4071
|
+
stderrDescriptor,
|
|
4072
|
+
childStdoutDescriptor,
|
|
4073
|
+
childStderrDescriptor,
|
|
4074
|
+
]).filter { $0 != STDOUT_FILENO && $0 != STDERR_FILENO }
|
|
4075
|
+
for descriptor in inheritedDescriptors {
|
|
4076
|
+
try checkPOSIX(
|
|
4077
|
+
posix_spawn_file_actions_addclose(&fileActions, descriptor),
|
|
4078
|
+
operation: "close inherited command descriptor"
|
|
4079
|
+
)
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
var attributes: posix_spawnattr_t?
|
|
4083
|
+
try checkPOSIX(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes")
|
|
4084
|
+
defer { posix_spawnattr_destroy(&attributes) }
|
|
4085
|
+
var defaultSignals = sigset_t()
|
|
4086
|
+
Darwin.sigemptyset(&defaultSignals)
|
|
4087
|
+
Darwin.sigaddset(&defaultSignals, SIGTERM)
|
|
4088
|
+
try checkPOSIX(
|
|
4089
|
+
posix_spawnattr_setsigdefault(&attributes, &defaultSignals),
|
|
4090
|
+
operation: "reset command termination signal"
|
|
4091
|
+
)
|
|
4092
|
+
var unblockedSignals = sigset_t()
|
|
4093
|
+
Darwin.sigemptyset(&unblockedSignals)
|
|
4094
|
+
try checkPOSIX(
|
|
4095
|
+
posix_spawnattr_setsigmask(&attributes, &unblockedSignals),
|
|
4096
|
+
operation: "unblock command signals"
|
|
4097
|
+
)
|
|
4098
|
+
let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK
|
|
4099
|
+
try checkPOSIX(
|
|
4100
|
+
posix_spawnattr_setflags(&attributes, Int16(spawnFlags)),
|
|
4101
|
+
operation: "configure command process group"
|
|
4102
|
+
)
|
|
4103
|
+
try checkPOSIX(
|
|
4104
|
+
posix_spawnattr_setpgroup(&attributes, 0),
|
|
4105
|
+
operation: "configure command process group leader"
|
|
4106
|
+
)
|
|
4107
|
+
|
|
4108
|
+
let environmentValues = (environment ?? ProcessInfo.processInfo.environment)
|
|
4109
|
+
.map { "\($0.key)=\($0.value)" }
|
|
4110
|
+
var processIdentifier: pid_t = 0
|
|
4111
|
+
let spawnResult = withMutableCStringArray([executable] + arguments) { argumentVector in
|
|
4112
|
+
withMutableCStringArray(environmentValues) { environmentVector in
|
|
4113
|
+
posix_spawn(
|
|
4114
|
+
&processIdentifier,
|
|
4115
|
+
executable,
|
|
4116
|
+
&fileActions,
|
|
4117
|
+
&attributes,
|
|
4118
|
+
argumentVector,
|
|
4119
|
+
environmentVector
|
|
4120
|
+
)
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
try checkPOSIX(spawnResult, operation: "launch command")
|
|
4124
|
+
return processIdentifier
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
private static func nonStandardDescriptor(
|
|
4128
|
+
_ descriptor: Int32,
|
|
4129
|
+
duplicates: inout [Int32]
|
|
4130
|
+
) throws -> Int32 {
|
|
4131
|
+
guard descriptor == STDOUT_FILENO || descriptor == STDERR_FILENO else {
|
|
4132
|
+
return descriptor
|
|
4133
|
+
}
|
|
4134
|
+
let duplicate = Darwin.fcntl(descriptor, F_DUPFD_CLOEXEC, 3)
|
|
4135
|
+
guard duplicate != -1 else {
|
|
4136
|
+
throw NSError(
|
|
4137
|
+
domain: NSPOSIXErrorDomain,
|
|
4138
|
+
code: Int(errno),
|
|
4139
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to duplicate command descriptor: \(String(cString: strerror(errno)))"]
|
|
4140
|
+
)
|
|
4141
|
+
}
|
|
4142
|
+
duplicates.append(duplicate)
|
|
4143
|
+
return duplicate
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
private static func withMutableCStringArray<Result>(
|
|
4147
|
+
_ strings: [String],
|
|
4148
|
+
body: (UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) throws -> Result
|
|
4149
|
+
) rethrows -> Result {
|
|
4150
|
+
var pointers = strings.map { strdup($0) }
|
|
4151
|
+
pointers.append(nil)
|
|
4152
|
+
defer {
|
|
4153
|
+
for pointer in pointers where pointer != nil {
|
|
4154
|
+
free(pointer)
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
return try pointers.withUnsafeMutableBufferPointer { buffer in
|
|
4158
|
+
try body(buffer.baseAddress!)
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
private static func checkPOSIX(_ result: Int32, operation: String) throws {
|
|
4163
|
+
guard result == 0 else {
|
|
4164
|
+
throw NSError(
|
|
4165
|
+
domain: NSPOSIXErrorDomain,
|
|
4166
|
+
code: Int(result),
|
|
4167
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to \(operation): \(String(cString: strerror(result)))"]
|
|
4168
|
+
)
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
private static func waitForUnreapedLeaderExit(
|
|
4173
|
+
_ processIdentifier: pid_t,
|
|
4174
|
+
timeout: TimeInterval,
|
|
4175
|
+
lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
|
|
4176
|
+
) throws -> Bool {
|
|
4177
|
+
let deadline = monotonicUptimeDeadline(after: timeout)
|
|
4178
|
+
repeat {
|
|
4179
|
+
var information = siginfo_t()
|
|
4180
|
+
var result: Int32
|
|
4181
|
+
repeat {
|
|
4182
|
+
result = Darwin.waitid(
|
|
4183
|
+
P_PID,
|
|
4184
|
+
id_t(processIdentifier),
|
|
4185
|
+
&information,
|
|
4186
|
+
WEXITED | WNOHANG | WNOWAIT
|
|
4187
|
+
)
|
|
4188
|
+
} while result == -1 && errno == EINTR
|
|
4189
|
+
guard result == 0 else {
|
|
4190
|
+
throw NSError(
|
|
4191
|
+
domain: NSPOSIXErrorDomain,
|
|
4192
|
+
code: Int(errno),
|
|
4193
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to observe command exit: \(String(cString: strerror(errno)))"]
|
|
4194
|
+
)
|
|
4195
|
+
}
|
|
4196
|
+
if information.si_pid == processIdentifier {
|
|
4197
|
+
lifecycleObserver?(.leaderExitObserved)
|
|
4198
|
+
return true
|
|
4199
|
+
}
|
|
4200
|
+
if DispatchTime.now().uptimeNanoseconds >= deadline { return false }
|
|
4201
|
+
usleep(10_000)
|
|
4202
|
+
} while true
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
private static func signalProcessGroup(
|
|
4206
|
+
_ processGroup: pid_t,
|
|
4207
|
+
signal: Int32,
|
|
4208
|
+
lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
|
|
4209
|
+
) {
|
|
4210
|
+
lifecycleObserver?(.processGroupSignaled(signal))
|
|
4211
|
+
_ = Darwin.kill(-processGroup, signal)
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
private static func reapLeader(
|
|
4215
|
+
_ processIdentifier: pid_t,
|
|
4216
|
+
lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
|
|
4217
|
+
) throws -> Int32 {
|
|
4218
|
+
var waitStatus: Int32 = 0
|
|
4219
|
+
var waitResult: pid_t
|
|
4220
|
+
repeat {
|
|
4221
|
+
waitResult = Darwin.waitpid(processIdentifier, &waitStatus, 0)
|
|
4222
|
+
} while waitResult == -1 && errno == EINTR
|
|
4223
|
+
guard waitResult == processIdentifier else {
|
|
4224
|
+
throw NSError(
|
|
4225
|
+
domain: NSPOSIXErrorDomain,
|
|
4226
|
+
code: Int(errno),
|
|
4227
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to reap command: \(String(cString: strerror(errno)))"]
|
|
4228
|
+
)
|
|
4229
|
+
}
|
|
4230
|
+
let terminationStatus = decode(waitStatus: waitStatus)
|
|
4231
|
+
lifecycleObserver?(.leaderReaped(terminationStatus))
|
|
4232
|
+
return terminationStatus
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
private static func reapLeaderInBackground(_ processIdentifier: pid_t) {
|
|
4236
|
+
DispatchQueue.global(qos: .utility).async {
|
|
4237
|
+
var waitStatus: Int32 = 0
|
|
4238
|
+
var waitResult: pid_t
|
|
4239
|
+
repeat {
|
|
4240
|
+
waitResult = Darwin.waitpid(processIdentifier, &waitStatus, 0)
|
|
4241
|
+
} while waitResult == -1 && errno == EINTR
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
private static func decode(waitStatus: Int32) -> Int32 {
|
|
4246
|
+
let signal = waitStatus & 0x7f
|
|
4247
|
+
if signal == 0 {
|
|
4248
|
+
return (waitStatus >> 8) & 0xff
|
|
4249
|
+
}
|
|
4250
|
+
return 128 + signal
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
private static func monotonicDispatchDeadline(after timeout: TimeInterval) -> DispatchTime {
|
|
4254
|
+
DispatchTime(uptimeNanoseconds: monotonicUptimeDeadline(after: timeout))
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
private static func monotonicUptimeDeadline(after timeout: TimeInterval) -> UInt64 {
|
|
4258
|
+
let now = DispatchTime.now().uptimeNanoseconds
|
|
4259
|
+
let maximumDelay = min(UInt64(Int64.max), UInt64.max - now)
|
|
4260
|
+
let requestedNanoseconds = timeout * 1_000_000_000
|
|
4261
|
+
let nanoseconds = requestedNanoseconds >= Double(maximumDelay)
|
|
4262
|
+
? maximumDelay
|
|
4263
|
+
: UInt64(requestedNanoseconds.rounded(.up))
|
|
4264
|
+
return now + nanoseconds
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
/// Shuts capture down deterministically. Waits up to `pipeDrainTimeout` for the
|
|
4268
|
+
/// readers to observe end-of-file on their own — the complete-output path — then
|
|
4269
|
+
/// cancels and joins whatever is still running. Both reader threads have provably
|
|
4270
|
+
/// exited and both pipe read ends are closed before this returns: a silent escaped
|
|
4271
|
+
/// descendant still holding a write end observes a widowed pipe at that point, and no
|
|
4272
|
+
/// snapshot of captured output can race a reader mid-append.
|
|
4273
|
+
private static func finishCaptures(
|
|
4274
|
+
_ readers: [PipeCaptureReader],
|
|
4275
|
+
pipeDrainTimeout: TimeInterval
|
|
4276
|
+
) -> ExecutionError? {
|
|
4277
|
+
let deadline = monotonicDispatchDeadline(after: pipeDrainTimeout)
|
|
4278
|
+
for reader in readers {
|
|
4279
|
+
reader.waitUntilExited(deadline: deadline)
|
|
4280
|
+
}
|
|
4281
|
+
for reader in readers {
|
|
4282
|
+
reader.cancel()
|
|
4283
|
+
}
|
|
4284
|
+
for reader in readers {
|
|
4285
|
+
reader.join()
|
|
4286
|
+
}
|
|
4287
|
+
return readers.lazy.compactMap(\.terminalError).first
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
/// Owns one capture pipe end to end: it creates the pipe, lends the write end to the
|
|
4291
|
+
/// spawn, and consumes the read end on a dedicated thread that multiplexes the
|
|
4292
|
+
/// nonblocking pipe with a private wakeup pipe through poll(2). `cancel()` writes one
|
|
4293
|
+
/// wakeup byte, so the thread deterministically leaves poll even when a silent
|
|
4294
|
+
/// escaped descendant keeps the write end open forever without writing a byte — POSIX
|
|
4295
|
+
/// does not promise that replacing or closing a descriptor interrupts a read(2)
|
|
4296
|
+
/// already blocked on it, so the blocking-read + dup2 revocation this replaces could
|
|
4297
|
+
/// leave the reader thread and the process's pipe reference alive indefinitely.
|
|
4298
|
+
///
|
|
4299
|
+
/// The runner must keep owning every reference to the read descriptor itself:
|
|
4300
|
+
/// `FileHandle.readabilityHandler` keeps a private duplicate of the descriptor that
|
|
4301
|
+
/// can survive `close()` while data is flowing, which would leave the pipe readable
|
|
4302
|
+
/// forever — a descendant that inherited the write end would never observe EPIPE, and
|
|
4303
|
+
/// the descriptor would leak in this process. Never reintroduce it here.
|
|
4304
|
+
final class PipeCaptureReader: @unchecked Sendable {
|
|
4305
|
+
struct SystemCalls: @unchecked Sendable {
|
|
4306
|
+
let makePipe: (_ operation: String) throws -> (read: Int32, write: Int32)
|
|
4307
|
+
let fcntl: (_ descriptor: Int32, _ command: Int32, _ value: Int32?) -> Int32
|
|
4308
|
+
let close: (_ descriptor: Int32) -> Int32
|
|
4309
|
+
let read: (
|
|
4310
|
+
_ descriptor: Int32,
|
|
4311
|
+
_ buffer: UnsafeMutableRawPointer?,
|
|
4312
|
+
_ count: Int
|
|
4313
|
+
) -> Int
|
|
4314
|
+
let poll: (
|
|
4315
|
+
_ descriptors: UnsafeMutablePointer<pollfd>?,
|
|
4316
|
+
_ count: nfds_t,
|
|
4317
|
+
_ timeout: Int32
|
|
4318
|
+
) -> Int32
|
|
4319
|
+
|
|
4320
|
+
init(
|
|
4321
|
+
makePipe: @escaping (_ operation: String) throws -> (read: Int32, write: Int32) = {
|
|
4322
|
+
try PipeCaptureReader.makePipe(operation: $0)
|
|
4323
|
+
},
|
|
4324
|
+
fcntl: @escaping (
|
|
4325
|
+
_ descriptor: Int32,
|
|
4326
|
+
_ command: Int32,
|
|
4327
|
+
_ value: Int32?
|
|
4328
|
+
) -> Int32 = { descriptor, command, value in
|
|
4329
|
+
if let value {
|
|
4330
|
+
return Darwin.fcntl(descriptor, command, value)
|
|
4331
|
+
}
|
|
4332
|
+
return Darwin.fcntl(descriptor, command)
|
|
4333
|
+
},
|
|
4334
|
+
close: @escaping (_ descriptor: Int32) -> Int32 = { Darwin.close($0) },
|
|
4335
|
+
read: @escaping (
|
|
4336
|
+
_ descriptor: Int32,
|
|
4337
|
+
_ buffer: UnsafeMutableRawPointer?,
|
|
4338
|
+
_ count: Int
|
|
4339
|
+
) -> Int = { Darwin.read($0, $1, $2) },
|
|
4340
|
+
poll: @escaping (
|
|
4341
|
+
_ descriptors: UnsafeMutablePointer<pollfd>?,
|
|
4342
|
+
_ count: nfds_t,
|
|
4343
|
+
_ timeout: Int32
|
|
4344
|
+
) -> Int32 = { Darwin.poll($0, $1, $2) }
|
|
4345
|
+
) {
|
|
4346
|
+
self.makePipe = makePipe
|
|
4347
|
+
self.fcntl = fcntl
|
|
4348
|
+
self.close = close
|
|
4349
|
+
self.read = read
|
|
4350
|
+
self.poll = poll
|
|
4351
|
+
}
|
|
4352
|
+
|
|
4353
|
+
static let live = SystemCalls()
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
/// Write end lent to the spawned child; the runner closes it through
|
|
4357
|
+
/// `closeWriteDescriptor()` once the child holds its own copies.
|
|
4358
|
+
let writeDescriptor: Int32
|
|
4359
|
+
/// Read end consumed — and eventually closed — exclusively by the reader thread.
|
|
4360
|
+
let readDescriptor: Int32
|
|
4361
|
+
|
|
4362
|
+
private let wakeupReadDescriptor: Int32
|
|
4363
|
+
private let wakeupWriteDescriptor: Int32
|
|
4364
|
+
private let systemCalls: SystemCalls
|
|
4365
|
+
private let capture = ProcessDataCapture()
|
|
4366
|
+
private let exited = DispatchSemaphore(value: 0)
|
|
4367
|
+
private let lock = NSLock()
|
|
4368
|
+
private var cancelRequested = false
|
|
4369
|
+
private var writeDescriptorClosed = false
|
|
4370
|
+
private var wakeupDescriptorsClosed = false
|
|
4371
|
+
private var storedTerminalError: ExecutionError?
|
|
4372
|
+
|
|
4373
|
+
/// Reads per drain burst between polls, so a flooding writer cannot starve the
|
|
4374
|
+
/// wakeup descriptor check.
|
|
4375
|
+
private static let drainReadLimit = 64
|
|
4376
|
+
/// Reads allowed after cancellation — comfortably above the kernel's largest pipe
|
|
4377
|
+
/// buffer, so an already-buffered tail is never dropped, yet `join()` stays
|
|
4378
|
+
/// prompt against a descendant that keeps writing.
|
|
4379
|
+
private static let cancelledDrainReadLimit = 16
|
|
4380
|
+
|
|
4381
|
+
init(systemCalls: SystemCalls = .live) throws {
|
|
4382
|
+
let dataPipe = try systemCalls.makePipe("create capture pipe")
|
|
4383
|
+
let wakeupPipe: (read: Int32, write: Int32)
|
|
4384
|
+
do {
|
|
4385
|
+
wakeupPipe = try systemCalls.makePipe("create capture wakeup pipe")
|
|
4386
|
+
} catch {
|
|
4387
|
+
_ = systemCalls.close(dataPipe.read)
|
|
4388
|
+
_ = systemCalls.close(dataPipe.write)
|
|
4389
|
+
throw error
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
let descriptors = [dataPipe.read, dataPipe.write, wakeupPipe.read, wakeupPipe.write]
|
|
4393
|
+
var setupSucceeded = false
|
|
4394
|
+
defer {
|
|
4395
|
+
if !setupSucceeded {
|
|
4396
|
+
for descriptor in descriptors {
|
|
4397
|
+
_ = systemCalls.close(descriptor)
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
for descriptor in descriptors {
|
|
4403
|
+
let descriptorFlags = try Self.checkedFcntl(
|
|
4404
|
+
descriptor,
|
|
4405
|
+
command: F_GETFD,
|
|
4406
|
+
operation: "read capture descriptor flags",
|
|
4407
|
+
systemCalls: systemCalls
|
|
4408
|
+
)
|
|
4409
|
+
_ = try Self.checkedFcntl(
|
|
4410
|
+
descriptor,
|
|
4411
|
+
command: F_SETFD,
|
|
4412
|
+
value: descriptorFlags | FD_CLOEXEC,
|
|
4413
|
+
operation: "protect capture descriptor from inheritance",
|
|
4414
|
+
systemCalls: systemCalls
|
|
4415
|
+
)
|
|
4416
|
+
}
|
|
4417
|
+
let readFlags = try Self.checkedFcntl(
|
|
4418
|
+
dataPipe.read,
|
|
4419
|
+
command: F_GETFL,
|
|
4420
|
+
operation: "read capture pipe status flags",
|
|
4421
|
+
systemCalls: systemCalls
|
|
4422
|
+
)
|
|
4423
|
+
_ = try Self.checkedFcntl(
|
|
4424
|
+
dataPipe.read,
|
|
4425
|
+
command: F_SETFL,
|
|
4426
|
+
value: readFlags | O_NONBLOCK,
|
|
4427
|
+
operation: "make capture pipe nonblocking",
|
|
4428
|
+
systemCalls: systemCalls
|
|
4429
|
+
)
|
|
4430
|
+
|
|
4431
|
+
readDescriptor = dataPipe.read
|
|
4432
|
+
writeDescriptor = dataPipe.write
|
|
4433
|
+
wakeupReadDescriptor = wakeupPipe.read
|
|
4434
|
+
wakeupWriteDescriptor = wakeupPipe.write
|
|
4435
|
+
self.systemCalls = systemCalls
|
|
4436
|
+
setupSucceeded = true
|
|
4437
|
+
Thread.detachNewThread { [self] in consumePipe() }
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
deinit {
|
|
4441
|
+
// The reader thread retains this object until it has closed the read end.
|
|
4442
|
+
// Callers must still close/cancel/join; this only releases any remaining
|
|
4443
|
+
// owner-side descriptors after the reader has already exited.
|
|
4444
|
+
if !writeDescriptorClosed { _ = systemCalls.close(writeDescriptor) }
|
|
4445
|
+
if !wakeupDescriptorsClosed {
|
|
4446
|
+
_ = systemCalls.close(wakeupReadDescriptor)
|
|
4447
|
+
_ = systemCalls.close(wakeupWriteDescriptor)
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
/// Everything captured so far; stable and complete once `join()` has returned.
|
|
4452
|
+
var data: Data { capture.data }
|
|
4453
|
+
|
|
4454
|
+
/// A terminal capture failure, stable once `join()` has returned. Cancellation
|
|
4455
|
+
/// and ordinary end-of-file are not failures.
|
|
4456
|
+
var terminalError: ExecutionError? {
|
|
4457
|
+
lock.lock()
|
|
4458
|
+
defer { lock.unlock() }
|
|
4459
|
+
return storedTerminalError
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
func closeWriteDescriptor() {
|
|
4463
|
+
lock.lock()
|
|
4464
|
+
defer { lock.unlock() }
|
|
4465
|
+
guard !writeDescriptorClosed else { return }
|
|
4466
|
+
writeDescriptorClosed = true
|
|
4467
|
+
_ = systemCalls.close(writeDescriptor)
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
/// Waits until `deadline` for the reader thread to exit on its own — that is,
|
|
4471
|
+
/// for end-of-file once every write end is closed. Returns whether it has.
|
|
4472
|
+
@discardableResult
|
|
4473
|
+
func waitUntilExited(deadline: DispatchTime) -> Bool {
|
|
4474
|
+
guard exited.wait(timeout: deadline) == .success else { return false }
|
|
4475
|
+
exited.signal()
|
|
4476
|
+
return true
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
/// Wakes the reader thread out of poll(2) even when the capture pipe never
|
|
4480
|
+
/// becomes readable again. Idempotent; never blocks.
|
|
4481
|
+
func cancel() {
|
|
4482
|
+
lock.lock()
|
|
4483
|
+
defer { lock.unlock() }
|
|
4484
|
+
guard !cancelRequested, !wakeupDescriptorsClosed else { return }
|
|
4485
|
+
cancelRequested = true
|
|
4486
|
+
var wakeupByte: UInt8 = 1
|
|
4487
|
+
var result: Int
|
|
4488
|
+
repeat {
|
|
4489
|
+
result = Darwin.write(wakeupWriteDescriptor, &wakeupByte, 1)
|
|
4490
|
+
} while result == -1 && errno == EINTR
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
/// Blocks until the reader thread has provably exited and closed the pipe read
|
|
4494
|
+
/// end, then releases the wakeup pipe. Callers must `cancel()` first whenever the
|
|
4495
|
+
/// pipe may never reach end-of-file; the wakeup then bounds this wait to thread
|
|
4496
|
+
/// scheduling plus one final drain burst. Idempotent.
|
|
4497
|
+
func join() {
|
|
4498
|
+
exited.wait()
|
|
4499
|
+
exited.signal()
|
|
4500
|
+
lock.lock()
|
|
4501
|
+
defer { lock.unlock() }
|
|
4502
|
+
guard !wakeupDescriptorsClosed else { return }
|
|
4503
|
+
wakeupDescriptorsClosed = true
|
|
4504
|
+
_ = systemCalls.close(wakeupReadDescriptor)
|
|
4505
|
+
_ = systemCalls.close(wakeupWriteDescriptor)
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
private func consumePipe() {
|
|
4509
|
+
Thread.current.name = "CLIRunner.PipeCaptureReader"
|
|
4510
|
+
var buffer = [UInt8](repeating: 0, count: 65_536)
|
|
4511
|
+
var cancelled = false
|
|
4512
|
+
readLoop: while true {
|
|
4513
|
+
var reads = 0
|
|
4514
|
+
var sawEndOfFile = false
|
|
4515
|
+
var sawFailure = false
|
|
4516
|
+
let readLimit = cancelled ? Self.cancelledDrainReadLimit : Self.drainReadLimit
|
|
4517
|
+
while reads < readLimit {
|
|
4518
|
+
let count = buffer.withUnsafeMutableBytes {
|
|
4519
|
+
systemCalls.read(readDescriptor, $0.baseAddress, $0.count)
|
|
4520
|
+
}
|
|
4521
|
+
if count > 0 {
|
|
4522
|
+
capture.append(Data(bytes: buffer, count: count))
|
|
4523
|
+
reads += 1
|
|
4524
|
+
continue
|
|
4525
|
+
}
|
|
4526
|
+
if count == 0 {
|
|
4527
|
+
sawEndOfFile = true
|
|
4528
|
+
} else if errno == EINTR {
|
|
4529
|
+
continue
|
|
4530
|
+
} else if errno != EAGAIN {
|
|
4531
|
+
storeTerminalError(operation: .read, code: errno)
|
|
4532
|
+
sawFailure = true
|
|
4533
|
+
}
|
|
4534
|
+
break
|
|
4535
|
+
}
|
|
4536
|
+
if sawEndOfFile || sawFailure || cancelled { break readLoop }
|
|
4537
|
+
var descriptors = [
|
|
4538
|
+
pollfd(fd: readDescriptor, events: Int16(POLLIN), revents: 0),
|
|
4539
|
+
pollfd(fd: wakeupReadDescriptor, events: Int16(POLLIN), revents: 0),
|
|
4540
|
+
]
|
|
4541
|
+
let events = systemCalls.poll(&descriptors, 2, -1)
|
|
4542
|
+
if events == -1 {
|
|
4543
|
+
if errno == EINTR { continue readLoop }
|
|
4544
|
+
storeTerminalError(operation: .poll, code: errno)
|
|
4545
|
+
break readLoop
|
|
4546
|
+
}
|
|
4547
|
+
if descriptors[0].revents & Int16(POLLNVAL) != 0 {
|
|
4548
|
+
storeTerminalError(operation: .poll, code: EBADF)
|
|
4549
|
+
break readLoop
|
|
4550
|
+
}
|
|
4551
|
+
if descriptors[0].revents & Int16(POLLERR) != 0 {
|
|
4552
|
+
storeTerminalError(operation: .poll, code: EIO)
|
|
4553
|
+
break readLoop
|
|
4554
|
+
}
|
|
4555
|
+
if descriptors[1].revents & Int16(POLLNVAL) != 0 {
|
|
4556
|
+
storeTerminalError(operation: .poll, code: EBADF)
|
|
4557
|
+
break readLoop
|
|
4558
|
+
}
|
|
4559
|
+
if descriptors[1].revents & Int16(POLLERR) != 0 {
|
|
4560
|
+
storeTerminalError(operation: .poll, code: EIO)
|
|
4561
|
+
break readLoop
|
|
4562
|
+
}
|
|
4563
|
+
if descriptors[1].revents != 0 {
|
|
4564
|
+
// One final bounded drain of already-buffered data, then exit.
|
|
4565
|
+
cancelled = true
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
_ = capture.finish()
|
|
4569
|
+
_ = systemCalls.close(readDescriptor)
|
|
4570
|
+
exited.signal()
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
private func storeTerminalError(operation: CaptureOperation, code: Int32) {
|
|
4574
|
+
lock.lock()
|
|
4575
|
+
defer { lock.unlock() }
|
|
4576
|
+
guard storedTerminalError == nil else { return }
|
|
4577
|
+
storedTerminalError = .captureFailed(operation: operation, code: code)
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4580
|
+
private static func makePipe(operation: String) throws -> (read: Int32, write: Int32) {
|
|
4581
|
+
var ends: [Int32] = [0, 0]
|
|
4582
|
+
guard Darwin.pipe(&ends) == 0 else {
|
|
4583
|
+
throw NSError(
|
|
4584
|
+
domain: NSPOSIXErrorDomain,
|
|
4585
|
+
code: Int(errno),
|
|
4586
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to \(operation): \(String(cString: strerror(errno)))"]
|
|
4587
|
+
)
|
|
4588
|
+
}
|
|
4589
|
+
return (ends[0], ends[1])
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
private static func checkedFcntl(
|
|
4593
|
+
_ descriptor: Int32,
|
|
4594
|
+
command: Int32,
|
|
4595
|
+
value: Int32? = nil,
|
|
4596
|
+
operation: String,
|
|
4597
|
+
systemCalls: SystemCalls
|
|
4598
|
+
) throws -> Int32 {
|
|
4599
|
+
var result: Int32
|
|
4600
|
+
repeat {
|
|
4601
|
+
result = systemCalls.fcntl(descriptor, command, value)
|
|
4602
|
+
} while result == -1 && errno == EINTR
|
|
4603
|
+
guard result != -1 else {
|
|
4604
|
+
let errorNumber = errno
|
|
4605
|
+
throw NSError(
|
|
4606
|
+
domain: NSPOSIXErrorDomain,
|
|
4607
|
+
code: Int(errorNumber),
|
|
4608
|
+
userInfo: [
|
|
4609
|
+
NSLocalizedDescriptionKey:
|
|
4610
|
+
"Failed to \(operation): \(String(cString: strerror(errorNumber)))"
|
|
4611
|
+
]
|
|
4612
|
+
)
|
|
4613
|
+
}
|
|
4614
|
+
return result
|
|
1302
4615
|
}
|
|
1303
4616
|
}
|
|
1304
4617
|
|
|
1305
4618
|
static func parseError(_ output: String) -> String? {
|
|
1306
4619
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1307
4620
|
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
1308
|
-
let message =
|
|
4621
|
+
let message = NativeErrorSanitizer.sanitize(
|
|
4622
|
+
trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
4623
|
+
)
|
|
1309
4624
|
let lowercased = message.lowercased()
|
|
1310
4625
|
if lowercased.contains("401") || lowercased.contains("incorrect api key")
|
|
1311
4626
|
|| lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
|
|
@@ -1324,6 +4639,19 @@ enum CLIRunner: Sendable {
|
|
|
1324
4639
|
return String(message.prefix(120))
|
|
1325
4640
|
}
|
|
1326
4641
|
|
|
4642
|
+
/// The raw (pre-enhancement) transcript from a CLI JSON envelope. Intent decisions must
|
|
4643
|
+
/// run on this, never on `processed_text`.
|
|
4644
|
+
static func parseRawTranscript(_ output: String) -> String? {
|
|
4645
|
+
guard let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
|
|
4646
|
+
s.lowerBound < e.upperBound else { return nil }
|
|
4647
|
+
let json = String(output[s.lowerBound..<e.upperBound])
|
|
4648
|
+
guard let data = json.data(using: .utf8),
|
|
4649
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
4650
|
+
let raw = obj["raw_text"] as? String,
|
|
4651
|
+
!raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
4652
|
+
return raw
|
|
4653
|
+
}
|
|
4654
|
+
|
|
1327
4655
|
static func parseJSON(_ output: String) -> String? {
|
|
1328
4656
|
if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
|
|
1329
4657
|
s.lowerBound < e.upperBound {
|