Skip to main content

side_huddle/
lib.rs

1    //! Detect a Teams / Zoom / Google Meet session and deliver a WAV recording.
2    //!
3    //! # Quick start
4    //! ```no_run
5    //! use side_huddle::{MeetingListener, Event};
6    //!
7    //! let listener = MeetingListener::new();
8    //!
9    //! listener.on(|event| println!("{event:?}"));
10    //!
11    //! let l = listener.clone();
12    //! listener.on(move |event| {
13    //!     if let Event::MeetingDetected { .. } = event { l.record(); }
14    //! });
15    //!
16    //! listener.start().unwrap();
17    //! std::thread::park();
18    //! ```
19
20    mod apps;
21    mod ffi;
22    mod mix;
23    mod monitor;
24    mod platform;
25    mod recorder;
26
27    pub use recorder::MeetingListener;
28
29    /// Window utilities re-exported for use from examples and external consumers.
30    #[cfg(target_os = "macos")]
31    pub mod window {
32        pub use crate::platform::darwin::window::{
33            cg_window_owner, find_primary_window, window_bounds, window_exists,
34        };
35    }
36
37    // ── Public event type ─────────────────────────────────────────────────────
38
39    /// All events emitted by [`MeetingListener`].
40    ///
41    /// Register handlers with [`MeetingListener::on`].
42    /// Multiple handlers for the same event are all called in registration order.
43    ///
44    /// Lifecycle order for a recorded meeting:
45    /// ```text
46    /// PermissionStatus × N  (macOS only, on start)
47    /// PermissionsGranted    (macOS only, once all perms OK)
48    /// MeetingDetected       (meeting begins)
49    /// MeetingUpdated        (title becomes known via window scan)
50    /// RecordingStarted      (if record() was called)
51    /// MeetingEnded          (meeting stops)
52    /// RecordingEnded        (capture stopped, WAV being written)
53    /// RecordingReady        (WAV file written to disk)
54    /// ```
55    #[derive(Debug, Clone)]
56    pub enum Event {
57        // ── Permissions ───────────────────────────────────────────────────────
58        /// Status of an individual permission, emitted once per permission on
59        /// [`MeetingListener::start`].  macOS only; not emitted on Windows / Linux
60        /// where no permissions are required.
61        PermissionStatus {
62            permission: Permission,
63            status:     PermissionGranted,
64        },
65
66        /// All required permissions are granted; recording can proceed.
67        /// Emitted immediately on non-macOS platforms.
68        PermissionsGranted,
69
70        // ── Meeting lifecycle ─────────────────────────────────────────────────
71        /// A Teams / Zoom / Google Meet session was detected (new start, or
72        /// already in progress when the listener started).
73        MeetingDetected { app: String },
74
75        /// Meeting metadata became known — currently the window title once the
76        /// window watcher identifies the call window.
77        MeetingUpdated { app: String, title: String },
78
79        /// The meeting has ended.
80        MeetingEnded { app: String },
81
82        // ── Recording lifecycle ───────────────────────────────────────────────
83        /// Audio capture has begun.  Fired when [`MeetingListener::record`]
84        /// successfully starts the system audio tap.
85        RecordingStarted { app: String },
86
87        /// Audio capture has stopped.  The WAV is being written; expect
88        /// [`Event::RecordingReady`] shortly after.
89        RecordingEnded { app: String },
90
91        /// A completed WAV recording is available at `path`.
92        /// Only fired when [`MeetingListener::record`] (or
93        /// [`MeetingListener::auto_record`]) was active during the meeting.
94        RecordingReady { path: std::path::PathBuf, app: String },
95
96        // ── Capture health ────────────────────────────────────────────────────
97        /// The audio or video capture stream was interrupted or resumed.
98        /// For example, moving the meeting window to an inactive virtual desktop
99        /// may interrupt capture.
100        CaptureStatus { kind: CaptureKind, capturing: bool },
101
102        // ── Errors ────────────────────────────────────────────────────────────
103        /// An error occurred (e.g. the audio tap failed to start).
104        Error { message: String },
105    }
106
107    // ── Supporting types ──────────────────────────────────────────────────────
108
109    /// Which macOS system permission is being reported.
110    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111    pub enum Permission {
112        /// Microphone access — required to capture local mic audio.
113        Microphone,
114        /// Screen Recording — required for the system audio tap (macOS 14.2+).
115        ScreenCapture,
116        /// Accessibility — required by some meeting detection methods.
117        Accessibility,
118    }
119
120    /// The current grant status of a permission.
121    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
122    pub enum PermissionGranted {
123        /// Permission has been explicitly granted.
124        Granted,
125        /// The user has not yet been prompted (soft — the OS dialog will appear).
126        NotRequested,
127        /// The user explicitly denied the permission (hard failure).
128        Denied,
129    }
130
131    /// Which media stream a [`CaptureStatus`] event refers to.
132    ///
133    /// [`CaptureStatus`]: Event::CaptureStatus
134    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
135    pub enum CaptureKind {
136        Audio,
137        Video,
138    }
139
140    // ── Internal detection types (monitor ↔ recorder only) ───────────────────
141
142    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
143    pub(crate) enum DetectionKind { Started, Updated, Ended }
144
145    #[derive(Debug, Clone)]
146    pub(crate) struct Detection {
147        pub(crate) kind:  DetectionKind,
148        pub(crate) app:   String,
149        /// Window title — set when kind == Updated.
150        pub(crate) title: Option<String>,
151    }
152
153    // ── Internal audio types ──────────────────────────────────────────────────
154
155    #[derive(Debug, Clone)]
156    pub(crate) struct AudioChunk {
157        pub(crate) pcm: Vec<i16>,
158    }
159
160    pub(crate) struct Recording {
161        pub(crate) rx:      crossbeam_channel::Receiver<AudioChunk>,
162        pub(crate) stop_fn: Option<Box<dyn FnOnce() + Send>>,
163    }
164
165    impl Drop for Recording {
166        fn drop(&mut self) {
167            if let Some(f) = self.stop_fn.take() { f(); }
168        }
169    }
170
171    // ── Errors ────────────────────────────────────────────────────────────────
172
173    #[derive(Debug, thiserror::Error)]
174    pub enum Error {
175        #[error("monitor already started")]
176        AlreadyStarted,
177        #[error("platform init failed: {0}")]
178        PlatformInit(String),
179        #[error("recording failed: {0}")]
180        RecordingFailed(String),
181        #[error("macOS 14.2+ required for system audio tap (running {major}.{minor})")]
182        MacOSVersionTooOld { major: u32, minor: u32 },
183        #[error("permission denied — check Screen Recording / Microphone in System Settings")]
184        PermissionDenied,
185        #[error("{0}")]
186        Other(String),
187    }
188
189    pub type Result<T> = std::result::Result<T, Error>;