@hasna/recordings 0.2.14 → 0.3.2

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.
Files changed (123) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2094 -280
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +19 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +635 -50
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +141 -57
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +188 -80
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +109 -50
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/ci-linux-suite.ts +403 -0
  59. package/scripts/ci-native-build.ts +402 -0
  60. package/scripts/enforce_identity_migration.sh +110 -0
  61. package/scripts/generate-sdk.ts +1 -1
  62. package/scripts/install_macos_app.sh +192 -31
  63. package/scripts/macos_artifact.ts +323 -43
  64. package/scripts/native/realtime-settle-probe/README.md +39 -0
  65. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  66. package/scripts/policy/local-only-approved-targets.txt +21 -0
  67. package/scripts/read_local_only_targets.sh +203 -0
  68. package/scripts/scan-artifact.ts +40 -0
  69. package/scripts/set-version.ts +145 -0
  70. package/scripts/smoke_macos_app.sh +62 -16
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
  81. package/src/native/Recordings/App/RecordingsApp.swift +37 -1
  82. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  83. package/src/native/Recordings/App/SidebarView.swift +1 -1
  84. package/src/native/Recordings/Package.swift +24 -5
  85. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  86. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  87. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  88. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  89. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  90. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  91. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
  92. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  93. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  94. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  95. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  96. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  97. package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
  98. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  99. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  100. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  101. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  102. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  103. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  104. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  105. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  106. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  107. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  108. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  109. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  110. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  111. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  112. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  113. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  114. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  115. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  116. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  117. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  118. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  119. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  120. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  121. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  122. package/src/native/Recordings/build.sh +226 -20
  123. 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 () -> Bool
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
- guard self.postPaste() else {
425
- settlement(transaction, .eventPostFailed)
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, .eventPostFailed)
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
- completion(transaction, .pasted)
431
- guard settlementDelay > 0 else {
432
- settlement(transaction, .pasted)
433
- self.state = .idle
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(settlementDelay) { [weak self] in
505
+ self.schedule(verificationDelay) { [weak self] in
437
506
  guard let self, self.state == .settling(transaction.id) else { return }
438
- settlement(transaction, .pasted)
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
- @Published public var isRecording = false
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
- updateStatus()
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 false
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
- return true
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
- private nonisolated static let realtimeFinishTimeoutMilliseconds: UInt64 = 700
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
- let home = FileManager.default.homeDirectoryForCurrentUser.path
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, Self.canBeginRecording(
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 { return }
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, self.activeTrigger == .fnKey else { return }
867
- guard self.isRecording else {
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 { return }
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
- guard self.activeTrigger == .keyboardShortcut else { return }
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
- guard let self, self.useFnKey, !self.fnMonitor.isRunning, AXIsProcessTrusted() else { return }
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
- statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
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 isRecording || isTranscribing || deliveryIsPending { return }
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 isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
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
- if !apiKey.isEmpty {
1300
- startRealtimeStreaming(
1301
- apiKey: apiKey,
1302
- transcriptionLanguage: transcriptionLanguage
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
- isRecording = true
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 startRealtimeStreaming(apiKey: String, transcriptionLanguage: String) {
1355
- let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
1356
- realtimeClient = client
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
- statusMessage = "Ready"
1427
- flowPhase = .idle
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.realtimeFinishTimeoutMilliseconds,
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
- self.finish(resolved.failureStatus ?? "No audio captured")
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
- guard finishResult.settled, finishResult.error == nil else { return nil }
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 screen already
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
- guard PostProcessingMode(rawValue: postProcessingMode) == .off else { return false }
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
- let completedTranscriptAlreadyOnClipboard = outcome == .targetUnavailable
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: posted ? "paste_posted" : "paste_failed",
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: posted ? .success : .failure,
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
- case .targetUnavailable, .clipboardOwnershipLost, .eventPostFailed, .pasted:
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
- outcome == .targetUnavailable && hasOwnershipToken && !stillOwnsPayload
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
- outcome == .targetUnavailable
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(