Skip to main content

smix_sdk/
ios_device.rs

1//! iOS `DeviceControl` impl backed by `SimctlClient`.
2//!
3//! Owns the active recording handle internally (`Mutex<Option<RecordingHandle>>`)
4//! so the trait surface stays platform-agnostic. App no longer holds
5//! recording state — delegates to `self.device.start_recording / stop_recording`.
6
7use async_trait::async_trait;
8use std::path::Path;
9use tokio::sync::Mutex;
10
11use smix_driver::Platform;
12use smix_simctl::{DeviceControlError, RecordingHandle, SimctlClient};
13
14use crate::PermissionAction;
15use crate::device_control::{DeviceControl, Permission};
16
17/// iOS `DeviceControl` impl. Wraps `SimctlClient` + owns active
18/// recording handle. Constructed by `App::new` / `App::connect_to_runner`
19/// when targeting iOS.
20pub struct IosDeviceControl {
21    client: SimctlClient,
22    recording: Mutex<Option<RecordingHandle>>,
23}
24
25impl Default for IosDeviceControl {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl IosDeviceControl {
32    #[must_use]
33    pub fn new() -> Self {
34        IosDeviceControl {
35            client: SimctlClient::new(),
36            recording: Mutex::new(None),
37        }
38    }
39
40    /// Construct with an existing `SimctlClient` (used by
41    /// `App::new(driver, simctl)` back-compat constructor).
42    #[must_use]
43    pub fn with_client(client: SimctlClient) -> Self {
44        IosDeviceControl {
45            client,
46            recording: Mutex::new(None),
47        }
48    }
49
50    /// Direct accessor for tests / iOS-only callers that need raw
51    /// simctl access beyond the trait surface.
52    #[must_use]
53    pub fn simctl(&self) -> &SimctlClient {
54        &self.client
55    }
56}
57
58#[async_trait]
59impl DeviceControl for IosDeviceControl {
60    fn platform(&self) -> Platform {
61        Platform::Ios
62    }
63
64    fn as_ios_simctl(&self) -> Option<&SimctlClient> {
65        Some(&self.client)
66    }
67
68    // === Lifecycle ===
69
70    async fn launch(&self, udid: &str, bundle_id: &str) -> Result<u32, DeviceControlError> {
71        self.client.launch(udid, bundle_id).await.map(|res| res.pid)
72    }
73
74    /// `activity` is Android's; a bundle id is already an entry point.
75    async fn launch_with_args(
76        &self,
77        udid: &str,
78        bundle_id: &str,
79        args: &[String],
80        _activity: Option<&str>,
81    ) -> Result<u32, DeviceControlError> {
82        self.client
83            .launch_with_args(udid, bundle_id, args)
84            .await
85            .map(|res| res.pid)
86    }
87
88    async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
89        self.client.terminate(udid, bundle_id).await
90    }
91
92    async fn install(&self, udid: &str, app_path: &str) -> Result<(), DeviceControlError> {
93        self.client.install(udid, app_path).await
94    }
95
96    async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
97        self.client.uninstall(udid, bundle_id).await
98    }
99
100    /// Nothing, on iOS, and the docs say so rather than implying
101    /// otherwise.
102    ///
103    /// There is no host-side lever. Measured on iOS 26.5:
104    ///
105    /// * `simctl ui <device>` offers `appearance`, `increase_contrast`
106    ///   and `content_size` — no motion option.
107    /// * `simctl spawn <device> defaults write <any domain> …` answers
108    ///   `Could not write domain …; exiting`. Not a permissions detail
109    ///   of one domain: `com.apple.UIKit` and `com.apple.Accessibility`
110    ///   both refuse.
111    /// * XCUITest runs in its own process, so
112    ///   `UIView.setAnimationsEnabled(false)` cannot reach the app.
113    ///
114    /// This was written as "Reduce Motion, the strongest lever smix can
115    /// pull on its own" and shipped without a device behind it. The
116    /// first run against one aborted, because `set_reduce_motion` had
117    /// passed `-bool 1` since the day it was written — `defaults` takes
118    /// `true`/`false` and answers anything else with its usage text and
119    /// exit 255 — and it had no callers, so nothing had ever run it.
120    ///
121    /// Returning `Ok` rather than an error is deliberate: a platform
122    /// that cannot be quietened is not a failed run. What is not
123    /// deliberate is pretending it was quietened, which is why this
124    /// says so here, in the guide, and in the changelog.
125    async fn set_animations_quiet(
126        &self,
127        _udid: &str,
128        _quiet: bool,
129    ) -> Result<(), DeviceControlError> {
130        Ok(())
131    }
132
133    async fn keychain_reset(&self, udid: &str) -> Result<(), DeviceControlError> {
134        self.client.keychain_reset(udid).await
135    }
136
137    /// Reset all privacy grants for the bundle via
138    /// `simctl privacy <udid> reset all <bundle-id>`.
139    async fn privacy_reset_all(
140        &self,
141        udid: &str,
142        bundle_id: &str,
143    ) -> Result<(), DeviceControlError> {
144        self.client.privacy_reset_all(udid, bundle_id).await
145    }
146
147    /// Wipe the app's sandbox (`Documents/`, `Library/`,
148    /// `tmp/`) via `simctl spawn <udid> rm -rf` under the app's
149    /// Containers/Data path. Preserves XCUITest binding.
150    async fn clear_app_sandbox(
151        &self,
152        udid: &str,
153        bundle_id: &str,
154    ) -> Result<(), DeviceControlError> {
155        self.client.clear_app_sandbox(udid, bundle_id).await
156    }
157
158    /// Per-key NSUserDefaults deletion via
159    /// `simctl spawn defaults delete`. See [`SimctlClient::user_defaults_delete`].
160    async fn user_defaults_delete(
161        &self,
162        udid: &str,
163        bundle_id: &str,
164        key: &str,
165    ) -> Result<bool, DeviceControlError> {
166        self.client.user_defaults_delete(udid, bundle_id, key).await
167    }
168
169    // === Lifecycle ancillary ===
170
171    async fn open_url(&self, udid: &str, url: &str) -> Result<(), DeviceControlError> {
172        self.client.open_url(udid, url).await
173    }
174
175    async fn send_push(
176        &self,
177        udid: &str,
178        bundle_id: &str,
179        apns_json_path: &str,
180    ) -> Result<(), DeviceControlError> {
181        self.client.send_push(udid, bundle_id, apns_json_path).await
182    }
183
184    async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
185        self.client.screenshot(udid).await
186    }
187
188    async fn capture_bgra(
189        &self,
190        udid: &str,
191    ) -> Result<smix_simctl::surface_capture::CapturedFrame, DeviceControlError> {
192        self.client.capture_bgra(udid).await
193    }
194
195    // === Clipboard / Media / Location ===
196
197    async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), DeviceControlError> {
198        self.client.pasteboard_set(udid, text).await
199    }
200
201    async fn pasteboard_get(&self, udid: &str) -> Result<String, DeviceControlError> {
202        self.client.pasteboard_get(udid).await
203    }
204
205    async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), DeviceControlError> {
206        self.client.add_media(udid, paths).await
207    }
208
209    async fn location_set(&self, udid: &str, lat: f64, lon: f64) -> Result<(), DeviceControlError> {
210        self.client.location_set(udid, lat, lon).await
211    }
212
213    async fn location_start(
214        &self,
215        udid: &str,
216        points: &[(f64, f64)],
217        speed_mps: Option<f64>,
218    ) -> Result<(), DeviceControlError> {
219        self.client.location_start(udid, points, speed_mps).await
220    }
221
222    // === Permissions ===
223
224    async fn set_permission(
225        &self,
226        udid: &str,
227        bundle_id: &str,
228        permission: Permission,
229        action: PermissionAction,
230    ) -> Result<(), DeviceControlError> {
231        let Some(simctl_perm) = permission.to_simctl() else {
232            // Android-only permission on iOS → no-op by design (a
233            // cross-platform yaml's `Storage` should not crash an iOS
234            // run) — but SAY so: this used to skip in total silence.
235            eprintln!(
236                "setPermissions: {permission:?} has no iOS mapping — skipped ({action:?} not applied)"
237            );
238            return Ok(());
239        };
240        match action {
241            PermissionAction::Grant => {
242                self.client
243                    .grant_permission(udid, simctl_perm, bundle_id)
244                    .await
245            }
246            PermissionAction::Revoke => {
247                self.client
248                    .revoke_permission(udid, simctl_perm, bundle_id)
249                    .await
250            }
251            PermissionAction::Reset => {
252                self.client
253                    .reset_permission(udid, simctl_perm, bundle_id)
254                    .await
255            }
256        }
257    }
258
259    // === Recording (state owned internally) ===
260
261    async fn start_recording(
262        &self,
263        udid: &str,
264        output_path: &Path,
265    ) -> Result<(), DeviceControlError> {
266        let mut guard = self.recording.lock().await;
267        if guard.is_some() {
268            // Caller must stop the existing one first; surface via DeviceControlError
269            // (the App layer translates this to an ExpectationFailure with a hint).
270            return Err(DeviceControlError::non_zero_exit(
271                "io recordVideo",
272                -1,
273                "a recording is already in progress (call stop_recording first)",
274            ));
275        }
276        let path_str = output_path.to_string_lossy();
277        let handle = self.client.record_video_start(udid, &path_str).await?;
278        *guard = Some(handle);
279        Ok(())
280    }
281
282    async fn stop_recording(&self) -> Result<(), DeviceControlError> {
283        let mut guard = self.recording.lock().await;
284        let handle = guard.take().ok_or_else(|| {
285            DeviceControlError::non_zero_exit(
286                "io recordVideo",
287                -1,
288                "no recording in progress (call start_recording first)",
289            )
290        })?;
291        self.client.record_video_stop(handle).await
292    }
293}