Skip to main content

ios_core/services/apps/
appservice.rs

1//! iOS 17+ CoreDevice appservice helpers for running processes and app lifecycle.
2//!
3//! Appservice is exposed through the CoreDevice feature invocation envelope rather
4//! than the legacy InstallationProxy service. Use it for process listing, launching,
5//! spawning executables, icon retrieval, and process termination monitoring when the
6//! device exposes the appservice features through RSD.
7
8use crate::xpc::{XpcClient, XpcError, XpcMessage, XpcValue};
9use bytes::Bytes;
10use indexmap::IndexMap;
11
12const FEATURE_LIST_PROCESSES: &str = "com.apple.coredevice.feature.listprocesses";
13const FEATURE_LIST_APPS: &str = "com.apple.coredevice.feature.listapps";
14const FEATURE_LIST_ROOTS: &str = "com.apple.coredevice.feature.listroots";
15const FEATURE_LAUNCH_APPLICATION: &str = "com.apple.coredevice.feature.launchapplication";
16const FEATURE_SPAWN_EXECUTABLE: &str = "com.apple.coredevice.feature.spawnexecutable";
17const FEATURE_FETCH_APP_ICONS: &str = "com.apple.coredevice.feature.fetchappicons";
18const FEATURE_MONITOR_PROCESS_TERMINATION: &str =
19    "com.apple.coredevice.feature.monitorprocesstermination";
20const FEATURE_SEND_SIGNAL: &str = "com.apple.coredevice.feature.sendsignaltoprocess";
21const SIGKILL: i64 = 9;
22
23/// Errors returned by CoreDevice appservice operations.
24#[derive(Debug, thiserror::Error)]
25pub enum AppServiceError {
26    /// Underlying XPC transport or encoding error.
27    #[error("xpc error: {0}")]
28    Xpc(#[from] XpcError),
29    /// Appservice response did not match the expected protocol shape.
30    #[error("protocol error: {0}")]
31    Protocol(String),
32}
33
34/// Running process entry returned by `list_processes`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RunningAppProcess {
37    /// Process identifier.
38    pub pid: u64,
39    /// Bundle identifier when the process maps to an app.
40    pub bundle_id: Option<String>,
41    /// Display or executable name reported by CoreDevice.
42    pub name: String,
43    /// Executable path or name when present in the response.
44    pub executable: Option<String>,
45    /// Whether CoreDevice classified the process as an application.
46    pub is_application: Option<bool>,
47}
48
49/// Filters used for CoreDevice app listing.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct ListAppsOptions {
52    /// Include App Clip bundles.
53    pub include_app_clips: bool,
54    /// Include removable user apps.
55    pub include_removable_apps: bool,
56    /// Include hidden apps.
57    pub include_hidden_apps: bool,
58    /// Include internal Apple apps.
59    pub include_internal_apps: bool,
60    /// Include default system apps.
61    pub include_default_apps: bool,
62}
63
64impl Default for ListAppsOptions {
65    fn default() -> Self {
66        Self {
67            include_app_clips: true,
68            include_removable_apps: true,
69            include_hidden_apps: true,
70            include_internal_apps: true,
71            include_default_apps: true,
72        }
73    }
74}
75
76/// Application metadata returned by CoreDevice app listing.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct CoreDeviceAppInfo {
79    /// Bundle identifier.
80    pub bundle_id: String,
81    /// Display name when CoreDevice provides one.
82    pub name: Option<String>,
83    /// Version string when present.
84    pub version: Option<String>,
85    /// Whether the app can be removed by the user.
86    pub is_removable: Option<bool>,
87    /// Whether the app is hidden from normal listing.
88    pub is_hidden: Option<bool>,
89    /// Whether CoreDevice marks the app as internal.
90    pub is_internal: Option<bool>,
91    /// Whether the bundle is an App Clip.
92    pub is_app_clip: Option<bool>,
93}
94
95/// Options for launching an application through CoreDevice.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct LaunchApplicationOptions {
98    /// Command-line arguments passed to the app process.
99    pub arguments: Vec<String>,
100    /// Environment variables passed to the app process.
101    pub environment_variables: IndexMap<String, String>,
102    /// Request pseudo-terminal backed standard I/O.
103    pub standard_io_uses_pseudoterminals: bool,
104    /// Start the process suspended.
105    pub start_stopped: bool,
106    /// Terminate an existing instance before launching.
107    pub terminate_existing: bool,
108    /// Optional CoreDevice standard I/O routing identifiers.
109    pub standard_io_identifiers: IndexMap<String, String>,
110}
111
112impl Default for LaunchApplicationOptions {
113    fn default() -> Self {
114        Self {
115            arguments: Vec::new(),
116            environment_variables: IndexMap::new(),
117            standard_io_uses_pseudoterminals: true,
118            start_stopped: false,
119            terminate_existing: false,
120            standard_io_identifiers: IndexMap::new(),
121        }
122    }
123}
124
125/// Raw icon payload and scale metadata returned by CoreDevice.
126#[derive(Debug, Clone, PartialEq)]
127pub struct AppIcon {
128    /// Encoded image bytes.
129    pub data: Bytes,
130    /// Logical icon width.
131    pub width: Option<f64>,
132    /// Logical icon height.
133    pub height: Option<f64>,
134    /// Icon scale factor.
135    pub scale: Option<f64>,
136}
137
138/// Process termination event returned by CoreDevice monitoring.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ProcessTermination {
141    /// Terminated process identifier.
142    pub pid: Option<u64>,
143    /// Exit status when the process exited normally.
144    pub exit_status: Option<i64>,
145    /// Signal number when the process was signaled.
146    pub signal: Option<i64>,
147    /// Human-readable reason when CoreDevice provides one.
148    pub reason: Option<String>,
149}
150
151/// Client for CoreDevice appservice feature calls.
152pub struct AppServiceClient {
153    client: XpcClient,
154    device_identifier: String,
155}
156
157impl AppServiceClient {
158    /// Create an appservice client from an initialized XPC client and device identifier.
159    pub fn new(client: XpcClient, device_identifier: impl Into<String>) -> Self {
160        Self {
161            client,
162            device_identifier: device_identifier.into(),
163        }
164    }
165
166    /// List running processes visible to CoreDevice.
167    pub async fn list_processes(&mut self) -> Result<Vec<RunningAppProcess>, AppServiceError> {
168        let response = self
169            .client
170            .call(build_request(
171                &self.device_identifier,
172                FEATURE_LIST_PROCESSES,
173                XpcValue::Dictionary(IndexMap::new()),
174            ))
175            .await?;
176        parse_processes(&response)
177    }
178
179    /// List installed apps using the supplied CoreDevice filters.
180    pub async fn list_apps(
181        &mut self,
182        options: ListAppsOptions,
183    ) -> Result<Vec<CoreDeviceAppInfo>, AppServiceError> {
184        let response = self
185            .client
186            .call(build_request(
187                &self.device_identifier,
188                FEATURE_LIST_APPS,
189                build_list_apps_input(options),
190            ))
191            .await?;
192        parse_apps(&response)
193    }
194
195    /// Return appservice root descriptors as the raw CoreDevice output value.
196    pub async fn list_roots(&mut self) -> Result<XpcValue, AppServiceError> {
197        let response = self
198            .client
199            .call(build_request(
200                &self.device_identifier,
201                FEATURE_LIST_ROOTS,
202                build_list_roots_input(),
203            ))
204            .await?;
205        parse_output_value(response)
206    }
207
208    /// Send SIGKILL to a process.
209    pub async fn kill_process(&mut self, pid: u64) -> Result<(), AppServiceError> {
210        self.send_signal(pid, SIGKILL).await
211    }
212
213    /// Send an arbitrary signal to a process identifier.
214    pub async fn send_signal(&mut self, pid: u64, signal: i64) -> Result<(), AppServiceError> {
215        let response = self
216            .client
217            .call(build_request(
218                &self.device_identifier,
219                FEATURE_SEND_SIGNAL,
220                build_send_signal_input(pid, signal)?,
221            ))
222            .await?;
223        ensure_no_error(&response)?;
224        Ok(())
225    }
226
227    /// Launch an app using default CoreDevice options.
228    pub async fn launch_application(
229        &mut self,
230        bundle_id: &str,
231    ) -> Result<Option<u64>, AppServiceError> {
232        let response = self
233            .client
234            .call(build_request(
235                &self.device_identifier,
236                FEATURE_LAUNCH_APPLICATION,
237                build_launch_application_input(bundle_id)?,
238            ))
239            .await?;
240        ensure_no_error(&response)?;
241        Ok(parse_pid(response.body.as_ref()))
242    }
243
244    /// Launch an app using explicit CoreDevice launch options.
245    pub async fn launch_application_with_options(
246        &mut self,
247        bundle_id: &str,
248        options: &LaunchApplicationOptions,
249    ) -> Result<Option<u64>, AppServiceError> {
250        let response = self
251            .client
252            .call(build_request(
253                &self.device_identifier,
254                FEATURE_LAUNCH_APPLICATION,
255                build_launch_application_input_with_options(bundle_id, options)?,
256            ))
257            .await?;
258        ensure_no_error(&response)?;
259        Ok(parse_pid(response.body.as_ref()))
260    }
261
262    /// Spawn an executable path with command-line arguments.
263    pub async fn spawn_executable(
264        &mut self,
265        executable: &str,
266        arguments: &[String],
267    ) -> Result<Option<u64>, AppServiceError> {
268        let response = self
269            .client
270            .call(build_request(
271                &self.device_identifier,
272                FEATURE_SPAWN_EXECUTABLE,
273                build_spawn_executable_input(executable, arguments)?,
274            ))
275            .await?;
276        ensure_no_error(&response)?;
277        Ok(parse_pid(response.body.as_ref()))
278    }
279
280    /// Fetch one or more rendered app icons for a bundle.
281    pub async fn fetch_app_icons(
282        &mut self,
283        bundle_id: &str,
284        width: f64,
285        height: f64,
286        scale: f64,
287        allow_placeholder: bool,
288    ) -> Result<Vec<AppIcon>, AppServiceError> {
289        let response = self
290            .client
291            .call(build_request(
292                &self.device_identifier,
293                FEATURE_FETCH_APP_ICONS,
294                build_fetch_app_icons_input(bundle_id, width, height, scale, allow_placeholder),
295            ))
296            .await?;
297        parse_app_icons(&response)
298    }
299
300    /// Wait for CoreDevice to report a process termination event.
301    pub async fn monitor_process_termination(
302        &mut self,
303        pid: u64,
304    ) -> Result<ProcessTermination, AppServiceError> {
305        let response = self
306            .client
307            .call(build_request(
308                &self.device_identifier,
309                FEATURE_MONITOR_PROCESS_TERMINATION,
310                build_monitor_process_termination_input(pid)?,
311            ))
312            .await?;
313        parse_process_termination(&response)
314    }
315}
316
317fn build_list_apps_input(options: ListAppsOptions) -> XpcValue {
318    XpcValue::Dictionary(IndexMap::from([
319        (
320            "includeAppClips".to_string(),
321            XpcValue::Bool(options.include_app_clips),
322        ),
323        (
324            "includeRemovableApps".to_string(),
325            XpcValue::Bool(options.include_removable_apps),
326        ),
327        (
328            "includeHiddenApps".to_string(),
329            XpcValue::Bool(options.include_hidden_apps),
330        ),
331        (
332            "includeInternalApps".to_string(),
333            XpcValue::Bool(options.include_internal_apps),
334        ),
335        (
336            "includeDefaultApps".to_string(),
337            XpcValue::Bool(options.include_default_apps),
338        ),
339    ]))
340}
341
342fn build_list_roots_input() -> XpcValue {
343    XpcValue::Dictionary(IndexMap::from([(
344        "rootPoint".to_string(),
345        XpcValue::Dictionary(IndexMap::from([(
346            "relative".to_string(),
347            XpcValue::String("/".to_string()),
348        )])),
349    )]))
350}
351
352fn build_send_signal_input(pid: u64, signal: i64) -> Result<XpcValue, AppServiceError> {
353    let pid = process_identifier_to_i64(pid)?;
354    Ok(XpcValue::Dictionary(IndexMap::from([
355        (
356            "process".to_string(),
357            XpcValue::Dictionary(IndexMap::from([(
358                "processIdentifier".to_string(),
359                XpcValue::Int64(pid),
360            )])),
361        ),
362        ("signal".to_string(), XpcValue::Int64(signal)),
363    ])))
364}
365
366fn build_spawn_executable_input(
367    executable: &str,
368    arguments: &[String],
369) -> Result<XpcValue, AppServiceError> {
370    let platform_specific_options = empty_binary_plist("platformSpecificOptions")?;
371
372    Ok(XpcValue::Dictionary(IndexMap::from([
373        (
374            "executableItem".to_string(),
375            XpcValue::Dictionary(IndexMap::from([(
376                "url".to_string(),
377                XpcValue::Dictionary(IndexMap::from([(
378                    "_0".to_string(),
379                    XpcValue::Dictionary(IndexMap::from([(
380                        "relative".to_string(),
381                        XpcValue::String(executable.to_string()),
382                    )])),
383                )])),
384            )])),
385        ),
386        (
387            "standardIOIdentifiers".to_string(),
388            XpcValue::Dictionary(IndexMap::new()),
389        ),
390        (
391            "options".to_string(),
392            XpcValue::Dictionary(IndexMap::from([
393                (
394                    "arguments".to_string(),
395                    XpcValue::Array(
396                        arguments
397                            .iter()
398                            .map(|argument| XpcValue::String(argument.clone()))
399                            .collect(),
400                    ),
401                ),
402                (
403                    "environmentVariables".to_string(),
404                    XpcValue::Dictionary(IndexMap::new()),
405                ),
406                (
407                    "standardIOUsesPseudoterminals".to_string(),
408                    XpcValue::Bool(true),
409                ),
410                ("startStopped".to_string(), XpcValue::Bool(false)),
411                (
412                    "user".to_string(),
413                    XpcValue::Dictionary(IndexMap::from([(
414                        "active".to_string(),
415                        XpcValue::Bool(true),
416                    )])),
417                ),
418                (
419                    "platformSpecificOptions".to_string(),
420                    XpcValue::Data(platform_specific_options),
421                ),
422            ])),
423        ),
424    ])))
425}
426
427fn build_fetch_app_icons_input(
428    bundle_id: &str,
429    width: f64,
430    height: f64,
431    scale: f64,
432    allow_placeholder: bool,
433) -> XpcValue {
434    XpcValue::Dictionary(IndexMap::from([
435        ("width".to_string(), XpcValue::Double(width)),
436        ("height".to_string(), XpcValue::Double(height)),
437        ("scale".to_string(), XpcValue::Double(scale)),
438        (
439            "allowPlaceholder".to_string(),
440            XpcValue::Bool(allow_placeholder),
441        ),
442        (
443            "bundleIdentifier".to_string(),
444            XpcValue::String(bundle_id.to_string()),
445        ),
446    ]))
447}
448
449fn build_monitor_process_termination_input(pid: u64) -> Result<XpcValue, AppServiceError> {
450    let pid = process_identifier_to_i64(pid)?;
451    Ok(XpcValue::Dictionary(IndexMap::from([(
452        "processToken".to_string(),
453        XpcValue::Dictionary(IndexMap::from([(
454            "processIdentifier".to_string(),
455            XpcValue::Int64(pid),
456        )])),
457    )])))
458}
459
460fn process_identifier_to_i64(pid: u64) -> Result<i64, AppServiceError> {
461    i64::try_from(pid).map_err(|_| {
462        AppServiceError::Protocol(format!("process id exceeds DTX integer range: {pid}"))
463    })
464}
465
466fn build_launch_application_input(bundle_id: &str) -> Result<XpcValue, AppServiceError> {
467    build_launch_application_input_with_options(bundle_id, &LaunchApplicationOptions::default())
468}
469
470fn build_launch_application_input_with_options(
471    bundle_id: &str,
472    options: &LaunchApplicationOptions,
473) -> Result<XpcValue, AppServiceError> {
474    let platform_specific_options = empty_binary_plist("platformSpecificOptions")?;
475
476    Ok(XpcValue::Dictionary(IndexMap::from([
477        (
478            "applicationSpecifier".to_string(),
479            XpcValue::Dictionary(IndexMap::from([(
480                "bundleIdentifier".to_string(),
481                XpcValue::Dictionary(IndexMap::from([(
482                    "_0".to_string(),
483                    XpcValue::String(bundle_id.to_string()),
484                )])),
485            )])),
486        ),
487        (
488            "options".to_string(),
489            XpcValue::Dictionary(IndexMap::from([
490                (
491                    "arguments".to_string(),
492                    XpcValue::Array(
493                        options
494                            .arguments
495                            .iter()
496                            .map(|argument| XpcValue::String(argument.clone()))
497                            .collect(),
498                    ),
499                ),
500                (
501                    "environmentVariables".to_string(),
502                    string_map_to_xpc_dict(&options.environment_variables),
503                ),
504                (
505                    "standardIOUsesPseudoterminals".to_string(),
506                    XpcValue::Bool(options.standard_io_uses_pseudoterminals),
507                ),
508                (
509                    "startStopped".to_string(),
510                    XpcValue::Bool(options.start_stopped),
511                ),
512                (
513                    "terminateExisting".to_string(),
514                    XpcValue::Bool(options.terminate_existing),
515                ),
516                (
517                    "user".to_string(),
518                    XpcValue::Dictionary(IndexMap::from([
519                        ("active".to_string(), XpcValue::Bool(true)),
520                        (
521                            "shortName".to_string(),
522                            XpcValue::String("mobile".to_string()),
523                        ),
524                    ])),
525                ),
526                (
527                    "platformSpecificOptions".to_string(),
528                    XpcValue::Data(platform_specific_options),
529                ),
530            ])),
531        ),
532        (
533            "standardIOIdentifiers".to_string(),
534            string_map_to_xpc_dict(&options.standard_io_identifiers),
535        ),
536    ])))
537}
538
539fn string_map_to_xpc_dict(values: &IndexMap<String, String>) -> XpcValue {
540    XpcValue::Dictionary(
541        values
542            .iter()
543            .map(|(key, value)| (key.clone(), XpcValue::String(value.clone())))
544            .collect(),
545    )
546}
547
548fn empty_binary_plist(field_name: &str) -> Result<Bytes, AppServiceError> {
549    let mut bytes = Vec::new();
550    plist::to_writer_binary(
551        &mut bytes,
552        &plist::Value::Dictionary(plist::Dictionary::new()),
553    )
554    .map_err(|error| {
555        AppServiceError::Protocol(format!("failed to encode {field_name}: {error}"))
556    })?;
557    Ok(Bytes::from(bytes))
558}
559
560fn build_request(device_identifier: &str, feature_identifier: &str, input: XpcValue) -> XpcValue {
561    crate::services::coredevice::build_request(device_identifier, feature_identifier, input)
562}
563
564fn parse_processes(response: &XpcMessage) -> Result<Vec<RunningAppProcess>, AppServiceError> {
565    let payload = output_ref(response)?;
566
567    let items = process_items(payload).ok_or_else(|| {
568        AppServiceError::Protocol(format!("unexpected process list payload: {payload:?}"))
569    })?;
570
571    Ok(items.iter().filter_map(parse_process).collect())
572}
573
574fn parse_apps(response: &XpcMessage) -> Result<Vec<CoreDeviceAppInfo>, AppServiceError> {
575    let payload = output_ref(response)?;
576    let items = app_items(payload).ok_or_else(|| {
577        AppServiceError::Protocol(format!("unexpected app list payload: {payload:?}"))
578    })?;
579
580    Ok(items.iter().filter_map(parse_app).collect())
581}
582
583fn parse_app_icons(response: &XpcMessage) -> Result<Vec<AppIcon>, AppServiceError> {
584    let payload = output_ref(response)?;
585    let items = icon_items(payload).ok_or_else(|| {
586        AppServiceError::Protocol(format!("unexpected app icon payload: {payload:?}"))
587    })?;
588
589    Ok(items.iter().filter_map(parse_app_icon).collect())
590}
591
592fn parse_process_termination(response: &XpcMessage) -> Result<ProcessTermination, AppServiceError> {
593    let payload = output_ref(response)?;
594    let dict = payload.as_dict().ok_or_else(|| {
595        AppServiceError::Protocol(format!(
596            "unexpected process termination payload: {payload:?}"
597        ))
598    })?;
599
600    Ok(ProcessTermination {
601        pid: parse_pid(Some(payload)),
602        exit_status: integer_field(dict, &["exitStatus", "exitCode", "status"]),
603        signal: integer_field(dict, &["signal", "terminationSignal"]),
604        reason: string_field(dict, &["reason", "terminationReason", "message"]),
605    })
606}
607
608fn parse_output_value(response: XpcMessage) -> Result<XpcValue, AppServiceError> {
609    crate::services::coredevice::parse_output(response).map_err(AppServiceError::Protocol)
610}
611
612fn output_ref(response: &XpcMessage) -> Result<&XpcValue, AppServiceError> {
613    ensure_no_error(response)?;
614    let body = response
615        .body
616        .as_ref()
617        .ok_or_else(|| AppServiceError::Protocol("missing response body".into()))?;
618    Ok(crate::services::coredevice::output(body).unwrap_or(body))
619}
620
621fn process_items(value: &XpcValue) -> Option<&[XpcValue]> {
622    match value {
623        XpcValue::Array(items) => Some(items.as_slice()),
624        XpcValue::Dictionary(dict) => {
625            for key in ["processTokens", "processes", "items"] {
626                if let Some(XpcValue::Array(items)) = dict.get(key) {
627                    return Some(items.as_slice());
628                }
629            }
630            None
631        }
632        _ => None,
633    }
634}
635
636fn app_items(value: &XpcValue) -> Option<&[XpcValue]> {
637    match value {
638        XpcValue::Array(items) => Some(items.as_slice()),
639        XpcValue::Dictionary(dict) => {
640            for key in ["apps", "appTokens", "applications", "items"] {
641                if let Some(XpcValue::Array(items)) = dict.get(key) {
642                    return Some(items.as_slice());
643                }
644            }
645            None
646        }
647        _ => None,
648    }
649}
650
651fn icon_items(value: &XpcValue) -> Option<&[XpcValue]> {
652    match value {
653        XpcValue::Array(items) => Some(items.as_slice()),
654        XpcValue::Dictionary(dict) => {
655            for key in ["icons", "appIcons", "items"] {
656                if let Some(XpcValue::Array(items)) = dict.get(key) {
657                    return Some(items.as_slice());
658                }
659            }
660            if has_icon_data(dict) {
661                Some(std::slice::from_ref(value))
662            } else {
663                None
664            }
665        }
666        _ => None,
667    }
668}
669
670fn parse_process(value: &XpcValue) -> Option<RunningAppProcess> {
671    let dict = value.as_dict()?;
672    let pid = dict
673        .get("processIdentifier")
674        .and_then(as_u64)
675        .or_else(|| dict.get("pid").and_then(as_u64))?;
676    let executable_url = url_relative(dict.get("executableURL"));
677    let name = string_field(
678        dict,
679        &[
680            "localizedName",
681            "name",
682            "executableDisplayName",
683            "bundleIdentifier",
684        ],
685    )
686    .or_else(|| executable_url.as_deref().and_then(file_name))
687    .unwrap_or_else(|| pid.to_string());
688    let bundle_id = string_field(dict, &["bundleIdentifier", "bundleIdentifierKey"]);
689    let executable = executable_url.or_else(|| string_field(dict, &["executableName", "name"]));
690    let is_application = dict.get("isApplication").and_then(as_bool);
691
692    Some(RunningAppProcess {
693        pid,
694        bundle_id,
695        name,
696        executable,
697        is_application,
698    })
699}
700
701fn parse_app(value: &XpcValue) -> Option<CoreDeviceAppInfo> {
702    let dict = value.as_dict()?;
703    let bundle_id = string_field(
704        dict,
705        &["bundleIdentifier", "bundleID", "CFBundleIdentifier"],
706    )?;
707
708    Some(CoreDeviceAppInfo {
709        bundle_id,
710        name: string_field(
711            dict,
712            &["localizedName", "displayName", "name", "CFBundleName"],
713        ),
714        version: string_field(dict, &["version", "bundleVersion", "CFBundleVersion"]),
715        is_removable: bool_field(dict, &["isRemovable", "removable"]),
716        is_hidden: bool_field(dict, &["isHidden", "hidden"]),
717        is_internal: bool_field(dict, &["isInternal", "internal"]),
718        is_app_clip: bool_field(dict, &["isAppClip", "appClip"]),
719    })
720}
721
722fn parse_app_icon(value: &XpcValue) -> Option<AppIcon> {
723    let dict = value.as_dict()?;
724    let data = data_field(dict, &["iconData", "data", "pngData", "bitmapData"])?;
725
726    Some(AppIcon {
727        data,
728        width: double_field(dict, &["width"]),
729        height: double_field(dict, &["height"]),
730        scale: double_field(dict, &["scale"]),
731    })
732}
733
734fn ensure_no_error(response: &XpcMessage) -> Result<(), AppServiceError> {
735    if let Some(body) = response.body.as_ref() {
736        crate::services::coredevice::ensure_no_error(body).map_err(AppServiceError::Protocol)?;
737    }
738    Ok(())
739}
740
741fn parse_pid(value: Option<&XpcValue>) -> Option<u64> {
742    match value {
743        Some(XpcValue::Uint64(pid)) => Some(*pid),
744        Some(XpcValue::Int64(pid)) if *pid >= 0 => Some(*pid as u64),
745        Some(XpcValue::Dictionary(dict)) => {
746            for key in ["processIdentifier", "pid"] {
747                if let Some(pid) = dict.get(key).and_then(as_u64) {
748                    return Some(pid);
749                }
750            }
751            for key in [
752                "CoreDevice.output",
753                "processToken",
754                "process",
755                "launchedProcess",
756                "executableToken",
757            ] {
758                if let Some(pid) = parse_pid(dict.get(key)) {
759                    return Some(pid);
760                }
761            }
762            None
763        }
764        _ => None,
765    }
766}
767
768fn string_field(dict: &IndexMap<String, XpcValue>, keys: &[&str]) -> Option<String> {
769    keys.iter().find_map(|key| {
770        dict.get(*key)
771            .and_then(|v| v.as_str())
772            .map(ToOwned::to_owned)
773    })
774}
775
776fn bool_field(dict: &IndexMap<String, XpcValue>, keys: &[&str]) -> Option<bool> {
777    keys.iter().find_map(|key| dict.get(*key).and_then(as_bool))
778}
779
780fn integer_field(dict: &IndexMap<String, XpcValue>, keys: &[&str]) -> Option<i64> {
781    keys.iter().find_map(|key| dict.get(*key).and_then(as_i64))
782}
783
784fn double_field(dict: &IndexMap<String, XpcValue>, keys: &[&str]) -> Option<f64> {
785    keys.iter().find_map(|key| dict.get(*key).and_then(as_f64))
786}
787
788fn data_field(dict: &IndexMap<String, XpcValue>, keys: &[&str]) -> Option<Bytes> {
789    keys.iter().find_map(|key| match dict.get(*key) {
790        Some(XpcValue::Data(data)) => Some(data.clone()),
791        _ => None,
792    })
793}
794
795fn url_relative(value: Option<&XpcValue>) -> Option<String> {
796    let dict = value?.as_dict()?;
797    dict.get("relative")
798        .and_then(|v| v.as_str())
799        .map(ToOwned::to_owned)
800}
801
802fn file_name(path: &str) -> Option<String> {
803    path.rsplit(['/', '\\'])
804        .find(|segment| !segment.is_empty())
805        .map(ToOwned::to_owned)
806}
807
808fn has_icon_data(dict: &IndexMap<String, XpcValue>) -> bool {
809    ["iconData", "data", "pngData", "bitmapData"]
810        .iter()
811        .any(|key| matches!(dict.get(*key), Some(XpcValue::Data(_))))
812}
813
814fn as_u64(value: &XpcValue) -> Option<u64> {
815    match value {
816        XpcValue::Uint64(n) => Some(*n),
817        XpcValue::Int64(n) if *n >= 0 => Some(*n as u64),
818        _ => None,
819    }
820}
821
822fn as_i64(value: &XpcValue) -> Option<i64> {
823    match value {
824        XpcValue::Int64(n) => Some(*n),
825        XpcValue::Uint64(n) => i64::try_from(*n).ok(),
826        _ => None,
827    }
828}
829
830fn as_f64(value: &XpcValue) -> Option<f64> {
831    match value {
832        XpcValue::Double(n) => Some(*n),
833        XpcValue::Int64(n) => Some(*n as f64),
834        XpcValue::Uint64(n) => Some(*n as f64),
835        _ => None,
836    }
837}
838
839fn as_bool(value: &XpcValue) -> Option<bool> {
840    match value {
841        XpcValue::Bool(v) => Some(*v),
842        _ => None,
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn build_request_wraps_coredevice_envelope() {
852        let request = build_request(
853            "DEVICE-ID",
854            FEATURE_SEND_SIGNAL,
855            XpcValue::Dictionary(IndexMap::from([
856                ("processIdentifier".to_string(), XpcValue::Uint64(42)),
857                ("signal".to_string(), XpcValue::Int64(SIGKILL)),
858            ])),
859        );
860
861        let dict = request.as_dict().unwrap();
862        assert_eq!(
863            dict["CoreDevice.featureIdentifier"].as_str(),
864            Some(FEATURE_SEND_SIGNAL)
865        );
866        assert_eq!(
867            dict["CoreDevice.deviceIdentifier"].as_str(),
868            Some("DEVICE-ID")
869        );
870        assert!(dict["CoreDevice.invocationIdentifier"]
871            .as_str()
872            .unwrap()
873            .contains('-'));
874    }
875
876    #[test]
877    fn build_send_signal_input_nests_process_identifier() {
878        let input = build_send_signal_input(42, SIGKILL).unwrap();
879        let dict = input.as_dict().unwrap();
880        let process = dict["process"].as_dict().unwrap();
881
882        assert_eq!(process["processIdentifier"], XpcValue::Int64(42));
883        assert_eq!(dict["signal"], XpcValue::Int64(SIGKILL));
884    }
885
886    #[test]
887    fn build_send_signal_input_rejects_pid_above_i64_max() {
888        let err = build_send_signal_input(u64::MAX, SIGKILL).unwrap_err();
889
890        assert!(
891            matches!(err, AppServiceError::Protocol(message) if message.contains("process id exceeds"))
892        );
893    }
894
895    #[test]
896    fn build_launch_application_input_matches_reference_shape() {
897        let input = build_launch_application_input("com.example.App").unwrap();
898        let dict = input.as_dict().unwrap();
899        let application_specifier = dict["applicationSpecifier"].as_dict().unwrap();
900        let bundle_identifier = application_specifier["bundleIdentifier"].as_dict().unwrap();
901        let options = dict["options"].as_dict().unwrap();
902        let user = options["user"].as_dict().unwrap();
903
904        assert_eq!(bundle_identifier["_0"].as_str(), Some("com.example.App"));
905        assert_eq!(options["arguments"], XpcValue::Array(Vec::new()));
906        assert_eq!(
907            options["environmentVariables"],
908            XpcValue::Dictionary(IndexMap::new())
909        );
910        assert_eq!(
911            options["standardIOUsesPseudoterminals"],
912            XpcValue::Bool(true)
913        );
914        assert_eq!(options["startStopped"], XpcValue::Bool(false));
915        assert_eq!(options["terminateExisting"], XpcValue::Bool(false));
916        assert_eq!(user["active"], XpcValue::Bool(true));
917        assert_eq!(user["shortName"].as_str(), Some("mobile"));
918        assert_eq!(
919            dict["standardIOIdentifiers"],
920            XpcValue::Dictionary(IndexMap::new())
921        );
922
923        let XpcValue::Data(platform_specific_options) = &options["platformSpecificOptions"] else {
924            panic!("platformSpecificOptions should be XPC data");
925        };
926        let decoded: plist::Value =
927            plist::from_bytes(platform_specific_options).expect("binary plist decode");
928        assert_eq!(decoded, plist::Value::Dictionary(plist::Dictionary::new()));
929    }
930
931    #[test]
932    fn build_launch_application_input_accepts_extended_options() {
933        let options = LaunchApplicationOptions {
934            arguments: vec!["--flag".into(), "value".into()],
935            environment_variables: IndexMap::from([("FOO".into(), "bar".into())]),
936            start_stopped: true,
937            terminate_existing: true,
938            standard_io_uses_pseudoterminals: false,
939            standard_io_identifiers: IndexMap::from([("standardOutput".into(), "socket-1".into())]),
940        };
941
942        let input = build_launch_application_input_with_options("com.example.App", &options)
943            .expect("launch input should build");
944        let dict = input.as_dict().unwrap();
945        let options = dict["options"].as_dict().unwrap();
946        let environment = options["environmentVariables"].as_dict().unwrap();
947        let stdio = dict["standardIOIdentifiers"].as_dict().unwrap();
948
949        assert_eq!(
950            options["arguments"],
951            XpcValue::Array(vec![
952                XpcValue::String("--flag".into()),
953                XpcValue::String("value".into())
954            ])
955        );
956        assert_eq!(environment["FOO"].as_str(), Some("bar"));
957        assert_eq!(options["startStopped"], XpcValue::Bool(true));
958        assert_eq!(options["terminateExisting"], XpcValue::Bool(true));
959        assert_eq!(
960            options["standardIOUsesPseudoterminals"],
961            XpcValue::Bool(false)
962        );
963        assert_eq!(stdio["standardOutput"].as_str(), Some("socket-1"));
964    }
965
966    #[test]
967    fn parse_processes_reads_coredevice_output_envelope() {
968        let process = XpcValue::Dictionary(IndexMap::from([
969            ("processIdentifier".to_string(), XpcValue::Uint64(99)),
970            (
971                "bundleIdentifier".to_string(),
972                XpcValue::String("com.example.App".into()),
973            ),
974            (
975                "localizedName".to_string(),
976                XpcValue::String("Example".into()),
977            ),
978            (
979                "executableName".to_string(),
980                XpcValue::String("ExampleBin".into()),
981            ),
982            ("isApplication".to_string(), XpcValue::Bool(true)),
983        ]));
984        let response = XpcMessage {
985            flags: 0,
986            msg_id: 1,
987            body: Some(XpcValue::Dictionary(IndexMap::from([(
988                "CoreDevice.output".to_string(),
989                XpcValue::Dictionary(IndexMap::from([(
990                    "processTokens".to_string(),
991                    XpcValue::Array(vec![process]),
992                )])),
993            )]))),
994        };
995
996        let parsed = parse_processes(&response).unwrap();
997        assert_eq!(parsed.len(), 1);
998        assert_eq!(parsed[0].pid, 99);
999        assert_eq!(parsed[0].bundle_id.as_deref(), Some("com.example.App"));
1000    }
1001
1002    #[test]
1003    fn ensure_no_error_reads_coredevice_error_envelope() {
1004        let response = XpcMessage {
1005            flags: 0,
1006            msg_id: 1,
1007            body: Some(XpcValue::Dictionary(IndexMap::from([(
1008                "CoreDevice.error".to_string(),
1009                XpcValue::Dictionary(IndexMap::from([(
1010                    "localizedDescription".to_string(),
1011                    XpcValue::String("boom".into()),
1012                )])),
1013            )]))),
1014        };
1015
1016        let err = ensure_no_error(&response).unwrap_err();
1017        assert!(matches!(err, AppServiceError::Protocol(message) if message == "boom"));
1018    }
1019
1020    #[test]
1021    fn parse_pid_accepts_coredevice_output_process_token() {
1022        let pid = parse_pid(Some(&XpcValue::Dictionary(IndexMap::from([(
1023            "CoreDevice.output".to_string(),
1024            XpcValue::Dictionary(IndexMap::from([(
1025                "processToken".to_string(),
1026                XpcValue::Dictionary(IndexMap::from([(
1027                    "processIdentifier".to_string(),
1028                    XpcValue::Uint64(31337),
1029                )])),
1030            )])),
1031        )]))));
1032
1033        assert_eq!(pid, Some(31337));
1034    }
1035
1036    #[test]
1037    fn build_list_apps_input_matches_reference_shape() {
1038        let input = build_list_apps_input(ListAppsOptions::default());
1039        let dict = input.as_dict().unwrap();
1040
1041        assert_eq!(dict["includeAppClips"], XpcValue::Bool(true));
1042        assert_eq!(dict["includeRemovableApps"], XpcValue::Bool(true));
1043        assert_eq!(dict["includeHiddenApps"], XpcValue::Bool(true));
1044        assert_eq!(dict["includeInternalApps"], XpcValue::Bool(true));
1045        assert_eq!(dict["includeDefaultApps"], XpcValue::Bool(true));
1046    }
1047
1048    #[test]
1049    fn build_list_roots_input_uses_root_point_relative_slash() {
1050        let input = build_list_roots_input();
1051        let dict = input.as_dict().unwrap();
1052        let root_point = dict["rootPoint"].as_dict().unwrap();
1053
1054        assert_eq!(root_point["relative"].as_str(), Some("/"));
1055    }
1056
1057    #[test]
1058    fn build_spawn_executable_input_matches_reference_shape() {
1059        let input = build_spawn_executable_input(
1060            "/usr/bin/log",
1061            &[
1062                "stream".to_string(),
1063                "--style".to_string(),
1064                "json".to_string(),
1065            ],
1066        )
1067        .unwrap();
1068        let dict = input.as_dict().unwrap();
1069        let executable_item = dict["executableItem"].as_dict().unwrap();
1070        let url = executable_item["url"].as_dict().unwrap();
1071        let url_payload = url["_0"].as_dict().unwrap();
1072        let options = dict["options"].as_dict().unwrap();
1073        let user = options["user"].as_dict().unwrap();
1074
1075        assert_eq!(url_payload["relative"].as_str(), Some("/usr/bin/log"));
1076        assert_eq!(
1077            options["arguments"],
1078            XpcValue::Array(vec![
1079                XpcValue::String("stream".into()),
1080                XpcValue::String("--style".into()),
1081                XpcValue::String("json".into())
1082            ])
1083        );
1084        assert_eq!(
1085            options["environmentVariables"],
1086            XpcValue::Dictionary(IndexMap::new())
1087        );
1088        assert_eq!(
1089            options["standardIOUsesPseudoterminals"],
1090            XpcValue::Bool(true)
1091        );
1092        assert_eq!(options["startStopped"], XpcValue::Bool(false));
1093        assert_eq!(user["active"], XpcValue::Bool(true));
1094        assert_eq!(
1095            dict["standardIOIdentifiers"],
1096            XpcValue::Dictionary(IndexMap::new())
1097        );
1098    }
1099
1100    #[test]
1101    fn build_fetch_app_icons_input_matches_reference_shape() {
1102        let input = build_fetch_app_icons_input("com.example.App", 60.0, 60.0, 3.0, true);
1103        let dict = input.as_dict().unwrap();
1104
1105        assert_eq!(dict["bundleIdentifier"].as_str(), Some("com.example.App"));
1106        assert_eq!(dict["width"], XpcValue::Double(60.0));
1107        assert_eq!(dict["height"], XpcValue::Double(60.0));
1108        assert_eq!(dict["scale"], XpcValue::Double(3.0));
1109        assert_eq!(dict["allowPlaceholder"], XpcValue::Bool(true));
1110    }
1111
1112    #[test]
1113    fn build_monitor_process_termination_input_nests_process_token() {
1114        let input = build_monitor_process_termination_input(1234).unwrap();
1115        let dict = input.as_dict().unwrap();
1116        let process_token = dict["processToken"].as_dict().unwrap();
1117
1118        assert_eq!(process_token["processIdentifier"], XpcValue::Int64(1234));
1119    }
1120
1121    #[test]
1122    fn build_monitor_process_termination_input_rejects_pid_above_i64_max() {
1123        let err = build_monitor_process_termination_input(u64::MAX).unwrap_err();
1124
1125        assert!(
1126            matches!(err, AppServiceError::Protocol(message) if message.contains("process id exceeds"))
1127        );
1128    }
1129
1130    #[test]
1131    fn parse_process_reads_executable_url_relative() {
1132        let process = XpcValue::Dictionary(IndexMap::from([
1133            ("processIdentifier".to_string(), XpcValue::Int64(77)),
1134            (
1135                "executableURL".to_string(),
1136                XpcValue::Dictionary(IndexMap::from([(
1137                    "relative".to_string(),
1138                    XpcValue::String("/usr/libexec/foo".into()),
1139                )])),
1140            ),
1141        ]));
1142
1143        let parsed = parse_process(&process).unwrap();
1144
1145        assert_eq!(parsed.pid, 77);
1146        assert_eq!(parsed.name, "foo");
1147        assert_eq!(parsed.executable.as_deref(), Some("/usr/libexec/foo"));
1148    }
1149
1150    #[test]
1151    fn parse_apps_reads_coredevice_output_variants() {
1152        let response = XpcMessage {
1153            flags: 0,
1154            msg_id: 1,
1155            body: Some(XpcValue::Dictionary(IndexMap::from([(
1156                "CoreDevice.output".to_string(),
1157                XpcValue::Dictionary(IndexMap::from([(
1158                    "apps".to_string(),
1159                    XpcValue::Array(vec![XpcValue::Dictionary(IndexMap::from([
1160                        (
1161                            "bundleIdentifier".to_string(),
1162                            XpcValue::String("com.example.App".into()),
1163                        ),
1164                        (
1165                            "localizedName".to_string(),
1166                            XpcValue::String("Example".into()),
1167                        ),
1168                        ("isRemovable".to_string(), XpcValue::Bool(true)),
1169                    ]))]),
1170                )])),
1171            )]))),
1172        };
1173
1174        let apps = parse_apps(&response).unwrap();
1175
1176        assert_eq!(apps.len(), 1);
1177        assert_eq!(apps[0].bundle_id, "com.example.App");
1178        assert_eq!(apps[0].name.as_deref(), Some("Example"));
1179        assert_eq!(apps[0].is_removable, Some(true));
1180    }
1181
1182    #[test]
1183    fn parse_app_icons_reads_coredevice_output() {
1184        let response = XpcMessage {
1185            flags: 0,
1186            msg_id: 1,
1187            body: Some(XpcValue::Dictionary(IndexMap::from([(
1188                "CoreDevice.output".to_string(),
1189                XpcValue::Dictionary(IndexMap::from([(
1190                    "icons".to_string(),
1191                    XpcValue::Array(vec![XpcValue::Dictionary(IndexMap::from([
1192                        ("width".to_string(), XpcValue::Double(60.0)),
1193                        ("height".to_string(), XpcValue::Double(60.0)),
1194                        ("scale".to_string(), XpcValue::Double(3.0)),
1195                        (
1196                            "iconData".to_string(),
1197                            XpcValue::Data(Bytes::from_static(b"png")),
1198                        ),
1199                    ]))]),
1200                )])),
1201            )]))),
1202        };
1203
1204        let icons = parse_app_icons(&response).unwrap();
1205
1206        assert_eq!(icons.len(), 1);
1207        assert_eq!(icons[0].width, Some(60.0));
1208        assert_eq!(icons[0].height, Some(60.0));
1209        assert_eq!(icons[0].scale, Some(3.0));
1210        assert_eq!(icons[0].data.as_ref(), b"png");
1211    }
1212
1213    #[test]
1214    fn parse_process_termination_reads_enveloped_process_token() {
1215        let response = XpcMessage {
1216            flags: 0,
1217            msg_id: 1,
1218            body: Some(XpcValue::Dictionary(IndexMap::from([(
1219                "CoreDevice.output".to_string(),
1220                XpcValue::Dictionary(IndexMap::from([
1221                    (
1222                        "processToken".to_string(),
1223                        XpcValue::Dictionary(IndexMap::from([(
1224                            "processIdentifier".to_string(),
1225                            XpcValue::Int64(1234),
1226                        )])),
1227                    ),
1228                    ("exitStatus".to_string(), XpcValue::Int64(0)),
1229                    ("reason".to_string(), XpcValue::String("exited".to_string())),
1230                ])),
1231            )]))),
1232        };
1233
1234        let termination = parse_process_termination(&response).unwrap();
1235
1236        assert_eq!(termination.pid, Some(1234));
1237        assert_eq!(termination.exit_status, Some(0));
1238        assert_eq!(termination.reason.as_deref(), Some("exited"));
1239    }
1240}