Skip to main content

smix_sdk/
device_control.rs

1//! v6.0 c1b — `DeviceControl` trait: cross-platform sim/host control.
2//!
3//! Per docs/plan-cold/v6-cross-platform-yaml-design.md §4.2 + §7
4//! (audit-revised 2026-06-23). Two-trait architecture: pair with
5//! [`smix_driver::Driver`] (sense+act).
6//!
7//! Methods on this trait wrap host-side simulator/emulator control
8//! commands (`xcrun simctl` for iOS, `adb` for Android in v6.0 c2).
9//! Sense+act methods (tap/find/etc) live on [`smix_driver::Driver`].
10
11use async_trait::async_trait;
12use smix_simctl::{SimctlClient, SimctlError, SimctlPermission};
13use std::path::Path;
14
15pub use crate::PermissionAction;
16
17/// Platform-agnostic permission name used in [`DeviceControl::set_permission`]
18/// and the cross-platform yaml `launchApp.permissions:` shape. Avoids
19/// leaking iOS-specific `SimctlPermission` into the trait signature.
20///
21/// Naming follows iOS convention where present; Android-only permissions
22/// (Storage, PostNotifications) have explicit variants. Cross-platform
23/// permissions (Camera/Location/etc.) map both ways via `to_simctl` and
24/// (v6.0 c2) `to_android`.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub enum Permission {
27    Camera,
28    Microphone,
29    PhotoLibrary,
30    Location,
31    LocationAlways,
32    Notifications,
33    Contacts,
34    Calendar,
35    Reminders,
36    Bluetooth,
37    Motion,
38    Media,
39    Health,
40    /// iOS-only — FaceID / TouchID biometric prompt.
41    FaceId,
42    /// iOS-only — HomeKit accessory access.
43    HomeKit,
44    /// Android-only — storage / files (iOS-side returns `None` from
45    /// `to_simctl`).
46    Storage,
47    /// Android-only POST_NOTIFICATIONS (API 33+). On iOS aliases to
48    /// `Notifications` for cross-platform yaml convenience.
49    PostNotifications,
50}
51
52impl Permission {
53    /// Map to iOS `SimctlPermission`. Returns `None` for Android-only
54    /// permissions (`Storage`).
55    #[must_use]
56    pub fn to_simctl(self) -> Option<SimctlPermission> {
57        match self {
58            Permission::Camera => Some(SimctlPermission::Camera),
59            Permission::Microphone => Some(SimctlPermission::Microphone),
60            Permission::PhotoLibrary => Some(SimctlPermission::Photos),
61            Permission::Location => Some(SimctlPermission::Location),
62            Permission::LocationAlways => Some(SimctlPermission::LocationAlways),
63            Permission::Notifications | Permission::PostNotifications => {
64                Some(SimctlPermission::Notifications)
65            }
66            Permission::Contacts => Some(SimctlPermission::Contacts),
67            Permission::Calendar => Some(SimctlPermission::Calendar),
68            Permission::Reminders => Some(SimctlPermission::Reminders),
69            Permission::Bluetooth => Some(SimctlPermission::Bluetooth),
70            Permission::Motion => Some(SimctlPermission::Motion),
71            Permission::Media => Some(SimctlPermission::Media),
72            Permission::Health => Some(SimctlPermission::Health),
73            Permission::FaceId => Some(SimctlPermission::Faceid),
74            Permission::HomeKit => Some(SimctlPermission::HomeKit),
75            Permission::Storage => None,
76        }
77    }
78
79    /// Reverse: map iOS `SimctlPermission` → `Permission`. Used by App
80    /// back-compat shim accepting `SimctlPermission` arg.
81    #[must_use]
82    pub fn from_simctl(perm: SimctlPermission) -> Self {
83        match perm {
84            SimctlPermission::Camera => Permission::Camera,
85            SimctlPermission::Microphone => Permission::Microphone,
86            SimctlPermission::Photos => Permission::PhotoLibrary,
87            SimctlPermission::Location => Permission::Location,
88            SimctlPermission::LocationAlways => Permission::LocationAlways,
89            SimctlPermission::Notifications => Permission::Notifications,
90            SimctlPermission::Contacts => Permission::Contacts,
91            SimctlPermission::Calendar => Permission::Calendar,
92            SimctlPermission::Reminders => Permission::Reminders,
93            SimctlPermission::Bluetooth => Permission::Bluetooth,
94            SimctlPermission::Motion => Permission::Motion,
95            SimctlPermission::Media => Permission::Media,
96            SimctlPermission::Health => Permission::Health,
97            SimctlPermission::Faceid => Permission::FaceId,
98            SimctlPermission::HomeKit => Permission::HomeKit,
99            SimctlPermission::AddressBook => Permission::Contacts,
100        }
101    }
102
103    /// Map to Android `android.permission.X` string. Returns `None` for
104    /// iOS-only permissions (`FaceId`, `HomeKit`). Wired by
105    /// `AndroidDeviceControl` in v6.0 c2; iOS impl ignores.
106    #[must_use]
107    pub fn to_android(self) -> Option<&'static str> {
108        match self {
109            Permission::Camera => Some("android.permission.CAMERA"),
110            Permission::Microphone => Some("android.permission.RECORD_AUDIO"),
111            Permission::PhotoLibrary => Some("android.permission.READ_MEDIA_IMAGES"),
112            Permission::Location => Some("android.permission.ACCESS_FINE_LOCATION"),
113            Permission::LocationAlways => Some("android.permission.ACCESS_BACKGROUND_LOCATION"),
114            Permission::Notifications | Permission::PostNotifications => {
115                Some("android.permission.POST_NOTIFICATIONS")
116            }
117            Permission::Contacts => Some("android.permission.READ_CONTACTS"),
118            Permission::Calendar => Some("android.permission.READ_CALENDAR"),
119            Permission::Bluetooth => Some("android.permission.BLUETOOTH_CONNECT"),
120            Permission::Motion => Some("android.permission.ACTIVITY_RECOGNITION"),
121            Permission::Media => Some("android.permission.READ_MEDIA_AUDIO"),
122            Permission::Storage => Some("android.permission.WRITE_EXTERNAL_STORAGE"),
123            Permission::Reminders
124            | Permission::Health
125            | Permission::FaceId
126            | Permission::HomeKit => None,
127        }
128    }
129}
130
131/// Sim/host control trait. iOS impl wraps `xcrun simctl`; Android impl
132/// (v6.0 c2) wraps `adb`.
133///
134/// Methods take `udid: &str` first (iOS terminology; Android maps to
135/// device serial). All return `Result<_, SimctlError>` for v6.0 c1b —
136/// Android impl in v6.0 c2 wraps adb errors into the same enum (or
137/// a new `DeviceError` introduced if needed).
138#[async_trait]
139pub trait DeviceControl: Send + Sync {
140    /// Platform identifier. Returns `smix_driver::Platform`.
141    fn platform(&self) -> smix_driver::Platform;
142
143    /// iOS-only escape hatch: downcast to `&SimctlClient` for legacy
144    /// `App::simctl()` API surface. Android impl returns `None`.
145    fn as_ios_simctl(&self) -> Option<&SimctlClient> {
146        None
147    }
148
149    // === Lifecycle ===
150
151    async fn launch(&self, udid: &str, bundle_id: &str) -> Result<u32, SimctlError>;
152    async fn launch_with_args(
153        &self,
154        udid: &str,
155        bundle_id: &str,
156        args: &[String],
157    ) -> Result<u32, SimctlError>;
158    async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError>;
159    async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError>;
160    async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError>;
161    async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError>;
162
163    /// v1.0.4 §D12 — reset all granted privacy permissions for a
164    /// bundle. Companion to `clear_app_sandbox`; together they form
165    /// the in-place `launchApp: clearState: true` replacement that
166    /// avoids `simctl uninstall + install` and its downstream
167    /// XCUITest binding loss (feedback §F) + ReportCrash dialog
168    /// (feedback §H). Default impl no-ops so non-iOS device controls
169    /// (Android) keep compiling; iOS override supplies real behavior.
170    async fn privacy_reset_all(&self, _udid: &str, _bundle_id: &str) -> Result<(), SimctlError> {
171        Ok(())
172    }
173
174    /// v1.0.4 §D12 — wipe an app's `Documents/`, `Library/`, `tmp/`
175    /// directories in the sim's Containers/Data root, without
176    /// uninstalling the app. Preserves the XCUITest binding.
177    /// Default impl no-ops; iOS override does the real wipe.
178    async fn clear_app_sandbox(&self, _udid: &str, _bundle_id: &str) -> Result<(), SimctlError> {
179        Ok(())
180    }
181
182    /// v1.0.27 — delete a single key from the target app's persisted
183    /// user-defaults / preferences store. iOS: `simctl spawn defaults
184    /// delete <bundle> <key>` (NSUserDefaults via the sim's cfprefsd).
185    /// Returns `Ok(true)` when the key existed, `Ok(false)` when
186    /// already absent (both are the "ensure absent" target state).
187    ///
188    /// Default impl errors explicitly — Android SharedPreferences has
189    /// no host-side per-key deletion path (files are app-private;
190    /// `pm clear` is the whole-store hammer, which is `clearAppData`'s
191    /// job, not this verb's). NOT a silent no-op: a consumer relying
192    /// on the deletion for test correctness must hear that it didn't
193    /// happen.
194    async fn user_defaults_delete(
195        &self,
196        _udid: &str,
197        _bundle_id: &str,
198        _key: &str,
199    ) -> Result<bool, SimctlError> {
200        Err(SimctlError::non_zero_exit(
201            "user-defaults-delete",
202            1,
203            "clearUserDefaults is not supported on this platform (iOS simulator only — \
204             Android SharedPreferences has no host-side per-key deletion; use clearAppData \
205             for a full store wipe)",
206        ))
207    }
208
209    // === Lifecycle ancillary ===
210
211    async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError>;
212    async fn send_push(
213        &self,
214        udid: &str,
215        bundle_id: &str,
216        apns_json_path: &str,
217    ) -> Result<(), SimctlError>;
218    async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError>;
219
220    // === Clipboard / Media / Location ===
221
222    async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError>;
223    async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError>;
224    async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError>;
225    async fn location_set(&self, udid: &str, lat: f64, lon: f64) -> Result<(), SimctlError>;
226    async fn location_start(
227        &self,
228        udid: &str,
229        points: &[(f64, f64)],
230        speed_mps: Option<f64>,
231    ) -> Result<(), SimctlError>;
232
233    // === Permissions (cross-platform `Permission` enum) ===
234
235    async fn set_permission(
236        &self,
237        udid: &str,
238        bundle_id: &str,
239        permission: Permission,
240        action: PermissionAction,
241    ) -> Result<(), SimctlError>;
242
243    // === Recording (state owned internally by impl, see IosDeviceControl) ===
244
245    async fn start_recording(&self, udid: &str, output_path: &Path) -> Result<(), SimctlError>;
246    async fn stop_recording(&self) -> Result<(), SimctlError>;
247}