Skip to main content

ios_core/services/notificationproxy/
mod.rs

1//! Minimal notification proxy client.
2//!
3//! Service: `com.apple.mobile.notification_proxy`
4//! Reference: go-ios/ios/notificationproxy/notificationproxy.go
5
6use std::collections::HashSet;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
11use tokio::time::Instant;
12
13pub const SERVICE_NAME: &str = "com.apple.mobile.notification_proxy";
14pub const SPRINGBOARD_FINISHED_STARTUP: &str = "com.apple.springboard.finishedstartup";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum NotificationEvent {
18    Notification(String),
19    ProxyDeath,
20}
21
22service_error!(
23    NotificationProxyError,
24    #[error("proxy closed before notification arrived")]
25    ProxyDeath,
26    #[error("timed out waiting for notification")]
27    Timeout,
28);
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum NotificationProxyEvent {
32    Notification(String),
33    ProxyDeath,
34}
35
36#[derive(Debug)]
37pub struct NotificationProxyClient<S> {
38    stream: S,
39    observing: HashSet<String>,
40}
41
42impl<S: AsyncRead + AsyncWrite + Unpin> NotificationProxyClient<S> {
43    pub fn new(stream: S) -> Self {
44        Self {
45            stream,
46            observing: HashSet::new(),
47        }
48    }
49
50    pub async fn observe(&mut self, notification: &str) -> Result<(), NotificationProxyError> {
51        if self.observing.contains(notification) {
52            return Ok(());
53        }
54
55        self.send_request(NotificationProxyRequest {
56            command: "ObserveNotification",
57            name: Some(notification),
58        })
59        .await?;
60        self.observing.insert(notification.to_string());
61        Ok(())
62    }
63
64    pub async fn post(&mut self, notification: &str) -> Result<(), NotificationProxyError> {
65        self.send_request(NotificationProxyRequest {
66            command: "PostNotification",
67            name: Some(notification),
68        })
69        .await
70    }
71
72    pub async fn wait_for(
73        &mut self,
74        notification: &str,
75        timeout: Duration,
76    ) -> Result<(), NotificationProxyError> {
77        self.observe(notification).await?;
78
79        let deadline = Instant::now() + timeout;
80        loop {
81            let remaining = deadline.saturating_duration_since(Instant::now());
82            if remaining.is_zero() {
83                return Err(NotificationProxyError::Timeout);
84            }
85
86            let event = tokio::time::timeout(remaining, self.recv_event())
87                .await
88                .map_err(|_| NotificationProxyError::Timeout)??;
89
90            match event {
91                NotificationEvent::Notification(name) if name == notification => return Ok(()),
92                NotificationEvent::ProxyDeath => return Err(NotificationProxyError::ProxyDeath),
93                NotificationEvent::Notification(_) => {}
94            }
95        }
96    }
97
98    pub async fn wait_for_springboard(
99        &mut self,
100        timeout: Duration,
101    ) -> Result<(), NotificationProxyError> {
102        self.wait_for(SPRINGBOARD_FINISHED_STARTUP, timeout).await
103    }
104
105    pub async fn next_event(
106        &mut self,
107        timeout: Duration,
108    ) -> Result<NotificationProxyEvent, NotificationProxyError> {
109        let message = tokio::time::timeout(timeout, self.recv_message())
110            .await
111            .map_err(|_| NotificationProxyError::Timeout)??;
112
113        match message.command.as_deref() {
114            Some("RelayNotification") => message
115                .name
116                .map(NotificationProxyEvent::Notification)
117                .ok_or_else(|| {
118                    NotificationProxyError::Protocol("RelayNotification missing Name field".into())
119                }),
120            Some("ProxyDeath") => Ok(NotificationProxyEvent::ProxyDeath),
121            other => Err(NotificationProxyError::Protocol(format!(
122                "unexpected notification proxy command: {}",
123                other.unwrap_or("<missing>")
124            ))),
125        }
126    }
127
128    pub async fn shutdown(&mut self) -> Result<(), NotificationProxyError> {
129        self.send_request(NotificationProxyRequest {
130            command: "Shutdown",
131            name: None,
132        })
133        .await
134    }
135
136    pub async fn recv_event(&mut self) -> Result<NotificationEvent, NotificationProxyError> {
137        let message = self.recv_message().await?;
138        match message.command.as_deref() {
139            Some("RelayNotification") => Ok(NotificationEvent::Notification(
140                message.name.ok_or_else(|| {
141                    NotificationProxyError::Protocol("RelayNotification missing Name".to_string())
142                })?,
143            )),
144            Some("ProxyDeath") => Ok(NotificationEvent::ProxyDeath),
145            Some(other) => Err(NotificationProxyError::Protocol(format!(
146                "unexpected notification proxy command: {other}"
147            ))),
148            None => Err(NotificationProxyError::Protocol(
149                "notification proxy message missing Command".to_string(),
150            )),
151        }
152    }
153
154    async fn send_request(
155        &mut self,
156        request: NotificationProxyRequest<'_>,
157    ) -> Result<(), NotificationProxyError> {
158        let mut buf = Vec::new();
159        plist::to_writer_xml(&mut buf, &request)?;
160        self.stream
161            .write_all(&(buf.len() as u32).to_be_bytes())
162            .await?;
163        self.stream.write_all(&buf).await?;
164        self.stream.flush().await?;
165        Ok(())
166    }
167
168    async fn recv_message(&mut self) -> Result<NotificationProxyMessage, NotificationProxyError> {
169        let mut len_buf = [0u8; 4];
170        self.stream.read_exact(&mut len_buf).await?;
171        let len = u32::from_be_bytes(len_buf) as usize;
172        const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
173        if len > MAX_PLIST_SIZE {
174            return Err(NotificationProxyError::Protocol(format!(
175                "plist length {len} exceeds max {MAX_PLIST_SIZE}"
176            )));
177        }
178        let mut buf = vec![0u8; len];
179        self.stream.read_exact(&mut buf).await?;
180        Ok(plist::from_bytes(&buf)?)
181    }
182}
183
184#[derive(Serialize)]
185#[serde(rename_all = "PascalCase")]
186struct NotificationProxyRequest<'a> {
187    command: &'static str,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    name: Option<&'a str>,
190}
191
192#[derive(Debug, Deserialize)]
193#[serde(rename_all = "PascalCase")]
194struct NotificationProxyMessage {
195    #[serde(default)]
196    command: Option<String>,
197    #[serde(default)]
198    name: Option<String>,
199}
200
201#[cfg(test)]
202mod tests {
203    use crate::test_util::MockStream;
204
205    use super::*;
206
207    fn plist_frame(value: plist::Value) -> Vec<u8> {
208        let mut buf = Vec::new();
209        plist::to_writer_xml(&mut buf, &value).unwrap();
210        buf
211    }
212
213    #[tokio::test]
214    async fn observe_encodes_notification_request() {
215        let mut stream = MockStream::default();
216        let mut client = NotificationProxyClient::new(&mut stream);
217        client.observe("com.apple.example.ready").await.unwrap();
218
219        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
220        let payload = &stream.written[4..4 + len];
221        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
222        assert_eq!(dict["Command"].as_string(), Some("ObserveNotification"));
223        assert_eq!(dict["Name"].as_string(), Some("com.apple.example.ready"));
224    }
225
226    #[tokio::test]
227    async fn post_encodes_notification_request() {
228        let mut stream = MockStream::default();
229        let mut client = NotificationProxyClient::new(&mut stream);
230        client.post("com.apple.example.trigger").await.unwrap();
231
232        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
233        let payload = &stream.written[4..4 + len];
234        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
235        assert_eq!(dict["Command"].as_string(), Some("PostNotification"));
236        assert_eq!(dict["Name"].as_string(), Some("com.apple.example.trigger"));
237    }
238
239    #[tokio::test]
240    async fn wait_for_matches_relay_notification() {
241        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
242            (
243                "Command".to_string(),
244                plist::Value::String("RelayNotification".into()),
245            ),
246            (
247                "Name".to_string(),
248                plist::Value::String("com.apple.example.ready".into()),
249            ),
250        ])));
251        let mut stream = MockStream::with_frames(vec![frame]);
252        let mut client = NotificationProxyClient::new(&mut stream);
253
254        client
255            .wait_for("com.apple.example.ready", Duration::from_millis(100))
256            .await
257            .unwrap();
258    }
259
260    #[tokio::test]
261    async fn recv_event_decodes_relay_notification() {
262        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
263            (
264                "Command".to_string(),
265                plist::Value::String("RelayNotification".into()),
266            ),
267            (
268                "Name".to_string(),
269                plist::Value::String("com.apple.example.ready".into()),
270            ),
271        ])));
272        let mut stream = MockStream::with_frames(vec![frame]);
273        let mut client = NotificationProxyClient::new(&mut stream);
274
275        let event = client.recv_event().await.unwrap();
276        assert_eq!(
277            event,
278            NotificationEvent::Notification("com.apple.example.ready".into())
279        );
280    }
281
282    #[tokio::test]
283    async fn recv_event_decodes_proxy_death() {
284        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([(
285            "Command".to_string(),
286            plist::Value::String("ProxyDeath".into()),
287        )])));
288        let mut stream = MockStream::with_frames(vec![frame]);
289        let mut client = NotificationProxyClient::new(&mut stream);
290
291        let event = client.recv_event().await.unwrap();
292        assert_eq!(event, NotificationEvent::ProxyDeath);
293    }
294
295    #[tokio::test]
296    async fn wait_for_springboard_uses_expected_name() {
297        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
298            (
299                "Command".to_string(),
300                plist::Value::String("RelayNotification".into()),
301            ),
302            (
303                "Name".to_string(),
304                plist::Value::String(SPRINGBOARD_FINISHED_STARTUP.into()),
305            ),
306        ])));
307        let mut stream = MockStream::with_frames(vec![frame]);
308        let mut client = NotificationProxyClient::new(&mut stream);
309
310        client
311            .wait_for_springboard(Duration::from_millis(100))
312            .await
313            .unwrap();
314    }
315
316    #[tokio::test]
317    async fn next_event_returns_notification_name() {
318        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
319            (
320                "Command".to_string(),
321                plist::Value::String("RelayNotification".into()),
322            ),
323            (
324                "Name".to_string(),
325                plist::Value::String("com.apple.example.stream".into()),
326            ),
327        ])));
328        let mut stream = MockStream::with_frames(vec![frame]);
329        let mut client = NotificationProxyClient::new(&mut stream);
330
331        let event = client.next_event(Duration::from_millis(100)).await.unwrap();
332        assert_eq!(
333            event,
334            NotificationProxyEvent::Notification("com.apple.example.stream".into())
335        );
336    }
337
338    #[tokio::test]
339    async fn next_event_maps_proxy_death() {
340        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([(
341            "Command".to_string(),
342            plist::Value::String("ProxyDeath".into()),
343        )])));
344        let mut stream = MockStream::with_frames(vec![frame]);
345        let mut client = NotificationProxyClient::new(&mut stream);
346
347        let event = client.next_event(Duration::from_millis(100)).await.unwrap();
348        assert_eq!(event, NotificationProxyEvent::ProxyDeath);
349    }
350
351    #[tokio::test]
352    async fn wait_for_times_out_when_no_notification_arrives() {
353        let (client_side, _server_side) = tokio::io::duplex(1024);
354        let mut client = NotificationProxyClient::new(client_side);
355
356        let err = client
357            .wait_for("com.apple.example.ready", Duration::from_millis(10))
358            .await
359            .unwrap_err();
360        assert!(matches!(err, NotificationProxyError::Timeout));
361    }
362}