@hasna/recordings 0.2.13 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) 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 +2078 -277
  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 +10 -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 +634 -49
  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 +140 -56
  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 +187 -79
  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 +108 -49
  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/build_native_fs_guard.sh +15 -2
  59. package/scripts/ci-linux-suite.ts +403 -0
  60. package/scripts/ci-native-build.ts +402 -0
  61. package/scripts/enforce_identity_migration.sh +110 -0
  62. package/scripts/generate-sdk.ts +1 -1
  63. package/scripts/install_macos_app.sh +138 -24
  64. package/scripts/macos_artifact.ts +205 -33
  65. package/scripts/native/realtime-settle-probe/README.md +39 -0
  66. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  67. package/scripts/policy/local-only-approved-targets.txt +21 -0
  68. package/scripts/read_local_only_targets.sh +203 -0
  69. package/scripts/scan-artifact.ts +40 -0
  70. package/scripts/set-version.ts +145 -0
  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 +14 -7
  81. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  82. package/src/native/Recordings/App/SidebarView.swift +1 -1
  83. package/src/native/Recordings/Package.swift +24 -5
  84. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  85. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  86. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  87. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  88. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  89. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  90. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  91. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  92. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  93. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  94. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  95. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  96. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  97. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  98. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  99. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  100. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  101. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  102. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  103. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  104. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  105. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  106. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  107. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  108. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  109. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  110. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  111. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  112. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  113. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  114. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  115. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  116. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  117. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  118. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  119. package/src/native/Recordings/build.sh +75 -9
  120. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -20,10 +20,24 @@ let package = Package(
20
20
  ],
21
21
  dependencies: [
22
22
  .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", exact: "1.12.0"),
23
- // Swift Testing pinned to the toolchain-matched release. The stations build with
24
- // CommandLineTools (no bundled Testing module), so the package is required but it
25
- // must track the compiler: the archived 0.99 shim under the 6.2 compiler silently
26
- // corrupted `#expect` Bool-comparison evaluation, passing false assertions.
23
+ // Swift Testing. The stations build with CommandLineTools (no bundled Testing
24
+ // module), so the package is required. The original rule here was "the pin must
25
+ // track the compiler", because an archived 0.99 shim under the 6.2 compiler once
26
+ // silently corrupted `#expect` Bool-comparison evaluation, passing false assertions.
27
+ //
28
+ // Measured on station06 (Apple Swift 6.3.2, swiftlang-6.3.2.1.108): the 6.3.x line
29
+ // CANNOT be used here. From swift-6.3-RELEASE onward swift-testing's own manifest adds
30
+ // `.linkedLibrary("_TestingInterop")` under `#if compiler(>=6.3)`. That library ships
31
+ // in the toolchain at `$(xcode-select -p)/Library/Developer/usr/lib`, which is not on
32
+ // SwiftPM's default search path, so `swift build --build-tests` fails with
33
+ // `ld: library '_TestingInterop' not found`. It links only if every invocation adds
34
+ // `-Xlinker -L$(xcode-select -p)/Library/Developer/usr/lib`, which nothing in this repo
35
+ // supplies. Repinning to 6.3.2 would therefore break `swift test` for every caller.
36
+ //
37
+ // So the pin stays on the 6.2 line, and the rule it was standing in for is now
38
+ // enforced directly instead of by version matching: ExpectationIntegrityTests asserts
39
+ // that `#expect` both admits true Bool comparisons and *records issues* for false ones.
40
+ // That turns the silent failure mode into a red test, which a version pin never could.
27
41
  .package(url: "https://github.com/apple/swift-testing.git", revision: "swift-6.2-RELEASE"),
28
42
  ],
29
43
  targets: [
@@ -46,7 +60,12 @@ let package = Package(
46
60
  .executableTarget(
47
61
  name: "RecordingsUpdateBroker",
48
62
  dependencies: ["RecordingsUpdateProtocol", "RecordingsVerifierLauncher"],
49
- path: "Updater/Broker"
63
+ path: "Updater/Broker",
64
+ // PeerIdentity resolves the XPC peer's audit token into a PID and EUID with
65
+ // audit_token_to_pid/_euid, which live in libbsm. Without this the broker
66
+ // compiles and then fails at link, which `swift build --target` does not
67
+ // surface because it stops short of linking the executable product.
68
+ linkerSettings: [.linkedLibrary("bsm")]
50
69
  ),
51
70
  .target(
52
71
  name: "RecordingsVerifierLauncher",
@@ -0,0 +1,91 @@
1
+ import Foundation
2
+
3
+ /// Deterministic local pre-screen for the CLI's enhancement decision.
4
+ ///
5
+ /// The helper CLI owns the real decision (`needsEnhancement` in `src/lib/enhancer.ts`):
6
+ /// in `auto` post-processing mode it rewrites transcripts that carry an explicit
7
+ /// configured trigger phrase or an instruction-shaped opening. Delivery may only run
8
+ /// ahead of persistence when that rewrite provably cannot happen — the user asked for
9
+ /// the rewritten text, so an eligible transcript must keep pasting the helper's output.
10
+ ///
11
+ /// This screen therefore mirrors the CLI's detection *conservatively*: it answers
12
+ /// "may the helper enhance this?" and fails closed (true) whenever it cannot decide.
13
+ /// The trigger list is config-owned and arrives as the same frozen JSON array the
14
+ /// engine already forwards to the helper via `--enhance-triggers-json`, so both sides
15
+ /// read one source of truth for the configurable half. The instruction patterns mirror
16
+ /// the CLI's built-in regexes; `EnhancementScreenTests` and the `enhancer.test.ts`
17
+ /// "cross-language fixture" suite pin both implementations to one shared fixture table
18
+ /// so they cannot drift apart silently.
19
+ public enum EnhancementScreen {
20
+ /// Built-in instruction shapes, mirrored from `instructionPatterns` in
21
+ /// `src/lib/enhancer.ts`. Order and wording must match the CLI; the shared fixture
22
+ /// tests fail when either side changes alone.
23
+ static let instructionPatternSources: [String] = [
24
+ #"(?:write|draft|compose|create)\s+(?:an?\s+)?(?:email|message|response|reply|letter|note|text|slack|dm)"#,
25
+ #"(?:give|provide|send)\s+(?:them|him|her|it|the\s+agent|the\s+team)\s+(?:full\s+)?instructions"#,
26
+ #"(?:tell|ask)\s+(?:them|him|her|it|the\s+agent)\s+(?:to|that)"#,
27
+ #"(?:make\s+it|make\s+this)\s+(?:sound|look|read)\s+(?:more\s+)?(?:professional|formal|casual|friendly|better)"#,
28
+ #"(?:ok\s+so|okay\s+so|alright\s+so)\s+(?:say|write|tell|put)"#,
29
+ #"(?:i\s+need|i\s+want)\s+(?:the\s+agent|it|them|you)\s+to\s+(?:build|create|implement|design|make)"#,
30
+ ]
31
+
32
+ private static let instructionPatterns: [NSRegularExpression]? = {
33
+ var patterns: [NSRegularExpression] = []
34
+ for source in instructionPatternSources {
35
+ guard let pattern = try? NSRegularExpression(
36
+ pattern: source,
37
+ options: [.caseInsensitive]
38
+ ) else { return nil }
39
+ patterns.append(pattern)
40
+ }
41
+ return patterns
42
+ }()
43
+
44
+ /// True when the helper CLI could rewrite `text` in `auto` mode — or when this
45
+ /// screen cannot prove it will not (malformed trigger JSON, an unbuildable
46
+ /// pattern): enhancement-eligible speech must never be pasted raw ahead of the
47
+ /// helper, so every unprovable case persists first.
48
+ public static func mayRequireEnhancement(text: String, enhanceTriggersJSON: String) -> Bool {
49
+ guard let triggers = decodeTriggers(enhanceTriggersJSON) else { return true }
50
+ let lowered = text.lowercased()
51
+ for trigger in triggers {
52
+ let loweredTrigger = trigger.lowercased()
53
+ // An EMPTY configured trigger matches EVERYTHING in the CLI:
54
+ // `needsEnhancement` tests `lower.includes(trigger.toLowerCase())` and
55
+ // JavaScript's `includes("")` is true for every string, so one empty row
56
+ // makes the helper rewrite every transcript (MEASURED on the CLI:
57
+ // needs=true, reason `Explicit trigger: ""`). This screen must answer
58
+ // may-enhance for the same input or it admits that speech to
59
+ // paste-before-persistence and pastes raw text where the user asked for
60
+ // the rewrite.
61
+ //
62
+ // Special-cased rather than left to `contains`, because Swift's answer for
63
+ // an empty needle depends on which overload resolves — Foundation's
64
+ // `range(of:)`-based one answers false, the stdlib `Collection` one answers
65
+ // true — and NO machine has ever executed this file, so neither answer may
66
+ // be assumed. That overload claim is an UNVERIFIED INFERENCE from the
67
+ // language, not a measurement; `return true` is the fail-closed answer
68
+ // under either.
69
+ guard !loweredTrigger.isEmpty else { return true }
70
+ if lowered.contains(loweredTrigger) { return true }
71
+ }
72
+
73
+ guard let instructionPatterns else { return true }
74
+ let range = NSRange(text.startIndex..., in: text)
75
+ return instructionPatterns.contains { pattern in
76
+ pattern.firstMatch(in: text, range: range) != nil
77
+ }
78
+ }
79
+
80
+ /// Decodes the frozen trigger array the engine forwards to the helper. An empty
81
+ /// string means "no configured triggers" (the built-in patterns still apply);
82
+ /// anything else must parse as a JSON string array or the screen fails closed.
83
+ private static func decodeTriggers(_ json: String) -> [String]? {
84
+ let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines)
85
+ guard !trimmed.isEmpty else { return [] }
86
+ guard let data = trimmed.data(using: .utf8),
87
+ let decoded = try? JSONDecoder().decode([String].self, from: data)
88
+ else { return nil }
89
+ return decoded
90
+ }
91
+ }
@@ -12,15 +12,31 @@ final class FnKeyMonitor: @unchecked Sendable {
12
12
  private var healthCheckTimer: Timer?
13
13
  private var fnIsDown = false
14
14
 
15
- var isRunning: Bool { eventTap != nil }
15
+ /// Whether the tap exists *and* is still enabled.
16
+ ///
17
+ /// `eventTap != nil` was not enough: macOS disables a tap when Accessibility is revoked
18
+ /// at runtime, and only `stop()` ever cleared the handle — so a dead tap reported itself
19
+ /// as running, the engine saw no blocker, and the `!isRunning` retry guard prevented
20
+ /// recovery. Ask the tap, not the handle.
21
+ var isRunning: Bool {
22
+ guard let eventTap else { return false }
23
+ return CGEvent.tapIsEnabled(tap: eventTap)
24
+ }
16
25
 
17
26
  private static let fnKeyCode: UInt16 = 63
18
27
 
19
28
  /// Start monitoring. Returns true if successful.
20
29
  func start() -> Bool {
21
- guard eventTap == nil else {
22
- fputs("[FnKeyMonitor] Already running\n", stderr)
23
- return true
30
+ if eventTap != nil {
31
+ guard !isRunning else {
32
+ fputs("[FnKeyMonitor] Already running\n", stderr)
33
+ return true
34
+ }
35
+ // A handle we still hold but that the system has disabled cannot be revived by
36
+ // re-enabling it here — the grant that backed it is gone. Tear it down so the
37
+ // code below can build a fresh one once trust returns.
38
+ fputs("[FnKeyMonitor] Existing tap is disabled — discarding it before retry\n", stderr)
39
+ stop()
24
40
  }
25
41
 
26
42
  let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
@@ -63,6 +79,13 @@ final class FnKeyMonitor: @unchecked Sendable {
63
79
  if !CGEvent.tapIsEnabled(tap: tap) {
64
80
  fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
65
81
  CGEvent.tapEnable(tap: tap, enable: true)
82
+ // If it will not come back, the permission behind it is gone. Drop the
83
+ // tap so `isRunning` turns false and the engine can report a blocker and
84
+ // rebuild, instead of polling a corpse every three seconds forever.
85
+ if !CGEvent.tapIsEnabled(tap: tap) {
86
+ fputs("[FnKeyMonitor] Re-enable failed — tearing down\n", stderr)
87
+ self.stop()
88
+ }
66
89
  }
67
90
  }
68
91
 
@@ -17,11 +17,11 @@
17
17
  <key>CFBundleIdentifier</key>
18
18
  <string>com.hasna.recordings</string>
19
19
  <key>CFBundleVersion</key>
20
- <string>0.2.13</string>
20
+ <string>0.3.1</string>
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>0.2.13</string>
24
+ <string>0.3.1</string>
25
25
  <key>LSMinimumSystemVersion</key>
26
26
  <string>26.0</string>
27
27
  <key>NSMicrophoneUsageDescription</key>
@@ -7,6 +7,12 @@ import Foundation
7
7
  /// present a busy state and a disabled Start affordance, never an idle state it would
8
8
  /// then refuse.
9
9
  public struct MenuBarPresentation: Equatable, Sendable {
10
+ /// Symbol for idle-but-blocked. `exclamationmark.triangle.fill` on purpose: it has shipped
11
+ /// since macOS 11, so it cannot resolve to nothing. An invalid `Image(systemName:)` renders
12
+ /// an EMPTY image, which would make the blocked state *less* visible than the bug it fixes.
13
+ public static let blockedIconName = "exclamationmark.triangle.fill"
14
+ public static let idleIconName = "mic.fill"
15
+
10
16
  public let iconName: String
11
17
  public let accessibilityLabel: String
12
18
  public let statusText: String
@@ -14,24 +20,71 @@ public struct MenuBarPresentation: Equatable, Sendable {
14
20
  /// (always enabled) Stop affordance; otherwise it is Start and must match
15
21
  /// `RecordingEngine.canStartRecording` exactly.
16
22
  public let primaryActionEnabled: Bool
23
+ /// Whether this is the idle-but-blocked state. Published so a view can tint or badge without
24
+ /// string-matching `statusText` to guess.
25
+ public let isBlocked: Bool
17
26
 
18
- public init(isRecording: Bool, canStartRecording: Bool, statusMessage: String) {
19
- if isRecording {
27
+ /// - Parameter blockedReason: `RecordingEngine.blockedReason` why the app cannot record or
28
+ /// deliver, when the reason outlives one status write.
29
+ ///
30
+ /// `blockedReason` is a parameter rather than something inferred from `statusMessage` because
31
+ /// the always-visible menu-bar item renders **only** `iconName` and `accessibilityLabel`
32
+ /// (`MenuBarStatusView.swift`: `Image(systemName:).accessibilityLabel(...)`). Until this
33
+ /// existed, the idle branch set `mic.fill` / "Recordings" and discarded `statusMessage`
34
+ /// entirely — so a blocked app was **byte-identical to Ready** in that surface, for sighted
35
+ /// and VoiceOver users alike, and the only channel carrying "press Cmd-V" was a popover the
36
+ /// user had to click open. A reason with no view consumer is not a disclosure.
37
+ /// - Parameter isWarmingUpCapture: `RecordingEngine.isWarmingUpCapture` — the microphone is
38
+ /// open but has not delivered a sample yet. No default: `false` is the *invisible* value,
39
+ /// and a surface that forgot it would compile and drop the glyph to the busy ellipsis for
40
+ /// ~100 ms in the middle of a hold.
41
+ ///
42
+ /// `blockedReason` has no default either, for exactly the same reason and after exactly the
43
+ /// same regression: `nil` is its invisible value, and while it was defaulted
44
+ /// `App/RuntimeSmoke.swift` omitted it and compiled — a surface silently rendering every
45
+ /// blocked state as plain idle. An argument whose absent value *is* the failure mode does
46
+ /// not get to be optional.
47
+ public init(
48
+ isRecording: Bool,
49
+ isWarmingUpCapture: Bool,
50
+ canStartRecording: Bool,
51
+ statusMessage: String,
52
+ blockedReason: String?
53
+ ) {
54
+ // Warm-up presents as recording. The user is holding the key and Stop is live; anything
55
+ // else would read as a dead app for the ~100 ms between `recorder.start()` returning and
56
+ // the first PCM chunk.
57
+ if isRecording || isWarmingUpCapture {
20
58
  iconName = "waveform"
21
59
  accessibilityLabel = "Recordings, recording"
22
60
  statusText = "Recording"
23
61
  primaryActionEnabled = true
62
+ isBlocked = false
24
63
  } else if !canStartRecording {
25
64
  let normalizedBusyStatus = statusMessage.trimmingCharacters(in: .punctuationCharacters)
26
65
  iconName = "ellipsis.circle"
27
66
  accessibilityLabel = "Recordings, \(normalizedBusyStatus.lowercased())"
28
67
  statusText = normalizedBusyStatus
29
68
  primaryActionEnabled = false
69
+ isBlocked = false
70
+ } else if let blockedReason, !blockedReason.isEmpty {
71
+ // Idle and startable, but something the owner has to act on is wrong. Distinct icon
72
+ // AND distinct label: the icon is the only signal a sighted user gets from the menu
73
+ // bar, and the label is the only signal VoiceOver gets. Fixing one and not the other
74
+ // leaves half the users exactly where they were.
75
+ iconName = Self.blockedIconName
76
+ accessibilityLabel = "Recordings, blocked: \(blockedReason)"
77
+ statusText = blockedReason
78
+ // Still true, and still this type's other contract: a blocked trigger or a blocked
79
+ // paste does not make Start unavailable, and `canStartRecording` is what governs.
80
+ primaryActionEnabled = true
81
+ isBlocked = true
30
82
  } else {
31
- iconName = "mic.fill"
83
+ iconName = Self.idleIconName
32
84
  accessibilityLabel = "Recordings"
33
85
  statusText = statusMessage
34
86
  primaryActionEnabled = true
87
+ isBlocked = false
35
88
  }
36
89
  }
37
90
  }
@@ -127,6 +127,19 @@ final class NativePCMRecorder: @unchecked Sendable {
127
127
  }
128
128
 
129
129
  do {
130
+ // These two calls are the ~520 ms a cold start costs, and they are why a short tap
131
+ // cannot capture anything: `RecordingEngine` only reports a live capture once the
132
+ // tap above delivers its first buffer, ~100 ms after `start()` returns.
133
+ //
134
+ // Pre-warming (holding a started engine between recordings, or hoisting
135
+ // `prepare()` to app launch) is the only way to make a sub-500 ms press record.
136
+ // It is deliberately NOT done here: a running engine with an installed input tap
137
+ // holds the input device open, which lights the macOS microphone-in-use indicator
138
+ // and lists the app under Control Center's microphone recents for as long as it
139
+ // runs. A permanently lit indicator is a worse regression than the bug. Whether
140
+ // `prepare()` alone — resource allocation without starting the IO thread — both
141
+ // absorbs a useful share of the 520 ms and leaves the indicator dark has to be
142
+ // measured on real hardware before anyone acts on it.
130
143
  engine.prepare()
131
144
  try engine.start()
132
145
  finishStart()