1use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, Instant};
15
16use serde::{Deserialize, Serialize};
17
18pub const CONTROLS_SUPPORTED: bool = cfg!(target_os = "macos");
19pub const POLL_SECONDS: u64 = 2;
20pub const OBSERVATION_HOLD: Duration = Duration::from_secs(30);
22const FULLSCREEN_OVERLAP: f64 = 0.97;
23const MEETING_OVERLAP: f64 = 0.18;
24
25pub fn controls_supported() -> bool {
26 CONTROLS_SUPPORTED
27}
28
29#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Environment {
32 pub fullscreen_on_main_monitor: bool,
33 pub meeting_or_share_active: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37pub struct Rect {
38 pub x: f64,
39 pub y: f64,
40 pub width: f64,
41 pub height: f64,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct WindowObservation {
47 pub owner: String,
48 #[serde(default)]
49 pub title: String,
50 #[serde(default)]
51 pub layer: i32,
52 pub bounds: Option<Rect>,
53}
54
55#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct AutoPrefs {
58 #[serde(default)]
59 pub auto_hide_fullscreen: bool,
60 #[serde(default)]
61 pub auto_dnd_meetings: bool,
62}
63
64#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
65pub struct AutoState {
66 pub hidden_for_fullscreen: bool,
67 pub dnd_for_meeting: bool,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct PresenceState {
73 pub manual_dnd: bool,
74 pub auto_hidden: bool,
75 pub auto_dnd: bool,
76 pub effective_dnd: bool,
77 pub controls_supported: bool,
78}
79
80#[derive(Default)]
81pub struct PresenceDnd {
82 manual: AtomicBool,
83 auto_hidden: AtomicBool,
84 auto_dnd: AtomicBool,
85 prefs: Mutex<AutoPrefs>,
86 observed_until: Mutex<Option<Instant>>,
88}
89
90impl PresenceDnd {
91 pub fn new() -> Self {
92 Self::default()
93 }
94 pub fn set_manual_dnd(&self, on: bool) {
95 self.manual.store(on, Ordering::Relaxed);
96 }
97 pub fn set_auto(&self, next: AutoState) -> (bool, bool) {
99 let h = self
100 .auto_hidden
101 .swap(next.hidden_for_fullscreen, Ordering::Relaxed);
102 let d = self.auto_dnd.swap(next.dnd_for_meeting, Ordering::Relaxed);
103 (h != next.hidden_for_fullscreen, d != next.dnd_for_meeting)
104 }
105 pub fn apply_observation(&self, next: AutoState) -> (bool, bool) {
108 let mut until = self
109 .observed_until
110 .lock()
111 .unwrap_or_else(|e| e.into_inner());
112 *until = Some(Instant::now() + OBSERVATION_HOLD);
113 self.set_auto(next)
114 }
115 pub fn apply_live(&self, next: AutoState) -> Option<(bool, bool)> {
117 let mut until = self
118 .observed_until
119 .lock()
120 .unwrap_or_else(|e| e.into_inner());
121 match *until {
122 Some(t) if Instant::now() < t => return None,
123 Some(_) => *until = None,
124 None => {}
125 }
126 Some(self.set_auto(next))
127 }
128 pub fn effective_dnd(&self) -> bool {
129 self.manual.load(Ordering::Relaxed) || self.auto_dnd.load(Ordering::Relaxed)
130 }
131 pub fn prefs(&self) -> AutoPrefs {
132 *self.prefs.lock().unwrap_or_else(|e| e.into_inner())
133 }
134 pub fn set_prefs(&self, p: AutoPrefs) {
135 *self.prefs.lock().unwrap_or_else(|e| e.into_inner()) = p;
136 }
137 pub fn snapshot(&self) -> PresenceState {
138 PresenceState {
139 manual_dnd: self.manual.load(Ordering::Relaxed),
140 auto_hidden: self.auto_hidden.load(Ordering::Relaxed),
141 auto_dnd: self.auto_dnd.load(Ordering::Relaxed),
142 effective_dnd: self.effective_dnd(),
143 controls_supported: CONTROLS_SUPPORTED,
144 }
145 }
146}
147
148pub fn reconcile(prefs: AutoPrefs, env: Environment) -> AutoState {
150 AutoState {
151 hidden_for_fullscreen: prefs.auto_hide_fullscreen && env.fullscreen_on_main_monitor,
152 dnd_for_meeting: prefs.auto_dnd_meetings && env.meeting_or_share_active,
153 }
154}
155
156pub fn detect(monitor: Rect, windows: &[WindowObservation], own_name: &str) -> Environment {
159 let monitor_area = area(&monitor);
160 let mut env = Environment::default();
161 for w in windows {
162 if w.layer != 0 || ignore(&w.owner, &w.title, own_name) {
163 continue;
164 }
165 let Some(b) = w.bounds else { continue };
166 let (overlap_ratio, window_ratio) = if monitor_area > 0.0 {
167 (
168 overlap(&b, &monitor) / monitor_area,
169 area(&b) / monitor_area,
170 )
171 } else {
172 (0.0, 0.0)
173 };
174 if overlap_ratio >= FULLSCREEN_OVERLAP {
175 env.fullscreen_on_main_monitor = true;
176 }
177 if is_meeting(&w.owner, &w.title)
178 && (overlap_ratio >= MEETING_OVERLAP || window_ratio >= MEETING_OVERLAP)
179 {
180 env.meeting_or_share_active = true;
181 }
182 if env.fullscreen_on_main_monitor && env.meeting_or_share_active {
183 break;
184 }
185 }
186 env
187}
188
189fn ignore(owner: &str, title: &str, own_name: &str) -> bool {
190 let (o, t, own) = (
191 owner.to_ascii_lowercase(),
192 title.to_ascii_lowercase(),
193 own_name.to_ascii_lowercase(),
194 );
195 o.is_empty()
196 || o == "window server"
197 || o == "dock"
198 || (!own.is_empty() && (o == own || t.contains(&own)))
199}
200
201fn is_meeting(owner: &str, title: &str) -> bool {
202 let (o, t) = (owner.to_ascii_lowercase(), title.to_ascii_lowercase());
203 const OWNERS: [&str; 10] = [
204 "zoom",
205 "zoom.us",
206 "microsoft teams",
207 "teams",
208 "webex",
209 "slack",
210 "feishu",
211 "lark",
212 "tencent meeting",
213 "腾讯会议",
214 ];
215 const TITLES: [&str; 14] = [
216 "zoom meeting",
217 "google meet",
218 " meet ",
219 "meeting",
220 "huddle",
221 "webex",
222 "teams",
223 "sharing",
224 "presenting",
225 "screen share",
226 "share screen",
227 "共享",
228 "演示",
229 "正在共享",
230 ];
231 OWNERS.iter().any(|k| o.contains(k))
232 || TITLES.iter().any(|k| t.contains(k))
233 || t.contains("正在演示")
234}
235
236fn area(r: &Rect) -> f64 {
237 r.width.max(0.0) * r.height.max(0.0)
238}
239
240fn overlap(a: &Rect, b: &Rect) -> f64 {
241 let (l, t) = (a.x.max(b.x), a.y.max(b.y));
242 let (r, bt) = (
243 (a.x + a.width).min(b.x + b.width),
244 (a.y + a.height).min(b.y + b.height),
245 );
246 (r - l).max(0.0) * (bt - t).max(0.0)
247}
248
249pub fn observe_os() -> Result<(Rect, Vec<WindowObservation>), String> {
251 platform::observe()
252}
253
254pub fn detect_live(own_name: &str) -> Result<(Environment, usize), String> {
256 let (monitor, windows) = observe_os()?;
257 Ok((detect(monitor, &windows, own_name), windows.len()))
258}
259
260pub fn start_loop(
264 state: Arc<PresenceDnd>,
265 own_name: String,
266 on_change: impl Fn(PresenceState) + Send + 'static,
267) {
268 if !CONTROLS_SUPPORTED {
269 return;
270 }
271 let _ = std::thread::Builder::new()
272 .name("rk-shell-presence".into())
273 .spawn(move || loop {
274 std::thread::sleep(Duration::from_secs(POLL_SECONDS));
275 let prefs = state.prefs();
276 let next = if prefs.auto_hide_fullscreen || prefs.auto_dnd_meetings {
277 match detect_live(&own_name) {
278 Ok((env, _)) => reconcile(prefs, env),
279 Err(_) => continue,
280 }
281 } else {
282 AutoState::default()
283 };
284 let Some((hidden, dnd)) = state.apply_live(next) else {
285 continue;
286 };
287 if hidden || dnd {
288 on_change(state.snapshot());
289 }
290 });
291}
292
293#[cfg(target_os = "macos")]
294mod platform {
295 use std::ffi::c_void;
296
297 use objc2::rc::Retained;
298 use objc2::runtime::AnyObject;
299 use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString};
300
301 use super::{Rect, WindowObservation};
302
303 #[repr(C)]
304 struct CGPoint {
305 x: f64,
306 y: f64,
307 }
308 #[repr(C)]
309 struct CGSize {
310 width: f64,
311 height: f64,
312 }
313 #[repr(C)]
314 struct CGRect {
315 origin: CGPoint,
316 size: CGSize,
317 }
318
319 #[link(name = "CoreGraphics", kind = "framework")]
320 extern "C" {
321 fn CGWindowListCopyWindowInfo(option: u32, relative_to: u32) -> *mut c_void;
322 fn CGMainDisplayID() -> u32;
323 fn CGDisplayBounds(display: u32) -> CGRect;
324 }
325 const ON_SCREEN_ONLY: u32 = 1;
326 const EXCLUDE_DESKTOP_ELEMENTS: u32 = 1 << 4;
327
328 type Dict = NSDictionary<NSString, AnyObject>;
329
330 fn string(d: &Dict, key: &str) -> Option<String> {
331 d.objectForKey(&NSString::from_str(key))?
332 .downcast_ref::<NSString>()
333 .map(|s| s.to_string())
334 }
335 fn number(d: &Dict, key: &str) -> Option<f64> {
336 d.objectForKey(&NSString::from_str(key))?
337 .downcast_ref::<NSNumber>()
338 .map(|n| n.doubleValue())
339 }
340
341 pub fn observe() -> Result<(Rect, Vec<WindowObservation>), String> {
342 let (bounds, raw) = unsafe {
345 let b = CGDisplayBounds(CGMainDisplayID());
346 (
347 Rect {
348 x: b.origin.x,
349 y: b.origin.y,
350 width: b.size.width,
351 height: b.size.height,
352 },
353 CGWindowListCopyWindowInfo(ON_SCREEN_ONLY | EXCLUDE_DESKTOP_ELEMENTS, 0),
354 )
355 };
356 let list: Retained<NSArray<Dict>> = unsafe { Retained::from_raw(raw.cast()) }
358 .ok_or("CGWindowListCopyWindowInfo returned no list")?;
359 let mut out = Vec::new();
360 for i in 0..list.count() {
361 let d = list.objectAtIndex(i);
362 let frame = d
363 .objectForKey(&NSString::from_str("kCGWindowBounds"))
364 .and_then(|o| o.downcast::<NSDictionary>().ok());
365 let bounds = frame.and_then(|f| {
366 let f: &Dict = unsafe { f.cast_unchecked() };
369 Some(Rect {
370 x: number(f, "X")?,
371 y: number(f, "Y")?,
372 width: number(f, "Width")?,
373 height: number(f, "Height")?,
374 })
375 });
376 out.push(WindowObservation {
377 owner: string(&d, "kCGWindowOwnerName").unwrap_or_default(),
378 title: string(&d, "kCGWindowName").unwrap_or_default(),
379 layer: number(&d, "kCGWindowLayer").unwrap_or(0.0) as i32,
380 bounds,
381 });
382 }
383 Ok((bounds, out))
384 }
385}
386
387#[cfg(not(target_os = "macos"))]
388mod platform {
389 use super::{Rect, WindowObservation};
390 pub fn observe() -> Result<(Rect, Vec<WindowObservation>), String> {
391 Err("window observation is not implemented on this platform".into())
392 }
393}