@hasna/recordings 0.3.13 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -37
- package/bun.lock +37 -38
- package/dist/cli/index.js +1884 -615
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2858 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1689 -456
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +566 -284
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1334 -871
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +20 -16
- package/scripts/migrate.ts +4 -20
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +6 -6
- package/src/native/Recordings/App/SidebarView.swift +0 -212
|
@@ -16,19 +16,23 @@ extension KeyboardShortcuts.Name {
|
|
|
16
16
|
|
|
17
17
|
public struct TranscriptionResult: Identifiable, Sendable {
|
|
18
18
|
public let id = UUID()
|
|
19
|
-
let rawText: String
|
|
20
|
-
let processedText: String?
|
|
21
|
-
let timestamp: Date
|
|
22
|
-
let projectId: String?
|
|
23
|
-
let projectName: String?
|
|
19
|
+
public let rawText: String
|
|
20
|
+
public let processedText: String?
|
|
21
|
+
public let timestamp: Date
|
|
22
|
+
public let projectId: String?
|
|
23
|
+
public let projectName: String?
|
|
24
|
+
public let captureID: String?
|
|
25
|
+
public let audioURL: URL?
|
|
24
26
|
public var displayText: String { processedText ?? rawText }
|
|
25
27
|
|
|
26
|
-
init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?) {
|
|
28
|
+
public init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?, captureID: String? = nil, audioURL: URL? = nil) {
|
|
27
29
|
self.rawText = rawText
|
|
28
30
|
self.processedText = processedText
|
|
29
31
|
self.timestamp = timestamp
|
|
30
32
|
self.projectId = projectId
|
|
31
33
|
self.projectName = projectName
|
|
34
|
+
self.captureID = captureID
|
|
35
|
+
self.audioURL = audioURL
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -95,7 +99,7 @@ struct FrontmostAppSnapshot: Equatable, Sendable {
|
|
|
95
99
|
|
|
96
100
|
/// The one capability `RecordingEngine` needs from an audio recorder; lets tests run the
|
|
97
101
|
/// production start path without microphone hardware or TCC grants.
|
|
98
|
-
protocol PCMRecordingSource: AnyObject {
|
|
102
|
+
protocol PCMRecordingSource: AnyObject, Sendable {
|
|
99
103
|
func start() throws
|
|
100
104
|
func stop()
|
|
101
105
|
}
|
|
@@ -671,7 +675,7 @@ private final class PCMStreamPipe: @unchecked Sendable {
|
|
|
671
675
|
private let continuation: AsyncStream<Data>.Continuation
|
|
672
676
|
private let processor: Task<Data, Never>
|
|
673
677
|
|
|
674
|
-
init(chunkSize: Int, client: RealtimeTranscriptionClient?) {
|
|
678
|
+
init(chunkSize: Int, client: RealtimeTranscriptionClient?, providerSession: (any RecordingTranscriptionSession)? = nil) {
|
|
675
679
|
var streamContinuation: AsyncStream<Data>.Continuation!
|
|
676
680
|
let stream = AsyncStream<Data>(bufferingPolicy: .unbounded) { continuation in
|
|
677
681
|
streamContinuation = continuation
|
|
@@ -682,17 +686,21 @@ private final class PCMStreamPipe: @unchecked Sendable {
|
|
|
682
686
|
var pendingChunk = Data()
|
|
683
687
|
|
|
684
688
|
for await data in stream {
|
|
689
|
+
guard !Task.isCancelled else { break }
|
|
685
690
|
guard !data.isEmpty else { continue }
|
|
686
691
|
recordedPCM.append(data)
|
|
687
692
|
pendingChunk.append(data)
|
|
688
693
|
|
|
689
694
|
while pendingChunk.count >= chunkSize {
|
|
690
|
-
|
|
695
|
+
let chunk = pendingChunk.prefixData(count: chunkSize)
|
|
696
|
+
providerSession?.appendPCM(chunk)
|
|
697
|
+
await client?.sendAudio(chunk)
|
|
691
698
|
pendingChunk.removeFirst(chunkSize)
|
|
692
699
|
}
|
|
693
700
|
}
|
|
694
701
|
|
|
695
|
-
if !pendingChunk.isEmpty {
|
|
702
|
+
if !Task.isCancelled && !pendingChunk.isEmpty {
|
|
703
|
+
providerSession?.appendPCM(pendingChunk)
|
|
696
704
|
await client?.sendAudio(pendingChunk)
|
|
697
705
|
}
|
|
698
706
|
return recordedPCM
|
|
@@ -737,7 +745,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
737
745
|
@Published public private(set) var isWarmingUpCapture = false
|
|
738
746
|
@Published public var useFnKey: Bool = false {
|
|
739
747
|
didSet {
|
|
740
|
-
|
|
748
|
+
preferences.set(useFnKey, forKey: "useFnKey")
|
|
741
749
|
updateFnMonitor()
|
|
742
750
|
refreshTriggerDiagnostics()
|
|
743
751
|
}
|
|
@@ -854,9 +862,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
854
862
|
private var deliveryBlockedReasonGeneration: UInt64?
|
|
855
863
|
/// Advanced fallback policy (Settings only): when off, every recording is dictated
|
|
856
864
|
/// literally and the classifier is never consulted.
|
|
857
|
-
@Published public var intentDetectionEnabled: Bool =
|
|
865
|
+
@Published public var intentDetectionEnabled: Bool = false {
|
|
858
866
|
didSet {
|
|
859
|
-
|
|
867
|
+
preferences.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
|
|
860
868
|
}
|
|
861
869
|
}
|
|
862
870
|
/// Typed Record-page state; views render idle/listening/finalizing/processing/ready/error
|
|
@@ -874,13 +882,64 @@ public final class RecordingEngine: ObservableObject {
|
|
|
874
882
|
@Published public var isTranscribing = false
|
|
875
883
|
@Published public var recordingDuration: TimeInterval = 0
|
|
876
884
|
@Published public var liveTranscriptionText = ""
|
|
885
|
+
@Published public private(set) var isPaused = false
|
|
886
|
+
@Published public private(set) var audioLevel: Double = 0
|
|
887
|
+
@Published public private(set) var recentPastes: [RecentPaste] = []
|
|
888
|
+
@Published public private(set) var latestAudioPath: String?
|
|
889
|
+
@Published public var autoPasteEnabled = true {
|
|
890
|
+
didSet {
|
|
891
|
+
if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
|
|
892
|
+
preferences.set(autoPasteEnabled, forKey: "recordingsAutoPaste")
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
private var captureMonitor = CaptureMonitor()
|
|
897
|
+
|
|
898
|
+
public func togglePause() {
|
|
899
|
+
guard isRecording else { return }
|
|
900
|
+
isPaused.toggle()
|
|
901
|
+
captureMonitor.setPaused(isPaused)
|
|
902
|
+
statusMessage = isPaused ? "Paused" : "Recording…"
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
public func clearRecentPastes() { recentPastes.removeAll() }
|
|
906
|
+
|
|
907
|
+
#if DEBUG
|
|
908
|
+
/// Explicitly isolated design review only; does not open a microphone or connection.
|
|
909
|
+
public func configureVisualPreview() {
|
|
910
|
+
isRecording = true
|
|
911
|
+
recordingDuration = 12
|
|
912
|
+
audioLevel = 0.65
|
|
913
|
+
liveTranscriptionText = "Here are the key takeaways from today’s meeting…"
|
|
914
|
+
let samples = [
|
|
915
|
+
("com.apple.MobileSMS", "To: James", "Schedule a review for tomorrow at 2 PM…"),
|
|
916
|
+
("com.apple.Notes", "New Note", "Here are the key takeaways from today’s…"),
|
|
917
|
+
("com.tinyspeck.slackmacgap", "#product", "Can you confirm we’re still on for Friday?"),
|
|
918
|
+
("com.apple.mail", "New message", "Sharing the updated timeline and next steps."),
|
|
919
|
+
("notion.id", "Page", "Write a short summary of the discussion…")
|
|
920
|
+
]
|
|
921
|
+
recentPastes = zip(samples, ["Messages", "Notes", "Slack", "Mail", "Notion"]).map { sample, name in RecentPaste(text: sample.2, bundleIdentifier: sample.0, appName: name, location: sample.1, status: "Pasted", verified: true) }
|
|
922
|
+
}
|
|
923
|
+
#endif
|
|
924
|
+
|
|
877
925
|
@Published public var transcriptionLanguage = OpenAIAPIKeyStore.defaultLanguage {
|
|
878
926
|
didSet {
|
|
879
|
-
|
|
880
|
-
|
|
927
|
+
preferences.set(transcriptionLanguage, forKey: "recordingsLanguage")
|
|
928
|
+
if !usesIsolatedProvider {
|
|
929
|
+
try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
|
|
930
|
+
}
|
|
881
931
|
}
|
|
882
932
|
}
|
|
883
933
|
|
|
934
|
+
private let preferences: UserDefaults
|
|
935
|
+
private let preferencesSuiteName: String?
|
|
936
|
+
private let installsGlobalHandlers: Bool
|
|
937
|
+
private let transcriptionProvider: (any RecordingTranscriptionProvider)?
|
|
938
|
+
private var usesIsolatedProvider: Bool { transcriptionProvider != nil }
|
|
939
|
+
private var providerSession: (any RecordingTranscriptionSession)?
|
|
940
|
+
private var providerConfiguration: RecordingProviderSessionConfiguration?
|
|
941
|
+
private var providerCompletionTask: Task<Void, Never>?
|
|
942
|
+
|
|
884
943
|
private var nativeRecorder: PCMRecordingSource?
|
|
885
944
|
private var recordingTimer: Timer?
|
|
886
945
|
private var activeTrigger: RecordingTrigger?
|
|
@@ -891,8 +950,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
891
950
|
private var targetAppPid: pid_t?
|
|
892
951
|
private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
|
|
893
952
|
public var projectStore: ProjectStore?
|
|
953
|
+
/// The minimal app uses only global cleanup preferences from the legacy settings
|
|
954
|
+
/// file. Keeping this separate prevents old active projects from tagging new captures.
|
|
955
|
+
public var globalRecordingPreferences: ProjectStore?
|
|
894
956
|
public var voiceShortcuts: VoiceShortcuts?
|
|
895
957
|
|
|
958
|
+
var recordingCleanupPreferences: (prompt: String, mode: String) {
|
|
959
|
+
if let settings = globalRecordingPreferences?.settings {
|
|
960
|
+
return (settings.globalSystemPrompt, (PostProcessingMode(rawValue: settings.postProcessingMode) ?? .auto).rawValue)
|
|
961
|
+
}
|
|
962
|
+
return (projectStore?.effectiveSystemPrompt ?? "", projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue)
|
|
963
|
+
}
|
|
964
|
+
|
|
896
965
|
// MARK: - Injectable boundaries
|
|
897
966
|
// Production defaults perform the real I/O; tests replace them to drive the production
|
|
898
967
|
// start/delivery paths without microphone, Accessibility, network, or CLI access.
|
|
@@ -1188,15 +1257,37 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1188
1257
|
let home: String
|
|
1189
1258
|
private var audioDir: String { "\(home)/.hasna/recordings/audio" }
|
|
1190
1259
|
|
|
1191
|
-
public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
|
|
1260
|
+
public convenience init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path, installsGlobalHandlers: Bool = true) {
|
|
1261
|
+
self.init(homePath: homePath, preferences: .standard, preferencesSuiteName: nil, installsGlobalHandlers: installsGlobalHandlers, transcriptionProvider: nil)
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/// Reuses native capture and paste with isolated preferences and an explicit provider.
|
|
1265
|
+
/// No global handlers, legacy keys, environment routing or helper CLI are consulted.
|
|
1266
|
+
public convenience init(configuration: RecordingEngineConfiguration, transcriptionProvider: any RecordingTranscriptionProvider) {
|
|
1267
|
+
// A validated non-empty suite name is supported by Foundation on macOS.
|
|
1268
|
+
let preferences = UserDefaults(suiteName: configuration.preferencesSuiteName)!
|
|
1269
|
+
self.init(homePath: configuration.isolatedHomePath, preferences: preferences, preferencesSuiteName: configuration.preferencesSuiteName, installsGlobalHandlers: false, transcriptionProvider: transcriptionProvider)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private init(homePath: String, preferences: UserDefaults, preferencesSuiteName: String?, installsGlobalHandlers: Bool, transcriptionProvider: (any RecordingTranscriptionProvider)?) {
|
|
1192
1273
|
home = homePath
|
|
1274
|
+
self.preferences = preferences
|
|
1275
|
+
self.preferencesSuiteName = preferencesSuiteName
|
|
1276
|
+
self.installsGlobalHandlers = installsGlobalHandlers
|
|
1277
|
+
self.transcriptionProvider = transcriptionProvider
|
|
1193
1278
|
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
1194
1279
|
log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
|
|
1195
1280
|
|
|
1196
1281
|
// Load preferences
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1282
|
+
if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
|
|
1283
|
+
autoPasteEnabled = storedPreference("recordingsAutoPaste") as? Bool ?? true
|
|
1284
|
+
}
|
|
1285
|
+
intentDetectionEnabled = storedPreference("intentDetectionEnabled") as? Bool ?? false
|
|
1286
|
+
transcriptionLanguage = usesIsolatedProvider
|
|
1287
|
+
? (storedPreference("recordingsLanguage") as? String ?? "en")
|
|
1288
|
+
: OpenAIAPIKeyStore.loadLanguage(homePath: home)
|
|
1289
|
+
useFnKey = storedPreference("useFnKey") as? Bool ?? false
|
|
1290
|
+
guard installsGlobalHandlers else { statusMessage = "Ready"; return }
|
|
1200
1291
|
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
1201
1292
|
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
1202
1293
|
}
|
|
@@ -1268,6 +1359,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1268
1359
|
updateStatus()
|
|
1269
1360
|
}
|
|
1270
1361
|
|
|
1362
|
+
private func storedPreference(_ key: String) -> Any? {
|
|
1363
|
+
if let preferencesSuiteName {
|
|
1364
|
+
// UserDefaults.object also consults process/global domains. A separate app's
|
|
1365
|
+
// engine reads only its explicitly named persistent domain.
|
|
1366
|
+
return preferences.persistentDomain(forName: preferencesSuiteName)?[key]
|
|
1367
|
+
}
|
|
1368
|
+
return preferences.object(forKey: key)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1271
1371
|
public var microphonePermissionLabel: String {
|
|
1272
1372
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
1273
1373
|
case .authorized:
|
|
@@ -1331,6 +1431,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1331
1431
|
/// was indistinguishable from a working one. Log the resolved binding so "is the
|
|
1332
1432
|
/// trigger armed, and to what" is answerable from the log alone.
|
|
1333
1433
|
public func logResolvedTrigger() {
|
|
1434
|
+
guard installsGlobalHandlers else { return }
|
|
1334
1435
|
let stored = KeyboardShortcuts.getShortcut(for: .toggleRecording)
|
|
1335
1436
|
let bound = stored
|
|
1336
1437
|
.map { "carbonKeyCode=\($0.carbonKeyCode) carbonModifiers=\($0.carbonModifiers)" }
|
|
@@ -1406,6 +1507,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1406
1507
|
}
|
|
1407
1508
|
|
|
1408
1509
|
private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
|
|
1510
|
+
guard installsGlobalHandlers else { return }
|
|
1409
1511
|
// Decided as a local first, then handed to the single writer once. Assigning the
|
|
1410
1512
|
// published property from each branch is how the per-source erasure bug got in.
|
|
1411
1513
|
var reason: String?
|
|
@@ -1512,6 +1614,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1512
1614
|
/// Re-evaluate every trigger's health, push it to the UI, and record it. The one entry
|
|
1513
1615
|
/// point callers should use after anything changes a binding.
|
|
1514
1616
|
public func refreshTriggerDiagnostics() {
|
|
1617
|
+
guard installsGlobalHandlers else { updateStatus(); return }
|
|
1515
1618
|
refreshHotkeyDiagnostics()
|
|
1516
1619
|
updateStatus()
|
|
1517
1620
|
logResolvedTrigger()
|
|
@@ -1702,7 +1805,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1702
1805
|
let shouldCaptureSelection = Self.shouldCaptureSelection(
|
|
1703
1806
|
targetPid: targetAppPid,
|
|
1704
1807
|
accessibilityTrusted: accessibilityTrustCheck(),
|
|
1705
|
-
intentDetectionEnabled: intentDetectionEnabled
|
|
1808
|
+
intentDetectionEnabled: !usesIsolatedProvider && intentDetectionEnabled
|
|
1706
1809
|
)
|
|
1707
1810
|
let capturePid = targetAppPid
|
|
1708
1811
|
let captureSelection = selectionCapture
|
|
@@ -1722,9 +1825,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1722
1825
|
// recording pipeline awaits this context only after the recorder has stopped.
|
|
1723
1826
|
let generation = recordingGeneration
|
|
1724
1827
|
let projectStore = projectStore
|
|
1828
|
+
let cleanupPreferences = recordingCleanupPreferences
|
|
1725
1829
|
let targetBundleIdentifierForProjects = targetAppBundleIdentifier
|
|
1726
1830
|
let transcriptionLanguageAtStart = transcriptionLanguage
|
|
1727
|
-
let intentDetectionEnabledAtStart = intentDetectionEnabled
|
|
1831
|
+
let intentDetectionEnabledAtStart = !usesIsolatedProvider && intentDetectionEnabled
|
|
1832
|
+
let usesIsolatedProvider = usesIsolatedProvider
|
|
1728
1833
|
let homePath = home
|
|
1729
1834
|
let startContext = Task { @MainActor [weak self] () -> RecordingStartResolvedContext in
|
|
1730
1835
|
let axSnapshot = await axSnapshotTask.value
|
|
@@ -1748,7 +1853,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1748
1853
|
self.log("project synchronization degraded; continuing capture: \(warning)")
|
|
1749
1854
|
}
|
|
1750
1855
|
}
|
|
1751
|
-
let modelSelection =
|
|
1856
|
+
let modelSelection = usesIsolatedProvider
|
|
1857
|
+
? ProcessingModelSelection(transcriptionPrompt: "", transcriptionModel: "", transcriberModel: "", enhancementModel: "", intentModel: "", enhanceTriggersJSON: "[]", keywordTransformsJSON: "{}")
|
|
1858
|
+
: OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
|
|
1752
1859
|
return RecordingStartResolvedContext(
|
|
1753
1860
|
selectionToken: axSnapshot.selectionToken,
|
|
1754
1861
|
canonicalProjectId: projectStore?.activeCanonicalProjectIdForRecording,
|
|
@@ -1756,8 +1863,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1756
1863
|
activeProjectName: projectStore?.activeProject?.name,
|
|
1757
1864
|
processing: RecordingProcessingConfiguration(
|
|
1758
1865
|
transcriptionPrompt: modelSelection.transcriptionPrompt,
|
|
1759
|
-
transcriberPrompt:
|
|
1760
|
-
postProcessingMode:
|
|
1866
|
+
transcriberPrompt: cleanupPreferences.prompt,
|
|
1867
|
+
postProcessingMode: cleanupPreferences.mode,
|
|
1761
1868
|
transcriptionLanguage: transcriptionLanguageAtStart,
|
|
1762
1869
|
transcriptionModel: modelSelection.transcriptionModel,
|
|
1763
1870
|
transcriberModel: modelSelection.transcriberModel,
|
|
@@ -1945,7 +2052,26 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1945
2052
|
}
|
|
1946
2053
|
|
|
1947
2054
|
private func startNativeRecording(startContext: Task<RecordingStartResolvedContext, Never>) {
|
|
1948
|
-
let apiKey = openAIAPIKeyProvider()
|
|
2055
|
+
let apiKey = usesIsolatedProvider ? "" : openAIAPIKeyProvider()
|
|
2056
|
+
if let transcriptionProvider {
|
|
2057
|
+
let generation = recordingGeneration
|
|
2058
|
+
let configuration = RecordingProviderSessionConfiguration(captureID: UUID().uuidString, language: transcriptionLanguage)
|
|
2059
|
+
do {
|
|
2060
|
+
providerSession = try transcriptionProvider.makeSession(configuration: configuration) { [weak self] text in
|
|
2061
|
+
Task { @MainActor [weak self] in
|
|
2062
|
+
guard let self, self.recordingGeneration == generation,
|
|
2063
|
+
self.captureIsActive || self.isTranscribing else { return }
|
|
2064
|
+
self.liveTranscriptionText = text
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
providerConfiguration = configuration
|
|
2068
|
+
} catch {
|
|
2069
|
+
resetRecordingIntent()
|
|
2070
|
+
statusMessage = error.localizedDescription
|
|
2071
|
+
flowPhase = .failed(statusMessage)
|
|
2072
|
+
return
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
1949
2075
|
let captureConfiguration = RecordingCaptureConfiguration(
|
|
1950
2076
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1951
2077
|
targetAppPid: targetAppPid,
|
|
@@ -1964,7 +2090,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1964
2090
|
: RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
1965
2091
|
realtimeClient = client
|
|
1966
2092
|
|
|
1967
|
-
let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
|
|
2093
|
+
let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client, providerSession: providerSession)
|
|
1968
2094
|
pcmStreamPipe = streamPipe
|
|
1969
2095
|
let homePath = home
|
|
1970
2096
|
let captureGeneration = recordingGeneration
|
|
@@ -1972,7 +2098,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1972
2098
|
self?.confirmCaptureIsLive(generation: generation)
|
|
1973
2099
|
}
|
|
1974
2100
|
let firstChunkLogged = LockedFlag()
|
|
2101
|
+
let monitor = CaptureMonitor()
|
|
2102
|
+
captureMonitor = monitor
|
|
2103
|
+
isPaused = false
|
|
2104
|
+
audioLevel = 0
|
|
1975
2105
|
let recorder = recorderFactory { data in
|
|
2106
|
+
guard monitor.admit(data) else { return }
|
|
1976
2107
|
if firstChunkLogged.take() {
|
|
1977
2108
|
NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
|
|
1978
2109
|
// The first sample is the only honest signal that this recording exists.
|
|
@@ -2004,10 +2135,14 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2004
2135
|
|
|
2005
2136
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
2006
2137
|
Task { @MainActor [weak self] in
|
|
2007
|
-
self
|
|
2138
|
+
guard let self else { return }
|
|
2139
|
+
let sample = monitor.snapshot()
|
|
2140
|
+
self.recordingDuration = sample.duration
|
|
2141
|
+
self.audioLevel = sample.level
|
|
2008
2142
|
}
|
|
2009
2143
|
}
|
|
2010
2144
|
} catch {
|
|
2145
|
+
cancelProviderSession()
|
|
2011
2146
|
log("native recorder failed error=\(error.localizedDescription)")
|
|
2012
2147
|
// Unreachable today — nothing between `recorder.start()` and `isWarmingUpCapture`
|
|
2013
2148
|
// can throw — but a warming flag left set here wedges the engine permanently: the
|
|
@@ -2049,6 +2184,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2049
2184
|
private func abandonWarmingCapture(reason: String, alert: RecordingAttemptAlert?) {
|
|
2050
2185
|
guard isWarmingUpCapture else { return }
|
|
2051
2186
|
log("capture abandoned before first audio reason=\(reason)")
|
|
2187
|
+
cancelProviderSession()
|
|
2052
2188
|
// Supersede the attempt so every completion still bound to it — a queued first-chunk
|
|
2053
2189
|
// confirmation, the resolved start context — is stale and cannot apply.
|
|
2054
2190
|
recordingGeneration &+= 1
|
|
@@ -2154,6 +2290,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2154
2290
|
// MARK: - Cancel (discard without transcribing)
|
|
2155
2291
|
|
|
2156
2292
|
public func cancelRecording() {
|
|
2293
|
+
if usesIsolatedProvider && isTranscribing {
|
|
2294
|
+
pipelineDeliveryGate.abandonPipeline(recordingGeneration)
|
|
2295
|
+
recordingGeneration &+= 1
|
|
2296
|
+
cancelProviderSession()
|
|
2297
|
+
isTranscribing = false
|
|
2298
|
+
liveTranscriptionText = ""
|
|
2299
|
+
recordedPCM.removeAll(keepingCapacity: true)
|
|
2300
|
+
activeAudioPath = nil
|
|
2301
|
+
resetRecordingIntent()
|
|
2302
|
+
updateStatus()
|
|
2303
|
+
return
|
|
2304
|
+
}
|
|
2157
2305
|
// Discard during warm-up: identical teardown, but the user asked for it, so the glyph
|
|
2158
2306
|
// stays quiet.
|
|
2159
2307
|
if isWarmingUpCapture {
|
|
@@ -2162,6 +2310,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2162
2310
|
}
|
|
2163
2311
|
guard isRecording else { return }
|
|
2164
2312
|
log("cancelRecording")
|
|
2313
|
+
cancelProviderSession()
|
|
2165
2314
|
|
|
2166
2315
|
recordingTimer?.invalidate()
|
|
2167
2316
|
recordingTimer = nil
|
|
@@ -2178,6 +2327,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2178
2327
|
pcmStreamPipe = nil
|
|
2179
2328
|
|
|
2180
2329
|
isRecording = false
|
|
2330
|
+
isPaused = false
|
|
2331
|
+
audioLevel = 0
|
|
2332
|
+
recordingDuration = 0
|
|
2181
2333
|
isTranscribing = false
|
|
2182
2334
|
liveTranscriptionText = ""
|
|
2183
2335
|
recordedPCM.removeAll(keepingCapacity: true)
|
|
@@ -2191,6 +2343,89 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2191
2343
|
|
|
2192
2344
|
// MARK: - Stop & Transcribe
|
|
2193
2345
|
|
|
2346
|
+
private func cancelProviderSession() {
|
|
2347
|
+
providerCompletionTask?.cancel()
|
|
2348
|
+
providerCompletionTask = nil
|
|
2349
|
+
providerSession?.cancel()
|
|
2350
|
+
providerSession = nil
|
|
2351
|
+
providerConfiguration = nil
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/// The provider path shares capture, WAV writing and verified paste delivery with the
|
|
2355
|
+
/// legacy recorder. It never enters the legacy CLI, credential or intent-model paths.
|
|
2356
|
+
private func stopWithProvider(
|
|
2357
|
+
recorder: PCMRecordingSource?, pipe: PCMStreamPipe?,
|
|
2358
|
+
session: any RecordingTranscriptionSession,
|
|
2359
|
+
configuration: RecordingProviderSessionConfiguration, audioPath: String?,
|
|
2360
|
+
targetAppBundleIdentifier: String?, targetAppPid: pid_t?,
|
|
2361
|
+
pipelineGeneration: UInt64, pipelineTrace: RecordingPipelineTrace
|
|
2362
|
+
) {
|
|
2363
|
+
providerCompletionTask = Task { [weak self] in
|
|
2364
|
+
// Shutdown and drain belong to this capture even if the user cancels and starts
|
|
2365
|
+
// another one before the native input tap has stopped.
|
|
2366
|
+
await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
|
|
2367
|
+
let pcm = await pipe?.finish() ?? Data()
|
|
2368
|
+
guard !Task.isCancelled, let self,
|
|
2369
|
+
self.recordingGeneration == pipelineGeneration else { return }
|
|
2370
|
+
do {
|
|
2371
|
+
guard !pcm.isEmpty, let audioPath else { throw RecordingProviderError.noAudio }
|
|
2372
|
+
let audioURL = URL(fileURLWithPath: audioPath)
|
|
2373
|
+
try await Task.detached(priority: .userInitiated) {
|
|
2374
|
+
try Self.writeWAV(pcmData: pcm, sampleRate: 24_000, channelCount: 1, bitsPerSample: 16, to: audioURL)
|
|
2375
|
+
}.value
|
|
2376
|
+
try Task.checkCancellation()
|
|
2377
|
+
guard self.recordingGeneration == pipelineGeneration else { return }
|
|
2378
|
+
let duration = Double(pcm.count) / 48_000
|
|
2379
|
+
self.recordingDuration = duration
|
|
2380
|
+
let result = try await session.finish(RecordingTranscriptionRequest(
|
|
2381
|
+
captureID: configuration.captureID, audioURL: audioURL,
|
|
2382
|
+
duration: duration, language: configuration.language
|
|
2383
|
+
))
|
|
2384
|
+
try Task.checkCancellation()
|
|
2385
|
+
guard self.recordingGeneration == pipelineGeneration else { return }
|
|
2386
|
+
let rawText = result.rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2387
|
+
let processed = result.processedText?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2388
|
+
let text = (processed?.isEmpty == false ? processed : nil) ?? rawText
|
|
2389
|
+
guard !text.isEmpty else { throw RecordingProviderError.emptyTranscript }
|
|
2390
|
+
self.isTranscribing = false
|
|
2391
|
+
self.liveTranscriptionText = ""
|
|
2392
|
+
self.activeAudioPath = nil
|
|
2393
|
+
self.providerSession = nil
|
|
2394
|
+
self.providerConfiguration = nil
|
|
2395
|
+
self.providerCompletionTask = nil
|
|
2396
|
+
// Publication precedes delivery. Consumers may persist asynchronously without
|
|
2397
|
+
// delaying paste; this is an in-memory result, not a persistence receipt.
|
|
2398
|
+
self.recentTranscriptions.insert(TranscriptionResult(
|
|
2399
|
+
rawText: rawText, processedText: processed?.isEmpty == false ? processed : nil,
|
|
2400
|
+
timestamp: Date(), projectId: nil, projectName: nil,
|
|
2401
|
+
captureID: configuration.captureID, audioURL: audioURL
|
|
2402
|
+
), at: 0)
|
|
2403
|
+
if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
|
|
2404
|
+
guard self.pipelineDeliveryGate.claimDelivery(for: pipelineGeneration) else { return }
|
|
2405
|
+
self.pasteIntoFrontApp(
|
|
2406
|
+
text, targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
2407
|
+
targetAppPid: targetAppPid, restoreClipboard: true,
|
|
2408
|
+
deliveryKind: .ordinaryDictation, captureID: configuration.captureID, pipelineTrace: pipelineTrace,
|
|
2409
|
+
pipelineGeneration: pipelineGeneration, deliveryCompleted: nil
|
|
2410
|
+
)
|
|
2411
|
+
} catch {
|
|
2412
|
+
guard self.recordingGeneration == pipelineGeneration, !Task.isCancelled else { return }
|
|
2413
|
+
session.cancel()
|
|
2414
|
+
self.providerSession = nil
|
|
2415
|
+
self.providerConfiguration = nil
|
|
2416
|
+
self.providerCompletionTask = nil
|
|
2417
|
+
self.activeAudioPath = nil
|
|
2418
|
+
self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
|
|
2419
|
+
// Do not log provider errors: third-party error text can contain a token or
|
|
2420
|
+
// request URL. The host UI receives the localized description for diagnosis.
|
|
2421
|
+
self.isTranscribing = false
|
|
2422
|
+
self.liveTranscriptionText = ""
|
|
2423
|
+
self.statusMessage = error.localizedDescription
|
|
2424
|
+
self.flowPhase = .failed(self.statusMessage)
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2194
2429
|
public func stopAndTranscribe() {
|
|
2195
2430
|
// Stop during the warm-up window has nothing to transcribe — the microphone was opened
|
|
2196
2431
|
// but has not delivered a sample. Abandon visibly instead of spending a transcription
|
|
@@ -2208,12 +2443,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2208
2443
|
|
|
2209
2444
|
let recorder = nativeRecorder
|
|
2210
2445
|
nativeRecorder = nil
|
|
2211
|
-
recorder?.stop()
|
|
2212
2446
|
|
|
2213
2447
|
isRecording = false
|
|
2214
2448
|
isTranscribing = true
|
|
2215
2449
|
|
|
2216
2450
|
guard let captureConfiguration = activeCaptureConfiguration else {
|
|
2451
|
+
recorder?.stop()
|
|
2217
2452
|
realtimeClient?.stop()
|
|
2218
2453
|
realtimeClient = nil
|
|
2219
2454
|
streamingTask?.cancel()
|
|
@@ -2230,6 +2465,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2230
2465
|
let targetAppBundleIdentifier = captureConfiguration.targetAppBundleIdentifier
|
|
2231
2466
|
let targetAppPid = captureConfiguration.targetAppPid
|
|
2232
2467
|
let audioPath = activeAudioPath
|
|
2468
|
+
latestAudioPath = audioPath
|
|
2469
|
+
isPaused = false
|
|
2470
|
+
audioLevel = 0
|
|
2233
2471
|
let pcmStreamPipe = pcmStreamPipe
|
|
2234
2472
|
let client = realtimeClient
|
|
2235
2473
|
let pipelineGeneration = recordingGeneration
|
|
@@ -2239,9 +2477,23 @@ public final class RecordingEngine: ObservableObject {
|
|
|
2239
2477
|
resetRecordingIntent()
|
|
2240
2478
|
self.pcmStreamPipe = nil
|
|
2241
2479
|
|
|
2480
|
+
if let providerSession, let providerConfiguration {
|
|
2481
|
+
stopWithProvider(
|
|
2482
|
+
recorder: recorder, pipe: pcmStreamPipe, session: providerSession,
|
|
2483
|
+
configuration: providerConfiguration, audioPath: audioPath,
|
|
2484
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid,
|
|
2485
|
+
pipelineGeneration: pipelineGeneration, pipelineTrace: pipelineTrace
|
|
2486
|
+
)
|
|
2487
|
+
return
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2242
2490
|
Task {
|
|
2491
|
+
// AVAudioEngine shutdown can block for hundreds of milliseconds. Keep
|
|
2492
|
+
// receiving realtime text and repainting while capture drains completely.
|
|
2493
|
+
await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
|
|
2243
2494
|
if let pcmStreamPipe {
|
|
2244
2495
|
self.recordedPCM = await pcmStreamPipe.finish()
|
|
2496
|
+
self.recordingDuration = Double(self.recordedPCM.count) / 48_000
|
|
2245
2497
|
}
|
|
2246
2498
|
self.log(pipelineTrace.message(
|
|
2247
2499
|
stage: "pcm_drain_complete",
|
|
@@ -3969,7 +4221,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3969
4221
|
_ text: String,
|
|
3970
4222
|
targetAppBundleIdentifier: String? = nil,
|
|
3971
4223
|
targetAppPid: pid_t? = nil,
|
|
3972
|
-
restoreClipboard: Bool = false
|
|
4224
|
+
restoreClipboard: Bool = false,
|
|
4225
|
+
captureID: String? = nil
|
|
3973
4226
|
) {
|
|
3974
4227
|
pasteIntoFrontApp(
|
|
3975
4228
|
text,
|
|
@@ -3977,6 +4230,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3977
4230
|
targetAppPid: targetAppPid,
|
|
3978
4231
|
restoreClipboard: restoreClipboard,
|
|
3979
4232
|
deliveryKind: .manualPaste,
|
|
4233
|
+
captureID: captureID,
|
|
3980
4234
|
pipelineTrace: nil,
|
|
3981
4235
|
pipelineGeneration: nil,
|
|
3982
4236
|
deliveryCompleted: nil
|
|
@@ -3989,6 +4243,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3989
4243
|
targetAppPid: pid_t? = nil,
|
|
3990
4244
|
restoreClipboard: Bool = false,
|
|
3991
4245
|
deliveryKind: PasteDeliveryKind,
|
|
4246
|
+
captureID: String? = nil,
|
|
3992
4247
|
selectionToken: AccessibilitySelectionToken? = nil,
|
|
3993
4248
|
pipelineTrace: RecordingPipelineTrace?,
|
|
3994
4249
|
pipelineGeneration: UInt64?,
|
|
@@ -4007,6 +4262,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4007
4262
|
deliveryCompleted?()
|
|
4008
4263
|
return
|
|
4009
4264
|
}
|
|
4265
|
+
if !autoPasteEnabled && deliveryKind != .manualPaste {
|
|
4266
|
+
updateDeliveryStatus("Transcript ready — auto-paste is off", kind: .success, pipelineGeneration: pipelineGeneration)
|
|
4267
|
+
deliveryCompleted?()
|
|
4268
|
+
return
|
|
4269
|
+
}
|
|
4010
4270
|
if let pasteInterceptorForTesting {
|
|
4011
4271
|
pasteInterceptorForTesting(text, deliveryKind, pipelineGeneration)
|
|
4012
4272
|
deliveryCompleted?()
|
|
@@ -4176,6 +4436,14 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4176
4436
|
detail: "chars=\(transaction.text.count)"
|
|
4177
4437
|
))
|
|
4178
4438
|
}
|
|
4439
|
+
let verified = Self.pasteTraceStage(for: outcome) == "paste_delivery_confirmed"
|
|
4440
|
+
let deliveryStatus = Self.recentPasteDeliveryStatus(for: outcome)
|
|
4441
|
+
self.recentPastes.insert(RecentPaste(
|
|
4442
|
+
text: transaction.text, bundleIdentifier: app.bundleIdentifier, appName: app.localizedName ?? "Application",
|
|
4443
|
+
location: "Focused field", status: verified ? "Pasted" : "Unconfirmed", verified: verified,
|
|
4444
|
+
captureID: captureID, deliveryStatus: deliveryStatus
|
|
4445
|
+
), at: 0)
|
|
4446
|
+
if self.recentPastes.count > 50 { self.recentPastes.removeLast() }
|
|
4179
4447
|
deliveryCompleted?()
|
|
4180
4448
|
let message = switch outcome {
|
|
4181
4449
|
case .pasted: "Pasted (\(transaction.text.count) chars)"
|
|
@@ -4521,6 +4789,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
4521
4789
|
/// Pipeline-timing stage name. `paste_posted` used to be emitted for every posted
|
|
4522
4790
|
/// keystroke, which made the timing trace read like a delivery record; the three delivery
|
|
4523
4791
|
/// verdicts are now distinct stages.
|
|
4792
|
+
nonisolated static func recentPasteDeliveryStatus(for outcome: PasteDeliveryOutcome) -> RecentPasteDeliveryStatus {
|
|
4793
|
+
switch outcome {
|
|
4794
|
+
case .pasted: .confirmed
|
|
4795
|
+
case .deliveredUnverified: .unconfirmed
|
|
4796
|
+
case .deliveryNotObserved, .secureInputActive, .targetUnavailable,
|
|
4797
|
+
.clipboardOwnershipLost, .clipboardWriteFailed, .eventPostFailed: .notDelivered
|
|
4798
|
+
}
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4524
4801
|
nonisolated static func pasteTraceStage(for outcome: PasteDeliveryOutcome) -> String {
|
|
4525
4802
|
switch outcome {
|
|
4526
4803
|
case .pasted: "paste_delivery_confirmed"
|
|
@@ -4792,14 +5069,17 @@ enum CLIRunner: Sendable {
|
|
|
4792
5069
|
_ args: [String],
|
|
4793
5070
|
home: String,
|
|
4794
5071
|
timeout: TimeInterval = 120,
|
|
4795
|
-
totalWallClockBudget: TimeInterval? = nil
|
|
5072
|
+
totalWallClockBudget: TimeInterval? = nil,
|
|
5073
|
+
environment suppliedEnvironment: [String: String]? = nil
|
|
4796
5074
|
) -> String {
|
|
4797
5075
|
let command = resolveCommand(home: home)
|
|
4798
5076
|
let arguments = command.argumentsPrefix + args
|
|
4799
|
-
let environment = ProcessInfo.processInfo.environment.merging([
|
|
4800
|
-
"PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
4801
|
-
]) { _, new in new }
|
|
4802
5077
|
do {
|
|
5078
|
+
let environment = try suppliedEnvironment ?? ServiceAPIConfiguration.childEnvironment(
|
|
5079
|
+
base: OpenAIAPIKeyStore.childEnvironment(base: ProcessInfo.processInfo.environment.merging([
|
|
5080
|
+
"PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
5081
|
+
]) { _, new in new }, homePath: home)
|
|
5082
|
+
)
|
|
4803
5083
|
let output = try runExecutable(
|
|
4804
5084
|
command.executable,
|
|
4805
5085
|
arguments: arguments,
|
|
@@ -5611,7 +5891,7 @@ enum CLIRunner: Sendable {
|
|
|
5611
5891
|
}
|
|
5612
5892
|
}
|
|
5613
5893
|
|
|
5614
|
-
static func parseError(_ output: String) -> String? {
|
|
5894
|
+
static func parseError(_ output: String, serviceAPI: Bool = false) -> String? {
|
|
5615
5895
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
5616
5896
|
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
5617
5897
|
let message = NativeErrorSanitizer.sanitize(
|
|
@@ -5620,10 +5900,12 @@ enum CLIRunner: Sendable {
|
|
|
5620
5900
|
let lowercased = message.lowercased()
|
|
5621
5901
|
if lowercased.contains("401") || lowercased.contains("incorrect api key")
|
|
5622
5902
|
|| lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
|
|
5903
|
+
if serviceAPI { return "Recordings API authentication failed — check the API connection in Settings" }
|
|
5623
5904
|
return "OpenAI API key invalid or expired — update it in Recordings Settings"
|
|
5624
5905
|
}
|
|
5625
5906
|
if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
|
|
5626
5907
|
|| lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
|
|
5908
|
+
if serviceAPI { return "Recordings API request limit reached — try again later" }
|
|
5627
5909
|
return "OpenAI quota exceeded — check the OpenAI account billing"
|
|
5628
5910
|
}
|
|
5629
5911
|
if message.contains("OpenAI API key not configured") {
|