Skip to main content

ios_core/services/apps/
appservice.rs

1//! iOS 17+ CoreDevice appservice helpers for running processes and app lifecycle.
2
3use crate::xpc::{XpcClient, XpcError, XpcMessage, XpcValue};
4use bytes::Bytes;
5use indexmap::IndexMap;
6
7const COREDEVICE_PROTOCOL_VERSION: i64 = 0;
8const COREDEVICE_VERSION: &str = "325.3";
9const FEATURE_LIST_PROCESSES: &str = "com.apple.coredevice.feature.listprocesses";
10const FEATURE_LAUNCH_APPLICATION: &str = "com.apple.coredevice.feature.launchapplication";
11const FEATURE_SEND_SIGNAL: &str = "com.apple.coredevice.feature.sendsignaltoprocess";
12const SIGKILL: i64 = 9;
13
14#[derive(Debug, thiserror::Error)]
15pub enum AppServiceError {
16    #[error("xpc error: {0}")]
17    Xpc(#[from] XpcError),
18    #[error("protocol error: {0}")]
19    Protocol(String),
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RunningAppProcess {
24    pub pid: u64,
25    pub bundle_id: Option<String>,
26    pub name: String,
27    pub executable: Option<String>,
28    pub is_application: Option<bool>,
29}
30
31pub struct AppServiceClient {
32    client: XpcClient,
33    device_identifier: String,
34}
35
36impl AppServiceClient {
37    pub fn new(client: XpcClient, _device_identifier: impl Into<String>) -> Self {
38        Self {
39            client,
40            device_identifier: invocation_identifier(),
41        }
42    }
43
44    pub async fn list_processes(&mut self) -> Result<Vec<RunningAppProcess>, AppServiceError> {
45        let response = self
46            .client
47            .call(build_request(
48                &self.device_identifier,
49                FEATURE_LIST_PROCESSES,
50                XpcValue::Dictionary(IndexMap::new()),
51            ))
52            .await?;
53        parse_processes(&response)
54    }
55
56    pub async fn kill_process(&mut self, pid: u64) -> Result<(), AppServiceError> {
57        self.send_signal(pid, SIGKILL).await
58    }
59
60    pub async fn send_signal(&mut self, pid: u64, signal: i64) -> Result<(), AppServiceError> {
61        let response = self
62            .client
63            .call(build_request(
64                &self.device_identifier,
65                FEATURE_SEND_SIGNAL,
66                build_send_signal_input(pid, signal),
67            ))
68            .await?;
69        ensure_no_error(&response)?;
70        Ok(())
71    }
72
73    pub async fn launch_application(
74        &mut self,
75        bundle_id: &str,
76    ) -> Result<Option<u64>, AppServiceError> {
77        let response = self
78            .client
79            .call(build_request(
80                &self.device_identifier,
81                FEATURE_LAUNCH_APPLICATION,
82                build_launch_application_input(bundle_id)?,
83            ))
84            .await?;
85        ensure_no_error(&response)?;
86        Ok(parse_pid(response.body.as_ref()))
87    }
88}
89
90fn build_send_signal_input(pid: u64, signal: i64) -> XpcValue {
91    XpcValue::Dictionary(IndexMap::from([
92        (
93            "process".to_string(),
94            XpcValue::Dictionary(IndexMap::from([(
95                "processIdentifier".to_string(),
96                XpcValue::Int64(pid as i64),
97            )])),
98        ),
99        ("signal".to_string(), XpcValue::Int64(signal)),
100    ]))
101}
102
103fn build_launch_application_input(bundle_id: &str) -> Result<XpcValue, AppServiceError> {
104    let mut platform_specific_options = Vec::new();
105    plist::to_writer_binary(
106        &mut platform_specific_options,
107        &plist::Value::Dictionary(plist::Dictionary::new()),
108    )
109    .map_err(|error| {
110        AppServiceError::Protocol(format!("failed to encode platformSpecificOptions: {error}"))
111    })?;
112
113    Ok(XpcValue::Dictionary(IndexMap::from([
114        (
115            "applicationSpecifier".to_string(),
116            XpcValue::Dictionary(IndexMap::from([(
117                "bundleIdentifier".to_string(),
118                XpcValue::Dictionary(IndexMap::from([(
119                    "_0".to_string(),
120                    XpcValue::String(bundle_id.to_string()),
121                )])),
122            )])),
123        ),
124        (
125            "options".to_string(),
126            XpcValue::Dictionary(IndexMap::from([
127                ("arguments".to_string(), XpcValue::Array(Vec::new())),
128                (
129                    "environmentVariables".to_string(),
130                    XpcValue::Dictionary(IndexMap::new()),
131                ),
132                (
133                    "standardIOUsesPseudoterminals".to_string(),
134                    XpcValue::Bool(true),
135                ),
136                ("startStopped".to_string(), XpcValue::Bool(false)),
137                ("terminateExisting".to_string(), XpcValue::Bool(false)),
138                (
139                    "user".to_string(),
140                    XpcValue::Dictionary(IndexMap::from([
141                        ("active".to_string(), XpcValue::Bool(true)),
142                        (
143                            "shortName".to_string(),
144                            XpcValue::String("mobile".to_string()),
145                        ),
146                    ])),
147                ),
148                (
149                    "platformSpecificOptions".to_string(),
150                    XpcValue::Data(Bytes::from(platform_specific_options)),
151                ),
152            ])),
153        ),
154        (
155            "standardIOIdentifiers".to_string(),
156            XpcValue::Dictionary(IndexMap::new()),
157        ),
158    ])))
159}
160
161fn build_request(device_identifier: &str, feature_identifier: &str, input: XpcValue) -> XpcValue {
162    let mut coredevice_version = IndexMap::new();
163    coredevice_version.insert(
164        "components".to_string(),
165        XpcValue::Array(vec![
166            XpcValue::Uint64(325),
167            XpcValue::Uint64(3),
168            XpcValue::Uint64(0),
169            XpcValue::Uint64(0),
170            XpcValue::Uint64(0),
171        ]),
172    );
173    coredevice_version.insert("originalComponentsCount".to_string(), XpcValue::Int64(2));
174    coredevice_version.insert(
175        "stringValue".to_string(),
176        XpcValue::String(COREDEVICE_VERSION.to_string()),
177    );
178
179    let mut body = IndexMap::new();
180    body.insert(
181        "CoreDevice.CoreDeviceDDIProtocolVersion".to_string(),
182        XpcValue::Int64(COREDEVICE_PROTOCOL_VERSION),
183    );
184    body.insert(
185        "CoreDevice.action".to_string(),
186        XpcValue::Dictionary(IndexMap::new()),
187    );
188    body.insert(
189        "CoreDevice.coreDeviceVersion".to_string(),
190        XpcValue::Dictionary(coredevice_version),
191    );
192    body.insert(
193        "CoreDevice.deviceIdentifier".to_string(),
194        XpcValue::String(device_identifier.to_string()),
195    );
196    body.insert(
197        "CoreDevice.featureIdentifier".to_string(),
198        XpcValue::String(feature_identifier.to_string()),
199    );
200    body.insert("CoreDevice.input".to_string(), input);
201    body.insert(
202        "CoreDevice.invocationIdentifier".to_string(),
203        XpcValue::String(invocation_identifier()),
204    );
205    XpcValue::Dictionary(body)
206}
207
208fn invocation_identifier() -> String {
209    use std::time::{SystemTime, UNIX_EPOCH};
210
211    let nanos = SystemTime::now()
212        .duration_since(UNIX_EPOCH)
213        .map(|d| d.as_nanos())
214        .unwrap_or(0);
215    let raw = format!("{nanos:032x}");
216    format!(
217        "{}-{}-{}-{}-{}",
218        &raw[0..8],
219        &raw[8..12],
220        &raw[12..16],
221        &raw[16..20],
222        &raw[20..32]
223    )
224}
225
226fn parse_processes(response: &XpcMessage) -> Result<Vec<RunningAppProcess>, AppServiceError> {
227    ensure_no_error(response)?;
228    let body = response
229        .body
230        .as_ref()
231        .ok_or_else(|| AppServiceError::Protocol("missing response body".into()))?;
232    let payload = coredevice_output(body).unwrap_or(body);
233
234    let items = process_items(payload).ok_or_else(|| {
235        AppServiceError::Protocol(format!("unexpected process list payload: {payload:?}"))
236    })?;
237
238    Ok(items.iter().filter_map(parse_process).collect())
239}
240
241fn coredevice_output(value: &XpcValue) -> Option<&XpcValue> {
242    value.as_dict()?.get("CoreDevice.output")
243}
244
245fn process_items(value: &XpcValue) -> Option<&[XpcValue]> {
246    match value {
247        XpcValue::Array(items) => Some(items.as_slice()),
248        XpcValue::Dictionary(dict) => {
249            for key in ["processTokens", "processes", "items"] {
250                if let Some(XpcValue::Array(items)) = dict.get(key) {
251                    return Some(items.as_slice());
252                }
253            }
254            None
255        }
256        _ => None,
257    }
258}
259
260fn parse_process(value: &XpcValue) -> Option<RunningAppProcess> {
261    let dict = value.as_dict()?;
262    let pid = dict
263        .get("processIdentifier")
264        .and_then(as_u64)
265        .or_else(|| dict.get("pid").and_then(as_u64))?;
266    let name = string_field(
267        dict,
268        &[
269            "localizedName",
270            "name",
271            "executableDisplayName",
272            "bundleIdentifier",
273        ],
274    )?;
275    let bundle_id = string_field(dict, &["bundleIdentifier", "bundleIdentifierKey"]);
276    let executable = string_field(dict, &["executableName", "name"]);
277    let is_application = dict.get("isApplication").and_then(as_bool);
278
279    Some(RunningAppProcess {
280        pid,
281        bundle_id,
282        name,
283        executable,
284        is_application,
285    })
286}
287
288fn ensure_no_error(response: &XpcMessage) -> Result<(), AppServiceError> {
289    if let Some(body) = response.body.as_ref() {
290        if let Some(message) = error_message(body) {
291            return Err(AppServiceError::Protocol(message));
292        }
293    }
294    Ok(())
295}
296
297fn error_message(value: &XpcValue) -> Option<String> {
298    let dict = value.as_dict()?;
299    for key in ["CoreDevice.error", "error", "Error", "NSError", "userInfo"] {
300        if let Some(found) = dict.get(key) {
301            if let Some(message) = nested_error_message(found) {
302                return Some(message);
303            }
304            return Some(format!("{found:?}"));
305        }
306    }
307    None
308}
309
310fn nested_error_message(value: &XpcValue) -> Option<String> {
311    match value {
312        XpcValue::String(s) => Some(s.clone()),
313        XpcValue::Dictionary(dict) => {
314            for key in [
315                "message",
316                "localizedDescription",
317                "NSLocalizedDescription",
318                "description",
319            ] {
320                if let Some(XpcValue::String(s)) = dict.get(key) {
321                    return Some(s.clone());
322                }
323            }
324            None
325        }
326        _ => None,
327    }
328}
329
330fn parse_pid(value: Option<&XpcValue>) -> Option<u64> {
331    match value {
332        Some(XpcValue::Uint64(pid)) => Some(*pid),
333        Some(XpcValue::Int64(pid)) if *pid >= 0 => Some(*pid as u64),
334        Some(XpcValue::Dictionary(dict)) => {
335            for key in ["processIdentifier", "pid"] {
336                if let Some(pid) = dict.get(key).and_then(as_u64) {
337                    return Some(pid);
338                }
339            }
340            for key in [
341                "CoreDevice.output",
342                "processToken",
343                "process",
344                "launchedProcess",
345            ] {
346                if let Some(pid) = parse_pid(dict.get(key)) {
347                    return Some(pid);
348                }
349            }
350            None
351        }
352        _ => None,
353    }
354}
355
356fn string_field(dict: &IndexMap<String, XpcValue>, keys: &[&str]) -> Option<String> {
357    keys.iter().find_map(|key| {
358        dict.get(*key)
359            .and_then(|v| v.as_str())
360            .map(ToOwned::to_owned)
361    })
362}
363
364fn as_u64(value: &XpcValue) -> Option<u64> {
365    match value {
366        XpcValue::Uint64(n) => Some(*n),
367        XpcValue::Int64(n) if *n >= 0 => Some(*n as u64),
368        _ => None,
369    }
370}
371
372fn as_bool(value: &XpcValue) -> Option<bool> {
373    match value {
374        XpcValue::Bool(v) => Some(*v),
375        _ => None,
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn build_request_wraps_coredevice_envelope() {
385        let request = build_request(
386            "DEVICE-ID",
387            FEATURE_SEND_SIGNAL,
388            XpcValue::Dictionary(IndexMap::from([
389                ("processIdentifier".to_string(), XpcValue::Uint64(42)),
390                ("signal".to_string(), XpcValue::Int64(SIGKILL)),
391            ])),
392        );
393
394        let dict = request.as_dict().unwrap();
395        assert_eq!(
396            dict["CoreDevice.featureIdentifier"].as_str(),
397            Some(FEATURE_SEND_SIGNAL)
398        );
399        assert_eq!(
400            dict["CoreDevice.deviceIdentifier"].as_str(),
401            Some("DEVICE-ID")
402        );
403        assert!(dict["CoreDevice.invocationIdentifier"]
404            .as_str()
405            .unwrap()
406            .contains('-'));
407    }
408
409    #[test]
410    fn build_send_signal_input_nests_process_identifier() {
411        let input = build_send_signal_input(42, SIGKILL);
412        let dict = input.as_dict().unwrap();
413        let process = dict["process"].as_dict().unwrap();
414
415        assert_eq!(process["processIdentifier"], XpcValue::Int64(42));
416        assert_eq!(dict["signal"], XpcValue::Int64(SIGKILL));
417    }
418
419    #[test]
420    fn build_launch_application_input_matches_reference_shape() {
421        let input = build_launch_application_input("com.example.App").unwrap();
422        let dict = input.as_dict().unwrap();
423        let application_specifier = dict["applicationSpecifier"].as_dict().unwrap();
424        let bundle_identifier = application_specifier["bundleIdentifier"].as_dict().unwrap();
425        let options = dict["options"].as_dict().unwrap();
426        let user = options["user"].as_dict().unwrap();
427
428        assert_eq!(bundle_identifier["_0"].as_str(), Some("com.example.App"));
429        assert_eq!(options["arguments"], XpcValue::Array(Vec::new()));
430        assert_eq!(
431            options["environmentVariables"],
432            XpcValue::Dictionary(IndexMap::new())
433        );
434        assert_eq!(
435            options["standardIOUsesPseudoterminals"],
436            XpcValue::Bool(true)
437        );
438        assert_eq!(options["startStopped"], XpcValue::Bool(false));
439        assert_eq!(options["terminateExisting"], XpcValue::Bool(false));
440        assert_eq!(user["active"], XpcValue::Bool(true));
441        assert_eq!(user["shortName"].as_str(), Some("mobile"));
442        assert_eq!(
443            dict["standardIOIdentifiers"],
444            XpcValue::Dictionary(IndexMap::new())
445        );
446
447        let XpcValue::Data(platform_specific_options) = &options["platformSpecificOptions"] else {
448            panic!("platformSpecificOptions should be XPC data");
449        };
450        let decoded: plist::Value =
451            plist::from_bytes(platform_specific_options).expect("binary plist decode");
452        assert_eq!(decoded, plist::Value::Dictionary(plist::Dictionary::new()));
453    }
454
455    #[test]
456    fn parse_processes_reads_coredevice_output_envelope() {
457        let process = XpcValue::Dictionary(IndexMap::from([
458            ("processIdentifier".to_string(), XpcValue::Uint64(99)),
459            (
460                "bundleIdentifier".to_string(),
461                XpcValue::String("com.example.App".into()),
462            ),
463            (
464                "localizedName".to_string(),
465                XpcValue::String("Example".into()),
466            ),
467            (
468                "executableName".to_string(),
469                XpcValue::String("ExampleBin".into()),
470            ),
471            ("isApplication".to_string(), XpcValue::Bool(true)),
472        ]));
473        let response = XpcMessage {
474            flags: 0,
475            msg_id: 1,
476            body: Some(XpcValue::Dictionary(IndexMap::from([(
477                "CoreDevice.output".to_string(),
478                XpcValue::Dictionary(IndexMap::from([(
479                    "processTokens".to_string(),
480                    XpcValue::Array(vec![process]),
481                )])),
482            )]))),
483        };
484
485        let parsed = parse_processes(&response).unwrap();
486        assert_eq!(parsed.len(), 1);
487        assert_eq!(parsed[0].pid, 99);
488        assert_eq!(parsed[0].bundle_id.as_deref(), Some("com.example.App"));
489    }
490
491    #[test]
492    fn ensure_no_error_reads_coredevice_error_envelope() {
493        let response = XpcMessage {
494            flags: 0,
495            msg_id: 1,
496            body: Some(XpcValue::Dictionary(IndexMap::from([(
497                "CoreDevice.error".to_string(),
498                XpcValue::Dictionary(IndexMap::from([(
499                    "localizedDescription".to_string(),
500                    XpcValue::String("boom".into()),
501                )])),
502            )]))),
503        };
504
505        let err = ensure_no_error(&response).unwrap_err();
506        assert!(matches!(err, AppServiceError::Protocol(message) if message == "boom"));
507    }
508
509    #[test]
510    fn parse_pid_accepts_coredevice_output_process_token() {
511        let pid = parse_pid(Some(&XpcValue::Dictionary(IndexMap::from([(
512            "CoreDevice.output".to_string(),
513            XpcValue::Dictionary(IndexMap::from([(
514                "processToken".to_string(),
515                XpcValue::Dictionary(IndexMap::from([(
516                    "processIdentifier".to_string(),
517                    XpcValue::Uint64(31337),
518                )])),
519            )])),
520        )]))));
521
522        assert_eq!(pid, Some(31337));
523    }
524}