@tryinget/pi-activity-strip 0.8.0 → 0.10.0

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 (58) hide show
  1. package/README.md +59 -21
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +4 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +1141 -84
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +471 -0
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +5 -0
  17. package/native/panel/src/protocol.rs +93 -8
  18. package/native/panel/src/runtime.rs +85 -12
  19. package/native/panel/src/style.css +85 -0
  20. package/package.json +6 -7
  21. package/src/broker/server.mjs +31 -14
  22. package/src/client/launcher.mjs +3 -7
  23. package/src/client/runtime-lock.mjs +2 -1
  24. package/src/common/agent-identity.mjs +6 -1
  25. package/src/common/contracts.d.ts +2 -0
  26. package/src/common/contracts.ts +39 -0
  27. package/src/common/keyboard-observation.mjs +35 -0
  28. package/src/common/overlay-readiness.mjs +11 -0
  29. package/src/common/presence-apps.mjs +57 -0
  30. package/src/common/recovery-identity.mjs +85 -0
  31. package/src/common/recovery-manifest.mjs +417 -0
  32. package/src/common/recovery-types.ts +75 -0
  33. package/src/common/ribbon-status.mjs +41 -0
  34. package/src/common/status-report.mjs +37 -2
  35. package/src/common/workspace-view.mjs +118 -0
  36. package/src/native/ak-runtime.mjs +50 -9
  37. package/src/native/focus-visibility.mjs +67 -0
  38. package/src/native/ghostty-tab-inventory.py +40 -23
  39. package/src/native/hover-keyboard.mjs +32 -0
  40. package/src/native/interaction-context.mjs +45 -0
  41. package/src/native/keyboard-entry.mjs +32 -0
  42. package/src/native/main.mjs +91 -42
  43. package/src/native/panel-binary.mjs +25 -8
  44. package/src/native/panel-neutral-source.mjs +53 -0
  45. package/src/native/panel-presentation.mjs +137 -0
  46. package/src/native/panel-projection.mjs +354 -18
  47. package/src/native/panel-protocol.mjs +23 -0
  48. package/src/native/panel-window-jump.mjs +90 -0
  49. package/src/native/placement.mjs +83 -20
  50. package/src/native/recovery-inventory.mjs +209 -0
  51. package/src/native/recovery-proc.mjs +178 -0
  52. package/src/native/recovery-sources.mjs +358 -0
  53. package/src/native/runtime-diagnostics.mjs +242 -0
  54. package/src/native/strip-hotkey-kdl.mjs +252 -0
  55. package/src/native/strip-hotkey.mjs +62 -0
  56. package/src/native/tab-inventory.mjs +62 -11
  57. package/src/native/window-jump.mjs +43 -0
  58. package/src/native/workspace-events.mjs +123 -2
@@ -0,0 +1,237 @@
1
+ use std::sync::OnceLock;
2
+ use std::time::Instant;
3
+
4
+ /// Idle ownership uses monotonic time, never the wall clock used for telemetry labels.
5
+ pub fn monotonic_ms() -> i64 {
6
+ static START: OnceLock<Instant> = OnceLock::new();
7
+ START
8
+ .get_or_init(Instant::now)
9
+ .elapsed()
10
+ .as_millis()
11
+ .try_into()
12
+ .unwrap_or(i64::MAX)
13
+ }
14
+
15
+ /// Pointer-entry attempts are not data revisions or callback tickets. Tokens never repeat during
16
+ /// a panel lifetime; leaving, hiding or explicit entry invalidates the pending attempt, not the counter.
17
+ #[derive(Default, Debug)]
18
+ pub struct HoverKeyboard {
19
+ next_token: u64,
20
+ pub inside: bool,
21
+ pub pending: Option<(u64, u64)>, // token, controller identity epoch
22
+ pub active: bool,
23
+ pub captured: bool,
24
+ touched_at: i64,
25
+ }
26
+
27
+ impl HoverKeyboard {
28
+ pub fn request(&mut self, epoch: u64, now: i64) -> Option<u64> {
29
+ if self.pending.is_some() || self.active {
30
+ return None;
31
+ }
32
+ self.next_token += 1;
33
+ self.pending = Some((self.next_token, epoch));
34
+ self.touched_at = now;
35
+ Some(self.next_token)
36
+ }
37
+
38
+ pub fn grant(
39
+ &mut self,
40
+ token: u64,
41
+ epoch: u64,
42
+ current_epoch: u64,
43
+ pointer_valid: bool,
44
+ now: i64,
45
+ ) -> bool {
46
+ if self.pending != Some((token, epoch))
47
+ || epoch != current_epoch
48
+ || !pointer_valid
49
+ || now.saturating_sub(self.touched_at) >= 3_000
50
+ {
51
+ return false;
52
+ }
53
+ self.pending = None;
54
+ self.active = true;
55
+ self.captured = false;
56
+ self.touched_at = now;
57
+ true
58
+ }
59
+
60
+ /// GTK focus confirms application of an ALREADY authorized grab; it never grants permission.
61
+ pub fn acquired(&mut self, now: i64) -> bool {
62
+ if !self.active || self.captured || self.expired(now) {
63
+ return false;
64
+ }
65
+ self.captured = true;
66
+ self.touched_at = now;
67
+ true
68
+ }
69
+
70
+ pub fn touch(&mut self, now: i64) {
71
+ if self.captured {
72
+ self.touched_at = now;
73
+ }
74
+ }
75
+
76
+ /// Observation in this process's monotonic domain, never a controller deadline.
77
+ pub fn captured_deadline_ms(&self) -> Option<i64> {
78
+ (self.active && self.captured).then(|| self.touched_at.saturating_add(3_000))
79
+ }
80
+
81
+ pub fn expired(&self, now: i64) -> bool {
82
+ // A parked nested compositor can take several seconds to commit a layer-shell change.
83
+ // Acquisition is separately bounded; the typing idle clock starts on actual focus.
84
+ let limit = if self.active && !self.captured {
85
+ 10_000
86
+ } else {
87
+ 3_000
88
+ };
89
+ (self.active || self.pending.is_some()) && now.saturating_sub(self.touched_at) >= limit
90
+ }
91
+
92
+ /// Retain `inside` on timeout/Escape: ordinary telemetry or child crossings cannot recapture.
93
+ /// Only a fresh physical region entry or click may request again.
94
+ pub fn clear(&mut self) -> Option<u64> {
95
+ self.active = false;
96
+ self.captured = false;
97
+ self.pending.take().map(|(token, _)| token)
98
+ }
99
+ }
100
+
101
+ #[cfg(test)]
102
+ mod tests {
103
+ use super::*;
104
+
105
+ #[test]
106
+ fn shared_hover_lifecycle_fixtures() {
107
+ let cases: serde_json::Value =
108
+ serde_json::from_str(include_str!("hover-lifecycle-fixtures.json")).unwrap();
109
+ for case in cases.as_array().unwrap() {
110
+ let mut h = HoverKeyboard::default();
111
+ let mut current = 0;
112
+ let mut old = 0;
113
+ for (index, step) in case["steps"].as_array().unwrap().iter().enumerate() {
114
+ let now = i64::try_from(index).unwrap() * 10;
115
+ match step[0].as_str().unwrap() {
116
+ "grant" => {
117
+ old = current;
118
+ current = h.request(7, now).unwrap();
119
+ assert!(h.grant(current, 7, 7, true, now));
120
+ }
121
+ "capture" => {
122
+ h.acquired(now);
123
+ }
124
+ "release" | "source-revoked" => {
125
+ h.clear();
126
+ }
127
+ "stale-permission" => {
128
+ assert!(!h.grant(old, 7, 7, true, now));
129
+ }
130
+ other => panic!("unknown fixture operation {other}"),
131
+ }
132
+ assert_eq!(
133
+ h.active,
134
+ step[1].as_bool().unwrap(),
135
+ "{} step {index}",
136
+ case["name"]
137
+ );
138
+ assert_eq!(h.captured_deadline_ms().is_some(), h.captured);
139
+ }
140
+ }
141
+ }
142
+
143
+ #[test]
144
+ fn attempts_in_one_epoch_are_unique_and_old_grants_cannot_win() {
145
+ let mut h = HoverKeyboard::default();
146
+ let first = h.request(7, 0).unwrap();
147
+ assert_eq!(h.clear(), Some(first));
148
+ let second = h.request(7, 10).unwrap();
149
+ assert_ne!(first, second);
150
+ assert!(!h.grant(first, 7, 7, true, 11));
151
+ assert_eq!(h.pending, Some((second, 7)));
152
+ assert!(!h.grant(second, 7, 8, true, 11));
153
+ assert!(!h.grant(second, 7, 7, false, 11));
154
+ assert!(h.grant(second, 7, 7, true, 11));
155
+ }
156
+
157
+ #[test]
158
+ fn hide_show_and_explicit_upgrade_revoke_pending_grants() {
159
+ let mut h = HoverKeyboard::default();
160
+ let token = h.request(1, 0).unwrap();
161
+ h.clear();
162
+ assert!(!h.grant(token, 1, 1, true, 1));
163
+ let next = h.request(3, 2).unwrap();
164
+ assert!(!h.grant(token, 1, 3, true, 3));
165
+ assert!(h.grant(next, 3, 3, true, 3));
166
+ }
167
+
168
+ #[test]
169
+ fn grant_expires_even_before_the_next_tick_is_dispatched() {
170
+ let mut h = HoverKeyboard::default();
171
+ let token = h.request(7, 0).unwrap();
172
+ assert!(!h.grant(token, 7, 7, true, 3_000));
173
+ h.clear();
174
+ let next = h.request(7, 3_001).unwrap();
175
+ assert!(!h.grant(token, 7, 7, true, 3_002));
176
+ assert!(h.grant(next, 7, 7, true, 3_002));
177
+ }
178
+
179
+ #[test]
180
+ fn pending_acquisition_and_actual_capture_have_separate_bounded_lifetimes() {
181
+ let mut h = HoverKeyboard::default();
182
+ assert!(!h.acquired(0), "GTK focus alone is not authorization");
183
+ let token = h.request(1, 0).unwrap();
184
+ assert!(h.expired(3_000), "controller handshake cannot wait forever");
185
+ assert!(h.grant(token, 1, 1, true, 10));
186
+ assert!(!h.expired(3_010), "not yet holding any keyboard input");
187
+ h.touch(9_999);
188
+ assert!(
189
+ h.expired(10_010),
190
+ "pre-focus key/GTK noise cannot prolong acquisition"
191
+ );
192
+ assert!(h.acquired(6_000));
193
+ assert!(
194
+ !h.acquired(7_000),
195
+ "ordinary GTK activity cannot restart idle"
196
+ );
197
+ assert!(!h.expired(8_999));
198
+ assert!(h.expired(9_000));
199
+ h.clear();
200
+ assert!(
201
+ !h.acquired(9_001),
202
+ "late GTK focus cannot revive a revoked claim"
203
+ );
204
+ }
205
+
206
+ #[test]
207
+ fn applied_focus_at_acquisition_expiry_cannot_restart_an_expired_claim() {
208
+ let mut h = HoverKeyboard::default();
209
+ let token = h.request(1, 0).unwrap();
210
+ assert!(h.grant(token, 1, 1, true, 1));
211
+ assert!(!h.acquired(10_001));
212
+ h.clear();
213
+ let next = h.request(1, 10_002).unwrap();
214
+ assert!(!h.grant(token, 1, 1, true, 10_003));
215
+ assert!(h.grant(next, 1, 1, true, 10_003));
216
+ assert!(h.acquired(10_004));
217
+ }
218
+
219
+ #[test]
220
+ fn idle_is_bounded_even_with_query_and_does_not_invent_new_pointer_entry() {
221
+ let mut h = HoverKeyboard {
222
+ inside: true,
223
+ ..Default::default()
224
+ };
225
+ let token = h.request(1, 0).unwrap();
226
+ assert!(h.grant(token, 1, 1, true, 10));
227
+ assert!(h.acquired(10));
228
+ assert!(!h.expired(3_009));
229
+ h.touch(2_500);
230
+ assert!(!h.expired(5_499));
231
+ assert!(h.expired(5_500));
232
+ h.clear();
233
+ assert!(h.inside);
234
+ assert!(!h.active);
235
+ assert!(h.pending.is_none());
236
+ }
237
+ }
@@ -0,0 +1,156 @@
1
+ use std::cell::Cell;
2
+ use std::rc::Rc;
3
+
4
+ /// Shared with delayed GTK callbacks. Revocation survives a hide/show cycle, unlike `is_mapped`.
5
+ #[derive(Clone, Default)]
6
+ pub struct InteractionLifetime(Rc<Cell<u64>>);
7
+
8
+ impl InteractionLifetime {
9
+ pub fn invalidate(&self) {
10
+ self.0.set(self.0.get().wrapping_add(1));
11
+ }
12
+
13
+ pub fn ticket(&self) -> u64 {
14
+ self.0.get()
15
+ }
16
+
17
+ pub fn accepts(&self, ticket: u64, visible: bool, interactive: bool) -> bool {
18
+ visible && interactive && ticket == self.ticket()
19
+ }
20
+ }
21
+
22
+ /// A real mapped pointer is necessary for hover intent, but controller permission is ALSO required.
23
+ pub fn accepts_pointer(visible: bool, interactive: bool, mapped: bool, contains: bool) -> bool {
24
+ visible && interactive && mapped && contains
25
+ }
26
+
27
+ /// Where a pointer that was inside the body sat when the panel hid. A Wayland pointer enter carries
28
+ /// no cause, so the compositor re-entering that unmoved pointer (remap, workspace return) looks like
29
+ /// a genuine return, except that it reproduces this exact surface position.
30
+ #[derive(Clone, Copy, Debug, PartialEq)]
31
+ pub enum PointerAnchor {
32
+ At(f64, f64),
33
+ /// GDK had no position at hide: fail safe, the next entry is not taken as intent.
34
+ Unknown,
35
+ }
36
+
37
+ /// Whether an entry is the anchored pointer coming back rather than a new arrival. An entry whose
38
+ /// position GDK cannot report is not evidence of movement either.
39
+ pub fn is_stationary_reentry(
40
+ anchor: Option<PointerAnchor>,
41
+ entered_at: Option<(f64, f64)>,
42
+ ) -> bool {
43
+ match anchor {
44
+ Some(PointerAnchor::At(x, y)) => entered_at.is_none_or(|at| at == (x, y)),
45
+ Some(PointerAnchor::Unknown) => true,
46
+ None => false,
47
+ }
48
+ }
49
+
50
+ /// A pointer anchor plus the mapped absences it has outlived. Only the first mapped/body-absent
51
+ /// Tick after a remap can fall before the compositor re-enters a stationary pointer; a second one,
52
+ /// a full Tick later, means the pointer is elsewhere, and the anchor stops applying.
53
+ #[derive(Clone, Copy, Debug, PartialEq)]
54
+ pub struct HideAnchor {
55
+ pub at: PointerAnchor,
56
+ mapped_absences: u8,
57
+ }
58
+
59
+ impl HideAnchor {
60
+ pub fn new(at: PointerAnchor) -> Self {
61
+ Self {
62
+ at,
63
+ mapped_absences: 0,
64
+ }
65
+ }
66
+
67
+ pub fn after_mapped_absence(self) -> Option<Self> {
68
+ (self.mapped_absences == 0).then_some(Self {
69
+ mapped_absences: 1,
70
+ ..self
71
+ })
72
+ }
73
+ }
74
+
75
+ pub fn surface_state(visible: bool, expanded: bool) -> (i32, i32) {
76
+ (
77
+ if visible && expanded { 276 } else { 84 },
78
+ if visible { 84 } else { -1 },
79
+ )
80
+ }
81
+
82
+ #[cfg(test)]
83
+ mod tests {
84
+ use super::*;
85
+
86
+ #[test]
87
+ fn hide_and_return_revoke_old_scroll_and_timer_tickets() {
88
+ let lifetime = InteractionLifetime::default();
89
+ let callback = lifetime.clone();
90
+ let ticket = lifetime.ticket();
91
+ assert!(callback.accepts(ticket, true, true));
92
+ lifetime.invalidate();
93
+ assert!(!callback.accepts(ticket, false, true));
94
+ assert!(!callback.accepts(ticket, true, true));
95
+ assert!(!callback.accepts(lifetime.ticket(), true, false));
96
+ }
97
+
98
+ #[test]
99
+ fn invisible_surface_cannot_expand_or_reclaim_zone() {
100
+ assert_eq!(surface_state(false, true), (84, -1));
101
+ assert_eq!(surface_state(false, false), (84, -1));
102
+ assert_eq!(surface_state(true, false), (84, 84));
103
+ assert_eq!(surface_state(true, true), (276, 84));
104
+ }
105
+
106
+ #[test]
107
+ fn pending_hover_requires_actual_pointer_on_an_interactive_mapped_surface() {
108
+ for facts in [
109
+ (false, true, true, true),
110
+ (true, false, true, true),
111
+ (true, true, false, true),
112
+ (true, true, true, false),
113
+ ] {
114
+ assert!(!accepts_pointer(facts.0, facts.1, facts.2, facts.3));
115
+ }
116
+ assert!(accepts_pointer(true, true, true, true));
117
+ }
118
+
119
+ #[test]
120
+ fn given_a_pointer_anchored_at_hide_when_an_entry_arrives_then_only_movement_is_intent() {
121
+ let anchor = Some(PointerAnchor::At(52.0, 32.0));
122
+ // Then: the exact pre-hide position is the stationary pointer, not a new arrival.
123
+ assert!(is_stationary_reentry(anchor, Some((52.0, 32.0))));
124
+ assert!(
125
+ is_stationary_reentry(anchor, None),
126
+ "unknown entry position is not movement"
127
+ );
128
+ assert!(is_stationary_reentry(
129
+ Some(PointerAnchor::Unknown),
130
+ Some((1.0, 1.0))
131
+ ));
132
+ // Then: any other position, or no anchor at all, is a genuine entry.
133
+ assert!(!is_stationary_reentry(anchor, Some((52.0, 32.5))));
134
+ assert!(!is_stationary_reentry(anchor, Some((53.0, 32.0))));
135
+ assert!(!is_stationary_reentry(None, Some((52.0, 32.0))));
136
+ assert!(!is_stationary_reentry(None, None));
137
+ }
138
+
139
+ #[test]
140
+ fn given_a_hide_anchor_when_mapped_absences_pass_then_only_the_first_is_survived() {
141
+ let anchor = HideAnchor::new(PointerAnchor::At(52.0, 32.0));
142
+ // Then: the remap-phase Tick (F02/F03) keeps the anchor.
143
+ let survived = anchor.after_mapped_absence().expect("first mapped absence");
144
+ assert_eq!(survived.at, PointerAnchor::At(52.0, 32.0));
145
+ // Then: a second mapped absence a full Tick later ends it, Unknown included.
146
+ assert_eq!(survived.after_mapped_absence(), None);
147
+ let unknown = HideAnchor::new(PointerAnchor::Unknown);
148
+ assert_eq!(
149
+ unknown
150
+ .after_mapped_absence()
151
+ .unwrap()
152
+ .after_mapped_absence(),
153
+ None
154
+ );
155
+ }
156
+ }
@@ -0,0 +1,241 @@
1
+ [
2
+ {
3
+ "name": "hover actual acquisition before native deadline",
4
+ "valid": true,
5
+ "event": {
6
+ "protocol": 2,
7
+ "type": "keyboard-observation",
8
+ "request": 1,
9
+ "origin": "hover",
10
+ "reason": "acquired",
11
+ "nativeMs": 1000,
12
+ "deadlineMs": 4000,
13
+ "captured": true
14
+ }
15
+ },
16
+ {
17
+ "name": "typing activity renews observed hover deadline",
18
+ "valid": true,
19
+ "event": {
20
+ "protocol": 2,
21
+ "type": "keyboard-observation",
22
+ "request": 1,
23
+ "origin": "hover",
24
+ "reason": "activity",
25
+ "nativeMs": 1200,
26
+ "deadlineMs": 4200,
27
+ "captured": true
28
+ }
29
+ },
30
+ {
31
+ "name": "source revocation before expiry",
32
+ "valid": true,
33
+ "event": {
34
+ "protocol": 2,
35
+ "type": "keyboard-observation",
36
+ "request": 1,
37
+ "origin": "hover",
38
+ "reason": "source-revoked",
39
+ "nativeMs": 1200,
40
+ "deadlineMs": 4000,
41
+ "captured": true
42
+ }
43
+ },
44
+ {
45
+ "name": "native idle expiry at boundary",
46
+ "valid": true,
47
+ "event": {
48
+ "protocol": 2,
49
+ "type": "keyboard-observation",
50
+ "request": 2,
51
+ "origin": "hover",
52
+ "reason": "idle-expired",
53
+ "nativeMs": 4000,
54
+ "deadlineMs": 4000,
55
+ "captured": true
56
+ }
57
+ },
58
+ {
59
+ "name": "explicit capture has no hover deadline",
60
+ "valid": true,
61
+ "event": {
62
+ "protocol": 2,
63
+ "type": "keyboard-observation",
64
+ "request": 3,
65
+ "origin": "explicit",
66
+ "reason": "acquired",
67
+ "nativeMs": 5000,
68
+ "deadlineMs": null,
69
+ "captured": true
70
+ }
71
+ },
72
+ {
73
+ "name": "explicit source revocation",
74
+ "valid": true,
75
+ "event": {
76
+ "protocol": 2,
77
+ "type": "keyboard-observation",
78
+ "request": 3,
79
+ "origin": "explicit",
80
+ "reason": "source-revoked",
81
+ "nativeMs": 5100,
82
+ "deadlineMs": null,
83
+ "captured": true
84
+ }
85
+ },
86
+ {
87
+ "name": "unknown text is never projected",
88
+ "valid": true,
89
+ "event": {
90
+ "protocol": 2,
91
+ "type": "keyboard-observation",
92
+ "request": 4,
93
+ "origin": "hover",
94
+ "reason": "acquired",
95
+ "nativeMs": 6000,
96
+ "deadlineMs": 9000,
97
+ "captured": true,
98
+ "query": "SECRET_QUERY",
99
+ "credential": "SECRET_CREDENTIAL"
100
+ }
101
+ },
102
+ {
103
+ "name": "grant is not capture",
104
+ "valid": false,
105
+ "event": {
106
+ "protocol": 2,
107
+ "type": "keyboard-observation",
108
+ "request": 4,
109
+ "origin": "hover",
110
+ "reason": "acquired",
111
+ "nativeMs": 6000,
112
+ "deadlineMs": 9000,
113
+ "captured": false
114
+ }
115
+ },
116
+ {
117
+ "name": "early idle cause is rejected",
118
+ "valid": false,
119
+ "event": {
120
+ "protocol": 2,
121
+ "type": "keyboard-observation",
122
+ "request": 4,
123
+ "origin": "hover",
124
+ "reason": "idle-expired",
125
+ "nativeMs": 8999,
126
+ "deadlineMs": 9000,
127
+ "captured": true
128
+ }
129
+ },
130
+ {
131
+ "name": "expired acquisition is rejected",
132
+ "valid": false,
133
+ "event": {
134
+ "protocol": 2,
135
+ "type": "keyboard-observation",
136
+ "request": 4,
137
+ "origin": "hover",
138
+ "reason": "acquired",
139
+ "nativeMs": 9000,
140
+ "deadlineMs": 9000,
141
+ "captured": true
142
+ }
143
+ },
144
+ {
145
+ "name": "explicit never expires by hover idle",
146
+ "valid": false,
147
+ "event": {
148
+ "protocol": 2,
149
+ "type": "keyboard-observation",
150
+ "request": 4,
151
+ "origin": "explicit",
152
+ "reason": "idle-expired",
153
+ "nativeMs": 9000,
154
+ "deadlineMs": null,
155
+ "captured": true
156
+ }
157
+ },
158
+ {
159
+ "name": "no missing native deadline",
160
+ "valid": false,
161
+ "event": {
162
+ "protocol": 2,
163
+ "type": "keyboard-observation",
164
+ "request": 4,
165
+ "origin": "hover",
166
+ "reason": "acquired",
167
+ "nativeMs": 6000,
168
+ "captured": true
169
+ }
170
+ },
171
+ {
172
+ "name": "no untyped origin",
173
+ "valid": false,
174
+ "event": {
175
+ "protocol": 2,
176
+ "type": "keyboard-observation",
177
+ "request": 4,
178
+ "origin": "SECRET",
179
+ "reason": "acquired",
180
+ "nativeMs": 6000,
181
+ "deadlineMs": 9000,
182
+ "captured": true
183
+ }
184
+ },
185
+ {
186
+ "name": "no negative clock",
187
+ "valid": false,
188
+ "event": {
189
+ "protocol": 2,
190
+ "type": "keyboard-observation",
191
+ "request": 4,
192
+ "origin": "hover",
193
+ "reason": "acquired",
194
+ "nativeMs": -1,
195
+ "deadlineMs": 9000,
196
+ "captured": true
197
+ }
198
+ },
199
+ {
200
+ "name": "no string clock coercion",
201
+ "valid": false,
202
+ "event": {
203
+ "protocol": 2,
204
+ "type": "keyboard-observation",
205
+ "request": 4,
206
+ "origin": "hover",
207
+ "reason": "acquired",
208
+ "nativeMs": "6000",
209
+ "deadlineMs": 9000,
210
+ "captured": true
211
+ }
212
+ },
213
+ {
214
+ "name": "no unsafe request integer",
215
+ "valid": false,
216
+ "event": {
217
+ "protocol": 2,
218
+ "type": "keyboard-observation",
219
+ "request": 9007199254740992,
220
+ "origin": "hover",
221
+ "reason": "acquired",
222
+ "nativeMs": 6000,
223
+ "deadlineMs": 9000,
224
+ "captured": true
225
+ }
226
+ },
227
+ {
228
+ "name": "legacy ABI still rejected",
229
+ "valid": false,
230
+ "event": {
231
+ "protocol": 1,
232
+ "type": "keyboard-observation",
233
+ "request": 4,
234
+ "origin": "hover",
235
+ "reason": "acquired",
236
+ "nativeMs": 6000,
237
+ "deadlineMs": 9000,
238
+ "captured": true
239
+ }
240
+ }
241
+ ]
@@ -0,0 +1,70 @@
1
+ //! Optional private-v2 observation, never authorization or a cross-clock countdown.
2
+ use serde_json::{Value, json};
3
+
4
+ const MAX_SAFE: u64 = 9_007_199_254_740_991;
5
+
6
+ /// Validate/allowlist the exact same executable protocol fixtures as the Node observer.
7
+ /// Unknown fields are discarded, including all application/query text.
8
+ pub(crate) fn normalize(value: &Value) -> Option<Value> {
9
+ if value["protocol"] != 2 || value["type"] != "keyboard-observation" {
10
+ return None;
11
+ }
12
+ let request = value["request"]
13
+ .as_u64()
14
+ .filter(|v| *v > 0 && *v <= MAX_SAFE)?;
15
+ let native_ms = value["nativeMs"].as_u64().filter(|v| *v <= MAX_SAFE)?;
16
+ let captured = value["captured"].as_bool()?;
17
+ let origin = value["origin"].as_str()?;
18
+ let reason = value["reason"].as_str()?;
19
+ if !captured
20
+ || !matches!(
21
+ reason,
22
+ "acquired" | "activity" | "idle-expired" | "source-revoked"
23
+ )
24
+ {
25
+ return None;
26
+ }
27
+ let deadline = value.get("deadlineMs")?;
28
+ match origin {
29
+ "hover" => {
30
+ let limit = deadline.as_u64().filter(|v| *v <= MAX_SAFE)?;
31
+ if (reason == "idle-expired" && native_ms < limit)
32
+ || (matches!(reason, "acquired" | "activity") && native_ms >= limit)
33
+ {
34
+ return None;
35
+ }
36
+ }
37
+ "explicit" if deadline.is_null() && reason != "idle-expired" => {}
38
+ _ => return None,
39
+ }
40
+ Some(
41
+ json!({"protocol":2, "type":"keyboard-observation", "request":request,
42
+ "origin":origin, "reason":reason, "nativeMs":native_ms,
43
+ "deadlineMs":deadline, "captured":captured}),
44
+ )
45
+ }
46
+
47
+ #[cfg(test)]
48
+ mod tests {
49
+ use super::*;
50
+
51
+ #[test]
52
+ fn shared_node_rust_protocol_transitions_are_observations_only() {
53
+ let cases: Value =
54
+ serde_json::from_str(include_str!("keyboard-observation-fixtures.json")).unwrap();
55
+ for case in cases.as_array().unwrap() {
56
+ let got = normalize(&case["event"]);
57
+ assert_eq!(
58
+ got.is_some(),
59
+ case["valid"].as_bool().unwrap(),
60
+ "{}",
61
+ case["name"]
62
+ );
63
+ if let Some(got) = got {
64
+ assert!(got.get("query").is_none());
65
+ assert!(got.get("credential").is_none());
66
+ assert_eq!(got.as_object().unwrap().len(), 8);
67
+ }
68
+ }
69
+ }
70
+ }