@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.
- package/README.md +59 -21
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +4 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +1141 -84
- package/native/panel/src/app_gui_tests.rs +730 -0
- package/native/panel/src/app_hover_gui_tests.rs +917 -0
- package/native/panel/src/card_view.rs +19 -4
- package/native/panel/src/directory_view.rs +471 -0
- package/native/panel/src/hints.rs +65 -0
- package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
- package/native/panel/src/hover_keyboard.rs +237 -0
- package/native/panel/src/interaction.rs +156 -0
- package/native/panel/src/keyboard-observation-fixtures.json +241 -0
- package/native/panel/src/keyboard_observation.rs +70 -0
- package/native/panel/src/main.rs +5 -0
- package/native/panel/src/protocol.rs +93 -8
- package/native/panel/src/runtime.rs +85 -12
- package/native/panel/src/style.css +85 -0
- package/package.json +6 -7
- package/src/broker/server.mjs +31 -14
- package/src/client/launcher.mjs +3 -7
- package/src/client/runtime-lock.mjs +2 -1
- package/src/common/agent-identity.mjs +6 -1
- package/src/common/contracts.d.ts +2 -0
- package/src/common/contracts.ts +39 -0
- package/src/common/keyboard-observation.mjs +35 -0
- package/src/common/overlay-readiness.mjs +11 -0
- package/src/common/presence-apps.mjs +57 -0
- package/src/common/recovery-identity.mjs +85 -0
- package/src/common/recovery-manifest.mjs +417 -0
- package/src/common/recovery-types.ts +75 -0
- package/src/common/ribbon-status.mjs +41 -0
- package/src/common/status-report.mjs +37 -2
- package/src/common/workspace-view.mjs +118 -0
- package/src/native/ak-runtime.mjs +50 -9
- package/src/native/focus-visibility.mjs +67 -0
- package/src/native/ghostty-tab-inventory.py +40 -23
- package/src/native/hover-keyboard.mjs +32 -0
- package/src/native/interaction-context.mjs +45 -0
- package/src/native/keyboard-entry.mjs +32 -0
- package/src/native/main.mjs +91 -42
- package/src/native/panel-binary.mjs +25 -8
- package/src/native/panel-neutral-source.mjs +53 -0
- package/src/native/panel-presentation.mjs +137 -0
- package/src/native/panel-projection.mjs +354 -18
- package/src/native/panel-protocol.mjs +23 -0
- package/src/native/panel-window-jump.mjs +90 -0
- package/src/native/placement.mjs +83 -20
- package/src/native/recovery-inventory.mjs +209 -0
- package/src/native/recovery-proc.mjs +178 -0
- package/src/native/recovery-sources.mjs +358 -0
- package/src/native/runtime-diagnostics.mjs +242 -0
- package/src/native/strip-hotkey-kdl.mjs +252 -0
- package/src/native/strip-hotkey.mjs +62 -0
- package/src/native/tab-inventory.mjs +62 -11
- package/src/native/window-jump.mjs +43 -0
- 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
|
+
}
|