1use 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}