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            .map_err(|e| NotificationProxyError::Plist(e.to_string()))?;
161        self.stream
162            .write_all(&(buf.len() as u32).to_be_bytes())
163            .await?;
164        self.stream.write_all(&buf).await?;
165        self.stream.flush().await?;
166        Ok(())
167    }
168
169    async fn recv_message(&mut self) -> Result<NotificationProxyMessage, NotificationProxyError> {
170        let mut len_buf = [0u8; 4];
171        self.stream.read_exact(&mut len_buf).await?;
172        let len = u32::from_be_bytes(len_buf) as usize;
173        const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
174        if len > MAX_PLIST_SIZE {
175            return Err(NotificationProxyError::Protocol(format!(
176                "plist length {len} exceeds max {MAX_PLIST_SIZE}"
177            )));
178        }
179        let mut buf = vec![0u8; len];
180        self.stream.read_exact(&mut buf).await?;
181        plist::from_bytes(&buf).map_err(|e| NotificationProxyError::Plist(e.to_string()))
182    }
183}
184
185#[derive(Serialize)]
186#[serde(rename_all = "PascalCase")]
187struct NotificationProxyRequest<'a> {
188    command: &'static str,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    name: Option<&'a str>,
191}
192
193#[derive(Debug, Deserialize)]
194#[serde(rename_all = "PascalCase")]
195struct NotificationProxyMessage {
196    #[serde(default)]
197    command: Option<String>,
198    #[serde(default)]
199    name: Option<String>,
200}
201
202#[cfg(test)]
203mod tests {
204    use crate::test_util::MockStream;
205
206    use super::*;
207
208    fn plist_frame(value: plist::Value) -> Vec<u8> {
209        let mut buf = Vec::new();
210        plist::to_writer_xml(&mut buf, &value).unwrap();
211        buf
212    }
213
214    #[tokio::test]
215    async fn observe_encodes_notification_request() {
216        let mut stream = MockStream::default();
217        let mut client = NotificationProxyClient::new(&mut stream);
218        client.observe("com.apple.example.ready").await.unwrap();
219
220        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
221        let payload = &stream.written[4..4 + len];
222        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
223        assert_eq!(dict["Command"].as_string(), Some("ObserveNotification"));
224        assert_eq!(dict["Name"].as_string(), Some("com.apple.example.ready"));
225    }
226
227    #[tokio::test]
228    async fn post_encodes_notification_request() {
229        let mut stream = MockStream::default();
230        let mut client = NotificationProxyClient::new(&mut stream);
231        client.post("com.apple.example.trigger").await.unwrap();
232
233        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
234        let payload = &stream.written[4..4 + len];
235        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
236        assert_eq!(dict["Command"].as_string(), Some("PostNotification"));
237        assert_eq!(dict["Name"].as_string(), Some("com.apple.example.trigger"));
238    }
239
240    #[tokio::test]
241    async fn wait_for_matches_relay_notification() {
242        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
243            (
244                "Command".to_string(),
245                plist::Value::String("RelayNotification".into()),
246            ),
247            (
248                "Name".to_string(),
249                plist::Value::String("com.apple.example.ready".into()),
250            ),
251        ])));
252        let mut stream = MockStream::with_frames(vec![frame]);
253        let mut client = NotificationProxyClient::new(&mut stream);
254
255        client
256            .wait_for("com.apple.example.ready", Duration::from_millis(100))
257            .await
258            .unwrap();
259    }
260
261    #[tokio::test]
262    async fn recv_event_decodes_relay_notification() {
263        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
264            (
265                "Command".to_string(),
266                plist::Value::String("RelayNotification".into()),
267            ),
268            (
269                "Name".to_string(),
270                plist::Value::String("com.apple.example.ready".into()),
271            ),
272        ])));
273        let mut stream = MockStream::with_frames(vec![frame]);
274        let mut client = NotificationProxyClient::new(&mut stream);
275
276        let event = client.recv_event().await.unwrap();
277        assert_eq!(
278            event,
279            NotificationEvent::Notification("com.apple.example.ready".into())
280        );
281    }
282
283    #[tokio::test]
284    async fn recv_event_decodes_proxy_death() {
285        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([(
286            "Command".to_string(),
287            plist::Value::String("ProxyDeath".into()),
288        )])));
289        let mut stream = MockStream::with_frames(vec![frame]);
290        let mut client = NotificationProxyClient::new(&mut stream);
291
292        let event = client.recv_event().await.unwrap();
293        assert_eq!(event, NotificationEvent::ProxyDeath);
294    }
295
296    #[tokio::test]
297    async fn wait_for_springboard_uses_expected_name() {
298        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
299            (
300                "Command".to_string(),
301                plist::Value::String("RelayNotification".into()),
302            ),
303            (
304                "Name".to_string(),
305                plist::Value::String(SPRINGBOARD_FINISHED_STARTUP.into()),
306            ),
307        ])));
308        let mut stream = MockStream::with_frames(vec![frame]);
309        let mut client = NotificationProxyClient::new(&mut stream);
310
311        client
312            .wait_for_springboard(Duration::from_millis(100))
313            .await
314            .unwrap();
315    }
316
317    #[tokio::test]
318    async fn next_event_returns_notification_name() {
319        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([
320            (
321                "Command".to_string(),
322                plist::Value::String("RelayNotification".into()),
323            ),
324            (
325                "Name".to_string(),
326                plist::Value::String("com.apple.example.stream".into()),
327            ),
328        ])));
329        let mut stream = MockStream::with_frames(vec![frame]);
330        let mut client = NotificationProxyClient::new(&mut stream);
331
332        let event = client.next_event(Duration::from_millis(100)).await.unwrap();
333        assert_eq!(
334            event,
335            NotificationProxyEvent::Notification("com.apple.example.stream".into())
336        );
337    }
338
339    #[tokio::test]
340    async fn next_event_maps_proxy_death() {
341        let frame = plist_frame(plist::Value::Dictionary(plist::Dictionary::from_iter([(
342            "Command".to_string(),
343            plist::Value::String("ProxyDeath".into()),
344        )])));
345        let mut stream = MockStream::with_frames(vec![frame]);
346        let mut client = NotificationProxyClient::new(&mut stream);
347
348        let event = client.next_event(Duration::from_millis(100)).await.unwrap();
349        assert_eq!(event, NotificationProxyEvent::ProxyDeath);
350    }
351
352    #[tokio::test]
353    async fn wait_for_times_out_when_no_notification_arrives() {
354        let (client_side, _server_side) = tokio::io::duplex(1024);
355        let mut client = NotificationProxyClient::new(client_side);
356
357        let err = client
358            .wait_for("com.apple.example.ready", Duration::from_millis(10))
359            .await
360            .unwrap_err();
361        assert!(matches!(err, NotificationProxyError::Timeout));
362    }
363}