Skip to content

Commit 8933167

Browse files
committed
feat(live-session): 前台离开自动暂停(ForegroundGate 250ms 滞回门控+自窗中性)
1 parent a7fa886 commit 8933167

4 files changed

Lines changed: 350 additions & 55 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
//! 前台自动暂停门控(批 2a:ForegroundGate 滞回状态机 + 锚定资格纯函数)。
2+
//!
3+
//! @ai-context(Why):课堂跟随录制时用户切到其他窗口(IM/浏览器/编辑器)的时段
4+
//! 是干扰期,内容不应混入笔记——媒体随播随停(REQ-291)只跟"视频
5+
//! 是否暂停",跟不了"人是否在看屏幕"。前台门控提供第三个自动暂停
6+
//! 源(PauseSource::Foreground):目标窗口失焦 连续 2×250ms foreign
7+
//! 确认 → 随停;目标回位 连续 2×250ms → 解除。与媒体源互不解除
8+
//! (只解自己,机器层在 pause_state.rs)——本模块只产出决策,
9+
//! 动作由屏幕 worker 经 request API 发出。
10+
//! @ai-context:滞回先例 fa1647aa(media_state SUSPEND 2 拍)——250ms 拍是前台
11+
//! 查询的节流粒度(GetForegroundWindow 是进程级系统调用,比帧采样
12+
//! 便宜一个量级;错 1 拍误停的代价高于晚 250ms 停);自窗(主窗/
13+
//! 浮窗/overlay/原生对话框,windows::is_self_hwnd)=中性:不算
14+
//! foreign(防自己的浮窗/设置触发随停)也不算 target(防误解除)。
15+
//! @ai-context:无窗口锚定(全屏捕获)或画面链短路(disable_ocr,无 worker 采样
16+
//! 点)时门控停摆(anchor_eligible=false——无目标可"失焦/回位")。
17+
18+
/// 滞回确认所需连续拍数(250ms × 2 = 500ms;先例 fa1647aa SUSPECT 2 拍)。
19+
/// 单拍过锐:临时切窗(复制课件/瞄一眼日历 <500ms)即误停不可接受。
20+
pub const FG_CONFIRM_TICKS: u32 = 2;
21+
/// 前台采样节拍(ms):GetForegroundWindow 查询粒度下限(见模块头 Why)。
22+
pub const FG_TICK_MS: u64 = 250;
23+
24+
/// 前台观察分类(采样点合成:系统前台窗口 vs 目标窗口 vs 本进程自窗/探测失败)。
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26+
pub enum ForegroundObs {
27+
/// 系统前台 ≠ 目标窗口且非本进程窗口(离开学习内容)
28+
Foreign,
29+
/// 系统前台 = 目标窗口(回位)
30+
Target,
31+
/// 自窗或探测失败(中性——不推进也不撤销,见模块头)
32+
Neutral,
33+
}
34+
35+
/// 门控决策(动作由 worker 经 request API 发出)。
36+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37+
pub enum ForegroundDecision {
38+
None,
39+
/// 确认离开 → 请求前台暂停
40+
Suspend,
41+
/// 确认回位 → 解除前台暂停(只解自己)
42+
Resume,
43+
}
44+
45+
/// 门控相位。
46+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47+
pub enum ForegroundPhase {
48+
/// 追踪中(目标在前台或中性)
49+
Active,
50+
/// 首个 foreign(等第二拍互证——防瞬间切窗误停)
51+
Suspect,
52+
/// 已确认离开(暂停生效中)
53+
Paused,
54+
/// 首个 target 回位(等第二拍互证——防瞬间切回误解除)
55+
Returning,
56+
}
57+
58+
/// 前台滞回门控(屏幕 worker 单线程持有;输入=每 FG_TICK_MS 一次分类观察)。
59+
///
60+
/// @ai-context: Suspect 相位记录首个 foreign,随后 foreign 连续累计至
61+
/// FG_CONFIRM_TICKS 才确认(中性不计数不撤销——见模块头"自窗=
62+
/// 中性"语义);Returning 对 target 对称。计数在相位迁出时清零。
63+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64+
pub struct ForegroundGate {
65+
pub phase: ForegroundPhase,
66+
/// Suspect 内已连续 foreign 拍数(中性拍不计)
67+
foreign_ticks: u32,
68+
/// Returning 内已连续 target 拍数(中性/foreign 拍中断回 Paused)
69+
target_ticks: u32,
70+
}
71+
72+
impl ForegroundGate {
73+
pub fn new() -> Self {
74+
Self { phase: ForegroundPhase::Active, foreign_ticks: 0, target_ticks: 0 }
75+
}
76+
77+
/// 喂一拍(滞回规则,SUSPEND/RESUME 对称确认):
78+
/// - Active:Foreign → Suspect(拍 1);Target/Neutral 停留
79+
/// - Suspect:Foreign 累计至 FG_CONFIRM_TICKS → Paused(产出 Suspend);
80+
/// Target → 撤销回 Active;Neutral 不计不撤销(停留等互证)
81+
/// - Paused:Target → Returning(拍 1);Foreign/Neutral 停留(暂停继续)
82+
/// - Returning:Target 累计至 FG_CONFIRM_TICKS → Active(产出 Resume);
83+
/// Foreign/Neutral → 回 Paused(回位未获互证,保守留在暂停)
84+
pub fn tick(&mut self, obs: ForegroundObs) -> ForegroundDecision {
85+
match self.phase {
86+
ForegroundPhase::Active => match obs {
87+
ForegroundObs::Foreign => {
88+
self.phase = ForegroundPhase::Suspect;
89+
self.foreign_ticks = 1;
90+
ForegroundDecision::None
91+
}
92+
ForegroundObs::Target | ForegroundObs::Neutral => ForegroundDecision::None,
93+
},
94+
ForegroundPhase::Suspect => match obs {
95+
ForegroundObs::Foreign => {
96+
self.foreign_ticks += 1;
97+
if self.foreign_ticks >= FG_CONFIRM_TICKS {
98+
self.foreign_ticks = 0;
99+
self.phase = ForegroundPhase::Paused;
100+
ForegroundDecision::Suspend
101+
} else {
102+
ForegroundDecision::None
103+
}
104+
}
105+
ForegroundObs::Target => {
106+
self.foreign_ticks = 0;
107+
self.phase = ForegroundPhase::Active;
108+
ForegroundDecision::None
109+
}
110+
ForegroundObs::Neutral => ForegroundDecision::None,
111+
},
112+
ForegroundPhase::Paused => match obs {
113+
ForegroundObs::Target => {
114+
self.phase = ForegroundPhase::Returning;
115+
self.target_ticks = 1;
116+
ForegroundDecision::None
117+
}
118+
ForegroundObs::Foreign | ForegroundObs::Neutral => ForegroundDecision::None,
119+
},
120+
ForegroundPhase::Returning => match obs {
121+
ForegroundObs::Target => {
122+
self.target_ticks += 1;
123+
if self.target_ticks >= FG_CONFIRM_TICKS {
124+
self.target_ticks = 0;
125+
self.phase = ForegroundPhase::Active;
126+
ForegroundDecision::Resume
127+
} else {
128+
ForegroundDecision::None
129+
}
130+
}
131+
ForegroundObs::Foreign | ForegroundObs::Neutral => {
132+
self.target_ticks = 0;
133+
self.phase = ForegroundPhase::Paused;
134+
ForegroundDecision::None
135+
}
136+
},
137+
}
138+
}
139+
}
140+
141+
impl Default for ForegroundGate {
142+
fn default() -> Self {
143+
Self::new()
144+
}
145+
}
146+
147+
/// 锚定资格判定(纯函数):前台源只在"窗口锚定"时可用。
148+
///
149+
/// @ai-context: 目标窗口句柄缺失(None=全屏捕获——无"目标窗口"可失焦/回位,
150+
/// 前台恒为系统其他窗口,会立即误停);画面链短路(disable_ocr,
151+
/// worker 不存在——前台采样点缺失)。任一不满足 → 门控停摆。
152+
pub fn anchor_eligible(has_target_hwnd: bool, ocr_enabled: bool) -> bool {
153+
has_target_hwnd && ocr_enabled
154+
}
155+
156+
#[cfg(test)]
157+
mod tests {
158+
use super::*;
159+
160+
#[test]
161+
fn two_consecutive_foreign_confirm_suspend() {
162+
// Arrange & Act:连续 2×250ms foreign → Suspend(拍 1 只进 Suspect)
163+
let mut g = ForegroundGate::new();
164+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None);
165+
assert_eq!(g.phase, ForegroundPhase::Suspect);
166+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::Suspend);
167+
assert_eq!(g.phase, ForegroundPhase::Paused);
168+
}
169+
170+
#[test]
171+
fn single_foreign_blip_does_not_suspend() {
172+
// 瞬间切窗(<500ms)→ 目标回位 → 撤销怀疑,不误停
173+
let mut g = ForegroundGate::new();
174+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None);
175+
assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::None);
176+
assert_eq!(g.phase, ForegroundPhase::Active);
177+
}
178+
179+
#[test]
180+
fn self_window_is_neutral_neither_suspends_nor_cancels() {
181+
// 自窗(浮窗/设置)中性:不算 foreign(不推进确认)也不算 target(不撤销)
182+
let mut g = ForegroundGate::new();
183+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None);
184+
assert_eq!(g.phase, ForegroundPhase::Suspect);
185+
assert_eq!(g.tick(ForegroundObs::Neutral), ForegroundDecision::None);
186+
assert_eq!(g.phase, ForegroundPhase::Suspect, "中性不撤销怀疑");
187+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::Suspend);
188+
assert_eq!(g.phase, ForegroundPhase::Paused);
189+
// 暂停期自窗中性:不解除
190+
assert_eq!(g.tick(ForegroundObs::Neutral), ForegroundDecision::None);
191+
assert_eq!(g.phase, ForegroundPhase::Paused);
192+
}
193+
194+
#[test]
195+
fn two_consecutive_targets_resume_after_pause() {
196+
// 回位解除同样滞回 2 拍;foreign/中性打断回位确认 → 保守留在暂停
197+
let mut g = ForegroundGate::new();
198+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None);
199+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::Suspend);
200+
assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::None);
201+
assert_eq!(g.phase, ForegroundPhase::Returning);
202+
// 拍 1 回位后短暂切走 → 回 Paused(回位未获互证)
203+
assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None);
204+
assert_eq!(g.phase, ForegroundPhase::Paused);
205+
// 再连续 2 拍 target → Resume
206+
assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::None);
207+
assert_eq!(g.phase, ForegroundPhase::Returning);
208+
assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::Resume);
209+
assert_eq!(g.phase, ForegroundPhase::Active);
210+
}
211+
212+
#[test]
213+
fn anchor_eligibility_table() {
214+
// 判定表:全屏捕获无锚点 / 画面链短路 → 停摆
215+
assert!(!anchor_eligible(false, true));
216+
assert!(!anchor_eligible(true, false));
217+
assert!(!anchor_eligible(false, false));
218+
assert!(anchor_eligible(true, true));
219+
}
220+
221+
#[test]
222+
fn constants_documented_precedent() {
223+
// 节拍与阈值先例锚点(fa1647aa SUSPECT 2 拍)——防静默改值
224+
assert_eq!(FG_CONFIRM_TICKS, 2);
225+
assert_eq!(FG_TICK_MS, 250);
226+
}
227+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ mod pause_state;
374374
#[cfg(target_os = "windows")]
375375
mod live_session_pause;
376376
#[cfg(target_os = "windows")]
377+
mod foreground_pause;
378+
#[cfg(target_os = "windows")]
377379
mod live_keyframes;
378380
mod load_monitor;
379381
// v0.7.0 M1(REQ-106,TRUST-4):诊断日志脱敏(OCR 文本/会话标题等敏感内容过滤)

0 commit comments

Comments
 (0)