Skip to main content

ios_core/services/instruments/
mod.rs

1//! Instruments services – performance monitoring via DTX.
2//!
3//! Service names:
4//!   - `com.apple.instruments.remoteserver`                      (iOS ≤13)
5//!   - `com.apple.instruments.remoteserver.DVTSecureSocketProxy` (iOS 14-16)
6//!   - `com.apple.instruments.dtservicehub`                      (iOS 17+ via RSD/tunnel)
7//!
8//! Flow:
9//!   1. Connect to service via lockdown StartService
10//!   2. Wrap stream in DtxConnection
11//!   3. request_channel(service_name) → channel_code
12//!   4. method_call(channel_code, "setConfig:", `[archived_config]`)
13//!   5. method_call_async(channel_code, "start")
14//!   6. Loop recv() → parse samples
15//!
16//! Reference: go-ios/ios/instruments/
17
18pub mod activity_trace;
19pub mod application_listing;
20pub mod core_profile_session;
21pub mod deviceinfo;
22pub mod devicestate;
23pub mod energy;
24pub mod fps;
25pub mod graphics;
26pub mod network;
27pub mod notifications;
28pub mod process_control;
29pub mod screenshot;
30pub mod tap;
31
32pub use activity_trace::{
33    ActivityTraceClient, ActivityTraceDecoder, ActivityTraceEntry, ActivityTraceValue,
34};
35pub use application_listing::ApplicationListingClient;
36pub use core_profile_session::{
37    CoreProfileConfig, CoreProfileEvent, CoreProfileSessionClient, CORE_PROFILE_SESSION_SVC,
38};
39pub use deviceinfo::{DeviceInfoClient, RunningProcess};
40pub use devicestate::{ConditionProfile, ConditionProfileType, DeviceStateClient};
41pub use energy::EnergyMonitorClient;
42pub use fps::{parse_frame_commit_timestamps, FpsSample, FpsWindowCalculator, MachTimeInfo};
43pub use graphics::GraphicsMonitorClient;
44pub use network::{
45    ConnectionDetectionEvent, ConnectionUpdateEvent, InterfaceDetectionEvent, NetworkMonitorClient,
46    NetworkMonitorEvent, SocketAddress,
47};
48pub use notifications::{NotificationClient, NotificationEvent};
49pub use process_control::{ProcessControl, ProcessInfo};
50pub use screenshot::take_screenshot_dtx;
51pub use screenshot::take_screenshot_dtx as start_screenshot;
52pub use tap::TapClient;
53
54// ── Service name constants ────────────────────────────────────────────────────
55
56pub const SERVICE_LEGACY: &str = "com.apple.instruments.remoteserver";
57pub const SERVICE_IOS14: &str = "com.apple.instruments.remoteserver.DVTSecureSocketProxy";
58pub const SERVICE_IOS17: &str = "com.apple.instruments.dtservicehub"; // via RSD
59pub const SYSMONTAP: &str = "com.apple.instruments.server.services.sysmontap";
60pub const DEVICE_INFO_SVC: &str = "com.apple.instruments.server.services.deviceinfo";
61pub const PROCESS_CTRL_SVC: &str = "com.apple.instruments.server.services.processcontrol";
62pub const SCREENSHOT_SVC: &str = "com.apple.instruments.server.services.screenshot";
63pub const APP_LISTING_SVC: &str = "com.apple.instruments.server.services.device.applictionListing";
64pub const ACTIVITY_TRACE_TAP_SVC: &str = "com.apple.instruments.server.services.activitytracetap";
65pub const CONDITION_INDUCER_SVC: &str = "com.apple.instruments.server.services.ConditionInducer";
66pub const ENERGY_MONITOR_SVC: &str = "com.apple.xcode.debug-gauge-data-providers.Energy";
67pub const GRAPHICS_MONITOR_SVC: &str = "com.apple.instruments.server.services.graphics.opengl";
68pub const MOBILE_NOTIFICATIONS_SVC: &str =
69    "com.apple.instruments.server.services.mobilenotifications";
70pub const NETWORK_MONITOR_SVC: &str = "com.apple.instruments.server.services.networking";
71
72// ── CPU sample types ──────────────────────────────────────────────────────────
73
74/// A CPU usage sample from sysmontap.
75#[derive(Debug, Clone)]
76pub struct CpuSample {
77    pub cpu_count: u64,
78    pub enabled_cpus: u64,
79    pub end_mach_abs_time: u64,
80    pub cpu_total_load: f64,
81    pub sample_type: u64,
82}
83
84/// A memory usage sample from sysmontap.
85#[derive(Debug, Clone)]
86pub struct MemSample {
87    pub memory_used: u64,  // bytes
88    pub memory_total: u64, // bytes
89}
90
91/// Per-process stats from a sysmontap snapshot.
92#[derive(Debug, Clone)]
93pub struct ProcessSample {
94    /// Process attribute values keyed by attribute name.
95    pub processes: Vec<serde_json::Map<String, serde_json::Value>>,
96    /// System CPU usage (if present in this sample).
97    pub system_cpu: Option<CpuSample>,
98}
99
100// ── SysmontapConfig ───────────────────────────────────────────────────────────
101
102/// Configuration for the sysmontap performance monitor.
103pub struct SysmontapConfig {
104    /// Update rate: lower = faster samples (Xcode default: 10)
105    pub update_rate: i32,
106    /// Report CPU usage
107    pub cpu_usage: bool,
108    /// Report physical memory footprint
109    pub phys_footprint: bool,
110    /// Sample interval in nanoseconds (500_000_000 = 0.5s)
111    pub sample_interval: i64,
112}
113
114impl Default for SysmontapConfig {
115    fn default() -> Self {
116        Self {
117            update_rate: 10,
118            cpu_usage: true,
119            phys_footprint: true,
120            sample_interval: 500_000_000,
121        }
122    }
123}
124
125// ── Sysmontap client ──────────────────────────────────────────────────────────
126
127use crate::proto::nskeyedarchiver_encode;
128use tokio::io::{AsyncRead, AsyncWrite};
129
130use crate::services::dtx::codec::{DtxConnection, DtxError};
131use crate::services::dtx::primitive_enc::archived_object;
132use crate::services::dtx::types::{DtxMessage, DtxPayload, NSObject};
133
134/// Connect and start a sysmontap CPU monitoring session.
135///
136/// Returns an async stream of `CpuSample` values.
137pub struct SysmontapService<S> {
138    conn: DtxConnection<S>,
139    channel_code: i32,
140}
141
142impl<S: AsyncRead + AsyncWrite + Unpin + Send> SysmontapService<S> {
143    /// Initialize sysmontap on an already-connected instruments stream.
144    ///
145    /// `sys_attrs` and `proc_attrs` are optional attribute lists from the deviceinfo service.
146    /// Pass `None` to use defaults (works on iOS 17+; older iOS may need them).
147    pub async fn start(
148        stream: S,
149        config: &SysmontapConfig,
150        sys_attrs: Option<Vec<plist::Value>>,
151        proc_attrs: Option<Vec<plist::Value>>,
152    ) -> Result<Self, DtxError> {
153        let mut conn = DtxConnection::new(stream);
154
155        // Request sysmontap channel
156        let ch = conn.request_channel(SYSMONTAP).await?;
157
158        // Build config dict (matches go-ios exactly)
159        let mut config_dict = build_sysmontap_config(config);
160        if let Some(attrs) = sys_attrs {
161            if !attrs.is_empty() {
162                config_dict.push(("sysAttrs".to_string(), plist::Value::Array(attrs)));
163            }
164        }
165        if let Some(attrs) = proc_attrs {
166            if !attrs.is_empty() {
167                config_dict.push(("procAttrs".to_string(), plist::Value::Array(attrs)));
168            }
169        }
170        let archived = nskeyedarchiver_encode::archive_dict(config_dict);
171
172        // setConfig:
173        let cfg_resp = conn
174            .method_call(ch, "setConfig:", &[archived_object(archived)])
175            .await?;
176        tracing::debug!("sysmontap setConfig: response: {:?}", cfg_resp.payload);
177
178        // start (fire-and-forget)
179        conn.method_call_async(ch, "start", &[]).await?;
180
181        Ok(Self {
182            conn,
183            channel_code: ch,
184        })
185    }
186
187    /// Receive the next CPU sample from the device.
188    /// Blocks until a sample arrives.
189    pub async fn next_cpu_sample(&mut self) -> Result<Option<CpuSample>, DtxError> {
190        loop {
191            let msg = self.conn.recv().await?;
192            tracing::debug!(
193                "next_cpu_sample: id={} ch={} expects_reply={} payload={:?}",
194                msg.identifier,
195                msg.channel_code,
196                msg.expects_reply,
197                std::mem::discriminant(&msg.payload)
198            );
199
200            // Ack if needed
201            if msg.expects_reply {
202                self.conn.send_ack(&msg).await?;
203            }
204
205            // Only process messages on our channel or channel -1 (sysmontap broadcasts on -1)
206            if msg.channel_code != self.channel_code && msg.channel_code != -1 {
207                continue;
208            }
209
210            tracing::debug!(
211                "sysmontap msg ch={} payload={:?}",
212                msg.channel_code,
213                &msg.payload
214            );
215
216            if let Some(sample) = parse_cpu_sample(&msg) {
217                return Ok(Some(sample));
218            }
219        }
220    }
221
222    /// Receive the next per-process snapshot from sysmontap.
223    ///
224    /// `proc_attr_names` should be the ordered list of attribute names matching
225    /// the `procAttrs` config (e.g. from `DeviceInfoClient::process_attributes()`).
226    /// Each process's values array is zipped with these names.
227    pub async fn next_process_snapshot(
228        &mut self,
229        proc_attr_names: &[String],
230    ) -> Result<Option<ProcessSample>, DtxError> {
231        loop {
232            let msg = self.conn.recv().await?;
233            if msg.expects_reply {
234                self.conn.send_ack(&msg).await?;
235            }
236            if msg.channel_code != self.channel_code && msg.channel_code != -1 {
237                continue;
238            }
239            if let Some(sample) = parse_process_snapshot(&msg, proc_attr_names) {
240                if !sample.processes.is_empty() {
241                    return Ok(Some(sample));
242                }
243            }
244        }
245    }
246
247    /// Stop the monitoring session.
248    pub async fn stop(&mut self) -> Result<(), DtxError> {
249        self.conn
250            .method_call_async(self.channel_code, "stop", &[])
251            .await
252    }
253}
254
255// ── Config builder ────────────────────────────────────────────────────────────
256
257fn build_sysmontap_config(cfg: &SysmontapConfig) -> Vec<(String, plist::Value)> {
258    vec![
259        (
260            "ur".to_string(),
261            plist::Value::Integer(cfg.update_rate.into()),
262        ),
263        ("bm".to_string(), plist::Value::Integer(0.into())),
264        ("cpuUsage".to_string(), plist::Value::Boolean(cfg.cpu_usage)),
265        (
266            "physFootprint".to_string(),
267            plist::Value::Boolean(cfg.phys_footprint),
268        ),
269        (
270            "sampleInterval".to_string(),
271            plist::Value::Integer(cfg.sample_interval.into()),
272        ),
273    ]
274}
275
276// ── Sample parser ─────────────────────────────────────────────────────────────
277
278fn parse_cpu_sample(msg: &DtxMessage) -> Option<CpuSample> {
279    // Payload is a MethodInvocation from the device with selector and args
280    // The args contain an NSArray of NSDictionary with CPU stats
281    let args = match &msg.payload {
282        DtxPayload::MethodInvocation { args, .. } => args,
283        DtxPayload::Response(NSObject::Array(arr)) => {
284            return parse_from_array(arr);
285        }
286        DtxPayload::Raw(bytes) => match unarchive_raw_payload(bytes) {
287            Some(NSObject::Array(arr)) => {
288                return parse_from_array(&arr);
289            }
290            _ => return None,
291        },
292        _ => return None,
293    };
294
295    // First arg should be an NSArray of sample dicts
296    for arg in args {
297        if let NSObject::Array(arr) = arg {
298            return parse_from_array(arr);
299        }
300    }
301    None
302}
303
304fn parse_from_array(arr: &[NSObject]) -> Option<CpuSample> {
305    // Each element is a dict; only process dicts that have SystemCPUUsage (Type=43 system data)
306    for item in arr {
307        if let NSObject::Dict(d) = item {
308            // Skip process-only messages (no SystemCPUUsage)
309            let sys_cpu = match d.get("SystemCPUUsage") {
310                Some(NSObject::Dict(s)) => s,
311                _ => continue,
312            };
313            let cpu_count = get_uint(d, "CPUCount").unwrap_or(0);
314            let enabled = get_uint(d, "EnabledCPUs").unwrap_or(0);
315            let end_time = get_uint(d, "EndMachAbsTime").unwrap_or(0);
316            let typ = get_uint(d, "Type").unwrap_or(0);
317            let cpu_load = get_float(sys_cpu, "CPU_TotalLoad").unwrap_or(0.0);
318
319            return Some(CpuSample {
320                cpu_count,
321                enabled_cpus: enabled,
322                end_mach_abs_time: end_time,
323                cpu_total_load: cpu_load,
324                sample_type: typ,
325            });
326        }
327    }
328    None
329}
330
331// ── Per-process snapshot parser ──────────────────────────────────────────────
332
333/// Parse per-process data from a sysmontap message.
334///
335/// The sysmontap sends data as NSKeyedArchiver-encoded arrays of dicts.
336/// The data list contains multiple dicts:
337///   - One with SystemCPUUsage (system-level CPU stats)
338///   - One with Processes (per-process data, keyed by PID)
339///
340/// The "Processes" dict maps PID (as string key) → Array of values
341/// in the same order as the `procAttrs` config.
342fn parse_process_snapshot(msg: &DtxMessage, attr_names: &[String]) -> Option<ProcessSample> {
343    match &msg.payload {
344        DtxPayload::MethodInvocation { args, .. } => {
345            for arg in args {
346                if let NSObject::Array(arr) = arg {
347                    return parse_process_from_array(arr, attr_names);
348                }
349            }
350            None
351        }
352        DtxPayload::Response(NSObject::Array(arr)) => parse_process_from_array(arr, attr_names),
353        DtxPayload::Response(NSObject::Dict(d)) => {
354            // Some iOS versions wrap in a single dict
355            parse_process_from_array(&[NSObject::Dict(d.clone())], attr_names)
356        }
357        DtxPayload::Raw(bytes) => match unarchive_raw_payload(bytes) {
358            Some(NSObject::Array(arr)) => parse_process_from_array(&arr, attr_names),
359            _ => None,
360        },
361        DtxPayload::RawWithAux { payload, aux } => {
362            // Try aux args first (process data may come via auxiliary)
363            for arg in aux {
364                if let NSObject::Array(arr) = arg {
365                    if let Some(sample) = parse_process_from_array(arr, attr_names) {
366                        return Some(sample);
367                    }
368                }
369            }
370            // Fall back to payload
371            match unarchive_raw_payload(payload) {
372                Some(NSObject::Array(arr)) => parse_process_from_array(&arr, attr_names),
373                _ => None,
374            }
375        }
376        _ => None,
377    }
378}
379
380fn parse_process_from_array(arr: &[NSObject], attr_names: &[String]) -> Option<ProcessSample> {
381    // The array contains multiple dicts. We need to find the one with "Processes" key
382    // and optionally the one with "SystemCPUUsage".
383    let mut processes = Vec::new();
384    let mut system_cpu = None;
385
386    for item in arr {
387        if let NSObject::Dict(d) = item {
388            // Extract system CPU if present
389            if system_cpu.is_none() {
390                if let Some(NSObject::Dict(sys_cpu)) = d.get("SystemCPUUsage") {
391                    system_cpu = Some(CpuSample {
392                        cpu_count: get_uint(d, "CPUCount").unwrap_or(0),
393                        enabled_cpus: get_uint(d, "EnabledCPUs").unwrap_or(0),
394                        end_mach_abs_time: get_uint(d, "EndMachAbsTime").unwrap_or(0),
395                        cpu_total_load: get_float(sys_cpu, "CPU_TotalLoad").unwrap_or(0.0),
396                        sample_type: get_uint(d, "Type").unwrap_or(0),
397                    });
398                }
399            }
400
401            // Extract per-process data: look for "Processes" key
402            // The value is a dict of PID → Array(values matching procAttrs order)
403            if let Some(NSObject::Dict(processes_dict)) = d.get("Processes") {
404                for (_pid_key, values) in processes_dict {
405                    if let NSObject::Array(vals) = values {
406                        let mut proc_map = serde_json::Map::new();
407                        for (i, val) in vals.iter().enumerate() {
408                            let key = attr_names
409                                .get(i)
410                                .cloned()
411                                .unwrap_or_else(|| format!("attr_{i}"));
412                            proc_map.insert(key, nsobject_to_json(val));
413                        }
414                        processes.push(proc_map);
415                    }
416                }
417            }
418        }
419    }
420
421    if !processes.is_empty() || system_cpu.is_some() {
422        Some(ProcessSample {
423            processes,
424            system_cpu,
425        })
426    } else {
427        None
428    }
429}
430
431fn get_uint(d: &indexmap::IndexMap<String, NSObject>, key: &str) -> Option<u64> {
432    match d.get(key) {
433        Some(NSObject::Uint(n)) => Some(*n),
434        Some(NSObject::Int(n)) => Some(*n as u64),
435        _ => None,
436    }
437}
438
439fn get_float(d: &indexmap::IndexMap<String, NSObject>, key: &str) -> Option<f64> {
440    match d.get(key) {
441        Some(NSObject::Double(f)) => Some(*f),
442        Some(NSObject::Int(n)) => Some(*n as f64),
443        _ => None,
444    }
445}
446
447pub(crate) fn archive_value_to_nsobject(
448    value: crate::proto::nskeyedarchiver::ArchiveValue,
449) -> NSObject {
450    use crate::proto::nskeyedarchiver::ArchiveValue;
451
452    match value {
453        ArchiveValue::Null => NSObject::Null,
454        ArchiveValue::Bool(value) => NSObject::Bool(value),
455        ArchiveValue::Int(value) => NSObject::Int(value),
456        ArchiveValue::Float(value) => NSObject::Double(value),
457        ArchiveValue::String(value) => NSObject::String(value),
458        ArchiveValue::Data(value) => NSObject::Data(value),
459        ArchiveValue::Array(values) => {
460            NSObject::Array(values.into_iter().map(archive_value_to_nsobject).collect())
461        }
462        ArchiveValue::Dict(dict) => NSObject::Dict(
463            dict.into_iter()
464                .map(|(key, value)| (key, archive_value_to_nsobject(value)))
465                .collect(),
466        ),
467        ArchiveValue::Unknown(name) => NSObject::String(format!("<{name}>")),
468    }
469}
470
471pub(crate) fn unarchive_raw_payload(payload: &bytes::Bytes) -> Option<NSObject> {
472    crate::proto::nskeyedarchiver::unarchive(payload)
473        .ok()
474        .map(archive_value_to_nsobject)
475}
476
477pub(crate) fn nsobject_to_json(value: &NSObject) -> serde_json::Value {
478    use serde_json::{Map, Number, Value};
479
480    match value {
481        NSObject::Int(value) => Value::from(*value),
482        NSObject::Uint(value) => Value::from(*value),
483        NSObject::Double(value) => Number::from_f64(*value)
484            .map(Value::Number)
485            .unwrap_or(Value::Null),
486        NSObject::Bool(value) => Value::Bool(*value),
487        NSObject::String(value) => Value::String(value.clone()),
488        NSObject::Data(value) => Value::String(
489            value
490                .iter()
491                .map(|byte| format!("{byte:02x}"))
492                .collect::<String>(),
493        ),
494        NSObject::Array(values) => Value::Array(values.iter().map(nsobject_to_json).collect()),
495        NSObject::Dict(dict) => Value::Object(
496            dict.iter()
497                .map(|(key, value)| (key.clone(), nsobject_to_json(value)))
498                .collect::<Map<_, _>>(),
499        ),
500        NSObject::Null => Value::Null,
501    }
502}