@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
@@ -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()
@@ -0,0 +1,460 @@
1
+ import AppKit
2
+ @preconcurrency import ApplicationServices
3
+ import CoreGraphics
4
+ import Foundation
5
+
6
+ // MARK: - Why a paste cannot be proven
7
+
8
+ /// `CGEvent.post` returns `Void`. macOS never tells the sender whether a synthetic keystroke
9
+ /// reached a consumer, so "we posted Cmd-V" is not evidence that text landed anywhere. The
10
+ /// only evidence available to this process is reading the target app's focused field back
11
+ /// over the Accessibility API and observing that it gained the pasted text.
12
+ ///
13
+ /// Every value in this file exists to keep three separable facts separable:
14
+ /// 1. the pasteboard write succeeded and we still own the payload,
15
+ /// 2. the key events were constructed and posted,
16
+ /// 3. the text was observed to land.
17
+ /// Only (3) is delivery. (1) and (2) are preconditions and are reported as such.
18
+
19
+ /// Why one Accessibility read of the focused field produced no comparable text.
20
+ enum FocusedTextReadFailure: String, Equatable, Sendable {
21
+ /// The target app exposes no focused element (AX disabled, no key window, app busy).
22
+ case elementUnavailable = "focused_element_unavailable"
23
+ /// The focused element has no readable `kAXValue` string. Normal for Chrome/Electron web
24
+ /// inputs that publish no value, terminals, and canvas-drawn editors.
25
+ case valueUnreadable = "focused_value_unreadable"
26
+ /// The field holds more text than we are willing to copy and compare on the paste path.
27
+ case valueTooLarge = "focused_value_too_large"
28
+ /// Focus moved to a different element between the baseline and the read-back, so the two
29
+ /// reads describe different fields and cannot be compared.
30
+ case elementChanged = "focused_element_changed"
31
+ }
32
+
33
+ /// Why a paste could not be *confirmed*. Every case means "we do not know that the text
34
+ /// landed" — never "it landed".
35
+ enum PasteDeliveryUnverifiedReason: Equatable, Sendable {
36
+ /// No read-back was wired up (test seams, and the default so a caller that forgets to
37
+ /// verify cannot accidentally inherit a success).
38
+ case readBackNotAttempted
39
+ case emptyPayload
40
+ case baselineUnreadable(FocusedTextReadFailure)
41
+ case readBackUnreadable(FocusedTextReadFailure)
42
+ /// The field changed but not in a way that accounts for our text: the app may have
43
+ /// transformed, truncated, or rejected it, or the change may be unrelated typing.
44
+ case changedWithoutMatch
45
+
46
+ var logToken: String {
47
+ switch self {
48
+ case .readBackNotAttempted: "read_back_not_attempted"
49
+ case .emptyPayload: "empty_payload"
50
+ case .baselineUnreadable(let failure): "baseline_unreadable:\(failure.rawValue)"
51
+ case .readBackUnreadable(let failure): "read_back_unreadable:\(failure.rawValue)"
52
+ case .changedWithoutMatch: "changed_without_match"
53
+ }
54
+ }
55
+ }
56
+
57
+ /// What the post-paste read of the focused field actually showed.
58
+ enum PasteDeliveryEvidence: Equatable, Sendable {
59
+ /// The focused field gained an occurrence of the pasted text. This is the only kind of
60
+ /// evidence that justifies telling the user the paste worked.
61
+ case confirmedByFocusedValue
62
+ /// The field's selection reads back as exactly the pasted text and did not before the
63
+ /// paste — the shape a paste over a selection leaves in some editors.
64
+ case confirmedBySelectedText
65
+ /// The field was readable before and after and did not change: the keystroke did not
66
+ /// reach it. A paste that landed somewhere else is not a paste that landed here.
67
+ case notObservedFocusedValueUnchanged
68
+ case unverified(PasteDeliveryUnverifiedReason)
69
+
70
+ var isConfirmed: Bool {
71
+ switch self {
72
+ case .confirmedByFocusedValue, .confirmedBySelectedText: true
73
+ case .notObservedFocusedValueUnchanged, .unverified: false
74
+ }
75
+ }
76
+
77
+ var logToken: String {
78
+ switch self {
79
+ case .confirmedByFocusedValue: "confirmed_focused_value"
80
+ case .confirmedBySelectedText: "confirmed_selected_text"
81
+ case .notObservedFocusedValueUnchanged: "not_observed_focused_value_unchanged"
82
+ case .unverified(let reason): "unverified:\(reason.logToken)"
83
+ }
84
+ }
85
+ }
86
+
87
+ // MARK: - Secure input
88
+
89
+ /// The process holding secure event input, when one does. `bundleIdentifier` is best effort:
90
+ /// the pid may belong to a process we cannot resolve or one that has already exited.
91
+ struct SecureInputHolder: Equatable, Sendable {
92
+ let pid: pid_t?
93
+ let bundleIdentifier: String?
94
+
95
+ var logToken: String {
96
+ "pid=\(pid.map(String.init) ?? "unknown"),app=\(bundleIdentifier ?? "unknown")"
97
+ }
98
+ }
99
+
100
+ /// Secure event input is a window-server mode a password field turns on. While it is on,
101
+ /// every synthetic key event is dropped for every consumer — a posted Cmd-V cannot paste,
102
+ /// and nothing in the posting API says so.
103
+ enum SecureInputState: Equatable, Sendable {
104
+ case inactive
105
+ case active(SecureInputHolder)
106
+ /// The window session could not be interrogated (no GUI session at all, or the session
107
+ /// dictionary did not carry the keys we know how to read). Reported, never assumed off.
108
+ case unknown
109
+
110
+ var isActive: Bool {
111
+ switch self {
112
+ case .active: true
113
+ case .inactive, .unknown: false
114
+ }
115
+ }
116
+
117
+ var logToken: String {
118
+ switch self {
119
+ case .inactive: "inactive"
120
+ case .active(let holder): "active(\(holder.logToken))"
121
+ case .unknown: "unknown"
122
+ }
123
+ }
124
+ }
125
+
126
+ enum SecureInputProbe {
127
+ /// Window-session dictionary keys. CoreGraphics publishes these as C string macros that
128
+ /// Swift does not import, so the key strings are named here once rather than spelled at
129
+ /// the call site. `secureInputPIDKey` is only present while some process holds secure
130
+ /// input, which is why its absence is read together with a session marker key: absent
131
+ /// key plus a recognisable session means "off", absent key plus no session means
132
+ /// "unknown".
133
+ static let secureInputPIDKey = "kCGSSessionSecureInputPID"
134
+ static let onConsoleKey = "kCGSSessionOnConsoleKey"
135
+ static let userIDKey = "kCGSSessionUserIDKey"
136
+
137
+ @MainActor
138
+ static func current() -> SecureInputState {
139
+ guard let rawSession = CGSessionCopyCurrentDictionary() else {
140
+ return state(sessionAvailable: false, sessionMarkerPresent: false, secureInputPID: nil)
141
+ }
142
+ let session = rawSession as NSDictionary
143
+ return state(
144
+ sessionAvailable: true,
145
+ sessionMarkerPresent: session[onConsoleKey] != nil || session[userIDKey] != nil,
146
+ secureInputPID: (session[secureInputPIDKey] as? NSNumber)?.intValue,
147
+ resolveBundleIdentifier: { NSRunningApplication(processIdentifier: $0)?.bundleIdentifier }
148
+ )
149
+ }
150
+
151
+ /// Pure decision table, so the three outcomes can be tested without a window session.
152
+ static func state(
153
+ sessionAvailable: Bool,
154
+ sessionMarkerPresent: Bool,
155
+ secureInputPID: Int?,
156
+ resolveBundleIdentifier: (pid_t) -> String? = { _ in nil }
157
+ ) -> SecureInputState {
158
+ guard sessionAvailable, sessionMarkerPresent else { return .unknown }
159
+ guard let secureInputPID, secureInputPID > 0 else { return .inactive }
160
+ let pid = pid_t(secureInputPID)
161
+ return .active(SecureInputHolder(pid: pid, bundleIdentifier: resolveBundleIdentifier(pid)))
162
+ }
163
+ }
164
+
165
+ // MARK: - Reading the focused field
166
+
167
+ struct FocusedTextSnapshot: Equatable, Sendable {
168
+ let value: String
169
+ let selectedText: String?
170
+ }
171
+
172
+ enum FocusedTextRead: Equatable, Sendable {
173
+ case read(FocusedTextSnapshot)
174
+ case unreadable(FocusedTextReadFailure)
175
+ }
176
+
177
+ /// Holds the focused element captured before a paste so the read-back compares the same
178
+ /// element rather than whatever happens to be focused afterwards. AX calls are Mach IPC and
179
+ /// thread safe; the stored elements are never mutated.
180
+ ///
181
+ /// CAPABILITY DISCLOSURE — read this before extending anything in here.
182
+ ///
183
+ /// This type is the reason a dictation app can see text the user did not dictate. It reads the
184
+ /// **full value** of the focused field in the target application, plus its current selection,
185
+ /// twice around each paste. That is a meaningful widening of what the app can observe and it is
186
+ /// deliberate: `CGEvent.post` returns `Void`, so nothing else can distinguish a paste that
187
+ /// landed from one the window server discarded, and the alternative is the app claiming a paste
188
+ /// it never proved.
189
+ ///
190
+ /// The constraints that make it acceptable are load-bearing, not incidental:
191
+ ///
192
+ /// - the read-back text is **never logged and never persisted** — only the verdict is;
193
+ /// - values above `maximumComparableCharacterCount` are reported unverifiable instead of copied;
194
+ /// - no new permission is requested: this rides the Accessibility grant the keystroke already
195
+ /// needs, which is precisely why the capability has to be documented rather than inferred.
196
+ ///
197
+ /// A change that logs, stores, transmits or forwards a `FocusedTextSnapshot` value breaks that
198
+ /// contract. `README.md` ("What the app reads to confirm a paste") states this to users; keep
199
+ /// the two in step.
200
+ final class FocusedTextProbe: @unchecked Sendable {
201
+ /// Cap on the field text copied into this process for comparison. A large document is
202
+ /// reported unverifiable rather than copied and scanned on the paste path.
203
+ static let maximumComparableCharacterCount = 20_000
204
+ /// Tighter than `AccessibilitySelectionToken.captureMessagingTimeout`: this read happens
205
+ /// twice around the keystroke, so a slow target app must not delay the paste or the
206
+ /// verdict. A timeout surfaces as `elementUnavailable`/`valueUnreadable`, never success.
207
+ static let messagingTimeout: Float = 0.12
208
+
209
+ private let applicationElement: AXUIElement
210
+ private let focusedElement: AXUIElement?
211
+ let baseline: FocusedTextRead
212
+
213
+ private init(
214
+ applicationElement: AXUIElement,
215
+ focusedElement: AXUIElement?,
216
+ baseline: FocusedTextRead
217
+ ) {
218
+ self.applicationElement = applicationElement
219
+ self.focusedElement = focusedElement
220
+ self.baseline = baseline
221
+ }
222
+
223
+ static func capture(pid: pid_t) -> FocusedTextProbe {
224
+ let application = AXUIElementCreateApplication(pid)
225
+ AXUIElementSetMessagingTimeout(application, messagingTimeout)
226
+ guard let focused = resolveFocusedElement(of: application) else {
227
+ return FocusedTextProbe(
228
+ applicationElement: application,
229
+ focusedElement: nil,
230
+ baseline: .unreadable(.elementUnavailable)
231
+ )
232
+ }
233
+ AXUIElementSetMessagingTimeout(focused, messagingTimeout)
234
+ return FocusedTextProbe(
235
+ applicationElement: application,
236
+ focusedElement: focused,
237
+ baseline: read(focused)
238
+ )
239
+ }
240
+
241
+ /// Re-reads the element the baseline came from, refusing to compare across a focus move.
242
+ func readBack() -> FocusedTextRead {
243
+ guard let focusedElement else { return .unreadable(.elementUnavailable) }
244
+ guard let current = Self.resolveFocusedElement(of: applicationElement) else {
245
+ return .unreadable(.elementUnavailable)
246
+ }
247
+ guard CFEqual(current, focusedElement) else { return .unreadable(.elementChanged) }
248
+ return Self.read(focusedElement)
249
+ }
250
+
251
+ private static func resolveFocusedElement(of application: AXUIElement) -> AXUIElement? {
252
+ var focusedElementRef: CFTypeRef?
253
+ guard AXUIElementCopyAttributeValue(
254
+ application,
255
+ kAXFocusedUIElementAttribute as CFString,
256
+ &focusedElementRef
257
+ ) == .success,
258
+ let focusedElementRef,
259
+ CFGetTypeID(focusedElementRef) == AXUIElementGetTypeID() else { return nil }
260
+ return (focusedElementRef as! AXUIElement)
261
+ }
262
+
263
+ private static func read(_ element: AXUIElement) -> FocusedTextRead {
264
+ guard let value = stringAttribute(kAXValueAttribute as CFString, on: element) else {
265
+ return .unreadable(.valueUnreadable)
266
+ }
267
+ guard value.count <= maximumComparableCharacterCount else {
268
+ return .unreadable(.valueTooLarge)
269
+ }
270
+ let selectedText = stringAttribute(kAXSelectedTextAttribute as CFString, on: element)
271
+ return .read(FocusedTextSnapshot(
272
+ value: value,
273
+ selectedText: selectedText.flatMap {
274
+ $0.count <= maximumComparableCharacterCount ? $0 : nil
275
+ }
276
+ ))
277
+ }
278
+
279
+ private static func stringAttribute(_ attribute: CFString, on element: AXUIElement) -> String? {
280
+ var valueRef: CFTypeRef?
281
+ guard AXUIElementCopyAttributeValue(element, attribute, &valueRef) == .success else {
282
+ return nil
283
+ }
284
+ return valueRef as? String
285
+ }
286
+ }
287
+
288
+ // MARK: - Verdict
289
+
290
+ enum PasteDeliveryVerifier {
291
+ /// Decides what two reads of the focused field prove. Ordered so that the positive
292
+ /// verdicts require an observed *gain* of the pasted text: text that was already in the
293
+ /// field before the paste can never be counted as this paste's delivery.
294
+ ///
295
+ /// Known false negative, in the safe direction and deliberately not chased: pasting text
296
+ /// identical to the selection it replaces (baseline `hello WORLD`, selection `WORLD`,
297
+ /// transcript `WORLD`) leaves both the value and the occurrence count unchanged, so a
298
+ /// successful delivery classifies as `.notObservedFocusedValueUnchanged`. Distinguishing it
299
+ /// would mean treating an unchanged field as a possible success, which is the direction that
300
+ /// manufactures false confirmations. Under-claiming is the correct failure mode here, and
301
+ /// the case name says "not observed" rather than "failed" so the log does not overstate it.
302
+ static func classify(
303
+ pastedText: String,
304
+ baseline: FocusedTextRead,
305
+ readBack: FocusedTextRead
306
+ ) -> PasteDeliveryEvidence {
307
+ guard !pastedText.isEmpty else { return .unverified(.emptyPayload) }
308
+
309
+ switch (baseline, readBack) {
310
+ case (.unreadable(let failure), _):
311
+ return .unverified(.baselineUnreadable(failure))
312
+ case (_, .unreadable(let failure)):
313
+ return .unverified(.readBackUnreadable(failure))
314
+ case (.read(let before), .read(let after)):
315
+ let occurrencesBefore = occurrences(of: pastedText, in: before.value)
316
+ let occurrencesAfter = occurrences(of: pastedText, in: after.value)
317
+ if occurrencesAfter > occurrencesBefore { return .confirmedByFocusedValue }
318
+ if selectionConfirms(before: before, after: after, pastedText: pastedText) {
319
+ return .confirmedBySelectedText
320
+ }
321
+ if normalized(after.value) == normalized(before.value) {
322
+ return .notObservedFocusedValueUnchanged
323
+ }
324
+ return .unverified(.changedWithoutMatch)
325
+ }
326
+ }
327
+
328
+ /// A selection that already read as the pasted text before the paste is indistinguishable
329
+ /// from a paste that never happened, so it does not count.
330
+ private static func selectionConfirms(
331
+ before: FocusedTextSnapshot,
332
+ after: FocusedTextSnapshot,
333
+ pastedText: String
334
+ ) -> Bool {
335
+ guard let selected = after.selectedText, !selected.isEmpty else { return false }
336
+ guard normalized(selected) == normalized(pastedText) else { return false }
337
+ return normalized(before.selectedText ?? "") != normalized(pastedText)
338
+ }
339
+
340
+ /// Line endings are the one transformation apps routinely apply to pasted text; anything
341
+ /// beyond that is reported as `changedWithoutMatch` rather than guessed at.
342
+ static func normalized(_ text: String) -> String {
343
+ text
344
+ .replacingOccurrences(of: "\r\n", with: "\n")
345
+ .replacingOccurrences(of: "\r", with: "\n")
346
+ }
347
+
348
+ static func occurrences(of needle: String, in haystack: String) -> Int {
349
+ let normalizedNeedle = normalized(needle)
350
+ guard !normalizedNeedle.isEmpty else { return 0 }
351
+ let normalizedHaystack = normalized(haystack)
352
+ var count = 0
353
+ var searchStart = normalizedHaystack.startIndex
354
+ while searchStart < normalizedHaystack.endIndex,
355
+ let found = normalizedHaystack.range(
356
+ of: normalizedNeedle,
357
+ range: searchStart..<normalizedHaystack.endIndex
358
+ ) {
359
+ count += 1
360
+ searchStart = found.upperBound
361
+ }
362
+ return count
363
+ }
364
+ }
365
+
366
+ // MARK: - What the keystroke attempt itself did
367
+
368
+ /// What a `PastePoster` is allowed to report.
369
+ ///
370
+ /// Deliberately narrower than `PasteAttempt`: a poster runs only after the target and the
371
+ /// clipboard have already been proved, so "the paste failed before the keystroke step" is not
372
+ /// something it can observe. Making that unrepresentable is not tidiness — `PasteAttempt` was
373
+ /// originally the poster's return type, and the coordinator's `switch` over it did not compile
374
+ /// (`switch must be exhaustive`) precisely because the type offered a case the poster could
375
+ /// never produce and no honest outcome could be mapped to.
376
+ enum PasteKeystrokeAttempt: Equatable, Sendable {
377
+ /// Two `CGEvent`s were constructed and handed to the window server. Claims nothing about
378
+ /// delivery — `CGEvent.post` returns `Void`.
379
+ case posted
380
+ case constructionFailed
381
+ /// Secure event input is held, so nothing was posted. Carries the holder so the log and the
382
+ /// status line can name it.
383
+ case refusedSecureInput(SecureInputHolder)
384
+ }
385
+
386
+ /// Outcome of the posting step alone, as reported in the log. `posted` claims nothing about
387
+ /// delivery — it means two `CGEvent`s were constructed and handed to the window server.
388
+ ///
389
+ /// Wider than `PasteKeystrokeAttempt` by exactly one case, because this type is also derived
390
+ /// from a finished outcome via `forOutcome(_:)`, where "never got as far as a keystroke" is a
391
+ /// real thing to report.
392
+ enum PasteAttempt: Equatable, Sendable {
393
+ case posted
394
+ case constructionFailed
395
+ case refusedSecureInput(SecureInputHolder)
396
+ /// The paste failed before the keystroke step (no target, clipboard write failed, payload
397
+ /// ownership lost). Reported so the log never implies a keystroke that never happened.
398
+ case notAttempted
399
+
400
+ var logToken: String {
401
+ switch self {
402
+ case .posted: "constructed_and_posted"
403
+ case .constructionFailed: "construction_failed"
404
+ case .refusedSecureInput: "not_posted_secure_input"
405
+ case .notAttempted: "not_attempted"
406
+ }
407
+ }
408
+
409
+ /// Widen what the poster reported into the log vocabulary. One direction only, so the two
410
+ /// types cannot drift into two descriptions of one event.
411
+ init(_ keystroke: PasteKeystrokeAttempt) {
412
+ switch keystroke {
413
+ case .posted: self = .posted
414
+ case .constructionFailed: self = .constructionFailed
415
+ case .refusedSecureInput(let holder): self = .refusedSecureInput(holder)
416
+ }
417
+ }
418
+
419
+ /// What the posting step did, read back off the transaction outcome so the log line and
420
+ /// the outcome can never disagree.
421
+ static func forOutcome(_ outcome: PasteDeliveryOutcome) -> PasteAttempt {
422
+ switch outcome {
423
+ case .pasted, .deliveryNotObserved, .deliveredUnverified: .posted
424
+ case .eventPostFailed: .constructionFailed
425
+ case .secureInputActive(let holder): .refusedSecureInput(holder)
426
+ case .targetUnavailable, .clipboardOwnershipLost, .clipboardWriteFailed: .notAttempted
427
+ }
428
+ }
429
+ }
430
+
431
+ // MARK: - One log line per delivery
432
+
433
+ /// The record a human reads after trying a dictation. Each field answers a different
434
+ /// question, so a failure cannot hide behind a neighbouring success:
435
+ /// `clipboard=` did the payload reach the pasteboard and stay ours
436
+ /// `clipboard_change_count=` did the pasteboard actually advance for our write
437
+ /// `events=` were the key events constructed and posted
438
+ /// `secure_input=` could any synthetic event have been delivered at all
439
+ /// `delivery=` was the text observed to land in the focused field
440
+ struct PasteDeliveryReport: Equatable, Sendable {
441
+ let targetBundleIdentifier: String?
442
+ let characterCount: Int
443
+ let clipboardWriteVerified: Bool
444
+ let clipboardChangeCountAdvanced: Bool
445
+ let attempt: PasteAttempt
446
+ let secureInput: SecureInputState?
447
+ let evidence: PasteDeliveryEvidence
448
+ let readBackAttempts: Int
449
+
450
+ var logLine: String {
451
+ "paste_delivery target=\(targetBundleIdentifier ?? "?")"
452
+ + " chars=\(characterCount)"
453
+ + " clipboard=\(clipboardWriteVerified ? "verified" : "unverified")"
454
+ + " clipboard_change_count=\(clipboardChangeCountAdvanced ? "advanced" : "unchanged")"
455
+ + " events=\(attempt.logToken)"
456
+ + " secure_input=\(secureInput?.logToken ?? "not_probed")"
457
+ + " delivery=\(evidence.logToken)"
458
+ + " read_back_attempts=\(readBackAttempts)"
459
+ }
460
+ }
@@ -7,10 +7,19 @@ public struct PermissionRequestLaunchPlan: Sendable, Equatable {
7
7
  public let runtimeSmokeOutputPath: String?
8
8
  public let runtimeSmokeAcknowledgementPath: String?
9
9
  public let runtimeSmokeCompletionPath: String?
10
+ public let isBarOnly: Bool
10
11
 
11
12
  public var isRuntimeSmoke: Bool { runtimeSmokeMode != nil }
12
13
  public var installsGlobalHandlers: Bool { !isHelper && !isRuntimeSmoke }
13
- public var declaresMainWindow: Bool { !isHelper && !isRuntimeSmoke }
14
+ /// The workspace window may be created only when this holds: never for the permission
15
+ /// helper, never for a bar launch, and regardless of runtime-smoke mode — a full
16
+ /// build's smoke keeps exercising the window deterministically, and only bar builds
17
+ /// finish windowless.
18
+ public var declaresWindow: Bool { !isHelper && !isBarOnly }
19
+ /// Controls the init auto-open and the reopen handler ONLY. Runtime smoke launches
20
+ /// are excluded so the smoke controls window creation deterministically, and bar
21
+ /// launches are excluded so a real bar launch never auto-opens the workspace window.
22
+ public var declaresMainWindow: Bool { !isHelper && !isRuntimeSmoke && !isBarOnly }
14
23
  public var declaresMenuBar: Bool {
15
24
  if isRuntimeSmoke { return runtimeSmokeMode == "normal" }
16
25
  return !isHelper
@@ -31,6 +40,17 @@ public struct PermissionRequestLaunchPlan: Sendable, Equatable {
31
40
  "--runtime-smoke-completion",
32
41
  arguments: arguments
33
42
  )
43
+ #if RECORDINGS_BAR_ONLY
44
+ // A bar build is bar-only by construction on EVERY launch path — installer
45
+ // relaunch, manual `open`, LaunchAgent, smoke. Relying on the argument alone left
46
+ // real launches of the installed bar behaviorally identical to the full app
47
+ // (review lineage hasna/apps#269, cycle-1 P1). The explicit --bar-only argument
48
+ // remains a belt-and-suspenders self-describing launch record; it is never the
49
+ // only carrier of the bar property.
50
+ isBarOnly = true
51
+ #else
52
+ isBarOnly = arguments.contains("--bar-only")
53
+ #endif
34
54
  }
35
55
 
36
56
  private static func optionValue(_ name: String, arguments: [String]) -> String? {