Skip to main content

visual_cortex/
session.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use arc_swap::ArcSwapOption;
5use tokio::sync::{broadcast, watch};
6use tokio::task::JoinHandle;
7use tokio::time::MissedTickBehavior;
8
9use std::time::SystemTime;
10
11use crate::error::{Error, Result};
12use crate::event::{Event, EventKind};
13use visual_cortex_capture::{CaptureError, Frame, FrameSource, Rate, Target};
14
15const BROADCAST_CAPACITY: usize = 256;
16const INITIAL_CAPTURE_RATE_HZ: f64 = 1.0;
17/// Reserved watcher name carried by session-wide events (target lost/reattached).
18const SESSION_WATCHER: &str = "$session";
19
20/// A capture session: owns the frame source, the capture loop, and all
21/// watcher tasks. Dropping the last `Session` handle stops everything.
22#[derive(Clone, Debug)]
23pub struct Session {
24    pub(crate) inner: Arc<Inner>,
25}
26
27#[derive(Debug)]
28pub(crate) struct Inner {
29    pub(crate) slot: Arc<ArcSwapOption<Frame>>,
30    pub(crate) events: broadcast::Sender<Event>,
31    pub(crate) rate_tx: watch::Sender<Rate>,
32    pub(crate) watchers: Mutex<HashMap<String, WatcherEntry>>,
33    pub(crate) capture_task: Mutex<Option<JoinHandle<()>>>,
34}
35
36/// One registered watcher: its evaluation rate (for capture-rate math) and the
37/// task running its loop. `task` is briefly `None` between name reservation and
38/// spawn in `subscribe`.
39#[derive(Debug)]
40pub(crate) struct WatcherEntry {
41    pub(crate) rate: Rate,
42    pub(crate) task: Option<JoinHandle<()>>,
43}
44
45impl Drop for Inner {
46    fn drop(&mut self) {
47        if let Some(task) = self.capture_task.lock().unwrap().take() {
48            task.abort();
49        }
50        for (_, mut entry) in self.watchers.lock().unwrap().drain() {
51            if let Some(task) = entry.task.take() {
52                task.abort();
53            }
54        }
55    }
56}
57
58impl Session {
59    pub fn builder() -> SessionBuilder {
60        SessionBuilder {
61            source: None,
62            target: None,
63            reattach: true,
64        }
65    }
66
67    /// Begin configuring a watcher. Call `.subscribe()` on the returned
68    /// builder to register it and get its event stream.
69    pub fn watch(&self, name: &str) -> crate::watcher::WatcherBuilder<'_> {
70        crate::watcher::WatcherBuilder::new(self, name)
71    }
72
73    /// Remove a watcher by name, aborting its task and recomputing the capture
74    /// rate — which may now *decrease* if this was the fastest watcher.
75    /// Returns whether a watcher with that name was registered.
76    pub fn unsubscribe(&self, name: &str) -> bool {
77        let entry = self.inner.watchers.lock().unwrap().remove(name);
78        match entry {
79            Some(mut entry) => {
80                if let Some(task) = entry.task.take() {
81                    task.abort();
82                }
83                recompute_capture_rate(&self.inner);
84                true
85            }
86            None => false,
87        }
88    }
89}
90
91pub struct SessionBuilder {
92    source: Option<Box<dyn FrameSource>>,
93    target: Option<Target>,
94    reattach: bool,
95}
96
97impl SessionBuilder {
98    /// Provide the frame source directly (used by tests and `FakeSource`).
99    /// Mutually exclusive with `target`.
100    pub fn source(mut self, source: impl FrameSource) -> Self {
101        self.source = Some(Box::new(source));
102        self
103    }
104
105    /// Capture a real display or window. Mutually exclusive with `source`;
106    /// macOS-only in this milestone.
107    pub fn target(mut self, target: Target) -> Self {
108        self.target = Some(target);
109        self
110    }
111
112    /// If the capture target is lost, keep trying to reattach (default `true`).
113    pub fn reattach(mut self, reattach: bool) -> Self {
114        self.reattach = reattach;
115        self
116    }
117
118    pub async fn build(self) -> Result<Session> {
119        let source = self.resolve_source()?;
120        let slot = Arc::new(ArcSwapOption::from(None));
121        let (events, _) = broadcast::channel(BROADCAST_CAPACITY);
122        let (rate_tx, rate_rx) = watch::channel(Rate::hz(INITIAL_CAPTURE_RATE_HZ));
123        let capture_task =
124            tokio::spawn(capture_loop(source, slot.clone(), rate_rx, events.clone()));
125        Ok(Session {
126            inner: Arc::new(Inner {
127                slot,
128                events,
129                rate_tx,
130                watchers: Mutex::new(HashMap::new()),
131                capture_task: Mutex::new(Some(capture_task)),
132            }),
133        })
134    }
135
136    fn resolve_source(self) -> Result<Box<dyn FrameSource>> {
137        match (self.source, self.target) {
138            (Some(_), Some(_)) => Err(Error::AmbiguousSource),
139            (Some(source), None) => Ok(source),
140            (None, Some(target)) => build_target_source(&target, self.reattach),
141            (None, None) => Err(Error::NoSource),
142        }
143    }
144}
145
146#[cfg(target_os = "macos")]
147fn build_target_source(target: &Target, reattach: bool) -> Result<Box<dyn FrameSource>> {
148    Ok(Box::new(visual_cortex_capture::ScapSource::new(
149        target, reattach,
150    )?))
151}
152
153#[cfg(not(target_os = "macos"))]
154fn build_target_source(_target: &Target, _reattach: bool) -> Result<Box<dyn FrameSource>> {
155    Err(Error::UnsupportedPlatform)
156}
157
158/// Set the capture rate to the fastest registered watcher rate, or the idle
159/// default when no watchers remain. Called on every subscribe and unsubscribe.
160pub(crate) fn recompute_capture_rate(inner: &Inner) {
161    let fastest = {
162        let watchers = inner.watchers.lock().unwrap();
163        watchers
164            .values()
165            .map(|entry| entry.rate)
166            // Fastest = smallest period; `Duration` is `Ord`.
167            .min_by_key(|rate| rate.period())
168            .unwrap_or_else(|| Rate::hz(INITIAL_CAPTURE_RATE_HZ))
169    };
170    // Avoid a needless notify (which rearms the capture interval) when unchanged.
171    if fastest != *inner.rate_tx.borrow() {
172        let _ = inner.rate_tx.send(fastest);
173    }
174}
175
176/// Emit a session-wide event under the reserved sentinel name. `EventStream`
177/// delivers these to every subscriber via `EventKind::is_session_level`.
178fn send_session_event(events: &broadcast::Sender<Event>, kind: EventKind) {
179    let _ = events.send(Event {
180        watcher: Arc::from(SESSION_WATCHER),
181        timestamp: SystemTime::now(),
182        kind,
183    });
184}
185
186/// Polls the source at the session capture rate and publishes each frame to the
187/// latest-frame slot. Translates target-lost errors into session-wide
188/// `TargetLost`/`TargetReattached` events (emitted once per transition).
189async fn capture_loop(
190    mut source: Box<dyn FrameSource>,
191    slot: Arc<ArcSwapOption<Frame>>,
192    mut rate_rx: watch::Receiver<Rate>,
193    events: broadcast::Sender<Event>,
194) {
195    let mut interval = tokio::time::interval(rate_rx.borrow().period());
196    interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
197    // Assume the target is present until a capture proves otherwise.
198    let mut target_present = true;
199    loop {
200        tokio::select! {
201            _ = interval.tick() => {
202                match source.capture_frame().await {
203                    Ok(frame) => {
204                        if !target_present {
205                            target_present = true;
206                            send_session_event(&events, EventKind::TargetReattached);
207                        }
208                        slot.store(Some(frame));
209                    }
210                    Err(CaptureError::TargetLost) | Err(CaptureError::TargetNotFound(_)) => {
211                        if target_present {
212                            target_present = false;
213                            send_session_event(&events, EventKind::TargetLost);
214                        }
215                    }
216                    Err(e) => tracing::warn!("capture error: {e}"),
217                }
218            }
219            changed = rate_rx.changed() => {
220                if changed.is_err() {
221                    break; // session dropped
222                }
223                // Rearming resets tick phase and fires one immediate tick — acceptable
224                // for the capture loop; watcher schedules are independent intervals.
225                interval = tokio::time::interval(rate_rx.borrow().period());
226                interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
227            }
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use visual_cortex_capture::FakeSource;
236
237    #[tokio::test]
238    async fn build_without_source_errors() {
239        let err = Session::builder().build().await.unwrap_err();
240        assert!(matches!(err, Error::NoSource));
241    }
242
243    #[tokio::test(start_paused = true)]
244    async fn capture_loop_fills_the_frame_slot() {
245        let session = Session::builder()
246            .source(FakeSource::new([Frame::solid(4, 4, [1, 2, 3, 255])]))
247            .build()
248            .await
249            .unwrap();
250        // paused-time test: sleeping auto-advances the clock past capture ticks
251        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
252        let frame = session
253            .inner
254            .slot
255            .load_full()
256            .expect("slot should hold a frame");
257        assert_eq!(frame.data()[0], 1);
258    }
259
260    use visual_cortex_vision::Brightness;
261
262    fn one_frame_session_source() -> FakeSource {
263        FakeSource::new([Frame::solid(4, 4, [0, 0, 0, 255])])
264    }
265
266    #[tokio::test(start_paused = true)]
267    async fn capture_rate_tracks_fastest_watcher_and_recovers_on_unsubscribe() {
268        let session = Session::builder()
269            .source(one_frame_session_source())
270            .build()
271            .await
272            .unwrap();
273
274        let _slow = session
275            .watch("slow")
276            .rate(Rate::hz(2.0))
277            .detector(Brightness)
278            .subscribe()
279            .unwrap();
280        assert_eq!(*session.inner.rate_tx.borrow(), Rate::hz(2.0));
281
282        let _fast = session
283            .watch("fast")
284            .rate(Rate::hz(10.0))
285            .detector(Brightness)
286            .subscribe()
287            .unwrap();
288        assert_eq!(*session.inner.rate_tx.borrow(), Rate::hz(10.0));
289
290        // Removing the fastest watcher lets the capture rate fall back.
291        assert!(session.unsubscribe("fast"));
292        assert_eq!(*session.inner.rate_tx.borrow(), Rate::hz(2.0));
293
294        // Removing the last watcher returns to the idle default.
295        assert!(session.unsubscribe("slow"));
296        assert_eq!(
297            *session.inner.rate_tx.borrow(),
298            Rate::hz(INITIAL_CAPTURE_RATE_HZ)
299        );
300    }
301
302    #[tokio::test]
303    async fn build_rejects_source_and_target_together() {
304        use visual_cortex_capture::Target;
305        let err = Session::builder()
306            .source(FakeSource::new([Frame::solid(2, 2, [0, 0, 0, 255])]))
307            .target(Target::display(0))
308            .build()
309            .await
310            .unwrap_err();
311        assert!(matches!(err, Error::AmbiguousSource));
312    }
313
314    #[tokio::test(start_paused = true)]
315    async fn unsubscribe_unknown_is_false_and_frees_the_name() {
316        let session = Session::builder()
317            .source(one_frame_session_source())
318            .build()
319            .await
320            .unwrap();
321
322        assert!(!session.unsubscribe("ghost"));
323
324        let _w = session.watch("w").detector(Brightness).subscribe().unwrap();
325        let dup = session.watch("w").detector(Brightness).subscribe();
326        assert!(matches!(dup, Err(Error::DuplicateWatcher(n)) if n == "w"));
327
328        // After unsubscribe the name is free to reuse.
329        assert!(session.unsubscribe("w"));
330        let _w2 = session.watch("w").detector(Brightness).subscribe().unwrap();
331    }
332}