//! iOS Simulators and Android Emulators next to a thread, as T3 Code's //! Device hub has them. The app owns discovery, the streaming helper //! (expo-device-hub) and the agent driver (agent-device); the Device panel //! renders the hub's per-device streams, and agents reach devices through //! the `device_*` tools and the `agent-device` CLI the app sets up for them. //! //! Discovery and boot go through the hub's JSON API: it normalizes both //! platforms into one device shape, and it's the process that must know a //! device booted before it can stream it. Sessions are the app's own //! bookkeeping (which thread looks at which device), so the panel and the //! tools agree, and a device an agent opens shows up in the panel. //! //! Everything here blocks; call it from a background thread. pub mod actions; pub mod agent; pub mod host; pub mod stream; pub mod toolchain; use std::{ sync::{Mutex, OnceLock}, time::Duration, }; use anyhow::{Context as _, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use serde_json::json; use host::{LocalHost, Ready}; use toolchain::Tool; /// The machine the app runs on, the only device host there is. pub const LOCAL_HOST: &str = "local"; const BOOT_TIMEOUT: Duration = Duration::from_secs(180); const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20); const HUB_TIMEOUT: Duration = Duration::from_secs(15); #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Platform { Ios, Android, } impl Platform { pub fn key(self) -> &'static str { match self { Platform::Ios => "ios", Platform::Android => "android", } } pub fn label(self) -> &'static str { match self { Platform::Ios => "iOS", Platform::Android => "Android", } } /// The device list's heading for the platform. pub fn group_label(self) -> &'static str { match self { Platform::Ios => "iOS Simulators", Platform::Android => "Android Emulators", } } fn vendor(self) -> &'static str { match self { Platform::Ios => "/vendor/serve-sim", Platform::Android => "/vendor/serve-emu", } } pub fn from_key(key: &str) -> Option { match key { "ios" => Some(Platform::Ios), "android" => Some(Platform::Android), _ => None, } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeviceSummary { pub host_id: String, /// Simulator udid or adb serial (an AVD's name while it isn't running). pub id: String, pub platform: Platform, pub name: String, /// "iOS 18.0", "Android 16.0". pub version: String, pub booted: bool, pub physical: bool, } /// Where the helper processes are in their lifecycle. Tools install on /// first use, so a fresh setup spends a while installing before anything /// can stream; the panel shows that instead of an empty list. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum HostStatus { #[default] Disabled, Idle, Installing, Starting, Ready, Failed, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PlatformAvailability { pub platform: Platform, pub available: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolVersion { #[serde(rename = "requiredVersion")] pub required: String, #[serde(rename = "installedVersions")] pub installed: Vec, #[serde(rename = "runningVersion")] pub running: Option, } impl ToolVersion { /// The version to show: the running one, the pinned one if installed, or the newest installed. pub fn shown(&self) -> Option<&str> { self.running .as_deref() .or_else(|| self.installed.contains(&self.required).then_some(self.required.as_str())) .or_else(|| self.installed.last().map(String::as_str)) } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HostSummary { pub id: String, pub kind: String, pub label: String, pub platforms: Vec, pub hub: ToolVersion, pub agent: ToolVersion, pub hub_installed: bool, pub agent_device_installed: bool, } /// A device a thread looks at: one per thread and device. A device can be /// open in several threads; they share its stream. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeviceSession { pub thread_id: String, pub host_id: String, pub device_id: String, pub platform: Platform, pub opened_at: String, } /// Everything the Device panel renders from. #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default)] pub struct State { pub enabled: bool, pub agent_access: bool, pub onboarding_completed: bool, pub host_status: HostStatus, pub host_detail: Option, pub hosts: Vec, pub devices: Vec, pub sessions: Vec, /// Devices starting up, with the thread that asked. pub booting: Vec<(DeviceSummary, String)>, pub revision: u64, } impl State { pub fn device(&self, host: &str, id: &str) -> Option<&DeviceSummary> { self.devices.iter().find(|device| device.host_id == host && device.id == id) } pub fn local(&self) -> Option<&HostSummary> { self.hosts.iter().find(|host| host.id == LOCAL_HOST) } pub fn host_label(&self, host: &str) -> String { self.hosts.iter().find(|summary| summary.id == host).map(|summary| summary.label.clone()).unwrap_or_else(|| "Device host".into()) } pub fn busy(&self) -> bool { matches!(self.host_status, HostStatus::Installing | HostStatus::Starting) } /// The panel's "iOS"/"Android" lines: ready, or what's missing. pub fn platform_status(&self, platform: Platform) -> (bool, String) { let availability = self.hosts.iter().flat_map(|host| &host.platforms).find(|candidate| candidate.platform == platform); let Some(availability) = availability.filter(|availability| availability.available) else { let reason = availability.and_then(|availability| availability.reason.clone()); return (false, reason.unwrap_or_else(|| format!("{} support was not detected.", platform.label()))); }; let _ = availability; if self.host_status == HostStatus::Ready && !self.devices.iter().any(|device| device.platform == platform) { return ( false, match platform { Platform::Ios => "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components.", Platform::Android => "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.", } .into(), ); } ( true, match platform { Platform::Ios => "Xcode and iOS Simulator are available.", Platform::Android => "The Android SDK and Emulator are available.", } .into(), ) } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum TextSize { Small, #[default] Default, Large, ExtraLarge, } impl TextSize { pub const ALL: [TextSize; 4] = [TextSize::Small, TextSize::Default, TextSize::Large, TextSize::ExtraLarge]; pub fn label(self) -> &'static str { match self { TextSize::Small => "Small", TextSize::Default => "Default", TextSize::Large => "Large", TextSize::ExtraLarge => "Extra large", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ColorFilter { None, Grayscale, RedGreen, GreenRed, BlueYellow, } impl ColorFilter { pub const ALL: [ColorFilter; 5] = [ColorFilter::None, ColorFilter::Grayscale, ColorFilter::RedGreen, ColorFilter::GreenRed, ColorFilter::BlueYellow]; pub fn key(self) -> &'static str { match self { ColorFilter::None => "none", ColorFilter::Grayscale => "grayscale", ColorFilter::RedGreen => "red-green", ColorFilter::GreenRed => "green-red", ColorFilter::BlueYellow => "blue-yellow", } } pub fn from_key(key: &str) -> Option { Self::ALL.into_iter().find(|filter| filter.key() == key) } pub fn label(self) -> &'static str { match self { ColorFilter::None => "None", ColorFilter::Grayscale => "Grayscale", ColorFilter::RedGreen => "Red / green (protanopia)", ColorFilter::GreenRed => "Green / red (deuteranopia)", ColorFilter::BlueYellow => "Blue / yellow (tritanopia)", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Orientation { Portrait, LandscapeLeft, PortraitUpsideDown, LandscapeRight, } impl Orientation { pub const ALL: [Orientation; 4] = [Orientation::Portrait, Orientation::LandscapeLeft, Orientation::PortraitUpsideDown, Orientation::LandscapeRight]; pub fn key(self) -> &'static str { match self { Orientation::Portrait => "portrait", Orientation::LandscapeLeft => "landscape_left", Orientation::PortraitUpsideDown => "portrait_upside_down", Orientation::LandscapeRight => "landscape_right", } } pub fn from_key(key: &str) -> Option { Self::ALL.into_iter().find(|orientation| orientation.key() == key) } pub fn label(self) -> &'static str { match self { Orientation::Portrait => "Portrait", Orientation::LandscapeLeft => "Landscape left", Orientation::PortraitUpsideDown => "Upside down", Orientation::LandscapeRight => "Landscape right", } } pub fn is_landscape(self) -> bool { matches!(self, Orientation::LandscapeLeft | Orientation::LandscapeRight) } pub fn next(self) -> Self { let ix = Self::ALL.iter().position(|orientation| *orientation == self).unwrap_or(0); Self::ALL[(ix + 1) % Self::ALL.len()] } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Toggle { ReduceMotion, IncreaseContrast, ReduceTransparency, ShowBorders, VoiceOver, NetworkEnabled, } /// Current values as read from the device; `None` is unsupported or unread. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Settings { pub dark: Option, pub text_size: Option, pub reduce_motion: Option, pub increase_contrast: Option, pub reduce_transparency: Option, pub show_borders: Option, pub voice_over: Option, pub liquid_glass_tinted: Option, pub color_filter: Option, pub network_enabled: Option, } impl Settings { pub fn toggle(&self, toggle: Toggle) -> Option { match toggle { Toggle::ReduceMotion => self.reduce_motion, Toggle::IncreaseContrast => self.increase_contrast, Toggle::ReduceTransparency => self.reduce_transparency, Toggle::ShowBorders => self.show_borders, Toggle::VoiceOver => self.voice_over, Toggle::NetworkEnabled => self.network_enabled, } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ForegroundApp { pub id: String, } /// One device's settings and foreground app, as last read. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Detail { pub settings: Settings, pub foreground: Option, } /// One control in the Tools drawer or the rail. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Action { SetAppearance(bool), SetTextSize(TextSize), SetToggle { setting: Toggle, value: bool }, SetLiquidGlass(bool), SetColorFilter(ColorFilter), SetOrientation(Orientation), SetLocation { latitude: f64, longitude: f64 }, ClearLocation, /// `permission` is a shared name (`camera`), `decision` grant, revoke or reset. SetPermission { app_id: String, permission: String, decision: String }, OpenUrl(String), LaunchApp(String), TerminateApp(String), SendPush { app_id: String, text: String }, } impl Action { pub fn name(&self) -> &'static str { match self { Action::SetAppearance(_) => "appearance", Action::SetTextSize(_) => "text size", Action::SetToggle { .. } => "setting", Action::SetLiquidGlass(_) => "Liquid Glass", Action::SetColorFilter(_) => "color filter", Action::SetOrientation(_) => "orientation", Action::SetLocation { .. } | Action::ClearLocation => "location", Action::SetPermission { .. } => "permission", Action::OpenUrl(_) => "open url", Action::LaunchApp(_) => "launch", Action::TerminateApp(_) => "terminate", Action::SendPush { .. } => "push", } } } /// What the service tells its listeners. #[derive(Clone, Debug)] pub enum Event { Changed, /// An agent opened a device in a thread: the panel shows it. AgentOpened { thread: String, device: DeviceSummary }, /// An agent closed a thread's devices (all of them without an id). AgentClosed { thread: String, device: Option }, } #[derive(Deserialize)] struct HubDevice { id: String, name: String, version: String, platform: Platform, booted: bool, #[serde(default)] physical: bool, } #[derive(Deserialize)] struct HubDeviceList { #[serde(default)] simulators: Vec, #[serde(default)] emulators: Vec, #[serde(default)] errors: Vec, } #[derive(Deserialize)] struct HubError { message: String, } #[derive(Deserialize)] struct HubActionResult { ok: bool, id: Option, serial: Option, error: Option, } pub struct DeviceService { host: LocalHost, state: Mutex, listeners: Mutex>>, /// Configure, readiness and session changes go one at a time. lifecycle: Mutex<()>, } /// Whether agents in a project get the device tools: device support and /// agent access on, and a platform this machine can run. pub fn agent_access_for(project: &std::path::Path) -> bool { crate::settings::get_for(project, |settings| settings.enable_device_support && settings.enable_agent_device_access) && host::availability(&host::android_sdk()).iter().any(|platform| platform.available) } /// The app's one device service. pub fn service() -> &'static DeviceService { static SERVICE: OnceLock = OnceLock::new(); SERVICE.get_or_init(DeviceService::new) } fn hub_agent(timeout: Duration) -> ureq::Agent { ureq::AgentBuilder::new().timeout(timeout).build() } /// GETs JSON from the hub. fn get_json(url: &str, timeout: Duration) -> Result { let body = hub_agent(timeout).get(url).call().map_err(|err| anyhow!("{err}"))?.into_string()?; Ok(serde_json::from_str(&body)?) } /// POSTs JSON to the hub and reads its JSON answer. fn post_json(url: &str, body: serde_json::Value, timeout: Duration) -> Result { let reply = hub_agent(timeout) .post(url) .set("Content-Type", "application/json") .send_string(&body.to_string()) .map_err(|err| anyhow!("{err}"))? .into_string()?; Ok(serde_json::from_str(&reply)?) } fn now() -> String { chrono::Local::now().to_rfc3339() } impl DeviceService { fn new() -> Self { let (enabled, agent_access, onboarding_completed) = crate::settings::get(|settings| { (settings.enable_device_support, settings.enable_agent_device_access, settings.device_onboarding_completed) }); let state = State { enabled, agent_access, onboarding_completed, host_status: if enabled { HostStatus::Idle } else { HostStatus::Disabled }, ..State::default() }; let service = Self { host: LocalHost::new(), state: Mutex::new(state), listeners: Mutex::default(), lifecycle: Mutex::new(()) }; let summary = service.summary(); service.lock_state().hosts = vec![summary]; service } fn lock_state(&self) -> std::sync::MutexGuard<'_, State> { self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } fn lock_lifecycle(&self) -> std::sync::MutexGuard<'_, ()> { self.lifecycle.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } pub fn state(&self) -> State { self.lock_state().clone() } /// Events from now on. pub fn subscribe(&self) -> smol::channel::Receiver { let (tx, rx) = smol::channel::unbounded(); self.listeners.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(tx); rx } fn broadcast(&self, event: Event) { let mut listeners = self.listeners.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); listeners.retain(|listener| listener.try_send(event.clone()).is_ok()); } fn publish(&self, update: impl FnOnce(&mut State)) -> State { let state = { let mut state = self.lock_state(); update(&mut state); state.revision += 1; state.clone() }; self.broadcast(Event::Changed); state } fn set_host_status(&self, status: HostStatus, detail: Option) { self.publish(|state| { state.host_status = status; state.host_detail = detail; }); } /// The local host's platforms and tool versions, without installing anything. fn summary(&self) -> HostSummary { let sdk = host::android_sdk(); let (hub_running, agent_running) = self.host.running_versions(); HostSummary { id: LOCAL_HOST.into(), kind: "local".into(), label: "This machine".into(), platforms: host::availability(&sdk), hub: toolchain::versions(Tool::Hub, hub_running.then_some(toolchain::HUB_VERSION)), agent: toolchain::versions(Tool::Agent, agent_running.as_deref()), hub_installed: toolchain::is_installed(Tool::Hub), agent_device_installed: toolchain::is_installed(Tool::Agent), } } fn enabled(&self) -> bool { crate::settings::get(|settings| settings.enable_device_support) } fn agent_access(&self) -> bool { crate::settings::get(|settings| settings.enable_device_support && settings.enable_agent_device_access) } fn any_platform(&self) -> bool { host::availability(&host::android_sdk()).iter().any(|platform| platform.available) } /// The hub's endpoints, starting it if needed, once device support is on. pub fn readiness(&self) -> Result { if !self.enabled() { bail!("Device support is off. Enable it in the Device panel before installing or starting device tools."); } let _guard = self.lock_lifecycle(); let on_phase = |status: HostStatus, detail: Option| self.set_host_status(status, detail); match self.host.ensure_ready(&on_phase) { Ok(ready) => { if self.lock_state().host_status != HostStatus::Ready { let summary = self.summary(); self.publish(|state| { state.host_status = HostStatus::Ready; state.hosts = vec![summary]; }); } Ok(ready) } Err(err) => { let detail = format!("{err:#}"); self.set_host_status(HostStatus::Failed, Some(detail.clone())); Err(anyhow!(detail)) } } } /// The hub's origin for another device's stream: running, or started /// when device support is on. pub fn hub_for_remote(&self) -> Option { self.hub_origin().or_else(|| self.enabled().then(|| self.readiness().ok().map(|ready| ready.origin)).flatten()) } /// The hub's origin if it runs, without starting it. pub fn hub_origin(&self) -> Option { self.host.current().map(|ready| ready.origin) } /// Starts agent-device, once agent access is on and a platform can run here. fn agent_readiness(&self) -> Result> { if !self.agent_access() || !self.any_platform() { return Ok(None); } let _guard = self.lock_lifecycle(); let on_phase = |status: HostStatus, detail: Option| self.set_host_status(status, detail); match self.host.ensure_agent_ready(&on_phase) { Ok(ready) => { let summary = self.summary(); self.publish(|state| { state.host_status = HostStatus::Ready; state.hosts = vec![summary]; }); Ok(Some(ready)) } Err(err) => { let detail = format!("{err:#}"); self.set_host_status(HostStatus::Failed, Some(detail.clone())); Err(anyhow!(detail)) } } } fn fetch_devices(&self, ready: &Ready) -> Result<(Vec, Option)> { let list: HubDeviceList = get_json(&format!("{}/api/devices", ready.origin), HUB_TIMEOUT) .map_err(|err| anyhow!("Device list failed: Could not communicate with device support. Try refreshing devices. ({err})"))?; let mut devices: Vec = list .simulators .into_iter() .chain(list.emulators) .map(|device| DeviceSummary { host_id: LOCAL_HOST.into(), id: device.id, platform: device.platform, name: device.name, version: device.version, booted: device.booted, physical: device.physical, }) .collect(); let sdk = host::android_sdk(); if host::platform_reason(Platform::Android, &sdk).is_none() { let avds = host::run("emulator", &["-list-avds"], Duration::from_secs(20), None).ok("list")?; for name in avds.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with("INFO")) { if !devices.iter().any(|device| device.platform == Platform::Android && device.name == name) { devices.push(DeviceSummary { host_id: LOCAL_HOST.into(), id: name.into(), name: name.into(), platform: Platform::Android, version: "Android".into(), booted: false, physical: false, }); } } } // Off macOS the hub still tries Xcode; that's no news to anyone. let ios_missing = host::platform_reason(Platform::Ios, &sdk).is_some(); let errors: Vec = list .errors .into_iter() .map(|error| error.message) .filter(|message| !(ios_missing && (message.contains("xcrun") || message.contains("apple-utils")))) .collect(); Ok((devices, (!errors.is_empty()).then(|| errors.join("\n")))) } fn refresh(&self, ready: &Ready) -> Result { let (devices, detail) = self.fetch_devices(ready)?; let summary = self.summary(); let _guard = self.lock_lifecycle(); if !self.enabled() { return Ok(self.state()); } Ok(self.publish(|state| { state.hosts = vec![summary]; state.devices = devices; state.host_status = HostStatus::Ready; state.host_detail = detail; })) } /// Refreshes the devices, starting the hub, once device support is on /// and this machine can run a platform. pub fn list(&self) -> State { if !self.enabled() { return self.state(); } if !self.any_platform() { let summary = self.summary(); return self.publish(|state| { state.hosts = vec![summary]; state.host_status = HostStatus::Ready; state.devices.clear(); }); } if let Err(err) = self.readiness().and_then(|ready| self.refresh(&ready)) { self.set_host_status(HostStatus::Failed, Some(format!("{err:#}"))); } self.state() } /// Reads installed tool versions without installing or starting anything. pub fn inspect(&self) -> State { let summary = self.summary(); self.publish(|state| state.hosts = vec![summary]) } /// Installs a tool's pinned version without turning anything on. pub fn update_tool(&self, tool: Tool) -> Result { toolchain::ensure(tool)?; Ok(self.inspect()) } /// Retries the host, agent tools included if access was granted. pub fn retry(&self) -> State { if !self.enabled() { return self.state(); } let result = self.agent_readiness().and_then(|agent| match agent { Some((ready, _)) => self.refresh(&ready).map(|_| ()), None => self.readiness().and_then(|ready| self.refresh(&ready)).map(|_| ()), }); if let Err(err) = result { self.set_host_status(HostStatus::Failed, Some(format!("{err:#}"))); } self.state() } /// Turns device support, agent access or the finished setup on or off. pub fn configure(&self, enabled: Option, agent_access: Option, onboarding: Option) -> Result { let (next_enabled, next_agent) = { let _guard = self.lock_lifecycle(); crate::settings::update(|settings| { if let Some(enabled) = enabled { settings.enable_device_support = enabled; } if let Some(agent) = agent_access { settings.enable_agent_device_access = agent; } if let Some(done) = onboarding { settings.device_onboarding_completed = done; } }); let (next_enabled, next_agent) = crate::settings::get(|settings| (settings.enable_device_support, settings.enable_agent_device_access)); if !next_enabled { self.host.stop(); } else if agent_access == Some(false) { self.host.stop_agent(); } let summary = self.summary(); self.publish(|state| { state.enabled = next_enabled; state.agent_access = next_agent; if let Some(done) = onboarding { state.onboarding_completed = done; } state.host_status = if next_enabled { HostStatus::Idle } else { HostStatus::Disabled }; state.host_detail = None; state.hosts = vec![summary]; if !next_enabled { state.devices.clear(); state.sessions.clear(); state.booting.clear(); } }); (next_enabled, next_agent) }; if next_enabled && next_agent && agent_access == Some(true) { self.agent_readiness()?; } Ok(self.list()) } /// Boots through the hub, so its list and its streaming helper both see /// the device come up. An AVD changes id when it boots (name to serial), /// so the id this returns is the one to use. fn boot(&self, ready: &Ready, device: &DeviceSummary) -> Result { let body = json!({"platform": device.platform, "id": device.id, "name": device.name}); let result: HubActionResult = post_json(&format!("{}/api/devices/boot", ready.origin), body, BOOT_TIMEOUT) .map_err(|err| anyhow!("Device boot failed: Could not communicate with device support. ({err})"))?; if !result.ok { let error = result.error.unwrap_or_default(); let lower = error.to_lowercase(); let explanation = if (lower.contains("disk") || lower.contains("space")) && (lower.contains("insufficient") || lower.contains("not enough") || lower.contains("no space")) { "There is not enough free disk space on this machine." } else if lower.contains("timeout") || lower.contains("timed out") { "The device did not become ready in time." } else { "The simulator or emulator could not start. Check its configuration." }; bail!("Device {} failed to boot: {explanation}", device.id); } if device.platform == Platform::Ios { self.attach_ios(ready, &device.id)?; } Ok(result.serial.or(result.id).unwrap_or_else(|| device.id.clone())) } /// Booting alone attaches no serve-sim helper to a simulator; the grid's /// start does both and is harmless on a booted one. fn attach_ios(&self, ready: &Ready, udid: &str) -> Result<()> { post_json::(&format!("{}/vendor/serve-sim/grid/api/start", ready.origin), json!({"udid": udid}), BOOT_TIMEOUT) .map_err(|err| anyhow!("Device attach stream failed: {err}"))?; Ok(()) } /// Opens a device in a thread, booting it unless `boot` is false. pub fn open(&self, thread: &str, device_id: &str, boot: bool) -> Result { let ready = self.readiness()?; let mut state = self.refresh(&ready)?; let mut device = state.device(LOCAL_HOST, device_id).cloned().ok_or_else(|| anyhow!("Device {device_id} was not found on host {LOCAL_HOST}."))?; if let Some(reason) = host::platform_reason(device.platform, &host::android_sdk()) { bail!("{} devices are unavailable on host {LOCAL_HOST}: {reason}", device.platform.key()); } if !device.booted && boot { let booting = (device.clone(), thread.to_string()); self.publish(|state| { state.booting.retain(|(entry, _)| entry.id != booting.0.id); state.booting.push(booting.clone()); }); let booted = self.boot(&ready, &device); self.publish(|state| state.booting.retain(|(entry, _)| entry.id != device.id)); let booted = booted?; state = self.refresh(&ready)?; device = state .device(LOCAL_HOST, &booted) .or_else(|| state.device(LOCAL_HOST, &device.id)) .cloned() .ok_or_else(|| anyhow!("Device {booted} was not found on host {LOCAL_HOST}."))?; } else if device.platform == Platform::Ios && device.booted { // A simulator booted elsewhere has no helper attached yet. self.attach_ios(&ready, &device.id)?; } let session = DeviceSession { thread_id: thread.to_string(), host_id: LOCAL_HOST.into(), device_id: device.id.clone(), platform: device.platform, opened_at: now(), }; let _guard = self.lock_lifecycle(); if !self.enabled() { bail!("Device support was turned off while the device was opening."); } self.publish(|state| { state.sessions.retain(|existing| !(existing.thread_id == thread && existing.device_id == session.device_id)); state.sessions.push(session.clone()); }); Ok(session) } /// Closes a thread's device (or all of them), powering it off on request. pub fn close(&self, thread: &str, device_id: Option<&str>, shutdown: bool) -> Result<()> { let closing: Vec = self .lock_state() .sessions .iter() .filter(|session| session.thread_id == thread && device_id.is_none_or(|id| session.device_id == id)) .cloned() .collect(); if closing.is_empty() { return Ok(()); } self.publish(|state| state.sessions.retain(|session| !closing.contains(session))); if shutdown { for session in closing { self.shutdown_device(&session.device_id, session.platform)?; } } Ok(()) } fn shutdown_device(&self, device_id: &str, platform: Platform) -> Result<()> { let ready = self.readiness()?; let post = |path: &str, body: serde_json::Value| -> Result<()> { let result: HubActionResult = post_json(&format!("{}{path}", ready.origin), body, HUB_TIMEOUT) .map_err(|err| anyhow!("Device shutdown failed: Could not communicate with device support. ({err})"))?; if !result.ok { bail!("Device shutdown failed: The device hub could not complete the request."); } Ok(()) }; match platform { // serve-sim closes its capture session before `simctl shutdown`; // a simulator that's already off fails there, which is fine. Platform::Ios => { if let Err(err) = post("/vendor/serve-sim/grid/api/shutdown", json!({"udid": device_id})) { let off = self.fetch_devices(&ready).ok().and_then(|(devices, _)| devices.into_iter().find(|device| device.id == device_id)).is_some_and(|device| !device.booted); if !off { return Err(err); } } } Platform::Android => post("/api/devices/shutdown", json!({"platform": platform, "id": device_id}))?, } self.publish(|state| { for device in state.devices.iter_mut().filter(|device| device.id == device_id) { device.booted = false; } state.sessions.retain(|session| session.device_id != device_id); }); // Discovery can stall while an emulator saves its snapshot; the // shutdown went through either way. let _ = self.refresh(&ready); Ok(()) } fn resolve(&self, device_id: &str) -> Result<(Ready, DeviceSummary)> { let ready = self.readiness()?; let known = self.lock_state().device(LOCAL_HOST, device_id).cloned(); let device = match known { Some(device) => device, None => self.refresh(&ready)?.device(LOCAL_HOST, device_id).cloned().ok_or_else(|| anyhow!("Device {device_id} was not found on host {LOCAL_HOST}."))?, }; Ok((ready, device)) } /// A PNG of the device's screen. pub fn screenshot(&self, device_id: &str) -> Result<(DeviceSummary, Vec)> { let (ready, device) = self.resolve(device_id)?; let url = format!("{}{}/api/screenshot?device={}", ready.origin, device.platform.vendor(), stream::urlencode(&device.id)); let response = hub_agent(SCREENSHOT_TIMEOUT) .post(&url) .call() .map_err(|err| anyhow!("Device screenshot failed: Could not communicate with device support. ({err})"))?; let mut png = Vec::new(); std::io::Read::read_to_end(&mut response.into_reader(), &mut png)?; Ok((device, png)) } /// The device's current settings and foreground app. pub fn detail(&self, device_id: &str) -> Result { let (ready, device) = self.resolve(device_id)?; let (settings, foreground) = actions::read(&ready, device.platform, &device.id); Ok(Detail { settings, foreground }) } /// Runs one action, then reads the device again. pub fn action(&self, device_id: &str, action: &Action) -> Result { let (ready, device) = self.resolve(device_id)?; actions::run(&ready, device.platform, &device.id, action)?; let (settings, foreground) = actions::read(&ready, device.platform, &device.id); Ok(Detail { settings, foreground }) } /// The accessibility elements on screen, normalized to 0..1, for the overlay. pub fn accessibility(&self, device_id: &str) -> Result> { let origin = self.hub_origin().context("The device hub is not running.")?; let platform = self.lock_state().device(LOCAL_HOST, device_id).map(|device| device.platform).unwrap_or(Platform::Android); let id = stream::urlencode(device_id); let url = match platform { Platform::Ios => format!("{origin}/vendor/serve-sim/helper/{id}/ax"), Platform::Android => format!("{origin}/vendor/serve-emu/api/accessibility?device={id}"), }; let payload: serde_json::Value = get_json(&url, HUB_TIMEOUT)?; Ok(match platform { Platform::Ios => flatten_ios_ax(&payload), Platform::Android => flatten_android_ax(&payload), }) } /// The flags that pin agent-device to a thread's device: `--config` and `--session`. pub fn agent_target(&self, thread: &str, device_id: &str) -> Result> { let Some((_, endpoint)) = self.agent_readiness()? else { bail!("Agent device access requires enabled device support, agent access, and an available simulator platform on this machine."); }; let config = agent::write_config(LOCAL_HOST, &endpoint)?; Ok(vec!["--config".into(), config.display().to_string(), "--session".into(), agent::session(thread, LOCAL_HOST, device_id)]) } /// The `agent-device` launcher, installing the CLI if needed. pub fn agent_cli(&self) -> Result { let node = toolchain::node().context("Device automation needs Node.js 22 or newer.")?; let entry = toolchain::ensure(Tool::Agent)?; agent::ensure_shim(&entry, &node) } /// The devices open in a thread. pub fn sessions_for(&self, thread: &str) -> Vec { self.lock_state().sessions.iter().filter(|session| session.thread_id == thread).cloned().collect() } pub fn notify_agent_opened(&self, thread: &str, device: DeviceSummary) { self.broadcast(Event::AgentOpened { thread: thread.to_string(), device }); } pub fn notify_agent_closed(&self, thread: &str, device: Option) { self.broadcast(Event::AgentClosed { thread: thread.to_string(), device }); } /// Stops the helpers when the app quits; devices keep running. pub fn stop_all(&self) { self.host.stop(); } } /// One accessibility element, normalized to the displayed screen. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AxElement { pub label: String, pub x: f32, pub y: f32, pub width: f32, pub height: f32, } const AX_LIMIT: usize = 500; fn number(value: &serde_json::Value) -> f32 { value.as_f64().unwrap_or(0.) as f32 } /// serve-sim's nested tree: the root covers the screen; nodes with its frame are skipped. fn flatten_ios_ax(payload: &serde_json::Value) -> Vec { let Some(roots) = payload.as_array() else { return Vec::new() }; let root = roots.first().map(|root| &root["frame"]); let (screen_w, screen_h) = root.map(|frame| (number(&frame["width"]).max(1.), number(&frame["height"]).max(1.))).unwrap_or((1., 1.)); let mut elements = Vec::new(); fn visit(node: &serde_json::Value, screen: (f32, f32), elements: &mut Vec) { if elements.len() >= AX_LIMIT || !node["frame"].is_object() { return; } let frame = &node["frame"]; let (width, height) = (number(&frame["width"]), number(&frame["height"])); let covers = (width - screen.0).abs() < 0.5 && (height - screen.1).abs() < 0.5; if !covers && width > 0. && height > 0. { elements.push(AxElement { label: node["AXLabel"].as_str().unwrap_or_default().to_string(), x: number(&frame["x"]) / screen.0, y: number(&frame["y"]) / screen.1, width: width / screen.0, height: height / screen.1, }); } for child in node["children"].as_array().into_iter().flatten() { visit(child, screen, elements); } } for root in roots { visit(root, (screen_w, screen_h), &mut elements); } elements } /// uiautomator's pixel bounds; the first node is the whole window. Only /// nodes someone could point at are drawn. fn flatten_android_ax(payload: &serde_json::Value) -> Vec { let Some(nodes) = payload["nodes"].as_array() else { return Vec::new() }; let nodes: Vec<&serde_json::Value> = nodes.iter().filter(|node| node["bounds"].is_object()).collect(); let Some(root) = nodes.first().map(|node| &node["bounds"]) else { return Vec::new() }; let (screen_w, screen_h) = (number(&root["right"]).max(1.), number(&root["bottom"]).max(1.)); nodes .iter() .skip(1) .filter_map(|node| { let bounds = &node["bounds"]; let (left, top) = (number(&bounds["left"]), number(&bounds["top"])); let width = (number(&bounds["right"]) - left) / screen_w; let height = (number(&bounds["bottom"]) - top) / screen_h; let text = node["text"].as_str().unwrap_or_default(); let description = node["contentDescription"].as_str().unwrap_or_default(); let label = if text.is_empty() { description } else { text }; if width >= 0.95 && height >= 0.9 { return None; } if label.is_empty() && node["clickable"] != true { return None; } Some(AxElement { label: label.to_string(), x: left / screen_w, y: top / screen_h, width, height }) }) .take(AX_LIMIT) .collect() } /// Width and height from a PNG's header; 0×0 when it isn't one. pub fn png_size(png: &[u8]) -> (u32, u32) { if png.len() < 24 || &png[..8] != b"\x89PNG\r\n\x1a\n" || &png[12..16] != b"IHDR" { return (0, 0); } let read = |at: usize| u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]); (read(16), read(20)) } #[cfg(test)] mod tests { use super::*; #[::core::prelude::v1::test] fn flattens_android_accessibility() { let payload = json!({"nodes": [ {"bounds": {"left": 0, "top": 0, "right": 1080, "bottom": 2400}}, {"bounds": {"left": 0, "top": 0, "right": 1080, "bottom": 2400}, "text": "", "clickable": false}, {"bounds": {"left": 108, "top": 240, "right": 540, "bottom": 480}, "text": "Sign in"}, {"bounds": {"left": 0, "top": 0, "right": 10, "bottom": 10}, "text": ""}, ]}); let elements = flatten_android_ax(&payload); assert_eq!(elements.len(), 1); assert_eq!(elements[0].label, "Sign in"); assert!((elements[0].x - 0.1).abs() < 1e-4 && (elements[0].height - 0.1).abs() < 1e-4); } #[::core::prelude::v1::test] fn reads_png_sizes() { let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); png.extend_from_slice(&576u32.to_be_bytes()); png.extend_from_slice(&1280u32.to_be_bytes()); assert_eq!(png_size(&png), (576, 1280)); assert_eq!(png_size(b"nope"), (0, 0)); } #[::core::prelude::v1::test] fn explains_missing_platforms() { let state = State { host_status: HostStatus::Ready, hosts: vec![HostSummary { id: LOCAL_HOST.into(), kind: "local".into(), label: "This machine".into(), platforms: vec![ PlatformAvailability { platform: Platform::Ios, available: false, reason: Some("iOS Simulators need macOS with Xcode.".into()) }, PlatformAvailability { platform: Platform::Android, available: true, reason: None }, ], hub: ToolVersion::default(), agent: ToolVersion::default(), hub_installed: true, agent_device_installed: false, }], ..State::default() }; assert_eq!(state.platform_status(Platform::Ios), (false, "iOS Simulators need macOS with Xcode.".into())); assert!(!state.platform_status(Platform::Android).0, "no AVD yet"); } }