1use 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#[derive(Debug, thiserror::Error)]
25pub enum AppServiceError {
26 #[error("xpc error: {0}")]
28 Xpc(#[from] XpcError),
29 #[error("protocol error: {0}")]
31 Protocol(String),
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RunningAppProcess {
37 pub pid: u64,
39 pub bundle_id: Option<String>,
41 pub name: String,
43 pub executable: Option<String>,
45 pub is_application: Option<bool>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct ListAppsOptions {
52 pub include_app_clips: bool,
54 pub include_removable_apps: bool,
56 pub include_hidden_apps: bool,
58 pub include_internal_apps: bool,
60 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#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct CoreDeviceAppInfo {
79 pub bundle_id: String,
81 pub name: Option<String>,
83 pub version: Option<String>,
85 pub is_removable: Option<bool>,
87 pub is_hidden: Option<bool>,
89 pub is_internal: Option<bool>,
91 pub is_app_clip: Option<bool>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct LaunchApplicationOptions {
98 pub arguments: Vec<String>,
100 pub environment_variables: IndexMap<String, String>,
102 pub standard_io_uses_pseudoterminals: bool,
104 pub start_stopped: bool,
106 pub terminate_existing: bool,
108 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#[derive(Debug, Clone, PartialEq)]
127pub struct AppIcon {
128 pub data: Bytes,
130 pub width: Option<f64>,
132 pub height: Option<f64>,
134 pub scale: Option<f64>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ProcessTermination {
141 pub pid: Option<u64>,
143 pub exit_status: Option<i64>,
145 pub signal: Option<i64>,
147 pub reason: Option<String>,
149}
150
151pub struct AppServiceClient {
153 client: XpcClient,
154 device_identifier: String,
155}
156
157impl AppServiceClient {
158 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 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 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 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 pub async fn kill_process(&mut self, pid: u64) -> Result<(), AppServiceError> {
210 self.send_signal(pid, SIGKILL).await
211 }
212
213 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 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 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 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 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 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}