@hasna/recordings 0.2.13 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2078 -277
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +10 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +634 -49
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +140 -56
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +187 -79
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +108 -49
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/build_native_fs_guard.sh +15 -2
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +138 -24
- package/scripts/macos_artifact.ts +205 -33
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +14 -7
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +75 -9
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
@preconcurrency import ApplicationServices
|
|
3
|
+
// CopySymbolicHotKeys lives in Carbon's HIToolbox.
|
|
4
|
+
import Carbon.HIToolbox
|
|
3
5
|
import Darwin
|
|
4
6
|
import SwiftUI
|
|
5
7
|
@preconcurrency import KeyboardShortcuts
|
|
@@ -303,16 +305,45 @@ struct PasteDeliveryTransaction: Equatable, Sendable {
|
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
enum PasteDeliveryOutcome: Equatable, Sendable {
|
|
308
|
+
/// Delivery *observed*: the focused field in the target app was read back after the
|
|
309
|
+
/// keystroke and had gained the pasted text. Reachable only from confirming evidence —
|
|
310
|
+
/// see `PasteDeliveryOutcome.forDeliveryEvidence`. A posted `CGEvent` never produces it,
|
|
311
|
+
/// because `CGEvent.post` returns no delivery receipt.
|
|
306
312
|
case pasted
|
|
313
|
+
/// The keystroke was posted and the focused field, readable before and after, did not
|
|
314
|
+
/// change. The paste did not land where it was aimed.
|
|
315
|
+
case deliveryNotObserved
|
|
316
|
+
/// The keystroke was posted and the target app's focused field could not be read back, so
|
|
317
|
+
/// delivery is unknown. Carries the reason so the log says which surface refused to
|
|
318
|
+
/// answer instead of implying success.
|
|
319
|
+
case deliveredUnverified(PasteDeliveryUnverifiedReason)
|
|
320
|
+
/// Secure event input is on, so no synthetic keystroke can reach any app. Nothing was
|
|
321
|
+
/// posted; the payload is left on the clipboard for the user to paste.
|
|
322
|
+
case secureInputActive(SecureInputHolder)
|
|
307
323
|
case targetUnavailable
|
|
308
324
|
case clipboardOwnershipLost
|
|
309
325
|
case clipboardWriteFailed
|
|
310
326
|
case eventPostFailed
|
|
327
|
+
|
|
328
|
+
/// The single place delivery evidence is allowed to become `.pasted`. Kept next to the
|
|
329
|
+
/// outcome so a reader can check the whole mapping at once: two confirming reads, one
|
|
330
|
+
/// contradicting read, everything else unverified.
|
|
331
|
+
static func forDeliveryEvidence(_ evidence: PasteDeliveryEvidence) -> PasteDeliveryOutcome {
|
|
332
|
+
switch evidence {
|
|
333
|
+
case .confirmedByFocusedValue, .confirmedBySelectedText: .pasted
|
|
334
|
+
case .notObservedFocusedValueUnchanged: .deliveryNotObserved
|
|
335
|
+
case .unverified(let reason): .deliveredUnverified(reason)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
311
338
|
}
|
|
312
339
|
|
|
313
340
|
struct PasteboardWriteResult: Equatable, Sendable {
|
|
314
341
|
let verified: Bool
|
|
315
342
|
let ownershipChangeCount: Int
|
|
343
|
+
/// Whether the pasteboard's `changeCount` actually advanced past its pre-write value.
|
|
344
|
+
/// Weaker than `verified` (which also re-reads the stored string) and reported separately
|
|
345
|
+
/// so a log reader can tell "the pasteboard moved" from "the pasteboard holds our text".
|
|
346
|
+
var changeCountAdvanced: Bool = false
|
|
316
347
|
}
|
|
317
348
|
|
|
318
349
|
/// Outcome of revalidating the frozen rewrite target immediately before a rewrite runs.
|
|
@@ -334,7 +365,11 @@ final class PasteTransactionCoordinator {
|
|
|
334
365
|
typealias ScheduledOperation = @MainActor @Sendable () -> Void
|
|
335
366
|
typealias Scheduler = @MainActor @Sendable (TimeInterval, @escaping ScheduledOperation) -> Void
|
|
336
367
|
typealias PayloadWriter = @MainActor @Sendable (String) -> PasteboardWriteResult
|
|
337
|
-
typealias PastePoster = @MainActor @Sendable () ->
|
|
368
|
+
typealias PastePoster = @MainActor @Sendable () -> PasteKeystrokeAttempt
|
|
369
|
+
/// Reads the target app back and reports what that read proves. Defaulted to
|
|
370
|
+
/// `.unverified(.readBackNotAttempted)` at every entry point so a caller that supplies no
|
|
371
|
+
/// verification gets an explicitly unverified outcome, never an assumed success.
|
|
372
|
+
typealias DeliveryVerifier = @MainActor @Sendable () -> PasteDeliveryEvidence
|
|
338
373
|
typealias WriteObserver = @MainActor @Sendable (PasteboardWriteResult) -> Void
|
|
339
374
|
typealias Completion = @MainActor @Sendable (PasteDeliveryTransaction, PasteDeliveryOutcome) -> Void
|
|
340
375
|
typealias Settlement = @MainActor @Sendable (PasteDeliveryTransaction, PasteDeliveryOutcome) -> Void
|
|
@@ -379,6 +414,14 @@ final class PasteTransactionCoordinator {
|
|
|
379
414
|
payloadIsReady: @escaping @MainActor @Sendable () -> Bool = { true },
|
|
380
415
|
prepare: @escaping ScheduledOperation = {},
|
|
381
416
|
writeAttempted: @escaping WriteObserver = { _ in },
|
|
417
|
+
verify: @escaping DeliveryVerifier = { .unverified(.readBackNotAttempted) },
|
|
418
|
+
// `verificationDelay` is the wait between posting the keystroke and reading the target
|
|
419
|
+
// app back; zero verifies on the posting turn, which only makes sense for tests since a
|
|
420
|
+
// real app cannot have processed the event yet. `verificationAttempts` bounds how many
|
|
421
|
+
// read-backs may run before "the field did not change" is accepted as the verdict —
|
|
422
|
+
// only that verdict is retried, and each retry costs one `verificationDelay`.
|
|
423
|
+
verificationDelay: TimeInterval = 0,
|
|
424
|
+
verificationAttempts: Int = 1,
|
|
382
425
|
completion: @escaping Completion,
|
|
383
426
|
settlement: @escaping Settlement = { _, _ in }
|
|
384
427
|
) -> Bool {
|
|
@@ -421,26 +464,92 @@ final class PasteTransactionCoordinator {
|
|
|
421
464
|
completion(transaction, .clipboardOwnershipLost)
|
|
422
465
|
return
|
|
423
466
|
}
|
|
424
|
-
|
|
425
|
-
|
|
467
|
+
// `@MainActor` is required, not decorative: a local function does not inherit the
|
|
468
|
+
// enclosing closure's actor isolation, so without it `state` cannot be mutated and
|
|
469
|
+
// `completion`/`settlement` cannot be called from here at all.
|
|
470
|
+
@MainActor func failNow(with outcome: PasteDeliveryOutcome) {
|
|
471
|
+
settlement(transaction, outcome)
|
|
426
472
|
self.state = .idle
|
|
427
|
-
completion(transaction,
|
|
473
|
+
completion(transaction, outcome)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
switch self.postPaste() {
|
|
477
|
+
case .constructionFailed:
|
|
478
|
+
failNow(with: .eventPostFailed)
|
|
479
|
+
return
|
|
480
|
+
case .refusedSecureInput(let holder):
|
|
481
|
+
// Nothing was posted: with secure input on, the window server drops synthetic
|
|
482
|
+
// key events for every consumer, so posting would only manufacture a success
|
|
483
|
+
// log for a paste that cannot happen.
|
|
484
|
+
failNow(with: .secureInputActive(holder))
|
|
428
485
|
return
|
|
486
|
+
case .posted:
|
|
487
|
+
break
|
|
429
488
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
489
|
+
|
|
490
|
+
// The keystroke is out. `CGEvent.post` returned no receipt, so the transaction
|
|
491
|
+
// stays open: the outcome comes from reading the target app back.
|
|
492
|
+
let pending = PendingDelivery(
|
|
493
|
+
transaction: transaction,
|
|
494
|
+
verify: verify,
|
|
495
|
+
verificationDelay: verificationDelay,
|
|
496
|
+
verificationAttempts: verificationAttempts,
|
|
497
|
+
settlementDelay: settlementDelay,
|
|
498
|
+
completion: completion,
|
|
499
|
+
settlement: settlement
|
|
500
|
+
)
|
|
501
|
+
guard verificationDelay > 0 else {
|
|
502
|
+
self.settleFromDeliveryEvidence(pending, readBackAttempt: verificationAttempts)
|
|
434
503
|
return
|
|
435
504
|
}
|
|
436
|
-
self.schedule(
|
|
505
|
+
self.schedule(verificationDelay) { [weak self] in
|
|
437
506
|
guard let self, self.state == .settling(transaction.id) else { return }
|
|
438
|
-
|
|
439
|
-
self.state = .idle
|
|
507
|
+
self.settleFromDeliveryEvidence(pending, readBackAttempt: 1)
|
|
440
508
|
}
|
|
441
509
|
}
|
|
442
510
|
return true
|
|
443
511
|
}
|
|
512
|
+
|
|
513
|
+
/// Everything the read-back loop needs after the keystroke has been posted.
|
|
514
|
+
private struct PendingDelivery: Sendable {
|
|
515
|
+
let transaction: PasteDeliveryTransaction
|
|
516
|
+
let verify: DeliveryVerifier
|
|
517
|
+
let verificationDelay: TimeInterval
|
|
518
|
+
let verificationAttempts: Int
|
|
519
|
+
let settlementDelay: TimeInterval
|
|
520
|
+
let completion: Completion
|
|
521
|
+
let settlement: Settlement
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/// Asks the verifier what the target app shows, retrying only the "field did not change"
|
|
525
|
+
/// verdict: that is the one a slow app can turn into a confirmation, while a confirmed or
|
|
526
|
+
/// unreadable result is already final.
|
|
527
|
+
private func settleFromDeliveryEvidence(_ pending: PendingDelivery, readBackAttempt: Int) {
|
|
528
|
+
let evidence = pending.verify()
|
|
529
|
+
guard evidence == .notObservedFocusedValueUnchanged,
|
|
530
|
+
readBackAttempt < pending.verificationAttempts else {
|
|
531
|
+
complete(pending, outcome: .forDeliveryEvidence(evidence))
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
schedule(pending.verificationDelay) { [weak self] in
|
|
535
|
+
guard let self, self.state == .settling(pending.transaction.id) else { return }
|
|
536
|
+
self.settleFromDeliveryEvidence(pending, readBackAttempt: readBackAttempt + 1)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private func complete(_ pending: PendingDelivery, outcome: PasteDeliveryOutcome) {
|
|
541
|
+
pending.completion(pending.transaction, outcome)
|
|
542
|
+
guard pending.settlementDelay > 0 else {
|
|
543
|
+
pending.settlement(pending.transaction, outcome)
|
|
544
|
+
state = .idle
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
schedule(pending.settlementDelay) { [weak self] in
|
|
548
|
+
guard let self, self.state == .settling(pending.transaction.id) else { return }
|
|
549
|
+
pending.settlement(pending.transaction, outcome)
|
|
550
|
+
self.state = .idle
|
|
551
|
+
}
|
|
552
|
+
}
|
|
444
553
|
}
|
|
445
554
|
|
|
446
555
|
struct PipelineDeliveryGate: Sendable {
|
|
@@ -615,14 +724,134 @@ private extension Data {
|
|
|
615
724
|
|
|
616
725
|
@MainActor
|
|
617
726
|
public final class RecordingEngine: ObservableObject {
|
|
618
|
-
|
|
727
|
+
/// Audio is arriving. Not "a start was requested": `AVAudioEngine.start()` returns before
|
|
728
|
+
/// the input tap delivers its first sample (measured cold on Apple silicon:
|
|
729
|
+
/// `native recorder started` at +541 ms, `native recorder received first PCM chunk` at
|
|
730
|
+
/// +644 ms), and a hold released inside that window captured nothing at all. Flipping this
|
|
731
|
+
/// on `start()` returning is what let a short tap fall through to the transcription
|
|
732
|
+
/// pipeline with an empty buffer and finish silently.
|
|
733
|
+
@Published public private(set) var isRecording = false
|
|
734
|
+
/// The microphone is open but has not produced a sample yet — the ~100 ms window above.
|
|
735
|
+
/// Surfaces render this as recording (the user is holding the key), and every teardown
|
|
736
|
+
/// path can abandon it, but nothing may treat it as audio that exists.
|
|
737
|
+
@Published public private(set) var isWarmingUpCapture = false
|
|
619
738
|
@Published public var useFnKey: Bool = false {
|
|
620
739
|
didSet {
|
|
621
740
|
UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
|
|
622
741
|
updateFnMonitor()
|
|
623
|
-
|
|
742
|
+
refreshTriggerDiagnostics()
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/// Where a blocked reason came from. The published reason is composed across these rather
|
|
746
|
+
/// than written per-source, because more than one can hold at once — fn and the hotkey can
|
|
747
|
+
/// both be blocked, and a delivery can be blocked while a trigger is too. A per-source
|
|
748
|
+
/// writer lets whichever ran last erase the others, which is the erasure bug this whole
|
|
749
|
+
/// mechanism exists to prevent.
|
|
750
|
+
///
|
|
751
|
+
/// `Comparable` by declaration order, so the composed string is stable no matter which
|
|
752
|
+
/// source was written last: a reason that reorders itself between renders reads as two
|
|
753
|
+
/// different problems.
|
|
754
|
+
enum BlockedReasonSource: Int, CaseIterable, Comparable, Sendable {
|
|
755
|
+
/// The last delivery could not reach the target app and the transcript is sitting on
|
|
756
|
+
/// the clipboard waiting for the user. Cleared by the next recording, not by the next
|
|
757
|
+
/// status write.
|
|
758
|
+
///
|
|
759
|
+
/// FIRST deliberately. This is the only reason that tells the owner their transcript is
|
|
760
|
+
/// still recoverable ("press Cmd-V"), and it used to sort LAST — so with a blocked
|
|
761
|
+
/// trigger as well it landed at the tail of a `.font(.caption)` `Text` in a 260-pt
|
|
762
|
+
/// popover, behind two reasons about key bindings. Data-recovery advice leads; the
|
|
763
|
+
/// trigger reasons are about a next press, which can wait.
|
|
764
|
+
case delivery
|
|
765
|
+
/// The keyboard shortcut collides with an enabled system shortcut.
|
|
766
|
+
case hotkey
|
|
767
|
+
/// The fn monitor cannot run (Accessibility).
|
|
768
|
+
case fnKey
|
|
769
|
+
/// A trigger fired but the press was consumed before recording could start — the
|
|
770
|
+
/// permission-prompt case. Transient, and cleared by the next start.
|
|
771
|
+
case pressConsumed
|
|
772
|
+
|
|
773
|
+
static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue }
|
|
774
|
+
|
|
775
|
+
/// Whether this source describes trigger *health* — the subject of the Settings
|
|
776
|
+
/// "Recording Shortcut" section. Exhaustive so a new source has to make the decision
|
|
777
|
+
/// rather than defaulting into a section whose remedy does not fit it.
|
|
778
|
+
var isTriggerHealth: Bool {
|
|
779
|
+
switch self {
|
|
780
|
+
// `.pressConsumed` IS trigger health: it is written from the fn and hotkey release
|
|
781
|
+
// handlers and says "press and hold again to record". Classifying it as non-trigger
|
|
782
|
+
// dropped it from the one section documented as "a trigger that is switched on but
|
|
783
|
+
// cannot arm must say so next to its own switch". `remedy` alone suppresses the wrong
|
|
784
|
+
// button, so the row belongs here.
|
|
785
|
+
case .hotkey, .fnKey, .pressConsumed: true
|
|
786
|
+
// Delivery is about the last paste, not about a binding. It gets its own row.
|
|
787
|
+
case .delivery: false
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/// The action that actually fixes this cause, so a surface offering a *button* keys it to
|
|
792
|
+
/// the cause instead of to whatever text happened to be composed.
|
|
793
|
+
var remedy: BlockedReasonEntry.Remedy {
|
|
794
|
+
switch self {
|
|
795
|
+
case .fnKey: .openAccessibilitySettings
|
|
796
|
+
// A chord collision is not a permissions problem; the Accessibility pane does
|
|
797
|
+
// nothing for it and the shortcut recorder is already on screen.
|
|
798
|
+
case .hotkey: .chooseAnotherShortcut
|
|
799
|
+
// Both say what to do in the message itself — "press Cmd-V", "press and hold
|
|
800
|
+
// again" — so a button would be a second, competing instruction.
|
|
801
|
+
case .delivery, .pressConsumed: .messageOnly
|
|
802
|
+
}
|
|
624
803
|
}
|
|
625
804
|
}
|
|
805
|
+
|
|
806
|
+
/// One source's reason together with the remedy that fixes it.
|
|
807
|
+
///
|
|
808
|
+
/// The composed `blockedReason` string is what the menu bar renders, and it is enough there
|
|
809
|
+
/// because that surface only reports. A Settings section that offers a *button* needs to know
|
|
810
|
+
/// WHICH problem it is offering to fix: rendering the composed string under "Recording
|
|
811
|
+
/// Shortcut" next to "Open Accessibility Settings" meant a secure-input paste failure showed
|
|
812
|
+
/// "transcript copied, press Cmd-V" beside a button that opens the Accessibility pane — the
|
|
813
|
+
/// wrong remedy, in the wrong section, for the wrong cause.
|
|
814
|
+
public struct BlockedReasonEntry: Identifiable, Equatable, Sendable {
|
|
815
|
+
public enum Remedy: Equatable, Sendable {
|
|
816
|
+
/// Grant Accessibility — what the fn monitor needs before its tap can be created.
|
|
817
|
+
case openAccessibilitySettings
|
|
818
|
+
/// Pick a different chord; the recorder that does it is in the same section.
|
|
819
|
+
case chooseAnotherShortcut
|
|
820
|
+
/// The message is the whole remedy. No button.
|
|
821
|
+
case messageOnly
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let source: BlockedReasonSource
|
|
825
|
+
public let message: String
|
|
826
|
+
public var remedy: Remedy { source.remedy }
|
|
827
|
+
/// Whether this belongs in the Settings trigger section.
|
|
828
|
+
public var isTriggerHealth: Bool { source.isTriggerHealth }
|
|
829
|
+
public var id: Int { source.rawValue }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/// Why the app currently cannot record or deliver, when the reason outlives one status
|
|
833
|
+
/// write. Held separately from `statusMessage` because `updateStatus()` rewrites that on
|
|
834
|
+
/// every return to idle; see `updateStatus()`.
|
|
835
|
+
///
|
|
836
|
+
/// This is the collapse of what were two fields — `triggerBlockedReason` (trigger health)
|
|
837
|
+
/// and `blockedReason` (secure-input delivery). Two published fields describing "the app
|
|
838
|
+
/// cannot do the thing you asked" is two places for a view to forget to read, and the
|
|
839
|
+
/// menu bar forgot to read either of them. One field, one writer.
|
|
840
|
+
@Published public private(set) var blockedReason: String?
|
|
841
|
+
/// The same reasons, per source and in precedence order. `blockedReason` is composed from
|
|
842
|
+
/// exactly this array, in the same call, so the two cannot disagree — this is the structured
|
|
843
|
+
/// form of one field, not a second field describing the same thing.
|
|
844
|
+
@Published public private(set) var blockedReasonEntries: [BlockedReasonEntry] = []
|
|
845
|
+
/// The ONLY writer of `blockedReason` is `setBlockedReason(_:for:)`. Do not assign the
|
|
846
|
+
/// published property anywhere else; `macos-shortcut-contract.test.ts` asserts that.
|
|
847
|
+
private var blockedReasons: [BlockedReasonSource: String] = [:]
|
|
848
|
+
/// The recording generation the `.delivery` reason was written for, or nil when none is held.
|
|
849
|
+
///
|
|
850
|
+
/// The `.delivery` reason is the only one that is a claim about a *specific* recording:
|
|
851
|
+
/// "press Cmd-V" is true of the clipboard that recording wrote, and stops being true once the
|
|
852
|
+
/// generation moves on. Tracked so `updateStatus()` can expire it structurally instead of
|
|
853
|
+
/// relying on someone having enumerated every path that ought to clear it.
|
|
854
|
+
private var deliveryBlockedReasonGeneration: UInt64?
|
|
626
855
|
/// Advanced fallback policy (Settings only): when off, every recording is dictated
|
|
627
856
|
/// literally and the classifier is never consulted.
|
|
628
857
|
@Published public var intentDetectionEnabled: Bool = true {
|
|
@@ -753,19 +982,90 @@ public final class RecordingEngine: ObservableObject {
|
|
|
753
982
|
let pasteboard = NSPasteboard.general
|
|
754
983
|
return RecordingEngine.writeClipboardAttempt(text, to: pasteboard)
|
|
755
984
|
},
|
|
756
|
-
postPaste: {
|
|
985
|
+
postPaste: { [weak self] in
|
|
986
|
+
// Secure input is checked here, on the posting turn, rather than earlier: a
|
|
987
|
+
// password field can take it between the readiness checks and the keystroke, and
|
|
988
|
+
// while it is held the window server drops every synthetic event.
|
|
989
|
+
let secureInput = SecureInputProbe.current()
|
|
990
|
+
self?.lastPasteSecureInputProbe = secureInput
|
|
991
|
+
if case .active(let holder) = secureInput {
|
|
992
|
+
return .refusedSecureInput(holder)
|
|
993
|
+
}
|
|
757
994
|
let source = CGEventSource(stateID: .hidSystemState)
|
|
758
995
|
guard let down = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true),
|
|
759
996
|
let up = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) else {
|
|
760
|
-
return
|
|
997
|
+
return .constructionFailed
|
|
761
998
|
}
|
|
762
999
|
down.flags = .maskCommand
|
|
763
1000
|
up.flags = .maskCommand
|
|
764
1001
|
down.post(tap: .cgSessionEventTap)
|
|
765
1002
|
up.post(tap: .cgSessionEventTap)
|
|
766
|
-
|
|
1003
|
+
// Probed AGAIN, after posting, because the pre-post reading cannot cover the case
|
|
1004
|
+
// that matters: a password field takes secure input between that probe and the
|
|
1005
|
+
// keystroke. The window server then drops both events, and the read-back — which
|
|
1006
|
+
// compares two AX reads of the focused field — can say "the text did not land" but
|
|
1007
|
+
// never "secure input ate it". Its vocabulary tops out at
|
|
1008
|
+
// `.notObservedFocusedValueUnchanged` / `.unverified(.readBackUnreadable)`, so the
|
|
1009
|
+
// outcome falls to `.deliveryNotObserved` or `.deliveredUnverified`, neither of which
|
|
1010
|
+
// is an `isSecureInputOutcome` — deliberately — so NOTHING is persisted: no warning
|
|
1011
|
+
// icon, no VoiceOver "blocked" label, and no "press Cmd-V", which is the only thing
|
|
1012
|
+
// telling the owner the transcript is still recoverable.
|
|
1013
|
+
//
|
|
1014
|
+
// And the log was not merely silent about the cause, it stated the opposite:
|
|
1015
|
+
// `lastPasteSecureInputProbe` still held the pre-post reading, so
|
|
1016
|
+
// `PasteDeliveryReport.logLine` recorded `secureInput=inactive` for a paste secure
|
|
1017
|
+
// input had actually eaten.
|
|
1018
|
+
//
|
|
1019
|
+
// Only an `.active` reading overwrites the stored probe. A post-post `.unknown` after
|
|
1020
|
+
// a definite pre-post `.inactive` is less informative about the posting turn, not
|
|
1021
|
+
// more, so it must not replace it. The residual race — secure input taken and
|
|
1022
|
+
// released entirely inside the posting window — is unobservable from here and stays
|
|
1023
|
+
// unobservable; this closes the case where it is still held.
|
|
1024
|
+
//
|
|
1025
|
+
// This reading does NOT change the return value, and that is the whole design of it.
|
|
1026
|
+
// Returning `.refusedSecureInput` here would be wrong twice over. The coordinator
|
|
1027
|
+
// answers that case with `failNow`, which settles immediately and never builds the
|
|
1028
|
+
// `PendingDelivery` — so the read-back, the ONLY evidence that can say whether the
|
|
1029
|
+
// keystroke actually landed, would be discarded at precisely the moment it is needed.
|
|
1030
|
+
// And a post-post `.active` reading cannot distinguish the two cases it spans: secure
|
|
1031
|
+
// input taken between `up.post()` and this probe leaves the events already dispatched
|
|
1032
|
+
// and probably delivered, while secure input taken during the posts drops them.
|
|
1033
|
+
// Calling that a refusal would tell the owner "transcript copied, press Cmd-V" for a
|
|
1034
|
+
// paste that already succeeded, and Cmd-V would paste it a second time — a worse
|
|
1035
|
+
// failure than the missing disclosure it was meant to fix. It would also make the log
|
|
1036
|
+
// say `not_posted_secure_input` for events that WERE posted, trading one false
|
|
1037
|
+
// statement for another. `.refusedSecureInput` means "nothing was posted"; it must
|
|
1038
|
+
// keep meaning that.
|
|
1039
|
+
//
|
|
1040
|
+
// So the read-back stays the arbiter (#28's ruling) and this probe makes the LOG
|
|
1041
|
+
// honest: `PasteDeliveryReport` now carries the post-post reading, where it previously
|
|
1042
|
+
// carried the pre-post one and recorded `secure_input=inactive` for a paste secure
|
|
1043
|
+
// input may have eaten — asserting the opposite of what happened rather than merely
|
|
1044
|
+
// omitting it.
|
|
1045
|
+
//
|
|
1046
|
+
// NOT restored, and deliberately: attribution strong enough to reach the persistence
|
|
1047
|
+
// and visibility path. Making a non-confirming read-back settle as
|
|
1048
|
+
// `.secureInputActive` needs a new keystroke-attempt case threaded through
|
|
1049
|
+
// `PasteAttempt`, or the log token lies about whether events were posted. That
|
|
1050
|
+
// widening is a design change, left for review rather than smuggled in here.
|
|
1051
|
+
let secureInputAfterPost = SecureInputProbe.current()
|
|
1052
|
+
switch secureInputAfterPost {
|
|
1053
|
+
// Held after the posts: the reading the log must carry.
|
|
1054
|
+
case .active: self?.lastPasteSecureInputProbe = secureInputAfterPost
|
|
1055
|
+
// Definite after an indefinite pre-post reading is strictly more informative, so it
|
|
1056
|
+
// replaces it; the reverse would discard a definite reading for an indefinite one.
|
|
1057
|
+
case .inactive: if case .unknown = secureInput { self?.lastPasteSecureInputProbe = secureInputAfterPost }
|
|
1058
|
+
case .unknown: break
|
|
1059
|
+
}
|
|
1060
|
+
// Constructed and posted. Nothing here observes delivery, which is why this
|
|
1061
|
+
// returns `.posted` and not a success.
|
|
1062
|
+
return .posted
|
|
767
1063
|
}
|
|
768
1064
|
)
|
|
1065
|
+
/// Secure-input reading taken on the last posting turn, or nil when no paste has reached
|
|
1066
|
+
/// the posting step. Kept so the delivery log can say whether synthetic input was even
|
|
1067
|
+
/// possible instead of leaving the reader to guess.
|
|
1068
|
+
private var lastPasteSecureInputProbe: SecureInputState?
|
|
769
1069
|
|
|
770
1070
|
/// Every coordinator the engine owns must publish its idle transitions:
|
|
771
1071
|
/// `canStartRecording` derives from coordinator state, and settlement back to idle is
|
|
@@ -807,7 +1107,40 @@ public final class RecordingEngine: ObservableObject {
|
|
|
807
1107
|
#endif
|
|
808
1108
|
|
|
809
1109
|
private nonisolated static let realtimePeriodicCommitIntervalMilliseconds: UInt64 = 900
|
|
810
|
-
|
|
1110
|
+
/// Floor of the post-release settlement budget. The settlement wait polls every 10 ms
|
|
1111
|
+
/// and returns the moment the final transcription lands, so the budget is only ever
|
|
1112
|
+
/// paid in full when the realtime session is still unsettled — the common settled case
|
|
1113
|
+
/// costs its actual settle time (measured 0.6-1.6 s on station-class hardware).
|
|
1114
|
+
private nonisolated static let realtimeSettleBudgetFloorMilliseconds: UInt64 = 1_500
|
|
1115
|
+
/// Additional settlement budget granted per second of captured audio. A settlement
|
|
1116
|
+
/// miss falls back to re-transcribing the whole recording through the batch API, which
|
|
1117
|
+
/// costs roughly a quarter of the recording's duration (measured: 4 s floor + ~25%
|
|
1118
|
+
/// of audio length) — so the longer the recording, the more waiting is worth it.
|
|
1119
|
+
private nonisolated static let realtimeSettleBudgetPerAudioSecondMilliseconds: UInt64 = 25
|
|
1120
|
+
/// Ceiling of the settlement budget: past this point the user has watched
|
|
1121
|
+
/// "Transcribing..." for so long that starting the recoverable batch path is the
|
|
1122
|
+
/// better trade even for very long recordings.
|
|
1123
|
+
private nonisolated static let realtimeSettleBudgetCeilingMilliseconds: UInt64 = 5_000
|
|
1124
|
+
/// PCM byte rate of the capture pipeline (24 kHz, 16-bit, mono) — used to convert
|
|
1125
|
+
/// captured byte counts back into audio seconds for the settlement budget.
|
|
1126
|
+
private nonisolated static let capturedPCMBytesPerSecond = 48_000
|
|
1127
|
+
|
|
1128
|
+
/// Settlement budget for `RealtimeTranscriptionClient.finish` scaled to the captured
|
|
1129
|
+
/// audio length. The previous fixed 700 ms budget was routinely missed by real
|
|
1130
|
+
/// sessions (final transcription completions arrive ~0.6-1.6 s after release), which
|
|
1131
|
+
/// silently demoted nearly every recording to the duration-proportional batch path —
|
|
1132
|
+
/// the "one minute to transcribe" failure mode this budget exists to prevent.
|
|
1133
|
+
public nonisolated static func realtimeSettleBudgetMilliseconds(pcmByteCount: Int) -> UInt64 {
|
|
1134
|
+
let audioSeconds = UInt64(max(pcmByteCount, 0)) / UInt64(capturedPCMBytesPerSecond)
|
|
1135
|
+
let (scaled, overflowed) = audioSeconds.multipliedReportingOverflow(
|
|
1136
|
+
by: realtimeSettleBudgetPerAudioSecondMilliseconds
|
|
1137
|
+
)
|
|
1138
|
+
guard !overflowed else { return realtimeSettleBudgetCeilingMilliseconds }
|
|
1139
|
+
let (budget, budgetOverflowed) = realtimeSettleBudgetFloorMilliseconds
|
|
1140
|
+
.addingReportingOverflow(scaled)
|
|
1141
|
+
guard !budgetOverflowed else { return realtimeSettleBudgetCeilingMilliseconds }
|
|
1142
|
+
return min(budget, realtimeSettleBudgetCeilingMilliseconds)
|
|
1143
|
+
}
|
|
811
1144
|
/// Hard wall-clock budget for the rewrite helper (CLI spawn + one model call), covering
|
|
812
1145
|
/// execution *and* CLIRunner's termination grace, kill grace, and pipe drain — not just
|
|
813
1146
|
/// the child execution deadline. The user is waiting with recording blocked, so this
|
|
@@ -825,15 +1158,38 @@ public final class RecordingEngine: ObservableObject {
|
|
|
825
1158
|
/// *observable* rewrite time under the public ceiling even when the execution window,
|
|
826
1159
|
/// termination grace, and pipe drain all run to exhaustion.
|
|
827
1160
|
nonisolated static let commandRewriteReturnMargin: TimeInterval = 1
|
|
1161
|
+
/// Wait before each read-back of the target app's focused field. The window server
|
|
1162
|
+
/// delivers the posted keystroke asynchronously and the app then does its own work, so a
|
|
1163
|
+
/// read taken on the posting turn would report "unchanged" for a paste that is simply
|
|
1164
|
+
/// still in flight.
|
|
1165
|
+
nonisolated static let pasteReadBackInterval: TimeInterval = 0.15
|
|
1166
|
+
/// How many read-backs before "the field did not change" is accepted as the verdict.
|
|
1167
|
+
/// Four reads spaced by `pasteReadBackInterval` give a slow target app ~0.6 s to show the
|
|
1168
|
+
/// paste; a confirmation on any read ends the wait immediately. The transaction stays
|
|
1169
|
+
/// pending for that window, which is why the budget is bounded rather than generous.
|
|
1170
|
+
nonisolated static let pasteReadBackAttempts = 4
|
|
828
1171
|
|
|
829
1172
|
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
830
1173
|
private let fnMonitor = FnKeyMonitor()
|
|
831
1174
|
private var permissionRetryTimer: Timer?
|
|
832
1175
|
|
|
833
|
-
|
|
1176
|
+
/// Filesystem root for every artifact the engine owns: the audio spool, the
|
|
1177
|
+
/// API-key/language store (`config.json`), `Recordings.log`, and the `recordings` CLI it
|
|
1178
|
+
/// shells out to. This is the same `homePath:` seam `NativeAppLog.write` and
|
|
1179
|
+
/// `OpenAIAPIKeyStore` already expose, defaulted the same way — production keeps the real
|
|
1180
|
+
/// home and is unchanged.
|
|
1181
|
+
///
|
|
1182
|
+
/// Unlike the closure seams above it must be supplied before `init` returns, because
|
|
1183
|
+
/// `init` already creates `audioDir` and logs; hence an `init(homePath:)` parameter
|
|
1184
|
+
/// rather than a settable property. Tests must pass a temp directory: with the default,
|
|
1185
|
+
/// every engine a test builds appends the suite's synthetic fixtures
|
|
1186
|
+
/// (`target=com.example.editor pid=99999`) to the operator's live `Recordings.log` and
|
|
1187
|
+
/// rewrites their real `config.json`.
|
|
1188
|
+
let home: String
|
|
834
1189
|
private var audioDir: String { "\(home)/.hasna/recordings/audio" }
|
|
835
1190
|
|
|
836
|
-
public init() {
|
|
1191
|
+
public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
|
|
1192
|
+
home = homePath
|
|
837
1193
|
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
838
1194
|
log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
|
|
839
1195
|
|
|
@@ -844,18 +1200,25 @@ public final class RecordingEngine: ObservableObject {
|
|
|
844
1200
|
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
845
1201
|
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
846
1202
|
}
|
|
1203
|
+
refreshHotkeyDiagnostics()
|
|
1204
|
+
logResolvedTrigger()
|
|
847
1205
|
|
|
848
1206
|
// Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
|
|
849
1207
|
fnMonitor.onFnKeyDown = { [weak self] in
|
|
850
1208
|
Task { @MainActor [weak self] in
|
|
851
1209
|
guard let self else { return }
|
|
852
1210
|
self.fnKeyIsDown = true
|
|
853
|
-
guard self.useFnKey
|
|
1211
|
+
guard self.useFnKey else { return }
|
|
1212
|
+
guard Self.canBeginRecording(
|
|
854
1213
|
isRecording: self.isRecording,
|
|
855
1214
|
isTranscribing: self.isTranscribing,
|
|
1215
|
+
isWarmingUpCapture: self.isWarmingUpCapture,
|
|
856
1216
|
isAwaitingMicrophonePermission: self.microphonePermissionStartGate.isAwaitingResponse,
|
|
857
1217
|
isDeliveryPending: self.deliveryIsPending
|
|
858
|
-
) else {
|
|
1218
|
+
) else {
|
|
1219
|
+
self.logIgnoredTrigger(.fnKey)
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
859
1222
|
self.startRecording(trigger: .fnKey)
|
|
860
1223
|
}
|
|
861
1224
|
}
|
|
@@ -863,14 +1226,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
863
1226
|
Task { @MainActor [weak self] in
|
|
864
1227
|
guard let self else { return }
|
|
865
1228
|
self.fnKeyIsDown = false
|
|
866
|
-
guard self.useFnKey
|
|
867
|
-
|
|
868
|
-
self.log("fn released before recording started; cancelling pending start")
|
|
869
|
-
self.resetRecordingIntent()
|
|
870
|
-
self.updateStatus()
|
|
871
|
-
return
|
|
872
|
-
}
|
|
873
|
-
self.stopAndTranscribe()
|
|
1229
|
+
guard self.useFnKey else { return }
|
|
1230
|
+
self.handleTriggerRelease(.fnKey)
|
|
874
1231
|
}
|
|
875
1232
|
}
|
|
876
1233
|
updateFnMonitor(allowAutomaticPrompt: false)
|
|
@@ -882,9 +1239,13 @@ public final class RecordingEngine: ObservableObject {
|
|
|
882
1239
|
guard Self.canBeginRecording(
|
|
883
1240
|
isRecording: self.isRecording,
|
|
884
1241
|
isTranscribing: self.isTranscribing,
|
|
1242
|
+
isWarmingUpCapture: self.isWarmingUpCapture,
|
|
885
1243
|
isAwaitingMicrophonePermission: self.microphonePermissionStartGate.isAwaitingResponse,
|
|
886
1244
|
isDeliveryPending: self.deliveryIsPending
|
|
887
|
-
) else {
|
|
1245
|
+
) else {
|
|
1246
|
+
self.logIgnoredTrigger(.keyboardShortcut)
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
888
1249
|
self.startRecording(trigger: .keyboardShortcut)
|
|
889
1250
|
}
|
|
890
1251
|
}
|
|
@@ -892,14 +1253,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
892
1253
|
Task { @MainActor [weak self] in
|
|
893
1254
|
guard let self, self.keyboardShortcutIsDown else { return }
|
|
894
1255
|
self.keyboardShortcutIsDown = false
|
|
895
|
-
|
|
896
|
-
guard self.isRecording else {
|
|
897
|
-
self.log("shortcut released before recording started; cancelling pending start")
|
|
898
|
-
self.resetRecordingIntent()
|
|
899
|
-
self.updateStatus()
|
|
900
|
-
return
|
|
901
|
-
}
|
|
902
|
-
self.stopAndTranscribe()
|
|
1256
|
+
self.handleTriggerRelease(.keyboardShortcut)
|
|
903
1257
|
}
|
|
904
1258
|
}
|
|
905
1259
|
|
|
@@ -907,9 +1261,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
907
1261
|
// so retry until permissions arrive instead of requiring a relaunch.
|
|
908
1262
|
permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|
909
1263
|
Task { @MainActor [weak self] in
|
|
910
|
-
|
|
911
|
-
self.log("accessibility granted — retrying fn monitor")
|
|
912
|
-
self.updateFnMonitor()
|
|
1264
|
+
self?.refreshFnMonitorHealth()
|
|
913
1265
|
}
|
|
914
1266
|
}
|
|
915
1267
|
|
|
@@ -972,7 +1324,91 @@ public final class RecordingEngine: ObservableObject {
|
|
|
972
1324
|
}
|
|
973
1325
|
}
|
|
974
1326
|
|
|
1327
|
+
/// Record which trigger is actually bound, at launch and whenever it changes.
|
|
1328
|
+
///
|
|
1329
|
+
/// The log already showed `startRecording trigger=keyboardShortcut`, but never which
|
|
1330
|
+
/// key was registered — so a hotkey silently rebound to a key the keyboard cannot send
|
|
1331
|
+
/// was indistinguishable from a working one. Log the resolved binding so "is the
|
|
1332
|
+
/// trigger armed, and to what" is answerable from the log alone.
|
|
1333
|
+
public func logResolvedTrigger() {
|
|
1334
|
+
let stored = KeyboardShortcuts.getShortcut(for: .toggleRecording)
|
|
1335
|
+
let bound = stored
|
|
1336
|
+
.map { "carbonKeyCode=\($0.carbonKeyCode) carbonModifiers=\($0.carbonModifiers)" }
|
|
1337
|
+
?? "none"
|
|
1338
|
+
// `getShortcut` is a UserDefaults read, so it says what is *configured*, never what
|
|
1339
|
+
// is *armed*: KeyboardShortcuts 1.12.0 discards RegisterEventHotKey's OSStatus, so a
|
|
1340
|
+
// chord already owned by another app is indistinguishable from a working one here.
|
|
1341
|
+
// Say "unknown" rather than let a stored value read as a live binding.
|
|
1342
|
+
let systemReserved = stored.map {
|
|
1343
|
+
Self.systemReservedShortcuts().contains([$0.carbonKeyCode, $0.carbonModifiers])
|
|
1344
|
+
}
|
|
1345
|
+
// The permission labels belong on the same line: a press that fires but delivers
|
|
1346
|
+
// nothing is a permission problem, and correlating two log lines by timestamp was
|
|
1347
|
+
// the only way to tell that apart from a trigger that never fired.
|
|
1348
|
+
log(
|
|
1349
|
+
"trigger bindings: shortcutStored=\(bound) "
|
|
1350
|
+
+ "shortcutArmed=unknown(carbon-registration-status-not-exposed) "
|
|
1351
|
+
+ "shortcutSystemReserved=\(systemReserved.map(String.init(describing:)) ?? "n/a") "
|
|
1352
|
+
+ "useFnKey=\(useFnKey) fnMonitorRunning=\(fnMonitor.isRunning) "
|
|
1353
|
+
+ "microphone=\(microphonePermissionLabel) accessibility=\(accessibilityPermissionLabel) "
|
|
1354
|
+
+ "blocked=\(blockedReason ?? "none")"
|
|
1355
|
+
)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/// Both global triggers used to `return` silently when the engine was busy, so a press
|
|
1359
|
+
/// that produced nothing left no trace at all — indistinguishable from a trigger that
|
|
1360
|
+
/// never fired. Name the refusal instead.
|
|
1361
|
+
///
|
|
1362
|
+
/// Every field the start gate consults must appear here, `isWarmingUpCapture` included:
|
|
1363
|
+
/// a press arriving during the ~100 ms warm-up is refused by that field alone, and
|
|
1364
|
+
/// omitting it would print every reason as false and reproduce the exact silence this
|
|
1365
|
+
/// function was added to end.
|
|
1366
|
+
private func logIgnoredTrigger(_ trigger: RecordingTrigger) {
|
|
1367
|
+
log(
|
|
1368
|
+
"trigger ignored trigger=\(trigger) isRecording=\(isRecording) "
|
|
1369
|
+
+ "isWarmingUpCapture=\(isWarmingUpCapture) "
|
|
1370
|
+
+ "isTranscribing=\(isTranscribing) deliveryPending=\(deliveryIsPending) "
|
|
1371
|
+
+ "awaitingMicrophonePermission=\(microphonePermissionStartGate.isAwaitingResponse)"
|
|
1372
|
+
)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/// Accessibility is the gate in practice: `FnKeyMonitor` creates an *active* tap
|
|
1376
|
+
/// (`options: .defaultTap`, and it returns nil to swallow fn), and an event-modifying
|
|
1377
|
+
/// tap requires Accessibility. Only a listen-only tap would fall under Input
|
|
1378
|
+
/// Monitoring, so naming both grants sent people to the wrong pane.
|
|
1379
|
+
static let fnAccessibilityBlockedMessage =
|
|
1380
|
+
"fn needs Accessibility: System Settings > Privacy & Security > Accessibility"
|
|
1381
|
+
|
|
1382
|
+
/// Periodic reconciliation of the fn tap against reality.
|
|
1383
|
+
///
|
|
1384
|
+
/// Two failures this closes. Granting Accessibility does not revive a tap that failed to
|
|
1385
|
+
/// create, so it has to be retried — and the retry used to run `updateFnMonitor()`
|
|
1386
|
+
/// without `updateStatus()`, so the stale "fn needs Accessibility" line survived the
|
|
1387
|
+
/// grant. And a tap can die *after* creation (Accessibility revoked at runtime), which
|
|
1388
|
+
/// no creation-time check can see; `FnKeyMonitor.isRunning` now reflects whether the tap
|
|
1389
|
+
/// is actually enabled, so that case is detected here instead of reading as "Ready".
|
|
1390
|
+
private func refreshFnMonitorHealth() {
|
|
1391
|
+
guard useFnKey else { return }
|
|
1392
|
+
if fnMonitor.isRunning {
|
|
1393
|
+
if blockedReasons[.fnKey] != nil {
|
|
1394
|
+
setBlockedReason(nil, for: .fnKey)
|
|
1395
|
+
updateStatus()
|
|
1396
|
+
}
|
|
1397
|
+
return
|
|
1398
|
+
}
|
|
1399
|
+
if AXIsProcessTrusted() {
|
|
1400
|
+
log("fn monitor not running while trusted — retrying")
|
|
1401
|
+
updateFnMonitor()
|
|
1402
|
+
} else {
|
|
1403
|
+
setBlockedReason(Self.fnAccessibilityBlockedMessage, for: .fnKey)
|
|
1404
|
+
}
|
|
1405
|
+
updateStatus()
|
|
1406
|
+
}
|
|
1407
|
+
|
|
975
1408
|
private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
|
|
1409
|
+
// Decided as a local first, then handed to the single writer once. Assigning the
|
|
1410
|
+
// published property from each branch is how the per-source erasure bug got in.
|
|
1411
|
+
var reason: String?
|
|
976
1412
|
if useFnKey {
|
|
977
1413
|
let ok = fnMonitor.start()
|
|
978
1414
|
log("fn monitor start ok=\(ok)")
|
|
@@ -981,23 +1417,220 @@ public final class RecordingEngine: ObservableObject {
|
|
|
981
1417
|
let result = accessibilityPromptGate.trustForProtectedOperation()
|
|
982
1418
|
log("fn monitor accessibility trusted=\(result.trusted) prompted=\(result.didPrompt)")
|
|
983
1419
|
}
|
|
984
|
-
|
|
1420
|
+
reason = Self.fnAccessibilityBlockedMessage
|
|
1421
|
+
log("trigger blocked: \(Self.fnAccessibilityBlockedMessage)")
|
|
985
1422
|
}
|
|
986
1423
|
} else {
|
|
987
1424
|
fnMonitor.stop()
|
|
988
1425
|
}
|
|
1426
|
+
setBlockedReason(reason, for: .fnKey)
|
|
989
1427
|
}
|
|
990
1428
|
|
|
1429
|
+
/// Record or clear one source's reason and recompute the published value. Each source owns
|
|
1430
|
+
/// its own slot; this is the **only** writer of `blockedReason`.
|
|
1431
|
+
///
|
|
1432
|
+
/// - Parameter generation: the recording generation a `.delivery` reason belongs to. Passed
|
|
1433
|
+
/// explicitly by the paste completion because that closure can run *after*
|
|
1434
|
+
/// `recordingGeneration` has already moved on — binding to the current value there would
|
|
1435
|
+
/// stamp a superseded reason as fresh, which is the whole failure this parameter closes.
|
|
1436
|
+
/// Ignored for every other source, none of which is recording-scoped.
|
|
1437
|
+
private func setBlockedReason(
|
|
1438
|
+
_ reason: String?,
|
|
1439
|
+
for source: BlockedReasonSource,
|
|
1440
|
+
generation: UInt64? = nil
|
|
1441
|
+
) {
|
|
1442
|
+
if let reason, !reason.isEmpty {
|
|
1443
|
+
// Refuse the write outright when the reason belongs to a superseded recording. The
|
|
1444
|
+
// expiry in `updateStatus()` is lazy — no production caller runs it on the delivery
|
|
1445
|
+
// completion or the return to idle — so a superseded reason written here would render
|
|
1446
|
+
// and stay rendered until the owner next touched a trigger. Declining the store is the
|
|
1447
|
+
// pre-render gate; the expiry remains as defence against a generation bump that
|
|
1448
|
+
// happens AFTER a legitimate write.
|
|
1449
|
+
if source == .delivery, let generation, generation != recordingGeneration {
|
|
1450
|
+
log("delivery blocked reason refused for superseded generation=\(generation) current=\(recordingGeneration)")
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
blockedReasons[source] = reason
|
|
1454
|
+
if source == .delivery {
|
|
1455
|
+
// `nil` means unscoped, NOT "current". The public `pasteIntoFrontApp` route has no
|
|
1456
|
+
// pipeline generation, so there is nothing that can supersede its reason and it
|
|
1457
|
+
// must not be stamped with a generation it never belonged to.
|
|
1458
|
+
deliveryBlockedReasonGeneration = generation
|
|
1459
|
+
}
|
|
1460
|
+
} else {
|
|
1461
|
+
blockedReasons.removeValue(forKey: source)
|
|
1462
|
+
if source == .delivery {
|
|
1463
|
+
deliveryBlockedReasonGeneration = nil
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
let entries = blockedReasons
|
|
1467
|
+
.sorted { $0.key < $1.key }
|
|
1468
|
+
.map { BlockedReasonEntry(source: $0.key, message: $0.value) }
|
|
1469
|
+
blockedReasonEntries = entries
|
|
1470
|
+
let composed = entries
|
|
1471
|
+
.map(\.message)
|
|
1472
|
+
.joined(separator: " · ")
|
|
1473
|
+
blockedReason = composed.isEmpty ? nil : composed
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/// Enabled system-reserved shortcuts, read straight from Carbon.
|
|
1477
|
+
///
|
|
1478
|
+
/// KeyboardShortcuts has an equivalent `Shortcut.isTakenBySystem`, but it sits in a
|
|
1479
|
+
/// plain (internal) extension in the pinned 1.12.0 source, so it cannot be reached from
|
|
1480
|
+
/// here. Only shortcuts flagged enabled count: a disabled system binding does not
|
|
1481
|
+
/// contend for the key.
|
|
1482
|
+
static func systemReservedShortcuts() -> Set<[Int]> {
|
|
1483
|
+
var unmanaged: Unmanaged<CFArray>?
|
|
1484
|
+
guard
|
|
1485
|
+
CopySymbolicHotKeys(&unmanaged) == noErr,
|
|
1486
|
+
let entries = unmanaged?.takeRetainedValue() as? [[String: Any]]
|
|
1487
|
+
else {
|
|
1488
|
+
return []
|
|
1489
|
+
}
|
|
1490
|
+
var reserved: Set<[Int]> = []
|
|
1491
|
+
for entry in entries {
|
|
1492
|
+
guard
|
|
1493
|
+
(entry[kHISymbolicHotKeyEnabled] as? Bool) == true,
|
|
1494
|
+
let code = entry[kHISymbolicHotKeyCode] as? Int,
|
|
1495
|
+
let modifiers = entry[kHISymbolicHotKeyModifiers] as? Int
|
|
1496
|
+
else {
|
|
1497
|
+
continue
|
|
1498
|
+
}
|
|
1499
|
+
reserved.insert([code, modifiers])
|
|
1500
|
+
}
|
|
1501
|
+
return reserved
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/// Re-evaluate whether the stored hotkey can plausibly arm.
|
|
1505
|
+
///
|
|
1506
|
+
/// This is the honest half of a hard limit. `RegisterEventHotKey`'s `OSStatus` is
|
|
1507
|
+
/// swallowed inside KeyboardShortcuts 1.12.0 (`CarbonKeyboardShortcuts.register` guards
|
|
1508
|
+
/// on `registerError == noErr` and returns Void), so a hotkey stolen by *another
|
|
1509
|
+
/// application* is not observable from here at all. A collision with an enabled
|
|
1510
|
+
/// *system* shortcut is observable, and it is the case that silently wins, so it gets a
|
|
1511
|
+
/// real blocked reason instead of a "Ready" that is not true.
|
|
1512
|
+
/// Re-evaluate every trigger's health, push it to the UI, and record it. The one entry
|
|
1513
|
+
/// point callers should use after anything changes a binding.
|
|
1514
|
+
public func refreshTriggerDiagnostics() {
|
|
1515
|
+
refreshHotkeyDiagnostics()
|
|
1516
|
+
updateStatus()
|
|
1517
|
+
logResolvedTrigger()
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
private func refreshHotkeyDiagnostics() {
|
|
1521
|
+
guard let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) else {
|
|
1522
|
+
setBlockedReason(nil, for: .hotkey)
|
|
1523
|
+
return
|
|
1524
|
+
}
|
|
1525
|
+
let key = [shortcut.carbonKeyCode, shortcut.carbonModifiers]
|
|
1526
|
+
var reason: String?
|
|
1527
|
+
if Self.systemReservedShortcuts().contains(key) {
|
|
1528
|
+
reason = "macOS already reserves this shortcut — pick another in Settings > Recording Shortcut"
|
|
1529
|
+
log("trigger blocked: hotkey collides with an enabled system shortcut \(key)")
|
|
1530
|
+
}
|
|
1531
|
+
setBlockedReason(reason, for: .hotkey)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/// Message shown after a trigger fired but the press was consumed before recording could
|
|
1535
|
+
/// start — in practice, the first fn press after a microphone permission prompt. Cancelling
|
|
1536
|
+
/// is correct for push-to-talk (releasing the key before the recorder starts must not leave
|
|
1537
|
+
/// a recording running with no key held); saying nothing about it is not.
|
|
1538
|
+
static let pressConsumedByPermissionPromptMessage =
|
|
1539
|
+
"Permission was requested — press and hold again to record"
|
|
1540
|
+
|
|
1541
|
+
/// The only writer of the idle status pair. Nothing else may set `statusMessage` to
|
|
1542
|
+
/// "Ready" — three separate callers had grown their own copy of that assignment, and each
|
|
1543
|
+
/// one silently bypassed the `blockedReason` branch below, overwriting a live "the app
|
|
1544
|
+
/// cannot do the thing you asked" disclosure with a cheerful "Ready". Route every
|
|
1545
|
+
/// return-to-idle through here so a fourth copy cannot appear.
|
|
991
1546
|
public func updateStatus() {
|
|
992
|
-
if
|
|
1547
|
+
if captureIsActive || isTranscribing || deliveryIsPending { return }
|
|
1548
|
+
expireStaleDeliveryBlockedReason()
|
|
1549
|
+
// A blocked trigger outlives one status write. `init` and every `useFnKey` change
|
|
1550
|
+
// called `updateFnMonitor()` and then `updateStatus()`, so the fn permission
|
|
1551
|
+
// warning was overwritten with "Ready" before it could ever be read — an enabled
|
|
1552
|
+
// trigger that could not arm looked exactly like a working one. Idle now carries
|
|
1553
|
+
// the reason until the blocker clears.
|
|
1554
|
+
if let blockedReason {
|
|
1555
|
+
statusMessage = blockedReason
|
|
1556
|
+
flowPhase = .idle
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
993
1559
|
statusMessage = "Ready"
|
|
994
1560
|
flowPhase = .idle
|
|
995
1561
|
}
|
|
996
1562
|
|
|
1563
|
+
/// Drop a `.delivery` reason whose recording has been superseded since it was written.
|
|
1564
|
+
///
|
|
1565
|
+
/// Lazily, and deliberately named as such: this runs only from `updateStatus()`, and no
|
|
1566
|
+
/// production caller invokes that on the delivery completion or on the return to idle. It is
|
|
1567
|
+
/// therefore NOT the pre-render gate — `setBlockedReason` declining the write is. This covers
|
|
1568
|
+
/// the other half: a generation bump that happens after a legitimate write, which
|
|
1569
|
+
/// `cancelIntentProcessing()` does.
|
|
1570
|
+
///
|
|
1571
|
+
/// This is the structural half of the delivery reason's lifetime, and it exists because the
|
|
1572
|
+
/// two halves are asymmetric: `updateDeliveryStatus` refuses to write a *status* for a
|
|
1573
|
+
/// superseded generation and clears nothing on that path, while the paste completion's
|
|
1574
|
+
/// `setBlockedReason(…, for: .delivery)` is ungated. A suppressed completion therefore
|
|
1575
|
+
/// withholds the status line and persists "press Cmd-V" anyway — pointing at a clipboard that
|
|
1576
|
+
/// has moved on, on the app's own instruction.
|
|
1577
|
+
///
|
|
1578
|
+
/// A generation check here rather than a fifth enumerated clear site: the four existing clear
|
|
1579
|
+
/// sites cover the paths someone thought of, and the paths that matter are the ones nobody
|
|
1580
|
+
/// did. `cancelIntentProcessing()` is already one of them — it bumps `recordingGeneration`
|
|
1581
|
+
/// without clearing — and `PasteTransactionCoordinator.failNow` releases the pending fence
|
|
1582
|
+
/// *before* running the completion closure, so the interleaving is one inserted `await` away
|
|
1583
|
+
/// from being reachable rather than latent.
|
|
1584
|
+
private func expireStaleDeliveryBlockedReason() {
|
|
1585
|
+
guard let generation = deliveryBlockedReasonGeneration,
|
|
1586
|
+
generation != recordingGeneration else { return }
|
|
1587
|
+
log("delivery blocked reason expired generation=\(generation) current=\(recordingGeneration)")
|
|
1588
|
+
setBlockedReason(nil, for: .delivery)
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// MARK: - Trigger release
|
|
1592
|
+
|
|
1593
|
+
/// Single key-up path for both hold-to-record triggers, so fn and the configurable
|
|
1594
|
+
/// shortcut can never diverge on the only question that matters here: whether any audio
|
|
1595
|
+
/// exists yet. A release that lands before the first PCM chunk is a tap that captured
|
|
1596
|
+
/// nothing, and it is abandoned rather than transcribed.
|
|
1597
|
+
func handleTriggerRelease(_ trigger: RecordingTrigger) {
|
|
1598
|
+
guard activeTrigger == trigger else { return }
|
|
1599
|
+
guard isRecording else {
|
|
1600
|
+
log("\(trigger) released before audio started; cancelling pending start")
|
|
1601
|
+
cancelPendingStart()
|
|
1602
|
+
return
|
|
1603
|
+
}
|
|
1604
|
+
stopAndTranscribe()
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/// Key-up with no audio yet. Two windows reach this: the microphone permission prompt
|
|
1608
|
+
/// (nothing was ever started, so there is nothing to tear down) and the warm-up window
|
|
1609
|
+
/// (the microphone is open and a realtime session may be negotiating, both of which must
|
|
1610
|
+
/// be closed).
|
|
1611
|
+
private func cancelPendingStart() {
|
|
1612
|
+
if isWarmingUpCapture {
|
|
1613
|
+
abandonWarmingCapture(
|
|
1614
|
+
reason: "trigger released before first audio",
|
|
1615
|
+
alert: .releasedBeforeAudio
|
|
1616
|
+
)
|
|
1617
|
+
return
|
|
1618
|
+
}
|
|
1619
|
+
// Nothing was started: the press landed while the microphone permission prompt was up
|
|
1620
|
+
// and was consumed by it. Both key-up handlers used to carry a copy of this disclosure;
|
|
1621
|
+
// it belongs here, once, with the rest of the no-audio branch.
|
|
1622
|
+
let consumedByPermissionPrompt = microphonePermissionStartGate.isAwaitingResponse
|
|
1623
|
+
resetRecordingIntent()
|
|
1624
|
+
if consumedByPermissionPrompt {
|
|
1625
|
+
setBlockedReason(Self.pressConsumedByPermissionPromptMessage, for: .pressConsumed)
|
|
1626
|
+
}
|
|
1627
|
+
updateStatus()
|
|
1628
|
+
}
|
|
1629
|
+
|
|
997
1630
|
// MARK: - Toggle
|
|
998
1631
|
|
|
999
1632
|
public func toggleRecording() {
|
|
1000
|
-
if
|
|
1633
|
+
if captureIsActive { stopAndTranscribe() } else { startRecording(trigger: .manual) }
|
|
1001
1634
|
}
|
|
1002
1635
|
|
|
1003
1636
|
// MARK: - Start Recording (Streaming)
|
|
@@ -1006,6 +1639,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1006
1639
|
guard Self.canBeginRecording(
|
|
1007
1640
|
isRecording: isRecording,
|
|
1008
1641
|
isTranscribing: isTranscribing,
|
|
1642
|
+
isWarmingUpCapture: isWarmingUpCapture,
|
|
1009
1643
|
isAwaitingMicrophonePermission: microphonePermissionStartGate.isAwaitingResponse,
|
|
1010
1644
|
isDeliveryPending: deliveryIsPending
|
|
1011
1645
|
) else {
|
|
@@ -1027,6 +1661,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1027
1661
|
activeTrigger = trigger
|
|
1028
1662
|
keyboardShortcutIsDown = trigger == .keyboardShortcut
|
|
1029
1663
|
conversationReply = nil
|
|
1664
|
+
// Both transient reasons are superseded by a new press, and the delivery one is the
|
|
1665
|
+
// reason this clearing exists: "transcript copied, press Cmd-V" was only ever cleared
|
|
1666
|
+
// by the NEXT delivery's completion, so a recording that produced no delivery left it
|
|
1667
|
+
// asserted indefinitely — and by then the clipboard may hold something else, so Cmd-V
|
|
1668
|
+
// pastes the wrong thing on the app's own instruction. This recording is about to
|
|
1669
|
+
// rewrite the clipboard, so the old instruction stops being true here.
|
|
1670
|
+
//
|
|
1671
|
+
// Accepted cost, stated rather than hidden: if this recording is itself cancelled, a
|
|
1672
|
+
// still-accurate "press Cmd-V" has been cleared early. Losing a true message is a
|
|
1673
|
+
// smaller failure than asserting a false one forever.
|
|
1674
|
+
setBlockedReason(nil, for: .pressConsumed)
|
|
1675
|
+
setBlockedReason(nil, for: .delivery)
|
|
1030
1676
|
|
|
1031
1677
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
1032
1678
|
let frontmostApp = frontmostAppSnapshot()
|
|
@@ -1166,13 +1812,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1166
1812
|
}
|
|
1167
1813
|
}
|
|
1168
1814
|
|
|
1815
|
+
/// `isWarmingUpCapture` deliberately has no default. It is a safety input, and a default of
|
|
1816
|
+
/// `false` is the permissive value: a call site that forgot it would compile, read as
|
|
1817
|
+
/// startable during the warm-up window, and open a second recorder on top of a live one.
|
|
1169
1818
|
nonisolated static func canBeginRecording(
|
|
1170
1819
|
isRecording: Bool,
|
|
1171
1820
|
isTranscribing: Bool,
|
|
1821
|
+
isWarmingUpCapture: Bool,
|
|
1172
1822
|
isAwaitingMicrophonePermission: Bool = false,
|
|
1173
1823
|
isDeliveryPending: Bool = false
|
|
1174
1824
|
) -> Bool {
|
|
1175
1825
|
!isRecording && !isTranscribing && !isAwaitingMicrophonePermission && !isDeliveryPending
|
|
1826
|
+
&& !isWarmingUpCapture
|
|
1176
1827
|
}
|
|
1177
1828
|
|
|
1178
1829
|
nonisolated static func shouldCaptureSelection(
|
|
@@ -1213,11 +1864,17 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1213
1864
|
Self.canBeginRecording(
|
|
1214
1865
|
isRecording: isRecording,
|
|
1215
1866
|
isTranscribing: isTranscribing,
|
|
1867
|
+
isWarmingUpCapture: isWarmingUpCapture,
|
|
1216
1868
|
isAwaitingMicrophonePermission: microphonePermissionStartGate.isAwaitingResponse,
|
|
1217
1869
|
isDeliveryPending: deliveryIsPending
|
|
1218
1870
|
)
|
|
1219
1871
|
}
|
|
1220
1872
|
|
|
1873
|
+
/// A capture attempt is in flight — warming up or live. Anything that used to read
|
|
1874
|
+
/// `isRecording` because it meant "a recording is happening" reads this instead, so the
|
|
1875
|
+
/// warm-up window can neither look idle nor accept a second start.
|
|
1876
|
+
public var captureIsActive: Bool { isWarmingUpCapture || isRecording }
|
|
1877
|
+
|
|
1221
1878
|
/// Whether an in-flight Deciding/Answering/Rewriting delivery can be cancelled. Once a
|
|
1222
1879
|
/// paste transaction is submitted the remaining window is sub-second and has its own
|
|
1223
1880
|
/// target/clipboard safety rails, so cancellation stops being offered.
|
|
@@ -1296,21 +1953,32 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1296
1953
|
)
|
|
1297
1954
|
activeCaptureConfiguration = captureConfiguration
|
|
1298
1955
|
log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1956
|
+
// Constructing the client is a plain allocation; the WebSocket handshake happens in
|
|
1957
|
+
// `beginRealtimeStreaming` below, only once the recorder has actually started. The
|
|
1958
|
+
// stream pipe needs the client at construction time, which is why the two steps are
|
|
1959
|
+
// split rather than simply reordered. The handshake cannot be deferred further — to
|
|
1960
|
+
// the first PCM chunk — without reworking `PCMStreamPipe`: `RealtimeTranscriptionClient`
|
|
1961
|
+
// silently drops audio queued while `isStreaming` is false.
|
|
1962
|
+
let client: RealtimeTranscriptionClient? = apiKey.isEmpty
|
|
1963
|
+
? nil
|
|
1964
|
+
: RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
1965
|
+
realtimeClient = client
|
|
1305
1966
|
|
|
1306
|
-
let client = realtimeClient
|
|
1307
1967
|
let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
|
|
1308
1968
|
pcmStreamPipe = streamPipe
|
|
1309
1969
|
let homePath = home
|
|
1970
|
+
let captureGeneration = recordingGeneration
|
|
1971
|
+
let confirmCapture: @MainActor @Sendable (UInt64) -> Void = { [weak self] generation in
|
|
1972
|
+
self?.confirmCaptureIsLive(generation: generation)
|
|
1973
|
+
}
|
|
1310
1974
|
let firstChunkLogged = LockedFlag()
|
|
1311
1975
|
let recorder = recorderFactory { data in
|
|
1312
1976
|
if firstChunkLogged.take() {
|
|
1313
1977
|
NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
|
|
1978
|
+
// The first sample is the only honest signal that this recording exists.
|
|
1979
|
+
// Promoting the capture here — not when `start()` returned — is what makes a
|
|
1980
|
+
// release during warm-up take the cancel path.
|
|
1981
|
+
Task { @MainActor in confirmCapture(captureGeneration) }
|
|
1314
1982
|
}
|
|
1315
1983
|
streamPipe.append(data)
|
|
1316
1984
|
}
|
|
@@ -1319,7 +1987,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1319
1987
|
try recorder.start()
|
|
1320
1988
|
log("native recorder started")
|
|
1321
1989
|
nativeRecorder = recorder
|
|
1322
|
-
|
|
1990
|
+
isWarmingUpCapture = true
|
|
1323
1991
|
recordingDuration = 0
|
|
1324
1992
|
streamingText = ""
|
|
1325
1993
|
liveTranscriptionText = ""
|
|
@@ -1327,7 +1995,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1327
1995
|
activeAudioPath = "\(audioDir)/recording-\(Self.timestampForFilename()).wav"
|
|
1328
1996
|
let trigger = activeTrigger ?? .manual
|
|
1329
1997
|
statusMessage = Self.recordingStatus(trigger: trigger)
|
|
1998
|
+
// The pane must not look dead for the ~100 ms of warm-up, so it enters the
|
|
1999
|
+
// listening layout immediately; Stop and Discard there both abandon the attempt.
|
|
1330
2000
|
flowPhase = .listening
|
|
2001
|
+
if let client {
|
|
2002
|
+
beginRealtimeStreaming(client: client, transcriptionLanguage: transcriptionLanguage)
|
|
2003
|
+
}
|
|
1331
2004
|
|
|
1332
2005
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
1333
2006
|
Task { @MainActor [weak self] in
|
|
@@ -1336,6 +2009,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1336
2009
|
}
|
|
1337
2010
|
} catch {
|
|
1338
2011
|
log("native recorder failed error=\(error.localizedDescription)")
|
|
2012
|
+
// Unreachable today — nothing between `recorder.start()` and `isWarmingUpCapture`
|
|
2013
|
+
// can throw — but a warming flag left set here wedges the engine permanently: the
|
|
2014
|
+
// start gate refuses every press and no teardown path runs, with the input device
|
|
2015
|
+
// possibly open. One added throwing call above is all it would take, so clear it
|
|
2016
|
+
// here rather than rely on the current statement order.
|
|
2017
|
+
isWarmingUpCapture = false
|
|
1339
2018
|
realtimeClient?.stop()
|
|
1340
2019
|
realtimeClient = nil
|
|
1341
2020
|
streamingTask?.cancel()
|
|
@@ -1349,11 +2028,87 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1349
2028
|
}
|
|
1350
2029
|
}
|
|
1351
2030
|
|
|
2031
|
+
// MARK: - Capture confirmation
|
|
2032
|
+
|
|
2033
|
+
/// Promotes a warming capture to live on the first PCM chunk. Bound to the generation that
|
|
2034
|
+
/// requested it, because a chunk already in the recorder's delivery queue can be handed to
|
|
2035
|
+
/// the MainActor *after* a key-up has abandoned the attempt — that late chunk must never
|
|
2036
|
+
/// resurrect a dead recording.
|
|
2037
|
+
private func confirmCaptureIsLive(generation: UInt64) {
|
|
2038
|
+
guard generation == recordingGeneration, isWarmingUpCapture else { return }
|
|
2039
|
+
isWarmingUpCapture = false
|
|
2040
|
+
isRecording = true
|
|
2041
|
+
log("native capture confirmed live")
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/// Tears down a capture attempt that never produced a sample. `recorder.start()` had
|
|
2045
|
+
/// succeeded, so the microphone is open and a realtime session may be mid-handshake, but
|
|
2046
|
+
/// there is no audio: running the transcription pipeline over that empty buffer is exactly
|
|
2047
|
+
/// what made a short tap finish silently. `alert` is nil when the user asked for the
|
|
2048
|
+
/// discard and therefore already knows the outcome.
|
|
2049
|
+
private func abandonWarmingCapture(reason: String, alert: RecordingAttemptAlert?) {
|
|
2050
|
+
guard isWarmingUpCapture else { return }
|
|
2051
|
+
log("capture abandoned before first audio reason=\(reason)")
|
|
2052
|
+
// Supersede the attempt so every completion still bound to it — a queued first-chunk
|
|
2053
|
+
// confirmation, the resolved start context — is stale and cannot apply.
|
|
2054
|
+
recordingGeneration &+= 1
|
|
2055
|
+
|
|
2056
|
+
recordingTimer?.invalidate()
|
|
2057
|
+
recordingTimer = nil
|
|
2058
|
+
|
|
2059
|
+
let recorder = nativeRecorder
|
|
2060
|
+
nativeRecorder = nil
|
|
2061
|
+
recorder?.stop()
|
|
2062
|
+
|
|
2063
|
+
realtimeClient?.stop()
|
|
2064
|
+
realtimeClient = nil
|
|
2065
|
+
streamingTask?.cancel()
|
|
2066
|
+
streamingTask = nil
|
|
2067
|
+
pcmStreamPipe?.cancel()
|
|
2068
|
+
pcmStreamPipe = nil
|
|
2069
|
+
|
|
2070
|
+
isWarmingUpCapture = false
|
|
2071
|
+
isRecording = false
|
|
2072
|
+
isTranscribing = false
|
|
2073
|
+
streamingText = ""
|
|
2074
|
+
liveTranscriptionText = ""
|
|
2075
|
+
recordedPCM.removeAll(keepingCapacity: true)
|
|
2076
|
+
activeAudioPath = nil
|
|
2077
|
+
activeCaptureConfiguration = nil
|
|
2078
|
+
resetRecordingIntent()
|
|
2079
|
+
|
|
2080
|
+
if let alert {
|
|
2081
|
+
discloseEmptyAttempt(alert)
|
|
2082
|
+
} else {
|
|
2083
|
+
// `updateStatus()`, never a direct "Ready": the state above is already cleared, so
|
|
2084
|
+
// its early return does not fire, and going through it is what preserves a live
|
|
2085
|
+
// `blockedReason` instead of overwriting the disclosure with "Ready".
|
|
2086
|
+
updateStatus()
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// MARK: - Visible outcome
|
|
2091
|
+
|
|
2092
|
+
/// Discloses an attempt that produced nothing, on the one surface that is always on screen.
|
|
2093
|
+
///
|
|
2094
|
+
/// Routed through `setBlockedReason(_:for: .pressConsumed)` rather than a published field of
|
|
2095
|
+
/// its own. That slot already means "a press was consumed and nothing was recorded", it is
|
|
2096
|
+
/// already cleared by the next `startRecording`, and `MenuBarPresentation` already renders
|
|
2097
|
+
/// `blockedReason` with a distinct icon and a distinct VoiceOver label. It also outlives a
|
|
2098
|
+
/// timer: the disclosure is still on the glyph a minute later, which a three-second badge on
|
|
2099
|
+
/// a surface the user had no reason to watch would not be.
|
|
2100
|
+
private func discloseEmptyAttempt(_ alert: RecordingAttemptAlert) {
|
|
2101
|
+
log("attempt produced no audio disclosure=\(alert)")
|
|
2102
|
+
setBlockedReason(alert.message, for: .pressConsumed)
|
|
2103
|
+
updateStatus()
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1352
2106
|
// MARK: - Real-time Streaming
|
|
1353
2107
|
|
|
1354
|
-
private func
|
|
1355
|
-
|
|
1356
|
-
|
|
2108
|
+
private func beginRealtimeStreaming(
|
|
2109
|
+
client: RealtimeTranscriptionClient,
|
|
2110
|
+
transcriptionLanguage: String
|
|
2111
|
+
) {
|
|
1357
2112
|
let language = OpenAIAPIKeyStore.apiLanguageHint(for: transcriptionLanguage)
|
|
1358
2113
|
log("realtime streaming task starting language=\(language.isEmpty ? "auto" : language)")
|
|
1359
2114
|
|
|
@@ -1399,6 +2154,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1399
2154
|
// MARK: - Cancel (discard without transcribing)
|
|
1400
2155
|
|
|
1401
2156
|
public func cancelRecording() {
|
|
2157
|
+
// Discard during warm-up: identical teardown, but the user asked for it, so the glyph
|
|
2158
|
+
// stays quiet.
|
|
2159
|
+
if isWarmingUpCapture {
|
|
2160
|
+
abandonWarmingCapture(reason: "discarded during warm-up", alert: nil)
|
|
2161
|
+
return
|
|
2162
|
+
}
|
|
1402
2163
|
guard isRecording else { return }
|
|
1403
2164
|
log("cancelRecording")
|
|
1404
2165
|
|
|
@@ -1423,13 +2184,21 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1423
2184
|
activeAudioPath = nil
|
|
1424
2185
|
activeCaptureConfiguration = nil
|
|
1425
2186
|
resetRecordingIntent()
|
|
1426
|
-
|
|
1427
|
-
|
|
2187
|
+
// Same reason as the warm-up abandon path: this used to assign "Ready" directly and
|
|
2188
|
+
// silently discard a live trigger-blocked warning.
|
|
2189
|
+
updateStatus()
|
|
1428
2190
|
}
|
|
1429
2191
|
|
|
1430
2192
|
// MARK: - Stop & Transcribe
|
|
1431
2193
|
|
|
1432
2194
|
public func stopAndTranscribe() {
|
|
2195
|
+
// Stop during the warm-up window has nothing to transcribe — the microphone was opened
|
|
2196
|
+
// but has not delivered a sample. Abandon visibly instead of spending a transcription
|
|
2197
|
+
// pipeline (and a CLI round trip) on an empty buffer.
|
|
2198
|
+
if isWarmingUpCapture {
|
|
2199
|
+
abandonWarmingCapture(reason: "stopped during warm-up", alert: .releasedBeforeAudio)
|
|
2200
|
+
return
|
|
2201
|
+
}
|
|
1433
2202
|
guard isRecording else { return }
|
|
1434
2203
|
let pipelineTrace = RecordingPipelineTrace()
|
|
1435
2204
|
log(pipelineTrace.message(stage: "release"))
|
|
@@ -1480,7 +2249,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1480
2249
|
))
|
|
1481
2250
|
|
|
1482
2251
|
let streamingResult = await client?.finish(
|
|
1483
|
-
timeoutMilliseconds: Self.
|
|
2252
|
+
timeoutMilliseconds: Self.realtimeSettleBudgetMilliseconds(
|
|
2253
|
+
pcmByteCount: self.recordedPCM.count
|
|
2254
|
+
),
|
|
1484
2255
|
pipelineID: pipelineTrace.id,
|
|
1485
2256
|
pipelineStartedUptimeMilliseconds: pipelineTrace.startedUptimeMilliseconds
|
|
1486
2257
|
)
|
|
@@ -1558,7 +2329,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1558
2329
|
postProcessingMode: postProcessingMode,
|
|
1559
2330
|
transcript: realtimeFastPathText,
|
|
1560
2331
|
hasSelection: selectionToken != nil,
|
|
1561
|
-
intentDetectionEnabled: processingConfiguration.intentDetectionEnabled
|
|
2332
|
+
intentDetectionEnabled: processingConfiguration.intentDetectionEnabled,
|
|
2333
|
+
enhanceTriggersJSON: processingConfiguration.enhanceTriggersJSON
|
|
1562
2334
|
) {
|
|
1563
2335
|
self.isTranscribing = false
|
|
1564
2336
|
_ = Self.deliverRealtimeBeforePersistence(
|
|
@@ -1693,7 +2465,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1693
2465
|
} else {
|
|
1694
2466
|
self.log("no audio captured")
|
|
1695
2467
|
self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
|
|
1696
|
-
|
|
2468
|
+
// The one failure the user has no other way to notice: nothing was typed,
|
|
2469
|
+
// nothing appeared, and the status line lives behind a click on a menu-bar
|
|
2470
|
+
// glyph that never changed. So disclose it on the glyph too.
|
|
2471
|
+
//
|
|
2472
|
+
// One message, used for both. `MenuBarPresentation` renders the blocked
|
|
2473
|
+
// state as `statusText = blockedReason`, so passing the generic constant here
|
|
2474
|
+
// while `finish` held a specific `failureStatus` would replace the specific
|
|
2475
|
+
// diagnosis with "No audio captured" in every surface that reads the
|
|
2476
|
+
// presentation — losing the more useful of the two.
|
|
2477
|
+
let failure = resolved.failureStatus ?? RecordingAttemptAlert.noAudioCaptured.message
|
|
2478
|
+
self.finish(failure)
|
|
2479
|
+
self.setBlockedReason(failure, for: .pressConsumed)
|
|
1697
2480
|
}
|
|
1698
2481
|
}
|
|
1699
2482
|
|
|
@@ -1733,7 +2516,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1733
2516
|
pcmByteCount: Int,
|
|
1734
2517
|
language: String
|
|
1735
2518
|
) -> String? {
|
|
1736
|
-
|
|
2519
|
+
// Transport failures cannot settle, so a settled transcript outranks incidental server errors.
|
|
2520
|
+
guard finishResult.settled else { return nil }
|
|
1737
2521
|
guard let text = safeRealtimeFallbackTranscript(
|
|
1738
2522
|
realtimeText: finishResult.text,
|
|
1739
2523
|
language: language
|
|
@@ -1741,18 +2525,39 @@ public final class RecordingEngine: ObservableObject {
|
|
|
1741
2525
|
return shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount) ? nil : text
|
|
1742
2526
|
}
|
|
1743
2527
|
|
|
1744
|
-
/// Delivery may only run ahead of persistence for transcripts the local
|
|
2528
|
+
/// Delivery may only run ahead of persistence for transcripts the local screens already
|
|
1745
2529
|
/// decided are plain dictation: the paste is near-instant, so persistence is deferred by
|
|
1746
2530
|
/// milliseconds. Command/conversation-shaped transcripts persist first — their delivery
|
|
1747
2531
|
/// can block on the classifier, the assistant, or the rewrite CLI, and the recording
|
|
1748
2532
|
/// must already be durable by then.
|
|
2533
|
+
///
|
|
2534
|
+
/// `off` mode never rewrites, so plain dictation always qualifies. `auto` mode
|
|
2535
|
+
/// qualifies only when `EnhancementScreen` proves the helper cannot rewrite the
|
|
2536
|
+
/// transcript — enhancement-eligible speech must keep pasting the helper's output,
|
|
2537
|
+
/// which only exists after persistence. `always` mode rewrites unconditionally and
|
|
2538
|
+
/// therefore always persists first.
|
|
1749
2539
|
nonisolated static func shouldPasteBeforePersistence(
|
|
1750
2540
|
postProcessingMode: String,
|
|
1751
2541
|
transcript: String,
|
|
1752
2542
|
hasSelection: Bool,
|
|
1753
|
-
intentDetectionEnabled: Bool
|
|
2543
|
+
intentDetectionEnabled: Bool,
|
|
2544
|
+
// No default: "[]" decodes successfully to "no configured triggers", which
|
|
2545
|
+
// silently fails OPEN for a caller that forgets the argument — the opposite
|
|
2546
|
+
// of the fail-closed contract documented on EnhancementScreen. Every caller
|
|
2547
|
+
// must state the configured triggers explicitly (review F2 on #30).
|
|
2548
|
+
enhanceTriggersJSON: String
|
|
1754
2549
|
) -> Bool {
|
|
1755
|
-
|
|
2550
|
+
switch PostProcessingMode(rawValue: postProcessingMode) {
|
|
2551
|
+
case .off:
|
|
2552
|
+
break
|
|
2553
|
+
case .auto:
|
|
2554
|
+
guard !EnhancementScreen.mayRequireEnhancement(
|
|
2555
|
+
text: transcript,
|
|
2556
|
+
enhanceTriggersJSON: enhanceTriggersJSON
|
|
2557
|
+
) else { return false }
|
|
2558
|
+
default:
|
|
2559
|
+
return false
|
|
2560
|
+
}
|
|
1756
2561
|
guard intentDetectionEnabled else { return true }
|
|
1757
2562
|
return IntentScreen.screen(text: transcript, hasSelection: hasSelection)?.intent == .dictate
|
|
1758
2563
|
}
|
|
@@ -3265,7 +4070,17 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3265
4070
|
|
|
3266
4071
|
let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
|
|
3267
4072
|
var ownedPasteboardChangeCount: Int?
|
|
4073
|
+
var clipboardWrite: PasteboardWriteResult?
|
|
3268
4074
|
var clipboardOwnershipWasLost = false
|
|
4075
|
+
// Focused field of the target app as it read immediately before the keystroke. The
|
|
4076
|
+
// read-back after the keystroke is compared against this and against nothing else.
|
|
4077
|
+
var deliveryProbe: FocusedTextProbe?
|
|
4078
|
+
// What the read-back proved, and how many reads it took. Both stay at their initial
|
|
4079
|
+
// values when the paste failed before the keystroke, so the log reports "no read-back"
|
|
4080
|
+
// rather than borrowing a verdict from a previous paste.
|
|
4081
|
+
var deliveryEvidence: PasteDeliveryEvidence = .unverified(.readBackNotAttempted)
|
|
4082
|
+
var readBackAttempts = 0
|
|
4083
|
+
lastPasteSecureInputProbe = nil
|
|
3269
4084
|
updateDeliveryStatus("Pasting...", kind: .progress, pipelineGeneration: pipelineGeneration)
|
|
3270
4085
|
let accepted = pasteTransactionCoordinator.submit(
|
|
3271
4086
|
text: text,
|
|
@@ -3299,14 +4114,36 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3299
4114
|
if restoreClipboard {
|
|
3300
4115
|
previousClipboard = ClipboardSnapshot(pasteboard: .general)
|
|
3301
4116
|
}
|
|
4117
|
+
// Captured before the clipboard write rather than immediately before the
|
|
4118
|
+
// keystroke: the readiness checks that follow re-validate focus anyway, and
|
|
4119
|
+
// two Accessibility round trips must not sit between the payload check and
|
|
4120
|
+
// the keystroke. A focus move in the gap is caught by the read-back, which
|
|
4121
|
+
// refuses to compare across a changed element.
|
|
4122
|
+
deliveryProbe = FocusedTextProbe.capture(pid: app.processIdentifier)
|
|
3302
4123
|
},
|
|
3303
4124
|
writeAttempted: { result in
|
|
3304
4125
|
ownedPasteboardChangeCount = result.ownershipChangeCount
|
|
3305
|
-
|
|
4126
|
+
clipboardWrite = result
|
|
4127
|
+
},
|
|
4128
|
+
verify: {
|
|
4129
|
+
guard let deliveryProbe else { return .unverified(.readBackNotAttempted) }
|
|
4130
|
+
readBackAttempts += 1
|
|
4131
|
+
let evidence = PasteDeliveryVerifier.classify(
|
|
4132
|
+
pastedText: text,
|
|
4133
|
+
baseline: deliveryProbe.baseline,
|
|
4134
|
+
readBack: deliveryProbe.readBack()
|
|
4135
|
+
)
|
|
4136
|
+
deliveryEvidence = evidence
|
|
4137
|
+
return evidence
|
|
4138
|
+
},
|
|
4139
|
+
verificationDelay: Self.pasteReadBackInterval,
|
|
4140
|
+
verificationAttempts: Self.pasteReadBackAttempts
|
|
3306
4141
|
) { transaction, outcome in
|
|
3307
|
-
let posted = outcome == .pasted
|
|
3308
4142
|
let accessibilityTrusted = AXIsProcessTrusted()
|
|
3309
|
-
|
|
4143
|
+
// Same reason the two static predicates below switch instead of comparing: a `==`
|
|
4144
|
+
// test answers `false` for any outcome added later, and this feeds
|
|
4145
|
+
// `shouldCopyAfterPasteFailure`, which decides whether the transcript is re-copied.
|
|
4146
|
+
let completedTranscriptAlreadyOnClipboard = Self.outcomeLeavesTranscriptOnClipboard(outcome)
|
|
3310
4147
|
&& !restoreClipboard
|
|
3311
4148
|
&& (ownedPasteboardChangeCount.map {
|
|
3312
4149
|
Self.clipboardStillOwned(.general, text: transaction.text, changeCount: $0)
|
|
@@ -3321,15 +4158,42 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3321
4158
|
let copiedAfterFailure = shouldCopyAfterFailure
|
|
3322
4159
|
&& Self.writeClipboardPreservingOnFailure(transaction.text, to: .general)
|
|
3323
4160
|
self.log("paste outcome=\(outcome) target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost) transaction=\(transaction.id)")
|
|
4161
|
+
// The line to read when asking "did the text land?". Every step reports itself, so
|
|
4162
|
+
// a posted keystroke can no longer stand in for delivery.
|
|
4163
|
+
self.log(PasteDeliveryReport(
|
|
4164
|
+
targetBundleIdentifier: app.bundleIdentifier,
|
|
4165
|
+
characterCount: transaction.text.count,
|
|
4166
|
+
clipboardWriteVerified: clipboardWrite?.verified ?? false,
|
|
4167
|
+
clipboardChangeCountAdvanced: clipboardWrite?.changeCountAdvanced ?? false,
|
|
4168
|
+
attempt: .forOutcome(outcome),
|
|
4169
|
+
secureInput: self.lastPasteSecureInputProbe,
|
|
4170
|
+
evidence: deliveryEvidence,
|
|
4171
|
+
readBackAttempts: readBackAttempts
|
|
4172
|
+
).logLine)
|
|
3324
4173
|
if let pipelineTrace {
|
|
3325
4174
|
self.log(pipelineTrace.message(
|
|
3326
|
-
stage:
|
|
4175
|
+
stage: Self.pasteTraceStage(for: outcome),
|
|
3327
4176
|
detail: "chars=\(transaction.text.count)"
|
|
3328
4177
|
))
|
|
3329
4178
|
}
|
|
3330
4179
|
deliveryCompleted?()
|
|
3331
4180
|
let message = switch outcome {
|
|
3332
4181
|
case .pasted: "Pasted (\(transaction.text.count) chars)"
|
|
4182
|
+
case .deliveryNotObserved: restoreClipboard
|
|
4183
|
+
? "Paste did not reach the target app"
|
|
4184
|
+
: "Paste did not reach the target app — text kept on the clipboard"
|
|
4185
|
+
case .deliveredUnverified: restoreClipboard
|
|
4186
|
+
? "Paste sent, delivery unconfirmed"
|
|
4187
|
+
: "Paste sent, delivery unconfirmed — text kept on the clipboard"
|
|
4188
|
+
// One message either way, because the clipboard is kept either way — see the
|
|
4189
|
+
// `shouldRestore` switch in `settlement`. Telling the owner to press Cmd-V is only
|
|
4190
|
+
// honest if the transcript is still there, so this branch may not depend on
|
|
4191
|
+
// `restoreClipboard`. When restore WAS requested, say that it was overridden
|
|
4192
|
+
// rather than letting the owner discover it.
|
|
4193
|
+
case .secureInputActive: restoreClipboard
|
|
4194
|
+
? "This field blocks typing (secure input) — transcript kept on the clipboard "
|
|
4195
|
+
+ "instead of restoring it, press Cmd-V"
|
|
4196
|
+
: "This field blocks typing (secure input) — transcript copied, press Cmd-V"
|
|
3333
4197
|
case .targetUnavailable: Self.targetUnavailableDeliveryStatus(
|
|
3334
4198
|
deliveryKind: deliveryKind,
|
|
3335
4199
|
accessibilityTrusted: accessibilityTrusted,
|
|
@@ -3344,11 +4208,35 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3344
4208
|
? "Paste failed because the paste event could not be posted"
|
|
3345
4209
|
: "Copied, but paste event could not be posted"
|
|
3346
4210
|
}
|
|
4211
|
+
// `updateDeliveryStatus` writes `statusMessage`, and `updateStatus()` rewrites it to
|
|
4212
|
+
// "Ready" on the next return to idle. A transient success line can afford that;
|
|
4213
|
+
// "press Cmd-V" cannot, because it is the only thing telling the owner their
|
|
4214
|
+
// transcript is recoverable. So the secure-input reason is persisted through the
|
|
4215
|
+
// one field every surface reads.
|
|
4216
|
+
//
|
|
4217
|
+
// ONLY this outcome persists, deliberately. `.deliveryNotObserved` also leaves the
|
|
4218
|
+
// transcript on the clipboard, but it has a documented false negative — pasting text
|
|
4219
|
+
// identical to the selection it replaces reads as "unchanged" — so persisting it
|
|
4220
|
+
// would raise a standing warning over a paste that worked. `.deliveredUnverified`
|
|
4221
|
+
// means "could not tell", and a standing blocked banner would over-claim it. Secure
|
|
4222
|
+
// input has no such path: it is measured from the window-session dictionary, and an
|
|
4223
|
+
// uninterrogable session yields `.unknown`, which never reaches here.
|
|
4224
|
+
// AFTER `updateDeliveryStatus`, not before: that call clears the delivery reason so
|
|
4225
|
+
// statuses which produce no outcome cannot leave a stale one behind, and this is the
|
|
4226
|
+
// one caller whose reason has to outlive its own status line.
|
|
3347
4227
|
self.updateDeliveryStatus(
|
|
3348
4228
|
message,
|
|
3349
|
-
kind:
|
|
4229
|
+
kind: Self.deliveryStatusKind(for: outcome),
|
|
3350
4230
|
pipelineGeneration: transaction.generation
|
|
3351
4231
|
)
|
|
4232
|
+
// Stamped with the delivery's OWN generation, not the engine's current one: this
|
|
4233
|
+
// closure can run after `recordingGeneration` has advanced, and binding to the
|
|
4234
|
+
// current value would mark a superseded reason fresh. `updateStatus()` expires it.
|
|
4235
|
+
self.setBlockedReason(
|
|
4236
|
+
Self.isSecureInputOutcome(outcome) ? message : nil,
|
|
4237
|
+
for: .delivery,
|
|
4238
|
+
generation: transaction.generation
|
|
4239
|
+
)
|
|
3352
4240
|
} settlement: { transaction, outcome in
|
|
3353
4241
|
let pasteboard = NSPasteboard.general
|
|
3354
4242
|
let stillOwnsChangeCount = ownedPasteboardChangeCount.map {
|
|
@@ -3368,7 +4256,16 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3368
4256
|
let shouldRestore = switch outcome {
|
|
3369
4257
|
case .clipboardWriteFailed:
|
|
3370
4258
|
stillOwnsChangeCount
|
|
3371
|
-
|
|
4259
|
+
// Never restore over secure input, even when `restoreClipboard` was requested.
|
|
4260
|
+
// By the time this outcome is reachable the payload writer has already run, so the
|
|
4261
|
+
// transcript IS the clipboard — and the status line has just told the owner to press
|
|
4262
|
+
// Cmd-V. Restoring would delete the exact text the app told them to paste. This
|
|
4263
|
+
// deliberately overrides an explicit opt-in, which is why the status message for
|
|
4264
|
+
// this outcome says the clipboard was kept instead of restored.
|
|
4265
|
+
case .secureInputActive:
|
|
4266
|
+
false
|
|
4267
|
+
case .targetUnavailable, .clipboardOwnershipLost, .eventPostFailed, .pasted,
|
|
4268
|
+
.deliveryNotObserved, .deliveredUnverified:
|
|
3372
4269
|
stillOwnsPayload
|
|
3373
4270
|
}
|
|
3374
4271
|
if shouldRestore {
|
|
@@ -3387,12 +4284,42 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3387
4284
|
}
|
|
3388
4285
|
}
|
|
3389
4286
|
|
|
4287
|
+
/// Whether an outcome ends with the transcript still sitting on the clipboard because the
|
|
4288
|
+
/// paste never consumed it — so re-copying it would be redundant.
|
|
4289
|
+
///
|
|
4290
|
+
/// Exhaustive on purpose. `.secureInputActive` answers `false` here even though it *does*
|
|
4291
|
+
/// leave the transcript on the clipboard: it gets there because `shouldRestore` refuses to
|
|
4292
|
+
/// restore, not because the paste was abandoned before the clipboard was written, and
|
|
4293
|
+
/// `shouldCopyAfterPasteFailure` is additionally gated on `!accessibilityTrusted`, which is
|
|
4294
|
+
/// never the path secure input takes.
|
|
4295
|
+
nonisolated static func outcomeLeavesTranscriptOnClipboard(_ outcome: PasteDeliveryOutcome) -> Bool {
|
|
4296
|
+
switch outcome {
|
|
4297
|
+
case .targetUnavailable: true
|
|
4298
|
+
case .pasted, .deliveryNotObserved, .deliveredUnverified, .clipboardOwnershipLost,
|
|
4299
|
+
.clipboardWriteFailed, .eventPostFailed, .secureInputActive: false
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
|
|
3390
4303
|
nonisolated static func clipboardOwnershipWasLostAfterPasteFailure(
|
|
3391
4304
|
outcome: PasteDeliveryOutcome,
|
|
3392
4305
|
hasOwnershipToken: Bool,
|
|
3393
4306
|
stillOwnsPayload: Bool
|
|
3394
4307
|
) -> Bool {
|
|
3395
|
-
|
|
4308
|
+
// Switched rather than compared against `.targetUnavailable` so the compiler forces a
|
|
4309
|
+
// decision here when an outcome is added. A `==` comparison answers `false` for every
|
|
4310
|
+
// new case without anyone having considered it, and this predicate decides whether the
|
|
4311
|
+
// engine still believes it owns the transcript — guessing wrong loses the text.
|
|
4312
|
+
let outcomeCanStrandThePayload: Bool
|
|
4313
|
+
switch outcome {
|
|
4314
|
+
case .targetUnavailable:
|
|
4315
|
+
outcomeCanStrandThePayload = true
|
|
4316
|
+
// Secure input cannot strand the payload: nothing else wrote to the clipboard, and the
|
|
4317
|
+
// transcript is deliberately kept there.
|
|
4318
|
+
case .pasted, .deliveryNotObserved, .deliveredUnverified, .clipboardOwnershipLost,
|
|
4319
|
+
.clipboardWriteFailed, .eventPostFailed, .secureInputActive:
|
|
4320
|
+
outcomeCanStrandThePayload = false
|
|
4321
|
+
}
|
|
4322
|
+
return outcomeCanStrandThePayload && hasOwnershipToken && !stillOwnsPayload
|
|
3396
4323
|
}
|
|
3397
4324
|
|
|
3398
4325
|
@discardableResult
|
|
@@ -3418,18 +4345,21 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3418
4345
|
_ text: String,
|
|
3419
4346
|
to pasteboard: NSPasteboard
|
|
3420
4347
|
) -> PasteboardWriteResult {
|
|
4348
|
+
let changeCountBeforeWrite = pasteboard.changeCount
|
|
3421
4349
|
let clearedChangeCount = pasteboard.clearContents()
|
|
3422
4350
|
guard pasteboard.setString(text, forType: .string) else {
|
|
3423
4351
|
return PasteboardWriteResult(
|
|
3424
4352
|
verified: false,
|
|
3425
|
-
ownershipChangeCount: clearedChangeCount
|
|
4353
|
+
ownershipChangeCount: clearedChangeCount,
|
|
4354
|
+
changeCountAdvanced: clearedChangeCount > changeCountBeforeWrite
|
|
3426
4355
|
)
|
|
3427
4356
|
}
|
|
3428
4357
|
let writtenChangeCount = pasteboard.changeCount
|
|
3429
4358
|
let storedText = pasteboard.string(forType: .string)
|
|
3430
4359
|
return PasteboardWriteResult(
|
|
3431
4360
|
verified: pasteboard.changeCount == writtenChangeCount && storedText == text,
|
|
3432
|
-
ownershipChangeCount: writtenChangeCount
|
|
4361
|
+
ownershipChangeCount: writtenChangeCount,
|
|
4362
|
+
changeCountAdvanced: writtenChangeCount > changeCountBeforeWrite
|
|
3433
4363
|
)
|
|
3434
4364
|
}
|
|
3435
4365
|
|
|
@@ -3473,7 +4403,19 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3473
4403
|
clipboardOwnershipWasLost: Bool = false,
|
|
3474
4404
|
completedTranscriptAlreadyOnClipboard: Bool = false
|
|
3475
4405
|
) -> Bool {
|
|
3476
|
-
|
|
4406
|
+
// Switched for the same reason as `clipboardOwnershipWasLostAfterPasteFailure`: a `==`
|
|
4407
|
+
// test silently answers `false` for any outcome added later. Secure input already leaves
|
|
4408
|
+
// the transcript on the clipboard, so re-copying it would be redundant at best — but that
|
|
4409
|
+
// is a decision the compiler should make someone state, not one to inherit by accident.
|
|
4410
|
+
let outcomeNeedsClipboardFallback: Bool
|
|
4411
|
+
switch outcome {
|
|
4412
|
+
case .targetUnavailable:
|
|
4413
|
+
outcomeNeedsClipboardFallback = true
|
|
4414
|
+
case .pasted, .deliveryNotObserved, .deliveredUnverified, .clipboardOwnershipLost,
|
|
4415
|
+
.clipboardWriteFailed, .eventPostFailed, .secureInputActive:
|
|
4416
|
+
outcomeNeedsClipboardFallback = false
|
|
4417
|
+
}
|
|
4418
|
+
return outcomeNeedsClipboardFallback
|
|
3477
4419
|
&& !accessibilityTrusted
|
|
3478
4420
|
&& !clipboardOwnershipWasLost
|
|
3479
4421
|
&& !completedTranscriptAlreadyOnClipboard
|
|
@@ -3533,6 +4475,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3533
4475
|
enum DeliveryStatusKind: Equatable, Sendable {
|
|
3534
4476
|
case progress
|
|
3535
4477
|
case success
|
|
4478
|
+
/// The pipeline finished but delivery could not be observed. Presented like a finished
|
|
4479
|
+
/// run — the recording is safe and the text is on the clipboard — while the message
|
|
4480
|
+
/// itself says the paste is unconfirmed. Never folded into `.success`: that is the
|
|
4481
|
+
/// false positive this state exists to avoid.
|
|
4482
|
+
case unverified
|
|
3536
4483
|
case failure
|
|
3537
4484
|
}
|
|
3538
4485
|
|
|
@@ -3542,11 +4489,48 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3542
4489
|
) -> RecordingFlowPhase {
|
|
3543
4490
|
switch kind {
|
|
3544
4491
|
case .progress: .processing(message)
|
|
3545
|
-
case .success: .ready(message)
|
|
4492
|
+
case .success, .unverified: .ready(message)
|
|
3546
4493
|
case .failure: .failed(message)
|
|
3547
4494
|
}
|
|
3548
4495
|
}
|
|
3549
4496
|
|
|
4497
|
+
/// Whether an outcome leaves a blocker the owner has to act on, so its explanation must
|
|
4498
|
+
/// outlive the delivery status rather than being overwritten with "Ready".
|
|
4499
|
+
///
|
|
4500
|
+
/// Written as an exhaustive switch rather than `if case`, so adding a `PasteDeliveryOutcome`
|
|
4501
|
+
/// forces a decision here instead of silently defaulting to invisible.
|
|
4502
|
+
nonisolated static func isSecureInputOutcome(_ outcome: PasteDeliveryOutcome) -> Bool {
|
|
4503
|
+
switch outcome {
|
|
4504
|
+
case .secureInputActive: true
|
|
4505
|
+
case .pasted, .deliveryNotObserved, .deliveredUnverified, .targetUnavailable,
|
|
4506
|
+
.clipboardOwnershipLost, .clipboardWriteFailed, .eventPostFailed: false
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
/// Only observed delivery is a success. An unreadable target is its own state, and both a
|
|
4511
|
+
/// contradicted read-back and a refused post are failures.
|
|
4512
|
+
nonisolated static func deliveryStatusKind(for outcome: PasteDeliveryOutcome) -> DeliveryStatusKind {
|
|
4513
|
+
switch outcome {
|
|
4514
|
+
case .pasted: .success
|
|
4515
|
+
case .deliveredUnverified: .unverified
|
|
4516
|
+
case .deliveryNotObserved, .secureInputActive, .targetUnavailable,
|
|
4517
|
+
.clipboardOwnershipLost, .clipboardWriteFailed, .eventPostFailed: .failure
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
/// Pipeline-timing stage name. `paste_posted` used to be emitted for every posted
|
|
4522
|
+
/// keystroke, which made the timing trace read like a delivery record; the three delivery
|
|
4523
|
+
/// verdicts are now distinct stages.
|
|
4524
|
+
nonisolated static func pasteTraceStage(for outcome: PasteDeliveryOutcome) -> String {
|
|
4525
|
+
switch outcome {
|
|
4526
|
+
case .pasted: "paste_delivery_confirmed"
|
|
4527
|
+
case .deliveredUnverified: "paste_delivery_unverified"
|
|
4528
|
+
case .deliveryNotObserved: "paste_delivery_not_observed"
|
|
4529
|
+
case .secureInputActive, .targetUnavailable, .clipboardOwnershipLost,
|
|
4530
|
+
.clipboardWriteFailed, .eventPostFailed: "paste_failed"
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
|
|
3550
4534
|
private func updateDeliveryStatus(
|
|
3551
4535
|
_ message: String,
|
|
3552
4536
|
kind: DeliveryStatusKind,
|
|
@@ -3565,6 +4549,18 @@ public final class RecordingEngine: ObservableObject {
|
|
|
3565
4549
|
}
|
|
3566
4550
|
statusMessage = message
|
|
3567
4551
|
flowPhase = Self.flowPhase(forDeliveryStatus: message, kind: kind)
|
|
4552
|
+
// A delivery status that actually reaches the screen replaces whatever explanation was
|
|
4553
|
+
// there, so a persisted reason must not survive it — otherwise `updateStatus()`
|
|
4554
|
+
// resurfaces it on the next return to idle. This closes the two leaks that produce no
|
|
4555
|
+
// `PasteDeliveryOutcome` at all, and which clearing on `startRecording()` therefore
|
|
4556
|
+
// cannot reach: the "Finish the previous paste before trying again" rejection, and the
|
|
4557
|
+
// conversation route's "Answered".
|
|
4558
|
+
//
|
|
4559
|
+
// ORDERING: the secure-input caller must call this FIRST and re-set its reason after,
|
|
4560
|
+
// which it does. `macos-shortcut-contract.test.ts` asserts that order, because getting it
|
|
4561
|
+
// backwards silently reinstates the invisible-blocked bug with every test still green.
|
|
4562
|
+
setBlockedReason(nil, for: .delivery)
|
|
4563
|
+
setBlockedReason(nil, for: .pressConsumed)
|
|
3568
4564
|
}
|
|
3569
4565
|
|
|
3570
4566
|
private func selectedRunningPasteTarget(
|