Skip to main content

ios_core/services/instruments/
process_control.rs

1//! Process control service – list running processes, launch/kill apps.
2//!
3//! Service: `com.apple.instruments.server.services.processcontrol`
4//! Reference: go-ios/ios/instruments/processcontrol.go
5
6use std::collections::HashMap;
7
8use crate::proto::nskeyedarchiver_encode;
9use tokio::io::{AsyncRead, AsyncWrite};
10
11use crate::services::dtx::codec::{DtxConnection, DtxError};
12use crate::services::dtx::primitive_enc::archived_object;
13use crate::services::dtx::types::{DtxPayload, NSObject};
14
15/// Info about a running process.
16#[derive(Debug, Clone)]
17pub struct ProcessInfo {
18    pub pid: u64,
19    pub name: String,
20    pub real_app_name: String,
21    pub is_application: bool,
22}
23
24/// Process control service client.
25/// Each instance owns its own DTX connection and channel.
26pub struct ProcessControl<S> {
27    conn: DtxConnection<S>,
28    channel_code: i32,
29}
30
31impl<S: AsyncRead + AsyncWrite + Unpin + Send> ProcessControl<S> {
32    /// Connect to the process control service.
33    pub async fn connect(stream: S) -> Result<Self, DtxError> {
34        let mut conn = DtxConnection::new(stream);
35        let ch = conn.request_channel(super::PROCESS_CTRL_SVC).await?;
36        Ok(Self {
37            conn,
38            channel_code: ch,
39        })
40    }
41
42    /// Launch an app by bundle ID; returns its PID.
43    pub async fn launch(
44        &mut self,
45        bundle_id: &str,
46        args: &[&str],
47        env: &HashMap<String, String>,
48    ) -> Result<u64, DtxError> {
49        self.launch_with_options(
50            bundle_id,
51            args,
52            env,
53            &[
54                (
55                    "StartSuspendedKey".to_string(),
56                    plist::Value::Boolean(false),
57                ),
58                ("KillExisting".to_string(), plist::Value::Boolean(false)),
59            ],
60        )
61        .await
62    }
63
64    /// Launch an app by bundle ID with explicit process-control options; returns its PID.
65    pub async fn launch_with_options(
66        &mut self,
67        bundle_id: &str,
68        args: &[&str],
69        env: &HashMap<String, String>,
70        options: &[(String, plist::Value)],
71    ) -> Result<u64, DtxError> {
72        // go-ios: path, bundleID, env, args, opts
73        let path_enc = archived_object(nskeyedarchiver_encode::archive_string("/private/"));
74        let bid_enc = archived_object(nskeyedarchiver_encode::archive_string(bundle_id));
75
76        // env: merge NSUnbufferedIO=YES with caller-supplied env
77        let mut full_env: Vec<(String, plist::Value)> = vec![(
78            "NSUnbufferedIO".to_string(),
79            plist::Value::String("YES".to_string()),
80        )];
81        for (k, v) in env {
82            full_env.push((k.clone(), plist::Value::String(v.clone())));
83        }
84        let env_enc = archived_object(nskeyedarchiver_encode::archive_dict(full_env));
85
86        let args_enc = archived_object(nskeyedarchiver_encode::archive_array(
87            args.iter()
88                .map(|s| plist::Value::String(s.to_string()))
89                .collect(),
90        ));
91
92        let opts_enc = archived_object(nskeyedarchiver_encode::archive_dict(options.to_vec()));
93
94        let msg = self.conn.method_call(
95            self.channel_code,
96            "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:",
97            &[path_enc, bid_enc, env_enc, args_enc, opts_enc],
98        ).await?;
99
100        if let DtxPayload::Response(NSObject::Int(pid)) = msg.payload {
101            return Ok(pid as u64);
102        }
103        if let DtxPayload::Response(NSObject::Uint(pid)) = msg.payload {
104            return Ok(pid);
105        }
106        Err(DtxError::Protocol(format!(
107            "unexpected launch response: {:?}",
108            msg.payload
109        )))
110    }
111
112    /// Send SIGKILL to a process.
113    pub async fn kill(&mut self, pid: u64) -> Result<(), DtxError> {
114        let pid_enc = archived_object(nskeyedarchiver_encode::archive_int(pid as i64));
115        self.conn
116            .method_call_async(self.channel_code, "killPid:", &[pid_enc])
117            .await
118    }
119
120    /// Disable the jetsam memory limit for a process.
121    pub async fn disable_memory_limit(&mut self, pid: u64) -> Result<bool, DtxError> {
122        let msg = self
123            .conn
124            .method_call(
125                self.channel_code,
126                "requestDisableMemoryLimitsForPid:",
127                &[crate::services::dtx::primitive_enc::PrimArg::Int32(
128                    pid as i32,
129                )],
130            )
131            .await?;
132
133        match msg.payload {
134            DtxPayload::Response(NSObject::Bool(disabled)) => Ok(disabled),
135            DtxPayload::Response(NSObject::Int(disabled)) => Ok(disabled != 0),
136            DtxPayload::Response(NSObject::Uint(disabled)) => Ok(disabled != 0),
137            other => Err(DtxError::Protocol(format!(
138                "unexpected disableMemoryLimit response: {other:?}"
139            ))),
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use plist::Value;
147    use tokio::io::{duplex, AsyncWriteExt};
148
149    use super::*;
150    use crate::services::dtx::{encode_dtx, read_dtx_frame, DtxPayload, NSObject};
151
152    const MSG_RESPONSE: u32 = 3;
153
154    #[tokio::test]
155    async fn launch_with_options_sends_expected_archived_arguments() {
156        let (client, mut server) = duplex(4096);
157        let task = tokio::spawn(async move {
158            let mut env = HashMap::new();
159            env.insert("TERM".to_string(), "xterm-256color".to_string());
160
161            let mut client = ProcessControl::connect(client).await.unwrap();
162            client
163                .launch_with_options(
164                    "com.example.demo",
165                    &["--flag"],
166                    &env,
167                    &[("KillExisting".to_string(), Value::Boolean(true))],
168                )
169                .await
170                .unwrap()
171        });
172
173        let channel_request = read_dtx_frame(&mut server).await.unwrap();
174        match channel_request.payload {
175            DtxPayload::MethodInvocation { selector, args } => {
176                assert_eq!(selector, "_requestChannelWithCode:identifier:");
177                assert!(
178                    matches!(args.get(1), Some(NSObject::String(name)) if name == super::super::PROCESS_CTRL_SVC)
179                );
180            }
181            other => panic!("unexpected channel request: {other:?}"),
182        }
183        server
184            .write_all(&encode_dtx(
185                channel_request.identifier,
186                1,
187                0,
188                false,
189                MSG_RESPONSE,
190                &[],
191                &[],
192            ))
193            .await
194            .unwrap();
195
196        let launch = read_dtx_frame(&mut server).await.unwrap();
197        match launch.payload {
198            DtxPayload::MethodInvocation { selector, args } => {
199                assert_eq!(
200                    selector,
201                    "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:"
202                );
203                assert!(
204                    matches!(args.first(), Some(NSObject::String(path)) if path == "/private/")
205                );
206                assert!(
207                    matches!(args.get(1), Some(NSObject::String(bundle)) if bundle == "com.example.demo")
208                );
209                match args.get(2) {
210                    Some(NSObject::Dict(env)) => {
211                        assert_eq!(
212                            env.get("NSUnbufferedIO"),
213                            Some(&NSObject::String("YES".into()))
214                        );
215                        assert_eq!(
216                            env.get("TERM"),
217                            Some(&NSObject::String("xterm-256color".into()))
218                        );
219                    }
220                    other => panic!("unexpected env payload: {other:?}"),
221                }
222                assert!(matches!(
223                    args.get(3),
224                    Some(NSObject::Array(values))
225                    if values == &vec![NSObject::String("--flag".into())]
226                ));
227                assert!(matches!(
228                    args.get(4),
229                    Some(NSObject::Dict(options))
230                    if options.get("KillExisting") == Some(&NSObject::Bool(true))
231                ));
232            }
233            other => panic!("unexpected launch request: {other:?}"),
234        }
235        server
236            .write_all(&encode_dtx(
237                launch.identifier,
238                1,
239                launch.channel_code,
240                false,
241                MSG_RESPONSE,
242                &crate::proto::nskeyedarchiver_encode::archive_int(4242),
243                &[],
244            ))
245            .await
246            .unwrap();
247
248        assert_eq!(task.await.unwrap(), 4242);
249    }
250
251    #[tokio::test]
252    async fn disable_memory_limit_treats_integer_response_as_boolean() {
253        let (client, mut server) = duplex(4096);
254        let task = tokio::spawn(async move {
255            let mut client = ProcessControl::connect(client).await.unwrap();
256            client.disable_memory_limit(99).await.unwrap()
257        });
258
259        let channel_request = read_dtx_frame(&mut server).await.unwrap();
260        server
261            .write_all(&encode_dtx(
262                channel_request.identifier,
263                1,
264                0,
265                false,
266                MSG_RESPONSE,
267                &[],
268                &[],
269            ))
270            .await
271            .unwrap();
272
273        let request = read_dtx_frame(&mut server).await.unwrap();
274        match request.payload {
275            DtxPayload::MethodInvocation { selector, args } => {
276                assert_eq!(selector, "requestDisableMemoryLimitsForPid:");
277                assert!(matches!(args.first(), Some(NSObject::Int(99))));
278            }
279            other => panic!("unexpected disable request: {other:?}"),
280        }
281        server
282            .write_all(&encode_dtx(
283                request.identifier,
284                1,
285                request.channel_code,
286                false,
287                MSG_RESPONSE,
288                &crate::proto::nskeyedarchiver_encode::archive_int(1),
289                &[],
290            ))
291            .await
292            .unwrap();
293
294        assert!(task.await.unwrap());
295    }
296}