use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags, params};
use std::path::PathBuf;
use tracing::debug;
const COCOA_EPOCH_OFFSET: f64 = 978307200.0;
#[derive(Debug, Clone)]
pub struct FaceTimeJoinNotification {
pub user_id: String,
pub conversation_id: String,
}
fn unix_to_cocoa(unix_secs: f64) -> f64 {
unix_secs - COCOA_EPOCH_OFFSET
}
fn cocoa_now() -> f64 {
let unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
unix_to_cocoa(unix)
}
fn get_db_path() -> Result<PathBuf> {
let output = std::process::Command::new("/usr/bin/getconf")
.arg("DARWIN_USER_DIR")
.output()
.context("Failed to run getconf DARWIN_USER_DIR")?;
let base = String::from_utf8(output.stdout)
.context("getconf output is not valid UTF-8")?
.trim()
.to_string();
Ok(PathBuf::from(base).join("com.apple.notificationcenter/db2/db"))
}
pub fn get_facetime_join_notifications(
lookback_secs: f64,
) -> Result<Vec<FaceTimeJoinNotification>> {
let db_path = get_db_path()?;
let conn = Connection::open_with_flags(
&db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| {
format!(
"Failed to open NotificationCenter DB at {}",
db_path.display()
)
})?;
let since = cocoa_now() - lookback_secs;
let mut stmt = conn.prepare(
"SELECT record.data FROM record \
LEFT JOIN app ON record.app_id = app.app_id \
WHERE app.identifier = 'com.apple.facetime' \
AND record.delivered_date >= ? \
ORDER BY record.delivered_date ASC",
)?;
let rows = stmt.query_map(params![since], |row| {
let data: Option<Vec<u8>> = row.get(0)?;
Ok(data)
})?;
let mut notifications = Vec::new();
for row in rows {
if let Ok(Some(data)) = row {
match parse_notification_data(&data) {
Some(joins) => notifications.extend(joins),
None => debug!(
"Could not parse notification center data blob ({} bytes)",
data.len()
),
}
}
}
Ok(notifications)
}
fn parse_notification_data(data: &[u8]) -> Option<Vec<FaceTimeJoinNotification>> {
if let Ok(value) = plist::Value::from_reader(std::io::Cursor::new(data))
&& let Some(joins) = extract_joins_from_plist(&value)
{
return Some(joins);
}
if let Some(joins) = extract_joins_from_embedded_plists(data) {
return Some(joins);
}
None
}
fn extract_joins_from_plist(value: &plist::Value) -> Option<Vec<FaceTimeJoinNotification>> {
let dict = value.as_dictionary()?;
if let Some(objects) = dict.get("$objects").and_then(|v| v.as_array()) {
return extract_joins_from_objects(objects);
}
for (_, v) in dict.iter() {
if let Some(data) = v.as_data()
&& let Ok(inner) = plist::Value::from_reader(std::io::Cursor::new(data))
&& let Some(joins) = extract_joins_from_plist(&inner)
{
return Some(joins);
}
if v.as_dictionary().is_some()
&& let Some(joins) = extract_joins_from_plist(v)
{
return Some(joins);
}
}
None
}
fn extract_joins_from_objects(objects: &[plist::Value]) -> Option<Vec<FaceTimeJoinNotification>> {
let has_join = objects.iter().any(|obj| {
if let Some(s) = obj.as_string() {
s.to_lowercase().contains("join")
} else {
false
}
});
if !has_join || objects.len() < 10 {
return None;
}
let user_id = objects.get(6)?.as_string()?;
let conversation_id = objects.get(9)?.as_string()?;
if user_id.is_empty() || conversation_id.is_empty() {
return None;
}
debug!(
"Found FaceTime join: userId={}, conversationId={}",
user_id, conversation_id
);
Some(vec![FaceTimeJoinNotification {
user_id: user_id.to_string(),
conversation_id: conversation_id.to_string(),
}])
}
fn extract_joins_from_embedded_plists(data: &[u8]) -> Option<Vec<FaceTimeJoinNotification>> {
let magic = b"bplist00";
let mut offset = 0;
while offset + magic.len() < data.len() {
if let Some(pos) = data[offset..].windows(magic.len()).position(|w| w == magic) {
let abs_pos = offset + pos;
if let Ok(value) = plist::Value::from_reader(std::io::Cursor::new(&data[abs_pos..]))
&& let Some(joins) = extract_joins_from_plist(&value)
{
return Some(joins);
}
offset = abs_pos + 1;
} else {
break;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cocoa_epoch_is_correct() {
assert_eq!(COCOA_EPOCH_OFFSET, 978307200.0);
}
#[test]
fn unix_to_cocoa_conversion() {
let cocoa = unix_to_cocoa(1735689600.0);
assert!((cocoa - 757382400.0).abs() < 0.001);
}
#[test]
fn empty_data_returns_none() {
assert!(parse_notification_data(&[]).is_none());
}
#[test]
fn invalid_data_returns_none() {
assert!(parse_notification_data(b"not a plist").is_none());
}
#[test]
fn objects_without_join_returns_none() {
let mut dict = plist::Dictionary::new();
let objects: Vec<plist::Value> = (0..10)
.map(|i| plist::Value::String(format!("item{i}")))
.collect();
dict.insert("$objects".to_string(), plist::Value::Array(objects));
let value = plist::Value::Dictionary(dict);
assert!(extract_joins_from_plist(&value).is_none());
}
}