Skip to main content

imessage_db/
notification_center.rs

1/// Notification Center database reader for FaceTime join detection.
2///
3/// Reads from $(getconf DARWIN_USER_DIR)/com.apple.notificationcenter/db2/db
4/// to detect when remote users enter a FaceTime waiting room (so we can
5/// admit them via the Private API `admit-pending-member` action).
6use anyhow::{Context, Result};
7use rusqlite::{Connection, OpenFlags, params};
8use std::path::PathBuf;
9use tracing::debug;
10
11/// Cocoa epoch offset: seconds between Unix epoch (1970) and Apple epoch (2001).
12const COCOA_EPOCH_OFFSET: f64 = 978307200.0;
13
14/// A parsed FaceTime join notification containing the IDs needed
15/// to call `admit-pending-member` via the Private API.
16#[derive(Debug, Clone)]
17pub struct FaceTimeJoinNotification {
18    pub user_id: String,
19    pub conversation_id: String,
20}
21
22/// Convert a Unix timestamp (seconds since 1970) to a Cocoa timestamp (seconds since 2001).
23fn unix_to_cocoa(unix_secs: f64) -> f64 {
24    unix_secs - COCOA_EPOCH_OFFSET
25}
26
27/// Get the current time as a Cocoa timestamp.
28fn cocoa_now() -> f64 {
29    let unix = std::time::SystemTime::now()
30        .duration_since(std::time::UNIX_EPOCH)
31        .unwrap_or_default()
32        .as_secs_f64();
33    unix_to_cocoa(unix)
34}
35
36/// Get the path to the Notification Center database.
37fn get_db_path() -> Result<PathBuf> {
38    let output = std::process::Command::new("/usr/bin/getconf")
39        .arg("DARWIN_USER_DIR")
40        .output()
41        .context("Failed to run getconf DARWIN_USER_DIR")?;
42    let base = String::from_utf8(output.stdout)
43        .context("getconf output is not valid UTF-8")?
44        .trim()
45        .to_string();
46    Ok(PathBuf::from(base).join("com.apple.notificationcenter/db2/db"))
47}
48
49/// Query the Notification Center DB for FaceTime join notifications
50/// delivered since `lookback_secs` seconds ago.
51///
52/// Returns parsed join notifications with user IDs and conversation IDs.
53pub fn get_facetime_join_notifications(
54    lookback_secs: f64,
55) -> Result<Vec<FaceTimeJoinNotification>> {
56    let db_path = get_db_path()?;
57    let conn = Connection::open_with_flags(
58        &db_path,
59        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
60    )
61    .with_context(|| {
62        format!(
63            "Failed to open NotificationCenter DB at {}",
64            db_path.display()
65        )
66    })?;
67
68    let since = cocoa_now() - lookback_secs;
69
70    let mut stmt = conn.prepare(
71        "SELECT record.data FROM record \
72         LEFT JOIN app ON record.app_id = app.app_id \
73         WHERE app.identifier = 'com.apple.facetime' \
74         AND record.delivered_date >= ? \
75         ORDER BY record.delivered_date ASC",
76    )?;
77
78    let rows = stmt.query_map(params![since], |row| {
79        let data: Option<Vec<u8>> = row.get(0)?;
80        Ok(data)
81    })?;
82
83    let mut notifications = Vec::new();
84    for row in rows {
85        if let Ok(Some(data)) = row {
86            match parse_notification_data(&data) {
87                Some(joins) => notifications.extend(joins),
88                None => debug!(
89                    "Could not parse notification center data blob ({} bytes)",
90                    data.len()
91                ),
92            }
93        }
94    }
95
96    Ok(notifications)
97}
98
99/// Parse notification data blob to extract FaceTime join info.
100///
101/// The data column can be in binary plist format (NSKeyedArchiver) or TypedStream.
102/// We try binary plist first since that's the most likely format on macOS 15+.
103fn parse_notification_data(data: &[u8]) -> Option<Vec<FaceTimeJoinNotification>> {
104    // Try binary plist
105    if let Ok(value) = plist::Value::from_reader(std::io::Cursor::new(data))
106        && let Some(joins) = extract_joins_from_plist(&value)
107    {
108        return Some(joins);
109    }
110
111    // The data blob may contain multiple plist-encoded objects concatenated
112    // or wrapped in a TypedStream. Try searching for embedded binary plists.
113    if let Some(joins) = extract_joins_from_embedded_plists(data) {
114        return Some(joins);
115    }
116
117    None
118}
119
120/// Extract FaceTime join information from a decoded plist value.
121///
122/// The notification data may be an NSKeyedArchiver archive with a `$objects` array.
123/// Extracts userId from `$objects[6]` and conversationId from `$objects[9]`.
124fn extract_joins_from_plist(value: &plist::Value) -> Option<Vec<FaceTimeJoinNotification>> {
125    let dict = value.as_dictionary()?;
126
127    // Check if this is an NSKeyedArchiver-style plist
128    if let Some(objects) = dict.get("$objects").and_then(|v| v.as_array()) {
129        return extract_joins_from_objects(objects);
130    }
131
132    // The data might be a plain dictionary with nested notification records.
133    // Search recursively for any embedded plist data that contains join info.
134    for (_, v) in dict.iter() {
135        if let Some(data) = v.as_data()
136            && let Ok(inner) = plist::Value::from_reader(std::io::Cursor::new(data))
137            && let Some(joins) = extract_joins_from_plist(&inner)
138        {
139            return Some(joins);
140        }
141        // Recurse into nested dictionaries
142        if v.as_dictionary().is_some()
143            && let Some(joins) = extract_joins_from_plist(v)
144        {
145            return Some(joins);
146        }
147    }
148
149    None
150}
151
152/// Extract join notifications from an NSKeyedArchiver `$objects` array.
153///
154/// Uses hardcoded indices: userId at [6], conversationId at [9].
155/// We additionally verify that a "join" string exists in the objects.
156fn extract_joins_from_objects(objects: &[plist::Value]) -> Option<Vec<FaceTimeJoinNotification>> {
157    // Verify this is a join notification by looking for "join" in any string
158    let has_join = objects.iter().any(|obj| {
159        if let Some(s) = obj.as_string() {
160            s.to_lowercase().contains("join")
161        } else {
162            false
163        }
164    });
165
166    if !has_join || objects.len() < 10 {
167        return None;
168    }
169
170    let user_id = objects.get(6)?.as_string()?;
171    let conversation_id = objects.get(9)?.as_string()?;
172
173    // Sanity check: both should look like UUIDs or identifiers
174    if user_id.is_empty() || conversation_id.is_empty() {
175        return None;
176    }
177
178    debug!(
179        "Found FaceTime join: userId={}, conversationId={}",
180        user_id, conversation_id
181    );
182
183    Some(vec![FaceTimeJoinNotification {
184        user_id: user_id.to_string(),
185        conversation_id: conversation_id.to_string(),
186    }])
187}
188
189/// Search for embedded binary plists within a data blob.
190///
191/// The outer data format might be a TypedStream or other container.
192/// We scan for `bplist00` magic bytes and try to parse each occurrence.
193fn extract_joins_from_embedded_plists(data: &[u8]) -> Option<Vec<FaceTimeJoinNotification>> {
194    let magic = b"bplist00";
195    let mut offset = 0;
196
197    while offset + magic.len() < data.len() {
198        if let Some(pos) = data[offset..].windows(magic.len()).position(|w| w == magic) {
199            let abs_pos = offset + pos;
200            // Try to parse from this position to end of data
201            if let Ok(value) = plist::Value::from_reader(std::io::Cursor::new(&data[abs_pos..]))
202                && let Some(joins) = extract_joins_from_plist(&value)
203            {
204                return Some(joins);
205            }
206            offset = abs_pos + 1;
207        } else {
208            break;
209        }
210    }
211
212    None
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn cocoa_epoch_is_correct() {
221        // 2001-01-01 00:00:00 UTC as Unix timestamp
222        assert_eq!(COCOA_EPOCH_OFFSET, 978307200.0);
223    }
224
225    #[test]
226    fn unix_to_cocoa_conversion() {
227        // 2025-01-01 00:00:00 UTC = 1735689600 Unix
228        let cocoa = unix_to_cocoa(1735689600.0);
229        // = 1735689600 - 978307200 = 757382400
230        assert!((cocoa - 757382400.0).abs() < 0.001);
231    }
232
233    #[test]
234    fn empty_data_returns_none() {
235        assert!(parse_notification_data(&[]).is_none());
236    }
237
238    #[test]
239    fn invalid_data_returns_none() {
240        assert!(parse_notification_data(b"not a plist").is_none());
241    }
242
243    #[test]
244    fn objects_without_join_returns_none() {
245        // Construct a minimal plist with $objects but no "join" string
246        let mut dict = plist::Dictionary::new();
247        let objects: Vec<plist::Value> = (0..10)
248            .map(|i| plist::Value::String(format!("item{i}")))
249            .collect();
250        dict.insert("$objects".to_string(), plist::Value::Array(objects));
251        let value = plist::Value::Dictionary(dict);
252        assert!(extract_joins_from_plist(&value).is_none());
253    }
254}