1use serde::{Deserialize, Serialize};
3
4pub const INPUT_TAG: usize = 0x4143544c;
5pub const REQUEST_MESSAGE: u32 = 0x8000 + 46;
6pub const CHECK_MESSAGE: u32 = 0x8000 + 47;
7pub const RECOVER_MESSAGE: u32 = 0x8000 + 48;
8pub const PENDING: usize = 1;
9pub const ALLOWED: usize = 2;
10pub const DEFERRED: usize = 3;
11pub const YIELDED: usize = 4;
12pub const PANEL_UNAVAILABLE: usize = 5;
13pub const MONITOR_UNAVAILABLE: usize = 6;
14pub const USER_PAUSED: usize = 7;
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct Request {
18 pub call: String,
19 pub task: String,
20 pub target: String,
21 pub ts_ms: u64,
22}
23impl Request {
24 pub fn fresh(&self, now: u64) -> bool {
25 self.ts_ms <= now
26 && now - self.ts_ms < 2000
27 && !self.call.is_empty()
28 && !self.task.is_empty()
29 }
30}
31
32#[derive(Default)]
33pub struct Consent {
34 grant: Option<(String, u64, u64, u64)>,
35}
36pub const GRACE_MS: u64 = 750;
41impl Consent {
42 pub fn allow(&mut self, task: &str, input_seq: u64, now: u64) {
45 self.grant = Some((task.into(), input_seq, now, now));
46 }
47 pub fn revoke(&mut self) {
48 self.grant = None;
49 }
50 pub fn check(&mut self, task: &str, input_seq: u64, now: u64) -> bool {
51 let Some((owner, seq, started, touched)) = self.grant.as_mut() else {
52 return false;
53 };
54 if now < *touched || now - *touched >= 60_000 || now - *started >= 600_000 {
55 self.revoke();
56 return false;
57 }
58 if *seq != input_seq {
59 if now - *started < GRACE_MS {
60 *seq = input_seq;
62 } else {
63 self.revoke();
64 return false;
65 }
66 }
67 if owner != task {
68 return false;
69 }
70 *touched = now;
71 true
72 }
73}
74
75pub fn external_input(injected: bool, tag: usize) -> bool {
77 !injected || tag != INPUT_TAG
78}
79
80pub fn error(reason: &str) -> crate::CtlError {
81 crate::CtlError::with_evidence(
82 crate::ErrorCode::NotActionable,
83 "desktop handoff required; user must choose Start/Continue in actl, then re-observe before retrying interrupted work",
84 serde_json::json!({"stage":"handoff", "reason":reason, "retry_input":false}),
85 )
86}
87
88pub fn foreground_error(expected: &str, actual: &str) -> crate::CtlError {
89 crate::CtlError::with_evidence(
90 crate::ErrorCode::NotActionable,
91 "physical foreground does not match the target; re-observe the window before retrying",
92 serde_json::json!({"stage":"foreground_verify", "reason":"foreground_mismatch", "expected":expected, "actual":actual}),
93 )
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 #[test]
100 fn injected_events_from_other_tools_still_yield() {
101 assert!(!external_input(true, INPUT_TAG));
102 assert!(external_input(false, INPUT_TAG));
103 assert!(external_input(false, 0));
104 assert!(external_input(true, 0));
105 }
106 #[test]
107 fn foreground_mismatch_never_recommends_elevation() {
108 let e = foreground_error("target", "");
109 assert_eq!(e.code, crate::ErrorCode::NotActionable);
110 assert!(!e.code.recovery_hint().contains("elevated"));
111 }
112 #[test]
113 fn consent_is_explicit_and_task_scoped() {
114 let mut c = Consent::default();
115 assert!(!c.check("a", 10, 100));
116 c.allow("a", 10, 100);
117 assert!(c.check("a", 10, 101));
118 assert!(!c.check("b", 10, 102));
119 assert!(c.check("a", 10, 103));
120 }
121 #[test]
122 fn same_field_input_revokes_even_without_focus_change() {
123 let mut c = Consent::default();
124 c.allow("a", 10, 10_000); assert!(!c.check("a", 11, 10_000 + GRACE_MS + 1));
126 assert!(!c.check("a", 10, 10_000 + GRACE_MS + 2));
127 c.allow("a", 12, 10_000 + GRACE_MS + 3);
128 assert!(c.check("a", 12, 10_000 + GRACE_MS + 4));
129 }
130
131 #[test]
132 fn grant_settle_grace_absorbs_then_goes_strict() {
133 let mut c = Consent::default();
134 c.allow("a", 10, 1_000);
135 assert!(c.check("a", 12, 1_050));
137 assert!(c.check("a", 15, 1_700));
138 assert!(!c.check("a", 16, 1_000 + GRACE_MS + 5));
140 assert!(!c.check("a", 16, 1_000 + GRACE_MS + 10));
142 }
143
144 #[test]
145 fn grace_does_not_change_task_scope_or_expiry() {
146 let mut c = Consent::default();
147 c.allow("a", 10, 1_000);
148 assert!(!c.check("b", 11, 1_100), "余韵不改任务归属");
149 assert!(c.check("a", 11, 1_100));
150 assert!(!c.check("a", 11, 1_100 + 60_000));
152 }
153 #[test]
154 fn expiry_restart_and_clock_reversal_fail_closed() {
155 let mut c = Consent::default();
156 c.allow("a", 0, 100);
157 assert!(!c.check("a", 0, 60_100));
158 c.allow("a", 0, 100);
159 assert!(!c.check("a", 0, 99));
160 c.allow("a", 0, 100);
161 for t in (101..600_100).step_by(1000) {
162 assert!(c.check("a", 0, t));
163 }
164 assert!(!c.check("a", 0, 600_100));
165 assert!(!Consent::default().check("a", 0, 101));
166 }
167}