Skip to main content

rightkit_tauri_shell/
presence.rs

1//! Presentation-aware presence and Do-Not-Disturb.
2//!
3//! Donor: CodeRight `presence_dnd.rs` (itself a port of Clyde's `environment.rs`): the overlap
4//! thresholds, the meeting/share keyword tables, the preference gates and the manual-DND layering are
5//! kept verbatim. The platform probe is new: on macOS it reads the real on-screen window list from
6//! CoreGraphics (`CGWindowListCopyWindowInfo`, owner names need no permission), on other platforms it
7//! reports an empty observation and `controls_supported() == false` instead of inventing state.
8//!
9//! `effective_dnd = manual || auto` and gates [`crate::notify`]. Auto-DND turns on while a meeting or
10//! screen-share window is visible and the app opted in with [`AutoPrefs::auto_dnd_meetings`].
11
12use 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;
20/// How long an observation injected through `rk_shell_presence_evaluate` overrides live polling.
21pub 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/// A visible window from the platform collector. `bounds: None` is skipped (malformed record).
45#[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    /// While set and in the future, the poller leaves an injected observation in place.
87    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    /// Returns `(hidden_changed, dnd_changed)`.
98    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    /// Apply an injected observation and hold it against live polling for [`OBSERVATION_HOLD`].
106    /// The hold and the write happen under one lock, so a concurrent poll cannot interleave.
107    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    /// Apply a live sample unless an injected observation is still held. `None` when held.
116    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
148/// Apply the preference gates. Disabling a control clears its stale automatic state.
149pub 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
156/// Fullscreen / meeting detection over normalised observations. Windows on non-zero layers,
157/// malformed bounds, the app's own windows, WindowServer and Dock are ignored.
158pub 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
249/// The real probe: main display bounds and the visible window list. `Err` when the OS refuses.
250pub fn observe_os() -> Result<(Rect, Vec<WindowObservation>), String> {
251    platform::observe()
252}
253
254/// Detect against the live OS for an app named `own_name`.
255pub 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
260/// Start the polling loop. It samples only while a preference needs it, never calls into the app
261/// thread, and ends when the process does. `on_change` receives the new state when an automatic
262/// flag flips.
263pub 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        // SAFETY: plain CoreGraphics queries; the returned CFArray is owned (+1) and toll-free
343        // bridged to NSArray, so `Retained::from_raw` takes over that single reference.
344        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        // SAFETY: see above.
357        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                // CoreGraphics returns an untyped NSDictionary; reinterpret its known NSString /
367                // AnyObject parameters before using typed objectForKey accessors.
368                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}