Vectrum
Sign In
1177 lines · 44.5 KB · Rust
//! 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");
    }
}