@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.
- package/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2094 -280
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +19 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -50
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +141 -57
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +188 -80
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +109 -50
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +192 -31
- package/scripts/macos_artifact.ts +323 -43
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/smoke_macos_app.sh +62 -16
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
- package/src/native/Recordings/App/RecordingsApp.swift +37 -1
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +226 -20
- 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
|
-
|
|
19
|
-
|
|
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 =
|
|
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
|
-
|
|
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? {
|