imessage_db/
notification_center.rs1use anyhow::{Context, Result};
7use rusqlite::{Connection, OpenFlags, params};
8use std::path::PathBuf;
9use tracing::debug;
10
11const COCOA_EPOCH_OFFSET: f64 = 978307200.0;
13
14#[derive(Debug, Clone)]
17pub struct FaceTimeJoinNotification {
18 pub user_id: String,
19 pub conversation_id: String,
20}
21
22fn unix_to_cocoa(unix_secs: f64) -> f64 {
24 unix_secs - COCOA_EPOCH_OFFSET
25}
26
27fn 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
36fn 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
49pub 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
99fn parse_notification_data(data: &[u8]) -> Option<Vec<FaceTimeJoinNotification>> {
104 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 if let Some(joins) = extract_joins_from_embedded_plists(data) {
114 return Some(joins);
115 }
116
117 None
118}
119
120fn extract_joins_from_plist(value: &plist::Value) -> Option<Vec<FaceTimeJoinNotification>> {
125 let dict = value.as_dictionary()?;
126
127 if let Some(objects) = dict.get("$objects").and_then(|v| v.as_array()) {
129 return extract_joins_from_objects(objects);
130 }
131
132 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 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
152fn extract_joins_from_objects(objects: &[plist::Value]) -> Option<Vec<FaceTimeJoinNotification>> {
157 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 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
189fn 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 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 assert_eq!(COCOA_EPOCH_OFFSET, 978307200.0);
223 }
224
225 #[test]
226 fn unix_to_cocoa_conversion() {
227 let cocoa = unix_to_cocoa(1735689600.0);
229 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 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}