//! 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<Self> {
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<String>,
}
#[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<String>,
#[serde(rename = "runningVersion")]
pub running: Option<String>,
}
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<PlatformAvailability>,
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<String>,
pub hosts: Vec<HostSummary>,
pub devices: Vec<DeviceSummary>,
pub sessions: Vec<DeviceSession>,
/// 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> {
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> {
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<bool>,
pub text_size: Option<TextSize>,
pub reduce_motion: Option<bool>,
pub increase_contrast: Option<bool>,
pub reduce_transparency: Option<bool>,
pub show_borders: Option<bool>,
pub voice_over: Option<bool>,
pub liquid_glass_tinted: Option<bool>,
pub color_filter: Option<ColorFilter>,
pub network_enabled: Option<bool>,
}
impl Settings {
pub fn toggle(&self, toggle: Toggle) -> Option<bool> {
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<ForegroundApp>,
}
/// 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<String> },
}
#[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<HubDevice>,
#[serde(default)]
emulators: Vec<HubDevice>,
#[serde(default)]
errors: Vec<HubError>,
}
#[derive(Deserialize)]
struct HubError {
message: String,
}
#[derive(Deserialize)]
struct HubActionResult {
ok: bool,
id: Option<String>,
serial: Option<String>,
error: Option<String>,
}
pub struct DeviceService {
host: LocalHost,
state: Mutex<State>,
listeners: Mutex<Vec<smol::channel::Sender<Event>>>,
/// 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<DeviceService> = 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<T: serde::de::DeserializeOwned>(url: &str, timeout: Duration) -> Result<T> {
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<T: serde::de::DeserializeOwned>(url: &str, body: serde_json::Value, timeout: Duration) -> Result<T> {
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<Event> {
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<String>) {
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<Ready> {
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<String>| 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<String> {
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<String> {
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<Option<(Ready, host::AgentEndpoint)>> {
if !self.agent_access() || !self.any_platform() {
return Ok(None);
}
let _guard = self.lock_lifecycle();
let on_phase = |status: HostStatus, detail: Option<String>| 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<DeviceSummary>, Option<String>)> {
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<DeviceSummary> = 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<String> = 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<State> {
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<State> {
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<bool>, agent_access: Option<bool>, onboarding: Option<bool>) -> Result<State> {
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<String> {
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::<serde_json::Value>(&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<DeviceSession> {
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<DeviceSession> = 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<u8>)> {
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<Detail> {
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<Detail> {
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<Vec<AxElement>> {
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<Vec<String>> {
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<std::path::PathBuf> {
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<DeviceSession> {
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<String>) {
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<AxElement> {
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<AxElement>) {
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<AxElement> {
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");
}
}