Skip to main content

imessage_private_api/
events.rs

1/// Incoming event types from the helper dylib.
2use std::collections::HashMap;
3
4use serde::Deserialize;
5use serde_json::Value;
6
7/// A raw event received from the helper dylib over TCP.
8#[derive(Debug, Clone, Deserialize)]
9pub struct RawEvent {
10    /// Event type (e.g., "ping", "started-typing", "facetime-call-status-changed")
11    pub event: Option<String>,
12
13    /// Transaction ID (present if this is a response to an outgoing action)
14    #[serde(rename = "transactionId")]
15    pub transaction_id: Option<String>,
16
17    /// Message/entity identifier returned in transaction responses
18    pub identifier: Option<String>,
19
20    /// Chat GUID (for typing events, etc.)
21    pub guid: Option<String>,
22
23    /// Event data payload
24    pub data: Option<Value>,
25
26    /// Error message (for failed transactions)
27    pub error: Option<String>,
28
29    /// Process bundle identifier (for ping events)
30    pub process: Option<String>,
31
32    /// Extra fields not covered above (e.g. "url", "silenced", "available").
33    /// The dylib often puts response data as top-level keys rather than
34    /// inside a "data" wrapper. This captures those fields so we can
35    /// merge them into the transaction result.
36    #[serde(flatten)]
37    pub extra: HashMap<String, Value>,
38}
39
40impl RawEvent {
41    /// Check if this is a transaction response (has transactionId).
42    pub fn is_transaction_response(&self) -> bool {
43        self.transaction_id.is_some()
44    }
45
46    /// Check if this is an event (has event field).
47    pub fn is_event(&self) -> bool {
48        self.event.is_some()
49    }
50
51    /// Extract the response data.
52    ///
53    /// The dylib puts data in one of two shapes:
54    ///   1. `{"transactionId": "…", "data": { … }}`  — explicit `data` wrapper
55    ///   2. `{"transactionId": "…", "url": "…"}`      — top-level fields
56    ///
57    /// If an explicit `data` field exists, use it. Otherwise collect all extra
58    /// fields (those not consumed by named struct fields) into a JSON object.
59    pub fn extract_data(&self) -> Option<Value> {
60        if self.data.is_some() {
61            return self.data.clone();
62        }
63        if !self.extra.is_empty() {
64            let map: serde_json::Map<String, Value> = self
65                .extra
66                .iter()
67                .map(|(k, v)| (k.clone(), v.clone()))
68                .collect();
69            Some(Value::Object(map))
70        } else {
71            None
72        }
73    }
74}
75
76/// All known incoming event types.
77pub mod event_types {
78    pub const PING: &str = "ping";
79    pub const READY: &str = "ready";
80    pub const STARTED_TYPING: &str = "started-typing";
81    pub const TYPING: &str = "typing";
82    pub const STOPPED_TYPING: &str = "stopped-typing";
83    pub const ALIASES_REMOVED: &str = "aliases-removed";
84    pub const FACETIME_CALL_STATUS_CHANGED: &str = "facetime-call-status-changed";
85    pub const NEW_FINDMY_LOCATION: &str = "new-findmy-location";
86}
87
88/// Parsed typing event.
89#[derive(Debug, Clone)]
90pub struct TypingEvent {
91    pub guid: String,
92    pub is_typing: bool,
93}
94
95/// Parsed FaceTime call status.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum FaceTimeStatus {
98    Unknown = 0,
99    Answered = 1,
100    Outgoing = 3,
101    Incoming = 4,
102    Disconnected = 6,
103}
104
105impl FaceTimeStatus {
106    pub fn from_i64(val: i64) -> Self {
107        match val {
108            1 => Self::Answered,
109            3 => Self::Outgoing,
110            4 => Self::Incoming,
111            6 => Self::Disconnected,
112            _ => Self::Unknown,
113        }
114    }
115
116    pub fn as_str(&self) -> &'static str {
117        match self {
118            Self::Unknown => "unknown",
119            Self::Answered => "answered",
120            Self::Outgoing => "outgoing",
121            Self::Incoming => "incoming",
122            Self::Disconnected => "disconnected",
123        }
124    }
125}
126
127/// Parsed FaceTime call event.
128#[derive(Debug, Clone)]
129pub struct FaceTimeEvent {
130    pub call_uuid: String,
131    pub status: FaceTimeStatus,
132    pub status_id: i64,
133    pub address: String,
134    pub ended_error: Option<String>,
135    pub ended_reason: Option<String>,
136    pub image_url: Option<String>,
137    pub is_outgoing: bool,
138    pub is_audio: bool,
139    pub is_video: bool,
140}
141
142/// Parsed FindMy location item.
143#[derive(Debug, Clone)]
144pub struct FindMyLocation {
145    pub handle: String,
146    pub coordinates: (f64, f64),
147    pub long_address: Option<String>,
148    pub short_address: Option<String>,
149    pub subtitle: Option<String>,
150    pub title: Option<String>,
151    pub last_updated: Option<i64>,
152    pub is_locating_in_progress: bool,
153    pub status: String,
154}
155
156/// Parse a typing event from a raw event.
157pub fn parse_typing_event(raw: &RawEvent) -> Option<TypingEvent> {
158    let event_type = raw.event.as_deref()?;
159    let guid = raw.guid.as_deref()?;
160
161    // Skip group chats (GUIDs containing ";+;")
162    if guid.contains(";+;") {
163        return None;
164    }
165
166    let is_typing = matches!(event_type, "started-typing" | "typing");
167    Some(TypingEvent {
168        guid: guid.to_string(),
169        is_typing,
170    })
171}
172
173/// Parse a FaceTime event from raw event data.
174pub fn parse_facetime_event(raw: &RawEvent) -> Option<FaceTimeEvent> {
175    let data = raw.data.as_ref()?;
176
177    let call_status = data.get("call_status")?.as_i64()?;
178    let call_uuid = data
179        .get("call_uuid")
180        .and_then(|v| v.as_str())
181        .unwrap_or("")
182        .to_string();
183    let address = data
184        .get("handle")
185        .and_then(|h| h.get("value"))
186        .and_then(|v| v.as_str())
187        .unwrap_or("")
188        .to_string();
189    let ended_error = data
190        .get("ended_error")
191        .and_then(|v| v.as_str())
192        .map(|s| s.to_string());
193    let ended_reason = data
194        .get("ended_reason")
195        .and_then(|v| v.as_str())
196        .map(|s| s.to_string());
197    let image_url = data
198        .get("image_url")
199        .and_then(|v| v.as_str())
200        .map(|s| s.to_string());
201    let is_outgoing = data
202        .get("is_outgoing")
203        .and_then(|v| v.as_bool())
204        .unwrap_or(false);
205    let is_audio = data
206        .get("is_sending_audio")
207        .and_then(|v| v.as_bool())
208        .unwrap_or(false);
209    let is_video = data
210        .get("is_sending_video")
211        .and_then(|v| v.as_bool())
212        .unwrap_or(false);
213
214    Some(FaceTimeEvent {
215        call_uuid,
216        status: FaceTimeStatus::from_i64(call_status),
217        status_id: call_status,
218        address,
219        ended_error,
220        ended_reason,
221        image_url,
222        is_outgoing,
223        is_audio,
224        is_video,
225    })
226}
227
228/// Parse FindMy locations from raw event data.
229pub fn parse_findmy_locations(raw: &RawEvent) -> Vec<FindMyLocation> {
230    let Some(data) = raw.data.as_ref() else {
231        return vec![];
232    };
233    let Some(items) = data.as_array() else {
234        return vec![];
235    };
236
237    items
238        .iter()
239        .filter_map(|item| {
240            let handle = item.get("handle")?.as_str()?.to_string();
241            let coords = item.get("coordinates")?.as_array()?;
242            let lat = coords.first()?.as_f64()?;
243            let lon = coords.get(1)?.as_f64()?;
244
245            Some(FindMyLocation {
246                handle,
247                coordinates: (lat, lon),
248                long_address: item
249                    .get("long_address")
250                    .and_then(|v| v.as_str())
251                    .map(|s| s.to_string()),
252                short_address: item
253                    .get("short_address")
254                    .and_then(|v| v.as_str())
255                    .map(|s| s.to_string()),
256                subtitle: item
257                    .get("subtitle")
258                    .and_then(|v| v.as_str())
259                    .map(|s| s.to_string()),
260                title: item
261                    .get("title")
262                    .and_then(|v| v.as_str())
263                    .map(|s| s.to_string()),
264                last_updated: item.get("last_updated").and_then(|v| v.as_i64()),
265                is_locating_in_progress: item
266                    .get("is_locating_in_progress")
267                    .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0)))
268                    .unwrap_or(false),
269                status: item
270                    .get("status")
271                    .and_then(|v| v.as_str())
272                    .unwrap_or("unknown")
273                    .to_string(),
274            })
275        })
276        .collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use serde_json::json;
283
284    fn raw_event(overrides: impl FnOnce(&mut RawEvent)) -> RawEvent {
285        let mut e = RawEvent {
286            event: None,
287            transaction_id: None,
288            identifier: None,
289            guid: None,
290            data: None,
291            error: None,
292            process: None,
293            extra: HashMap::new(),
294        };
295        overrides(&mut e);
296        e
297    }
298
299    #[test]
300    fn parse_typing_dm() {
301        let raw = raw_event(|e| {
302            e.event = Some("started-typing".into());
303            e.guid = Some("iMessage;-;+15551234567".into());
304        });
305        let result = parse_typing_event(&raw).unwrap();
306        assert!(result.is_typing);
307        assert_eq!(result.guid, "iMessage;-;+15551234567");
308    }
309
310    #[test]
311    fn parse_typing_skips_group() {
312        let raw = raw_event(|e| {
313            e.event = Some("started-typing".into());
314            e.guid = Some("iMessage;+;chat123456".into());
315        });
316        assert!(parse_typing_event(&raw).is_none());
317    }
318
319    #[test]
320    fn parse_stopped_typing() {
321        let raw = raw_event(|e| {
322            e.event = Some("stopped-typing".into());
323            e.guid = Some("iMessage;-;+15551234567".into());
324        });
325        let result = parse_typing_event(&raw).unwrap();
326        assert!(!result.is_typing);
327    }
328
329    #[test]
330    fn parse_facetime_incoming() {
331        let raw = raw_event(|e| {
332            e.event = Some("facetime-call-status-changed".into());
333            e.data = Some(json!({
334                "call_status": 4,
335                "call_uuid": "abc-123",
336                "handle": { "value": "+15551234567" },
337                "is_outgoing": false,
338                "is_sending_audio": true,
339                "is_sending_video": false
340            }));
341        });
342        let facetime_event = parse_facetime_event(&raw).unwrap();
343        assert_eq!(facetime_event.status, FaceTimeStatus::Incoming);
344        assert_eq!(facetime_event.call_uuid, "abc-123");
345        assert_eq!(facetime_event.address, "+15551234567");
346        assert!(facetime_event.is_audio);
347        assert!(!facetime_event.is_video);
348    }
349
350    #[test]
351    fn parse_findmy_locations_multiple() {
352        let raw = raw_event(|e| {
353            e.event = Some("new-findmy-location".into());
354            e.data = Some(json!([
355                {
356                    "handle": "+15551234567",
357                    "coordinates": [37.7749, -122.4194],
358                    "long_address": "San Francisco, CA",
359                    "status": "live"
360                },
361                {
362                    "handle": "user@icloud.com",
363                    "coordinates": [40.7128, -74.0060],
364                    "status": "legacy"
365                }
366            ]));
367        });
368        let locations = parse_findmy_locations(&raw);
369        assert_eq!(locations.len(), 2);
370        assert_eq!(locations[0].handle, "+15551234567");
371        assert_eq!(locations[0].status, "live");
372        assert_eq!(locations[1].handle, "user@icloud.com");
373    }
374
375    #[test]
376    fn facetime_status_from_i64() {
377        assert_eq!(FaceTimeStatus::from_i64(0), FaceTimeStatus::Unknown);
378        assert_eq!(FaceTimeStatus::from_i64(1), FaceTimeStatus::Answered);
379        assert_eq!(FaceTimeStatus::from_i64(4), FaceTimeStatus::Incoming);
380        assert_eq!(FaceTimeStatus::from_i64(6), FaceTimeStatus::Disconnected);
381        assert_eq!(FaceTimeStatus::from_i64(99), FaceTimeStatus::Unknown);
382    }
383
384    #[test]
385    fn extract_data_prefers_explicit_data_field() {
386        let raw = raw_event(|e| {
387            e.data = Some(json!({"links": []}));
388            e.extra.insert("stray".into(), json!("ignored"));
389        });
390        assert_eq!(raw.extract_data(), Some(json!({"links": []})));
391    }
392
393    #[test]
394    fn extract_data_falls_back_to_extra_fields() {
395        // Matches dylib responses like {"transactionId": "…", "url": "https://…"}
396        let raw = raw_event(|e| {
397            e.extra.insert(
398                "url".into(),
399                json!("https://facetime.apple.com/join#v=1&abc"),
400            );
401        });
402        let data = raw.extract_data().unwrap();
403        assert_eq!(data["url"], "https://facetime.apple.com/join#v=1&abc");
404    }
405
406    #[test]
407    fn extract_data_returns_none_when_empty() {
408        let raw = raw_event(|_| {});
409        assert!(raw.extract_data().is_none());
410    }
411
412    #[test]
413    fn extract_data_from_deserialized_json() {
414        // Simulate what serde does with a real dylib response
415        let json_str = r#"{"transactionId":"abc","silenced":true}"#;
416        let raw: RawEvent = serde_json::from_str(json_str).unwrap();
417        assert!(raw.transaction_id.as_deref() == Some("abc"));
418        assert!(raw.data.is_none()); // no "data" key
419        let data = raw.extract_data().unwrap();
420        assert_eq!(data["silenced"], true);
421    }
422}