iOS Simulators and Android Emulators next to a thread, streamed by expo-device-hub and driven by agents through agent-device, both npm packages installed at pinned versions after a three-step setup. - Right panel Device surface: one tab per device, a device list to open or start from, the live screen with taps, drags and typing, a rail of quick controls and the Tools drawer (settings read back from the device, location, permissions, accessibility frames). - Android H.264 decoded in the app with OpenH264; iOS over MJPEG. - Float a device over the chat, moved by its handle and resized from any edge. - device_list, device_open, device_screenshot and device_close for agents, with the agent-device launcher on their PATH; an agent's device floats over the chat and its activity shows in the timeline. - Settings > Integrations > Devices, and Auto-show floating preview. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 30 changed files with 6999 additions and 63 deletions.
| @@ -4068,6 +4068,15 @@ dependencies = [ | ||
| 4068 | 4068 | "unicode-ident", |
| 4069 | 4069 | ] |
| 4070 | 4070 | |
| 4071 | + [[package]] | |
| 4072 | + name = "nasm-rs" | |
| 4073 | + version = "0.3.2" | |
| 4074 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4075 | + checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" | |
| 4076 | + dependencies = [ | |
| 4077 | + "log", | |
| 4078 | + ] | |
| 4079 | + | |
| 4071 | 4080 | [[package]] |
| 4072 | 4081 | name = "ndk-sys" |
| 4073 | 4082 | version = "0.6.0+11769913" |
| @@ -4741,6 +4750,27 @@ dependencies = [ | ||
| 4741 | 4750 | "libc", |
| 4742 | 4751 | ] |
| 4743 | 4752 | |
| 4753 | + [[package]] | |
| 4754 | + name = "openh264" | |
| 4755 | + version = "0.9.8" | |
| 4756 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4757 | + checksum = "fcc9071a0b5f9c501ddd01066c2600d922cc799dc450a52975222bcda7755a08" | |
| 4758 | + dependencies = [ | |
| 4759 | + "openh264-sys2", | |
| 4760 | + "wide", | |
| 4761 | + ] | |
| 4762 | + | |
| 4763 | + [[package]] | |
| 4764 | + name = "openh264-sys2" | |
| 4765 | + version = "0.9.8" | |
| 4766 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4767 | + checksum = "6ada29a4cadc13d4d326737af87c13e224210d7d99fe47594e9f3f9b0102fd70" | |
| 4768 | + dependencies = [ | |
| 4769 | + "cc", | |
| 4770 | + "nasm-rs", | |
| 4771 | + "walkdir", | |
| 4772 | + ] | |
| 4773 | + | |
| 4744 | 4774 | [[package]] |
| 4745 | 4775 | name = "openssl-probe" |
| 4746 | 4776 | version = "0.2.1" |
| @@ -5895,6 +5925,15 @@ version = "1.0.23" | ||
| 5895 | 5925 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5896 | 5926 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
| 5897 | 5927 | |
| 5928 | + [[package]] | |
| 5929 | + name = "safe_arch" | |
| 5930 | + version = "1.2.0" | |
| 5931 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5932 | + checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" | |
| 5933 | + dependencies = [ | |
| 5934 | + "bytemuck", | |
| 5935 | + ] | |
| 5936 | + | |
| 5898 | 5937 | [[package]] |
| 5899 | 5938 | name = "same-file" |
| 5900 | 5939 | version = "1.0.6" |
| @@ -7408,6 +7447,7 @@ dependencies = [ | ||
| 7408 | 7447 | "gpui-kit", |
| 7409 | 7448 | "image", |
| 7410 | 7449 | "libc", |
| 7450 | + "openh264", | |
| 7411 | 7451 | "serde", |
| 7412 | 7452 | "serde_json", |
| 7413 | 7453 | "smol", |
| @@ -7906,6 +7946,16 @@ dependencies = [ | ||
| 7906 | 7946 | "libc", |
| 7907 | 7947 | ] |
| 7908 | 7948 | |
| 7949 | + [[package]] | |
| 7950 | + name = "wide" | |
| 7951 | + version = "1.7.1" | |
| 7952 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7953 | + checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" | |
| 7954 | + dependencies = [ | |
| 7955 | + "bytemuck", | |
| 7956 | + "safe_arch", | |
| 7957 | + ] | |
| 7958 | + | |
| 7909 | 7959 | [[package]] |
| 7910 | 7960 | name = "winapi" |
| 7911 | 7961 | version = "0.3.9" |
| @@ -17,6 +17,7 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha | ||
| 17 | 17 | chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } |
| 18 | 18 | image = { version = "0.25", default-features = false, features = ["jpeg"] } |
| 19 | 19 | alacritty_terminal = "0.26" |
| 20 | + openh264 = "0.9" | |
| 20 | 21 | |
| 21 | 22 | [profile.dev] |
| 22 | 23 | debug = "line-tables-only" |
| @@ -105,7 +105,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 105 | 105 | switcher, **Commit** (asks the agent to commit, or commit and push), the size of the |
| 106 | 106 | uncommitted changes, and the thread's cost. |
| 107 | 107 | - A right panel, as T3 Code has it: its button in the top bar (Ctrl+Alt+B) opens "Open a |
| 108 | - surface", which lists the subagents, the browser and a terminal, keyed S, B and T. A | |
| 108 | + surface", which lists the subagents, the browser, a device and a terminal, keyed S, B, D | |
| 109 | + and T. A | |
| 109 | 110 | terminal there holds the thread's terminals instead of the drawer under the conversation. |
| 110 | 111 | - A subagents panel (Ctrl+Alt+S, or S in the right panel), in the project panel's |
| 111 | 112 | place: every subagent the thread started, the working ones first with what each is |
| @@ -138,6 +139,26 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 138 | 139 | its own headless Chrome session, which its agent drives with the `agent-browser` CLI |
| 139 | 140 | while the panel shows it live. Click, scroll and type in the page yourself, or go to a |
| 140 | 141 | URL. When the agent opens a page while the panel is closed, a light on the globe says so. |
| 142 | + - A Device panel, as T3 Code's Device hub (D in the right panel): iOS Simulators and | |
| 143 | + Android Emulators live next to the thread, streamed by Expo's | |
| 144 | + [expo-device-hub](https://github.com/expo/expo-device-hub). First use walks through | |
| 145 | + three steps (the device hub, simulator support, agent access); nothing installs or | |
| 146 | + starts before you agree. Pick a running device to watch it, or **Start** a stopped one. | |
| 147 | + Each device opens in a tab of its own (**+** for another; double-click or right-click a | |
| 148 | + tab to rename it), and only the tab showing streams. The screen takes taps, drags and | |
| 149 | + typing; the rail beside it has Home, Back and Recents on Android, rotation, dark mode, | |
| 150 | + text size, a screenshot, **Float device over chat** (a small window you drag by its | |
| 151 | + handle and resize from any edge) and **Power off device**. The **Tools** drawer shows the | |
| 152 | + foreground app and switches appearance, text size, accessibility settings, orientation | |
| 153 | + and network, overlays the accessibility element frames, sets a fake location and grants | |
| 154 | + or revokes app permissions, each value read back from the device. Android video is | |
| 155 | + H.264, decoded in the app with OpenH264; iOS comes as MJPEG. Agents get `device_list`, | |
| 156 | + `device_open`, `device_screenshot` and `device_close`, and drive devices with the | |
| 157 | + [agent-device](https://github.com/callstack/agent-device) CLI through a launcher the app | |
| 158 | + puts on their PATH. A device an agent opens floats over the chat (or opens in the right | |
| 159 | + panel with **Auto-show floating preview** off), and its activity shows in the timeline. | |
| 160 | + Both tools are npm packages the app installs at pinned versions into its data folder and | |
| 161 | + runs with your Node; quitting stops them, while simulators and emulators keep running. | |
| 141 | 162 | - A terminal drawer under the conversation, like T3 Code's (Ctrl+J, or the panel toggle in |
| 142 | 163 | the chat header): each thread has its own terminals, which start in the thread's folder |
| 143 | 164 | (its worktree when it has one). Drag the drawer's top edge to resize it. Split a terminal |
| @@ -219,7 +240,9 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 219 | 240 | `secrets.json` only you can read), and its models: favorites, which ones the picker |
| 220 | 241 | shows, their order, and custom models. **Add provider** walks through kind, identity and |
| 221 | 242 | configuration. Below: usage hubs (CLIProxyAPI servers) and the health check interval. |
| 222 | - - **Integrations**: whether agents may use the shared browser, and the editor "Open in" uses. | |
| 243 | + - **Integrations**: whether agents may use the shared browser, whether an agent's device | |
| 244 | + floats over the chat, the device hub, simulator support and agent device access (each | |
| 245 | + tool's running, required and installed versions), and the editor "Open in" uses. | |
| 223 | 246 | - **Source Control**: the git and GitHub CLI found on this machine, and how worktree |
| 224 | 247 | branches are named (a static prefix, a prefix the model picks, or your instructions). |
| 225 | 248 | - **Storage**: where new worktrees go, and the app's folders. |
| @@ -238,6 +261,10 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 238 | 261 | (`curl -fsSL https://jcode.sh/install | bash`, then `jcode login`) |
| 239 | 262 | - Optional: agent-browser for the browser panel |
| 240 | 263 | (`npm install -g agent-browser && agent-browser install`) |
| 264 | + - Optional, for the Device panel: Node.js 22 or newer with npm, and the Android SDK | |
| 265 | + (Platform-Tools, Emulator, Command-line Tools (latest)) with a virtual device made in | |
| 266 | + Android Studio's Device Manager, or Xcode on macOS for iOS Simulators. The SDK is found in | |
| 267 | + its standard locations or through `ANDROID_HOME`. | |
| 241 | 268 | - GPUI's Linux system libraries: Vulkan, Wayland/X11, xkbcommon, fontconfig, freetype |
| 242 | 269 | (on Arch/CachyOS: `vulkan-icd-loader` plus your GPU's Vulkan driver, `wayland`, |
| 243 | 270 | `libxkbcommon-x11`, `libxcb`, `fontconfig`, `freetype2`) |
| @@ -318,6 +318,8 @@ object 0.37.3 Apache-2.0 OR MIT https://github.com/gimli-rs/object | ||
| 318 | 318 | once_cell 1.21.4 MIT OR Apache-2.0 https://github.com/matklad/once_cell |
| 319 | 319 | oo7 0.6.0 MIT https://github.com/linux-credentials/oo7 |
| 320 | 320 | open 5.4.4 MIT https://github.com/Byron/open-rs |
| 321 | + openh264 0.9.8 BSD-2-Clause https://github.com/ralfbiedert/openh264-rs | |
| 322 | + openh264-sys2 0.9.8 BSD-2-Clause https://github.com/ralfbiedert/openh264-rs | |
| 321 | 323 | option-ext 0.2.0 MPL-2.0 https://github.com/soc/option-ext.git |
| 322 | 324 | ordered-float 5.5.0 MIT https://github.com/reem/rust-ordered-float |
| 323 | 325 | ordered-stream 0.2.0 MIT OR Apache-2.0 https://github.com/danieldg/ordered-stream |
| @@ -414,6 +416,7 @@ rustls-webpki 0.103.15 ISC https://github.com/rustls/webpki | ||
| 414 | 416 | rustversion 1.0.23 MIT OR Apache-2.0 https://github.com/dtolnay/rustversion |
| 415 | 417 | rustybuzz 0.20.1 MIT https://github.com/harfbuzz/rustybuzz |
| 416 | 418 | ryu 1.0.23 Apache-2.0 OR BSL-1.0 https://github.com/dtolnay/ryu |
| 419 | + safe_arch 1.2.0 Zlib OR Apache-2.0 OR MIT https://github.com/Lokathor/safe_arch | |
| 417 | 420 | same-file 1.0.6 Unlicense OR MIT https://github.com/BurntSushi/same-file |
| 418 | 421 | schemars 1.2.2 MIT https://github.com/GREsau/schemars |
| 419 | 422 | schemars_derive 1.2.2 MIT https://github.com/GREsau/schemars |
| @@ -566,6 +569,7 @@ wgpu-core-deps-windows-linux-android 29.0.4 MIT OR Apache-2.0 https://github.com | ||
| 566 | 569 | wgpu-hal 29.0.4 MIT OR Apache-2.0 https://github.com/gfx-rs/wgpu |
| 567 | 570 | wgpu-naga-bridge 29.0.4 MIT OR Apache-2.0 https://github.com/gfx-rs/wgpu |
| 568 | 571 | wgpu-types 29.0.4 MIT OR Apache-2.0 https://github.com/gfx-rs/wgpu |
| 572 | + wide 1.7.1 Zlib OR Apache-2.0 OR MIT https://github.com/Lokathor/wide | |
| 569 | 573 | winnow 0.7.15 MIT https://github.com/winnow-rs/winnow |
| 570 | 574 | winnow 1.0.4 MIT https://github.com/winnow-rs/winnow |
| 571 | 575 | writeable 0.6.4 Unicode-3.0 https://github.com/unicode-org/icu4x |
| @@ -47,24 +47,30 @@ pub fn session_name(thread_id: &str) -> String { | ||
| 47 | 47 | |
| 48 | 48 | /// Where `agent-browser` is: PATH, then the usual npm, bun and Homebrew bin folders. |
| 49 | 49 | pub fn find_binary() -> Option<PathBuf> { |
| 50 | + find_program(BINARY) | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Where a command-line program is: PATH, then the usual npm, bun, nvm and | |
| 54 | + /// Homebrew bin folders, since desktop launchers often start apps without | |
| 55 | + /// the user's shell PATH. | |
| 56 | + pub fn find_program(name: &str) -> Option<PathBuf> { | |
| 50 | 57 | let on_path = std::env::var_os("PATH") |
| 51 | 58 | .into_iter() |
| 52 | 59 | .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>()) |
| 53 | - .map(|dir| dir.join(BINARY)); | |
| 54 | - // Desktop launchers often start apps without the user's shell PATH. | |
| 60 | + .map(|dir| dir.join(name)); | |
| 55 | 61 | let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); |
| 56 | 62 | let mut fallbacks: Vec<PathBuf> = [".npm-global/bin", ".local/bin", ".bun/bin", ".volta/bin", ".local/share/pnpm"] |
| 57 | 63 | .iter() |
| 58 | - .map(|dir| home.join(dir).join(BINARY)) | |
| 64 | + .map(|dir| home.join(dir).join(name)) | |
| 59 | 65 | .collect(); |
| 60 | 66 | fallbacks.extend( |
| 61 | 67 | ["/home/linuxbrew/.linuxbrew/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] |
| 62 | 68 | .iter() |
| 63 | - .map(|dir| Path::new(dir).join(BINARY)), | |
| 69 | + .map(|dir| Path::new(dir).join(name)), | |
| 64 | 70 | ); |
| 65 | 71 | // nvm keeps one bin directory per Node version. |
| 66 | 72 | if let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node")) { |
| 67 | - fallbacks.extend(versions.flatten().map(|version| version.path().join("bin").join(BINARY))); | |
| 73 | + fallbacks.extend(versions.flatten().map(|version| version.path().join("bin").join(name))); | |
| 68 | 74 | } |
| 69 | 75 | on_path.chain(fallbacks).find(|path| is_executable(path)) |
| 70 | 76 | } |
| @@ -153,9 +153,11 @@ pub struct LaunchConfig { | ||
| 153 | 153 | pub resume: Option<String>, |
| 154 | 154 | /// The thread's agent-browser session, shared with the browser panel. |
| 155 | 155 | pub browser_session: Option<String>, |
| 156 | - /// The thread, when its agent may delegate tasks to the app's subagents | |
| 157 | - /// (subagents' own threads don't). | |
| 158 | - pub delegating_thread: Option<String>, | |
| 156 | + /// The thread the agent runs for. | |
| 157 | + pub thread: String, | |
| 158 | + /// Which of the app's tools it gets: subagents (not in subagents' own | |
| 159 | + /// threads) and devices (when agent device access is on). | |
| 160 | + pub tools: orchestrator::Caps, | |
| 159 | 161 | } |
| 160 | 162 | |
| 161 | 163 | /// Everything the background reader threads report back to the UI. |
| @@ -180,17 +182,22 @@ impl AgentProcess { | ||
| 180 | 182 | // environment; the thread tells it about the browser in a message. |
| 181 | 183 | Provider::Jcode => jcode_command(config)?, |
| 182 | 184 | }; |
| 183 | - // The app's subagent tools; jcode's are registered over its API. | |
| 185 | + // The app's tools; jcode's are registered over its API. | |
| 186 | + orchestrator::set_caps(&config.thread, config.tools); | |
| 184 | 187 | match config.instance.kind { |
| 185 | 188 | Provider::Claude => { |
| 186 | - if let Some(mcp) = config.delegating_thread.as_deref().and_then(orchestrator::claude_config) { | |
| 189 | + if let Some(mcp) = config.tools.any().then(|| orchestrator::claude_config(&config.thread)).flatten() { | |
| 187 | 190 | let allowed = format!("mcp__{}", orchestrator::SERVER); |
| 188 | 191 | command.args(["--mcp-config", &mcp, "--allowedTools", &allowed]); |
| 189 | 192 | } |
| 190 | 193 | } |
| 191 | - Provider::Pi => orchestrator::prepare_pi(&mut command, config.delegating_thread.as_deref()), | |
| 194 | + Provider::Pi => orchestrator::prepare_pi(&mut command, &config.thread, config.tools), | |
| 192 | 195 | Provider::Jcode => {} |
| 193 | 196 | } |
| 197 | + // The agent-device launcher, on PATH for agents that may drive devices. | |
| 198 | + if config.tools.devices { | |
| 199 | + crate::device::agent::prepare_command(&mut command); | |
| 200 | + } | |
| 194 | 201 | // Claude and Pi take extra system prompt with this flag. |
| 195 | 202 | if config.instance.kind != Provider::Jcode |
| 196 | 203 | && let Some(session) = &config.browser_session |
| @@ -0,0 +1,388 @@ | ||
| 1 | + //! Device settings and one-shot actions, run against the platform tools | |
| 2 | + //! rather than through the hub: `xcrun simctl` and serve-sim's | |
| 3 | + //! `serve-sim-ax-settings` helper for iOS, `adb shell` for Android. One typed | |
| 4 | + //! action per control, as T3 Code does it; the hub's own command channel is | |
| 5 | + //! never used, since that would let anything that reaches it run commands. | |
| 6 | + | |
| 7 | + use std::time::Duration; | |
| 8 | + | |
| 9 | + use anyhow::{Result, anyhow, bail}; | |
| 10 | + | |
| 11 | + use super::host::{self, Ready}; | |
| 12 | + use super::{Action, ColorFilter, ForegroundApp, Orientation, Platform, Settings, TextSize, Toggle}; | |
| 13 | + | |
| 14 | + const TIMEOUT: Duration = Duration::from_secs(20); | |
| 15 | + | |
| 16 | + /// Whether a platform can run an action; the Tools drawer hides the rest. | |
| 17 | + pub fn supports(platform: Platform, action: &Action) -> bool { | |
| 18 | + match (platform, action) { | |
| 19 | + (_, Action::SetToggle { setting, .. }) => match platform { | |
| 20 | + Platform::Ios => matches!( | |
| 21 | + setting, | |
| 22 | + Toggle::ReduceMotion | Toggle::IncreaseContrast | Toggle::ReduceTransparency | Toggle::ShowBorders | Toggle::VoiceOver | |
| 23 | + ), | |
| 24 | + Platform::Android => matches!(setting, Toggle::ReduceMotion | Toggle::NetworkEnabled), | |
| 25 | + }, | |
| 26 | + (Platform::Ios, Action::SetOrientation(_)) => false, | |
| 27 | + (Platform::Android, Action::SetLiquidGlass(_) | Action::SetColorFilter(_) | Action::SendPush { .. }) => false, | |
| 28 | + _ => true, | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + fn unsupported(action: &Action, platform: Platform) -> anyhow::Error { | |
| 33 | + anyhow!("Device {} is not supported on {}.", action.name(), platform.key()) | |
| 34 | + } | |
| 35 | + | |
| 36 | + pub fn run(ready: &Ready, platform: Platform, device: &str, action: &Action) -> Result<()> { | |
| 37 | + if !supports(platform, action) { | |
| 38 | + return Err(unsupported(action, platform)); | |
| 39 | + } | |
| 40 | + match platform { | |
| 41 | + Platform::Ios => run_ios(ready, device, action), | |
| 42 | + Platform::Android => run_android(device, action), | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// iOS text-size categories for the four shared steps; `default` is what a | |
| 47 | + /// fresh simulator reports. | |
| 48 | + fn ios_text_size(size: TextSize) -> &'static str { | |
| 49 | + match size { | |
| 50 | + TextSize::Small => "small", | |
| 51 | + TextSize::Default => "large", | |
| 52 | + TextSize::Large => "extra-extra-large", | |
| 53 | + TextSize::ExtraLarge => "accessibility-large", | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + fn text_size_from_ios(category: &str) -> TextSize { | |
| 58 | + for size in [TextSize::Small, TextSize::Default, TextSize::Large, TextSize::ExtraLarge] { | |
| 59 | + if ios_text_size(size) == category { | |
| 60 | + return size; | |
| 61 | + } | |
| 62 | + } | |
| 63 | + if category.starts_with("accessibility") { | |
| 64 | + TextSize::ExtraLarge | |
| 65 | + } else if category.contains("extra") { | |
| 66 | + TextSize::Large | |
| 67 | + } else if matches!(category, "extra-small" | "small" | "medium") { | |
| 68 | + TextSize::Small | |
| 69 | + } else { | |
| 70 | + TextSize::Default | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + fn android_text_size(size: TextSize) -> &'static str { | |
| 75 | + match size { | |
| 76 | + TextSize::Small => "0.85", | |
| 77 | + TextSize::Default => "1.0", | |
| 78 | + TextSize::Large => "1.15", | |
| 79 | + TextSize::ExtraLarge => "1.3", | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + fn text_size_from_android(scale: f64) -> TextSize { | |
| 84 | + if scale <= 0.9 { | |
| 85 | + TextSize::Small | |
| 86 | + } else if scale >= 1.25 { | |
| 87 | + TextSize::ExtraLarge | |
| 88 | + } else if scale >= 1.1 { | |
| 89 | + TextSize::Large | |
| 90 | + } else { | |
| 91 | + TextSize::Default | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + fn simctl(udid: &str, args: &[&str], operation: &str) -> Result<String> { | |
| 96 | + let mut full = vec!["simctl", args[0], udid]; | |
| 97 | + full.extend_from_slice(&args[1..]); | |
| 98 | + host::run("xcrun", &full, TIMEOUT, None).ok(operation) | |
| 99 | + } | |
| 100 | + | |
| 101 | + fn ax_settings(ready: &Ready, udid: &str, args: &[&str]) -> Result<String> { | |
| 102 | + let Some(helper) = &ready.helpers.ax_settings else { | |
| 103 | + bail!("Device accessibility requires a helper missing from this install. Set up device support again."); | |
| 104 | + }; | |
| 105 | + let helper = helper.display().to_string(); | |
| 106 | + let mut full = vec!["simctl", "spawn", udid, &helper]; | |
| 107 | + full.extend_from_slice(args); | |
| 108 | + host::run("xcrun", &full, TIMEOUT, None).ok("accessibility") | |
| 109 | + } | |
| 110 | + | |
| 111 | + fn ios_toggle_option(setting: Toggle) -> &'static str { | |
| 112 | + match setting { | |
| 113 | + Toggle::ReduceMotion => "reduce-motion", | |
| 114 | + Toggle::IncreaseContrast => "increase-contrast", | |
| 115 | + Toggle::ReduceTransparency => "reduce-transparency", | |
| 116 | + Toggle::ShowBorders => "show-borders", | |
| 117 | + Toggle::VoiceOver => "voiceover", | |
| 118 | + Toggle::NetworkEnabled => "network", | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + fn run_ios(ready: &Ready, udid: &str, action: &Action) -> Result<()> { | |
| 123 | + match action { | |
| 124 | + Action::SetAppearance(dark) => simctl(udid, &["ui", "appearance", if *dark { "dark" } else { "light" }], "appearance")?, | |
| 125 | + Action::SetTextSize(size) => simctl(udid, &["ui", "content_size", ios_text_size(*size)], "text size")?, | |
| 126 | + Action::SetToggle { setting: Toggle::IncreaseContrast, value } => simctl( | |
| 127 | + udid, | |
| 128 | + &["ui", "increase_contrast", if *value { "enabled" } else { "disabled" }], | |
| 129 | + "increase contrast", | |
| 130 | + )?, | |
| 131 | + Action::SetToggle { setting, value } => { | |
| 132 | + ax_settings(ready, udid, &["set", ios_toggle_option(*setting), if *value { "on" } else { "off" }])? | |
| 133 | + } | |
| 134 | + Action::SetLiquidGlass(tinted) => ax_settings(ready, udid, &["set", "liquid-glass", if *tinted { "tinted" } else { "clear" }])?, | |
| 135 | + Action::SetColorFilter(filter) => ax_settings(ready, udid, &["set", "color-filter", filter.key()])?, | |
| 136 | + Action::SetLocation { latitude, longitude } => { | |
| 137 | + simctl(udid, &["location", "set", &format!("{latitude},{longitude}")], "location")? | |
| 138 | + } | |
| 139 | + Action::ClearLocation => simctl(udid, &["location", "clear"], "location")?, | |
| 140 | + Action::SetPermission { app_id, permission, decision } => { | |
| 141 | + if *permission == "notifications" { | |
| 142 | + // simctl has no notification verb; serve-sim's CLI edits the plist for it. | |
| 143 | + let Some(cli) = &ready.helpers.serve_sim_cli else { | |
| 144 | + bail!("Device permission requires a helper missing from this install. Set up device support again."); | |
| 145 | + }; | |
| 146 | + let node = ready.node.display().to_string(); | |
| 147 | + let cli = cli.display().to_string(); | |
| 148 | + host::run(&node, &[&cli, "permissions", decision, permission, app_id, "-d", udid], TIMEOUT, None) | |
| 149 | + .ok("permission")? | |
| 150 | + } else { | |
| 151 | + simctl(udid, &["privacy", decision, permission, app_id], "permission")? | |
| 152 | + } | |
| 153 | + } | |
| 154 | + Action::OpenUrl(url) => simctl(udid, &["openurl", url], "open url")?, | |
| 155 | + Action::LaunchApp(app) => simctl(udid, &["launch", app], "launch")?, | |
| 156 | + Action::TerminateApp(app) => simctl(udid, &["terminate", app], "terminate")?, | |
| 157 | + Action::SendPush { app_id, text } => { | |
| 158 | + let payload = serde_json::json!({"aps": {"alert": text}}).to_string(); | |
| 159 | + host::run("xcrun", &["simctl", "push", udid, app_id, "-"], TIMEOUT, Some(&payload)).ok("push")? | |
| 160 | + } | |
| 161 | + Action::SetOrientation(_) => return Err(unsupported(action, Platform::Ios)), | |
| 162 | + }; | |
| 163 | + Ok(()) | |
| 164 | + } | |
| 165 | + | |
| 166 | + /// Android permissions behind each shared permission name. | |
| 167 | + fn android_permissions(permission: &str) -> &'static [&'static str] { | |
| 168 | + match permission { | |
| 169 | + "camera" => &["android.permission.CAMERA"], | |
| 170 | + "microphone" => &["android.permission.RECORD_AUDIO"], | |
| 171 | + "photos" => &["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], | |
| 172 | + "contacts" => &["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], | |
| 173 | + "calendar" => &["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"], | |
| 174 | + "location" => &["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"], | |
| 175 | + "notifications" => &["android.permission.POST_NOTIFICATIONS"], | |
| 176 | + "motion" => &["android.permission.ACTIVITY_RECOGNITION"], | |
| 177 | + _ => &[], | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + /// The gravity vector that makes the emulator report each orientation, and | |
| 182 | + /// the window manager's rotation index for the same. | |
| 183 | + fn android_gravity(orientation: Orientation) -> (&'static str, &'static str) { | |
| 184 | + match orientation { | |
| 185 | + Orientation::Portrait => ("0:9.81:0", "0"), | |
| 186 | + Orientation::LandscapeLeft => ("9.81:0:0", "1"), | |
| 187 | + Orientation::PortraitUpsideDown => ("0:-9.81:0", "2"), | |
| 188 | + Orientation::LandscapeRight => ("-9.81:0:0", "3"), | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + fn adb(serial: &str, args: &[&str], operation: &str) -> Result<String> { | |
| 193 | + let mut full = vec!["-s", serial]; | |
| 194 | + full.extend_from_slice(args); | |
| 195 | + host::run("adb", &full, TIMEOUT, None).ok(operation) | |
| 196 | + } | |
| 197 | + | |
| 198 | + fn shell(serial: &str, args: &[&str], operation: &str) -> Result<String> { | |
| 199 | + let mut full = vec!["shell"]; | |
| 200 | + full.extend_from_slice(args); | |
| 201 | + adb(serial, &full, operation) | |
| 202 | + } | |
| 203 | + | |
| 204 | + fn run_android(serial: &str, action: &Action) -> Result<()> { | |
| 205 | + match action { | |
| 206 | + Action::SetAppearance(dark) => { | |
| 207 | + shell(serial, &["cmd", "uimode", "night", if *dark { "yes" } else { "no" }], "appearance")?; | |
| 208 | + } | |
| 209 | + Action::SetTextSize(size) => { | |
| 210 | + shell(serial, &["settings", "put", "system", "font_scale", android_text_size(*size)], "text size")?; | |
| 211 | + } | |
| 212 | + Action::SetToggle { setting: Toggle::NetworkEnabled, value } => { | |
| 213 | + let state = if *value { "enable" } else { "disable" }; | |
| 214 | + shell(serial, &["svc", "wifi", state], "network")?; | |
| 215 | + shell(serial, &["svc", "data", state], "network")?; | |
| 216 | + } | |
| 217 | + Action::SetToggle { setting: Toggle::ReduceMotion, value } => { | |
| 218 | + let scale = if *value { "0" } else { "1" }; | |
| 219 | + for key in ["animator_duration_scale", "transition_animation_scale", "window_animation_scale"] { | |
| 220 | + shell(serial, &["settings", "put", "global", key, scale], "reduce motion")?; | |
| 221 | + } | |
| 222 | + } | |
| 223 | + Action::SetOrientation(orientation) => { | |
| 224 | + let (gravity, rotation) = android_gravity(*orientation); | |
| 225 | + // Locking the rotation only turns the window content on recent | |
| 226 | + // images; the captured display stays put. Tilting the emulator's | |
| 227 | + // accelerometer turns it for real. Physical devices get the lock. | |
| 228 | + if serial.starts_with("emulator-") { | |
| 229 | + shell(serial, &["settings", "put", "system", "accelerometer_rotation", "1"], "orientation")?; | |
| 230 | + shell(serial, &["cmd", "window", "user-rotation", "free"], "orientation")?; | |
| 231 | + adb(serial, &["emu", "sensor", "set", "acceleration", gravity], "orientation")?; | |
| 232 | + } else { | |
| 233 | + shell(serial, &["cmd", "window", "user-rotation", "lock", rotation], "orientation")?; | |
| 234 | + } | |
| 235 | + } | |
| 236 | + Action::SetLocation { latitude, longitude } => { | |
| 237 | + adb(serial, &["emu", "geo", "fix", &longitude.to_string(), &latitude.to_string()], "location")?; | |
| 238 | + } | |
| 239 | + // The emulator has no "clear": the last fix stays. | |
| 240 | + Action::ClearLocation => {} | |
| 241 | + Action::SetPermission { app_id, permission, decision } => { | |
| 242 | + let permissions = android_permissions(permission); | |
| 243 | + if permissions.is_empty() { | |
| 244 | + return Err(unsupported(action, Platform::Android)); | |
| 245 | + } | |
| 246 | + let verb = if *decision == "grant" { "grant" } else { "revoke" }; | |
| 247 | + for permission in permissions { | |
| 248 | + // Not every app declares every permission of a group. | |
| 249 | + let _ = shell(serial, &["pm", verb, app_id, permission], "permission"); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + Action::OpenUrl(url) => { | |
| 253 | + shell(serial, &["am", "start", "-a", "android.intent.action.VIEW", "-d", url], "open url")?; | |
| 254 | + } | |
| 255 | + Action::LaunchApp(app) => { | |
| 256 | + shell(serial, &["monkey", "-p", app, "-c", "android.intent.category.LAUNCHER", "1"], "launch")?; | |
| 257 | + } | |
| 258 | + Action::TerminateApp(app) => { | |
| 259 | + shell(serial, &["am", "force-stop", app], "terminate")?; | |
| 260 | + } | |
| 261 | + Action::SetToggle { .. } | Action::SetLiquidGlass(_) | Action::SetColorFilter(_) | Action::SendPush { .. } => { | |
| 262 | + return Err(unsupported(action, Platform::Android)); | |
| 263 | + } | |
| 264 | + } | |
| 265 | + Ok(()) | |
| 266 | + } | |
| 267 | + | |
| 268 | + /// The current settings and foreground app. What can't be read stays unknown. | |
| 269 | + pub fn read(ready: &Ready, platform: Platform, device: &str) -> (Settings, Option<ForegroundApp>) { | |
| 270 | + match platform { | |
| 271 | + Platform::Ios => (read_ios(ready, device), None), | |
| 272 | + Platform::Android => read_android(device), | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + fn read_ios(ready: &Ready, udid: &str) -> Settings { | |
| 277 | + let ui = |option: &str| simctl(udid, &["ui", option], option).ok().map(|out| out.trim().to_lowercase()); | |
| 278 | + let appearance = ui("appearance"); | |
| 279 | + let content_size = ui("content_size"); | |
| 280 | + let contrast = ui("increase_contrast"); | |
| 281 | + let status: Option<std::collections::HashMap<String, String>> = | |
| 282 | + ax_settings(ready, udid, &["status"]).ok().and_then(|text| serde_json::from_str(&text).ok()); | |
| 283 | + let on_off = |key: &str| -> Option<bool> { | |
| 284 | + match status.as_ref()?.get(key)?.as_str() { | |
| 285 | + "on" => Some(true), | |
| 286 | + "off" => Some(false), | |
| 287 | + _ => None, | |
| 288 | + } | |
| 289 | + }; | |
| 290 | + Settings { | |
| 291 | + dark: match appearance.as_deref() { | |
| 292 | + Some("dark") => Some(true), | |
| 293 | + Some("light") => Some(false), | |
| 294 | + _ => None, | |
| 295 | + }, | |
| 296 | + text_size: content_size.as_deref().filter(|size| !size.is_empty()).map(text_size_from_ios), | |
| 297 | + increase_contrast: contrast.map(|value| value == "enabled"), | |
| 298 | + reduce_motion: on_off("reduce-motion"), | |
| 299 | + reduce_transparency: on_off("reduce-transparency"), | |
| 300 | + show_borders: on_off("show-borders"), | |
| 301 | + voice_over: on_off("voiceover"), | |
| 302 | + liquid_glass_tinted: match status.as_ref().and_then(|status| status.get("liquid-glass")).map(String::as_str) { | |
| 303 | + Some("tinted") => Some(true), | |
| 304 | + Some("clear") => Some(false), | |
| 305 | + _ => None, | |
| 306 | + }, | |
| 307 | + color_filter: status.as_ref().and_then(|status| status.get("color-filter")).and_then(|value| ColorFilter::from_key(value)), | |
| 308 | + network_enabled: None, | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + fn read_android(serial: &str) -> (Settings, Option<ForegroundApp>) { | |
| 313 | + let read = |args: &'static [&'static str]| { | |
| 314 | + let serial = serial.to_string(); | |
| 315 | + std::thread::spawn(move || shell(&serial, args, args[0]).ok().map(|out| out.trim().to_string())) | |
| 316 | + }; | |
| 317 | + let night = read(&["cmd", "uimode", "night"]); | |
| 318 | + let font = read(&["settings", "get", "system", "font_scale"]); | |
| 319 | + let animator = read(&["settings", "get", "global", "animator_duration_scale"]); | |
| 320 | + let wifi = read(&["settings", "get", "global", "wifi_on"]); | |
| 321 | + // `dumpsys window windows` stopped printing the focus on API 36; the full dump still does. | |
| 322 | + let focus = read(&["dumpsys", "window"]); | |
| 323 | + let [night, font, animator, wifi, focus] = [night, font, animator, wifi, focus].map(|handle| handle.join().ok().flatten()); | |
| 324 | + let scale = font.as_deref().filter(|value| *value != "null").and_then(|value| value.parse::<f64>().ok()); | |
| 325 | + let settings = Settings { | |
| 326 | + dark: match night.as_deref() { | |
| 327 | + Some(text) if text.contains("yes") => Some(true), | |
| 328 | + Some(text) if text.contains("no") => Some(false), | |
| 329 | + _ => None, | |
| 330 | + }, | |
| 331 | + text_size: scale.map(text_size_from_android), | |
| 332 | + reduce_motion: animator | |
| 333 | + .as_deref() | |
| 334 | + .filter(|value| *value != "null") | |
| 335 | + .and_then(|value| value.parse::<f64>().ok()) | |
| 336 | + .map(|value| value == 0.), | |
| 337 | + network_enabled: match wifi.as_deref() { | |
| 338 | + Some("1") => Some(true), | |
| 339 | + Some("0") => Some(false), | |
| 340 | + _ => None, | |
| 341 | + }, | |
| 342 | + ..Settings::default() | |
| 343 | + }; | |
| 344 | + (settings, focus.as_deref().and_then(focused_package).map(|id| ForegroundApp { id })) | |
| 345 | + } | |
| 346 | + | |
| 347 | + /// The package of the focused window in `dumpsys window`. | |
| 348 | + fn focused_package(dump: &str) -> Option<String> { | |
| 349 | + for line in dump.lines() { | |
| 350 | + let line = line.trim(); | |
| 351 | + let Some(rest) = line.strip_prefix("mCurrentFocus=").or_else(|| line.strip_prefix("mFocusedApp=")) else { | |
| 352 | + continue; | |
| 353 | + }; | |
| 354 | + // `Window{4c5e u0 com.example/com.example.Main}` or an ActivityRecord. | |
| 355 | + let Some(start) = rest.find(" u") else { continue }; | |
| 356 | + let after = &rest[start + 2..]; | |
| 357 | + let after = after.trim_start_matches(|c: char| c.is_ascii_digit()).trim_start(); | |
| 358 | + if let Some(package) = after.split('/').next().filter(|package| !package.is_empty() && after.contains('/')) { | |
| 359 | + return Some(package.split_whitespace().next().unwrap_or(package).to_string()); | |
| 360 | + } | |
| 361 | + } | |
| 362 | + None | |
| 363 | + } | |
| 364 | + | |
| 365 | + #[cfg(test)] | |
| 366 | + mod tests { | |
| 367 | + use super::{focused_package, text_size_from_android, text_size_from_ios}; | |
| 368 | + use crate::device::TextSize; | |
| 369 | + | |
| 370 | + #[::core::prelude::v1::test] | |
| 371 | + fn reads_the_focused_package() { | |
| 372 | + let dump = " mCurrentFocus=Window{4c5e0e9 u0 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}\n"; | |
| 373 | + assert_eq!(focused_package(dump).as_deref(), Some("com.google.android.apps.nexuslauncher")); | |
| 374 | + let dump = " mFocusedApp=ActivityRecord{1a2b u0 com.example/.Main t12}"; | |
| 375 | + assert_eq!(focused_package(dump).as_deref(), Some("com.example")); | |
| 376 | + assert_eq!(focused_package("mCurrentFocus=null"), None); | |
| 377 | + } | |
| 378 | + | |
| 379 | + #[::core::prelude::v1::test] | |
| 380 | + fn maps_text_sizes_onto_four_steps() { | |
| 381 | + assert_eq!(text_size_from_ios("large"), TextSize::Default); | |
| 382 | + assert_eq!(text_size_from_ios("accessibility-medium"), TextSize::ExtraLarge); | |
| 383 | + assert_eq!(text_size_from_ios("extra-large"), TextSize::Large); | |
| 384 | + assert_eq!(text_size_from_android(0.85), TextSize::Small); | |
| 385 | + assert_eq!(text_size_from_android(1.0), TextSize::Default); | |
| 386 | + assert_eq!(text_size_from_android(1.3), TextSize::ExtraLarge); | |
| 387 | + } | |
| 388 | + } | |
| @@ -0,0 +1,183 @@ | ||
| 1 | + //! What agents drive devices with: the `agent-device` CLI, through a | |
| 2 | + //! launcher the app writes. `device_open` hands the agent the launcher's | |
| 3 | + //! path and the flags that pin every command to one device, along with how | |
| 4 | + //! to use it, so threads that never open a device pay nothing for it. | |
| 5 | + | |
| 6 | + use std::path::{Path, PathBuf}; | |
| 7 | + | |
| 8 | + use anyhow::{Context as _, Result}; | |
| 9 | + | |
| 10 | + use super::host::AgentEndpoint; | |
| 11 | + use super::{DeviceSummary, Platform}; | |
| 12 | + | |
| 13 | + /// What the agent's instructions say about devices, when it has them. | |
| 14 | + pub const INSTRUCTIONS: &str = "\ | |
| 15 | + ## Vectrum Code devices | |
| 16 | + | |
| 17 | + Vectrum Code also offers `device_*` tools for iOS Simulators and Android Emulators on this machine. For mobile verification, call `device_list`, then `device_open` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the `agent-device` CLI, using the exact launcher path returned by `device_open`. Keep the host config and session flags returned by `device_open` on every command so concurrent devices stay independent: prefer `agent-device snapshot -i` refs over coordinates, and use `device_screenshot` when you need to see the screen. Prefer these tools and `agent-device` for opening and driving devices. Platform tools such as `xcrun simctl` and `adb` remain available for anything they do not cover, such as builds, logs, or port forwarding. If `device_list` reports a platform as unavailable, say so."; | |
| 18 | + | |
| 19 | + /// A short stable key: FNV-1a over the text, twice with different seeds. | |
| 20 | + fn key(text: &str) -> String { | |
| 21 | + let hash = |seed: u64| { | |
| 22 | + text.bytes().fold(seed, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)) | |
| 23 | + }; | |
| 24 | + format!("{:016x}{:08x}", hash(0xcbf29ce484222325), hash(0x84222325cbf29ce4) as u32) | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// The flags that pin every agent-device command to one device. | |
| 28 | + pub fn target_args(device: &DeviceSummary) -> Vec<String> { | |
| 29 | + match device.platform { | |
| 30 | + Platform::Ios => vec!["--platform".into(), "ios".into(), "--udid".into(), device.id.clone()], | |
| 31 | + Platform::Android => vec!["--platform".into(), "android".into(), "--serial".into(), device.id.clone()], | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// One config file per host, so the daemon's endpoint can change without | |
| 36 | + /// retargeting commands. | |
| 37 | + fn config_path(host: &str) -> Option<PathBuf> { | |
| 38 | + Some(super::toolchain::base_dir()?.join("device").join("hosts").join(format!("{}.json", key(host)))) | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// Writes the host's config: where the daemon is and its token. | |
| 42 | + pub fn write_config(host: &str, endpoint: &AgentEndpoint) -> Result<PathBuf> { | |
| 43 | + let path = config_path(host).context("no data folder")?; | |
| 44 | + std::fs::create_dir_all(path.parent().context("no folder")?)?; | |
| 45 | + let content = serde_json::json!({"daemonBaseUrl": endpoint.base_url, "daemonAuthToken": endpoint.token}).to_string(); | |
| 46 | + if std::fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) { | |
| 47 | + return Ok(path); | |
| 48 | + } | |
| 49 | + let temporary = path.with_extension(format!("tmp-{}", std::process::id())); | |
| 50 | + { | |
| 51 | + use std::os::unix::fs::OpenOptionsExt as _; | |
| 52 | + let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&temporary)?; | |
| 53 | + std::io::Write::write_all(&mut file, content.as_bytes())?; | |
| 54 | + } | |
| 55 | + std::fs::rename(&temporary, &path)?; | |
| 56 | + Ok(path) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// The agent-device session of one thread on one device. | |
| 60 | + pub fn session(thread: &str, host: &str, device: &str) -> String { | |
| 61 | + format!("vectrum-{}", key(&serde_json::json!([thread, host, device]).to_string())) | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// The folder holding the `agent-device` launcher. | |
| 65 | + pub fn shim_dir() -> Option<PathBuf> { | |
| 66 | + Some(super::toolchain::base_dir()?.join("device").join("bin")) | |
| 67 | + } | |
| 68 | + | |
| 69 | + /// Puts the launcher's folder first on an agent's PATH, so `agent-device` | |
| 70 | + /// is the pinned version whatever else is installed. | |
| 71 | + pub fn prepare_command(command: &mut std::process::Command) { | |
| 72 | + let Some(dir) = shim_dir() else { return }; | |
| 73 | + let current = command | |
| 74 | + .get_envs() | |
| 75 | + .find(|(key, _)| *key == "PATH") | |
| 76 | + .and_then(|(_, value)| value.map(|value| value.to_os_string())) | |
| 77 | + .or_else(|| std::env::var_os("PATH")) | |
| 78 | + .unwrap_or_default(); | |
| 79 | + let dirs = std::iter::once(dir).chain(std::env::split_paths(¤t)); | |
| 80 | + if let Ok(path) = std::env::join_paths(dirs) { | |
| 81 | + command.env("PATH", path); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Writes the launcher that runs the pinned agent-device with the app's | |
| 86 | + /// Node, refusing commands without the flags `device_open` hands out. | |
| 87 | + /// Returns the launcher's path. | |
| 88 | + pub fn ensure_shim(entry: &Path, node: &Path) -> Result<PathBuf> { | |
| 89 | + let dir = shim_dir().context("no data folder")?; | |
| 90 | + std::fs::create_dir_all(&dir)?; | |
| 91 | + let json = |path: &Path| serde_json::Value::String(path.display().to_string()).to_string(); | |
| 92 | + let launcher = dir.join("agent-device-launcher.mjs"); | |
| 93 | + let source = format!( | |
| 94 | + r#"import {{ spawn }} from "node:child_process"; | |
| 95 | + const args = process.argv.slice(2); | |
| 96 | + const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]); | |
| 97 | + const hasValue = flag => {{ const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); }}; | |
| 98 | + if (!informational && !(hasValue("--config") && hasValue("--session"))) {{ | |
| 99 | + console.error("Call device_open first and include its --config and --session flags."); | |
| 100 | + process.exit(1); | |
| 101 | + }} | |
| 102 | + const env = {{ ...process.env }}; | |
| 103 | + delete env.AGENT_DEVICE_DAEMON_BASE_URL; | |
| 104 | + delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; | |
| 105 | + delete env.AGENT_DEVICE_CONFIG; | |
| 106 | + const child = spawn({node}, [{entry}, ...args], {{ stdio: "inherit", env }}); | |
| 107 | + child.on("error", error => {{ console.error(error.message); process.exitCode = 1; }}); | |
| 108 | + child.on("exit", code => {{ process.exitCode = code ?? 1; }}); | |
| 109 | + "#, | |
| 110 | + node = json(node), | |
| 111 | + entry = json(entry), | |
| 112 | + ); | |
| 113 | + write_if_changed(&launcher, &source)?; | |
| 114 | + let quote = |value: &Path| format!("'{}'", value.display().to_string().replace('\'', "'\"'\"'")); | |
| 115 | + let shim = dir.join("agent-device"); | |
| 116 | + write_if_changed(&shim, &format!("#!/bin/sh\nexec {} {} \"$@\"\n", quote(node), quote(&launcher)))?; | |
| 117 | + use std::os::unix::fs::PermissionsExt as _; | |
| 118 | + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755))?; | |
| 119 | + Ok(shim) | |
| 120 | + } | |
| 121 | + | |
| 122 | + fn write_if_changed(path: &Path, content: &str) -> Result<()> { | |
| 123 | + if std::fs::read_to_string(path).ok().as_deref() != Some(content) { | |
| 124 | + std::fs::write(path, content)?; | |
| 125 | + } | |
| 126 | + Ok(()) | |
| 127 | + } | |
| 128 | + | |
| 129 | + fn shell_quote(arg: &str) -> String { | |
| 130 | + if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "_./:-".contains(c)) { | |
| 131 | + arg.to_string() | |
| 132 | + } else { | |
| 133 | + format!("'{}'", arg.replace('\'', "'\"'\"'")) | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// How to drive the device, returned from `device_open`. | |
| 138 | + pub fn quick_start(device: &DeviceSummary, target_args: &[String], command: &str) -> String { | |
| 139 | + let executable = shell_quote(command); | |
| 140 | + let target: Vec<String> = target_args.iter().map(|arg| shell_quote(arg)).collect(); | |
| 141 | + let target = target.join(" "); | |
| 142 | + let platform_note = match device.platform { | |
| 143 | + Platform::Ios => "First use builds an XCTest runner and can take a couple of minutes; later commands are fast.", | |
| 144 | + Platform::Android => "The Android snapshot helper installs itself on first use.", | |
| 145 | + }; | |
| 146 | + [ | |
| 147 | + format!("The user is watching {} ({}) in the Device panel.", device.name, device.version), | |
| 148 | + format!("Drive it with {executable}. Use this exact executable path; login shells may reset PATH. Always pass {target}."), | |
| 149 | + "Typical loop:".into(), | |
| 150 | + format!(" {executable} open <bundle-or-package-id> {target} # or: open <app> <deep-link-url>"), | |
| 151 | + format!(" {executable} snapshot -i {target} # accessibility tree with @eN refs"), | |
| 152 | + format!(" {executable} click @e3 {target}"), | |
| 153 | + format!(" {executable} fill @e5 \"text\" {target}"), | |
| 154 | + format!(" {executable} screenshot /tmp/shot.png {target} # or call device_screenshot"), | |
| 155 | + format!(" {executable} install <app> <path-to-.app-or-.apk> {target}"), | |
| 156 | + format!("Prefer snapshot refs over coordinates. Run {executable} help for workflow guides and {executable} <command> --help for flags."), | |
| 157 | + "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.".into(), | |
| 158 | + "Keep the returned --config and --session flags on every command. Other devices can be used concurrently; opening one does not switch these commands.".into(), | |
| 159 | + platform_note.into(), | |
| 160 | + ] | |
| 161 | + .join("\n") | |
| 162 | + } | |
| 163 | + | |
| 164 | + #[cfg(test)] | |
| 165 | + mod tests { | |
| 166 | + use super::{key, session, shell_quote}; | |
| 167 | + | |
| 168 | + #[::core::prelude::v1::test] | |
| 169 | + fn keys_are_stable_and_distinct() { | |
| 170 | + assert_eq!(key("local"), key("local")); | |
| 171 | + assert_ne!(key("local"), key("other")); | |
| 172 | + assert_eq!(key("local").len(), 24); | |
| 173 | + assert_ne!(session("a", "local", "emulator-5554"), session("b", "local", "emulator-5554")); | |
| 174 | + assert!(session("a", "local", "x").starts_with("vectrum-")); | |
| 175 | + } | |
| 176 | + | |
| 177 | + #[::core::prelude::v1::test] | |
| 178 | + fn quotes_only_what_needs_it() { | |
| 179 | + assert_eq!(shell_quote("--serial"), "--serial"); | |
| 180 | + assert_eq!(shell_quote("/a b/agent-device"), "'/a b/agent-device'"); | |
| 181 | + assert_eq!(shell_quote("it's"), "'it'\"'\"'s'"); | |
| 182 | + } | |
| 183 | + } | |
| @@ -0,0 +1,631 @@ | ||
| 1 | + //! The device host that is this machine, as T3 Code's `LocalDeviceHost`: | |
| 2 | + //! expo-device-hub runs as a supervised child on a loopback port, and the | |
| 3 | + //! agent-device daemon runs in HTTP mode under the app's own state folder. | |
| 4 | + //! Both start lazily, once the user has agreed to them. | |
| 5 | + //! | |
| 6 | + //! The hub binds loopback only. It has unauthenticated routes that run | |
| 7 | + //! commands, so it's never exposed beyond this machine. | |
| 8 | + | |
| 9 | + use std::{ | |
| 10 | + io::{BufRead as _, BufReader, Read as _, Write as _}, | |
| 11 | + os::unix::process::CommandExt as _, | |
| 12 | + path::{Path, PathBuf}, | |
| 13 | + process::{Child, Command, Stdio}, | |
| 14 | + sync::{Arc, Mutex}, | |
| 15 | + thread, | |
| 16 | + time::{Duration, Instant}, | |
| 17 | + }; | |
| 18 | + | |
| 19 | + use anyhow::{Context as _, Result, anyhow, bail}; | |
| 20 | + use serde::{Deserialize, Serialize}; | |
| 21 | + | |
| 22 | + use super::toolchain::{self, Tool}; | |
| 23 | + use super::{HostStatus, Platform, PlatformAvailability}; | |
| 24 | + | |
| 25 | + const HUB_READY_TIMEOUT: Duration = Duration::from_secs(30); | |
| 26 | + const DAEMON_READY_TIMEOUT: Duration = Duration::from_secs(30); | |
| 27 | + /// A hub that ran this long before dying restarts at once; one that dies | |
| 28 | + /// sooner waits, doubling up to the maximum, so a crash on boot can't spin. | |
| 29 | + const HUB_STABLE_UPTIME: Duration = Duration::from_secs(60); | |
| 30 | + const HUB_RESTART_MAX: Duration = Duration::from_secs(30); | |
| 31 | + | |
| 32 | + /// Where the Android SDK is, and which of its parts are there. | |
| 33 | + #[derive(Clone, Debug, Default)] | |
| 34 | + pub struct AndroidSdk { | |
| 35 | + pub root: Option<PathBuf>, | |
| 36 | + adb: bool, | |
| 37 | + emulator: bool, | |
| 38 | + avdmanager: bool, | |
| 39 | + legacy_avdmanager: bool, | |
| 40 | + } | |
| 41 | + | |
| 42 | + /// `ANDROID_HOME`, the standard locations, then the folder above an `adb` | |
| 43 | + /// on PATH. | |
| 44 | + pub fn android_sdk() -> AndroidSdk { | |
| 45 | + let var = |name: &str| std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty()); | |
| 46 | + let explicit = var("ANDROID_HOME").or_else(|| var("ANDROID_SDK_ROOT")); | |
| 47 | + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); | |
| 48 | + let mut candidates: Vec<PathBuf> = match &explicit { | |
| 49 | + Some(root) => vec![PathBuf::from(root)], | |
| 50 | + None => vec![home.join("Library/Android/sdk"), home.join("Android/Sdk")], | |
| 51 | + }; | |
| 52 | + if explicit.is_none() { | |
| 53 | + for dir in std::env::var_os("PATH").map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>()).unwrap_or_default() { | |
| 54 | + if let Ok(adb) = std::fs::canonicalize(dir.join("adb")) | |
| 55 | + && let Some(root) = adb.parent().and_then(Path::parent) | |
| 56 | + { | |
| 57 | + candidates.push(root.to_path_buf()); | |
| 58 | + } | |
| 59 | + } | |
| 60 | + } | |
| 61 | + for root in candidates { | |
| 62 | + let adb = root.join("platform-tools/adb").is_file(); | |
| 63 | + let emulator = root.join("emulator/emulator").is_file(); | |
| 64 | + if explicit.is_some() || adb || emulator { | |
| 65 | + let avdmanager = root.join("cmdline-tools/latest/bin/avdmanager").is_file(); | |
| 66 | + let legacy_avdmanager = !avdmanager && root.join("tools/bin/avdmanager").is_file(); | |
| 67 | + return AndroidSdk { root: Some(root), adb, emulator, avdmanager, legacy_avdmanager }; | |
| 68 | + } | |
| 69 | + } | |
| 70 | + AndroidSdk::default() | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// Why a platform can't run here, or `None` when it can. | |
| 74 | + pub fn platform_reason(platform: Platform, sdk: &AndroidSdk) -> Option<String> { | |
| 75 | + match platform { | |
| 76 | + Platform::Ios => { | |
| 77 | + if !cfg!(target_os = "macos") { | |
| 78 | + Some("iOS Simulators need macOS with Xcode.".into()) | |
| 79 | + } else if crate::browser::find_program("xcrun").is_none() { | |
| 80 | + Some("Xcode command line tools were not found.".into()) | |
| 81 | + } else { | |
| 82 | + None | |
| 83 | + } | |
| 84 | + } | |
| 85 | + Platform::Android => { | |
| 86 | + let Some(root) = &sdk.root else { | |
| 87 | + return Some( | |
| 88 | + "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory." | |
| 89 | + .into(), | |
| 90 | + ); | |
| 91 | + }; | |
| 92 | + let root = root.display(); | |
| 93 | + if !sdk.adb { | |
| 94 | + Some(format!("Android SDK Platform-Tools are missing from {root}. Install them in Android Studio's SDK Manager.")) | |
| 95 | + } else if !sdk.emulator { | |
| 96 | + Some(format!("Android Emulator is missing from {root}. Install it in Android Studio's SDK Manager.")) | |
| 97 | + } else if !sdk.avdmanager && sdk.legacy_avdmanager { | |
| 98 | + Some(format!( | |
| 99 | + "The Android SDK command-line tools in {root} appear to be an older, unsupported version. Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools." | |
| 100 | + )) | |
| 101 | + } else if !sdk.avdmanager { | |
| 102 | + Some(format!("Android SDK Command-line Tools (latest) are missing from {root}. Install them in Android Studio's SDK Manager.")) | |
| 103 | + } else { | |
| 104 | + None | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + pub fn availability(sdk: &AndroidSdk) -> Vec<PlatformAvailability> { | |
| 111 | + [Platform::Ios, Platform::Android] | |
| 112 | + .into_iter() | |
| 113 | + .map(|platform| { | |
| 114 | + let reason = platform_reason(platform, sdk); | |
| 115 | + PlatformAvailability { platform, available: reason.is_none(), reason } | |
| 116 | + }) | |
| 117 | + .collect() | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// The environment the hub and the platform tools run with: the SDK's | |
| 121 | + /// tools on PATH and its root in `ANDROID_HOME`. | |
| 122 | + fn apply_env(command: &mut Command, sdk: &AndroidSdk) { | |
| 123 | + let mut dirs: Vec<PathBuf> = Vec::new(); | |
| 124 | + if let Some(root) = &sdk.root { | |
| 125 | + command.env("ANDROID_HOME", root); | |
| 126 | + dirs.push(root.join("platform-tools")); | |
| 127 | + dirs.push(root.join("emulator")); | |
| 128 | + } | |
| 129 | + // npm-installed tools run with the Node the app found. | |
| 130 | + if let Some(node_dir) = toolchain::node().as_deref().and_then(Path::parent) { | |
| 131 | + dirs.push(node_dir.to_path_buf()); | |
| 132 | + } | |
| 133 | + let current = std::env::var_os("PATH").unwrap_or_default(); | |
| 134 | + let path = std::env::join_paths(dirs.into_iter().chain(std::env::split_paths(¤t))).unwrap_or(current); | |
| 135 | + command.env("PATH", path).env("FORCE_COLOR", "0").env("NO_COLOR", "1"); | |
| 136 | + // SSH sessions may lack the folder the emulator publishes its gRPC token in. | |
| 137 | + if std::env::var_os("XDG_RUNTIME_DIR").is_none() { | |
| 138 | + let dir = PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() })); | |
| 139 | + if dir.is_dir() { | |
| 140 | + command.env("XDG_RUNTIME_DIR", dir); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// What a finished command printed. | |
| 146 | + #[derive(Debug, Default)] | |
| 147 | + pub struct Output { | |
| 148 | + pub stdout: String, | |
| 149 | + pub stderr: String, | |
| 150 | + /// The exit code; 124 for a timeout, 127 when it couldn't start. | |
| 151 | + pub code: i32, | |
| 152 | + } | |
| 153 | + | |
| 154 | + impl Output { | |
| 155 | + pub fn ok(self, operation: &str) -> Result<String> { | |
| 156 | + if self.code == 0 { | |
| 157 | + Ok(self.stdout) | |
| 158 | + } else { | |
| 159 | + let detail = self.stderr.trim(); | |
| 160 | + let detail = if detail.is_empty() { self.stdout.trim() } else { detail }; | |
| 161 | + let detail: String = detail.chars().take(400).collect(); | |
| 162 | + Err(anyhow!( | |
| 163 | + "Device {operation} failed: The device command failed (exit code {}).{}", | |
| 164 | + self.code, | |
| 165 | + if detail.is_empty() { String::new() } else { format!("\n{detail}") } | |
| 166 | + )) | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + /// Runs a host command (`adb`, `emulator`, `xcrun`, or Node) with the SDK's | |
| 172 | + /// environment, waiting at most `timeout`. | |
| 173 | + pub fn run(program: &str, args: &[&str], timeout: Duration, stdin: Option<&str>) -> Output { | |
| 174 | + let sdk = android_sdk(); | |
| 175 | + let program: PathBuf = match (program, &sdk.root) { | |
| 176 | + ("emulator", Some(root)) => root.join("emulator/emulator"), | |
| 177 | + ("adb", Some(root)) if root.join("platform-tools/adb").is_file() => root.join("platform-tools/adb"), | |
| 178 | + _ => PathBuf::from(program), | |
| 179 | + }; | |
| 180 | + let mut command = Command::new(&program); | |
| 181 | + command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 182 | + command.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }); | |
| 183 | + apply_env(&mut command, &sdk); | |
| 184 | + let mut child = match command.spawn() { | |
| 185 | + Ok(child) => child, | |
| 186 | + Err(err) => return Output { stderr: err.to_string(), code: 127, ..Output::default() }, | |
| 187 | + }; | |
| 188 | + if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) { | |
| 189 | + let text = text.to_string(); | |
| 190 | + thread::spawn(move || { | |
| 191 | + let _ = pipe.write_all(text.as_bytes()); | |
| 192 | + }); | |
| 193 | + } | |
| 194 | + let read = |pipe: Option<Box<dyn std::io::Read + Send>>| { | |
| 195 | + thread::spawn(move || { | |
| 196 | + let mut text = Vec::new(); | |
| 197 | + if let Some(mut pipe) = pipe { | |
| 198 | + let _ = pipe.read_to_end(&mut text); | |
| 199 | + } | |
| 200 | + String::from_utf8_lossy(&text).into_owned() | |
| 201 | + }) | |
| 202 | + }; | |
| 203 | + let stdout = read(child.stdout.take().map(|pipe| Box::new(pipe) as Box<dyn std::io::Read + Send>)); | |
| 204 | + let stderr = read(child.stderr.take().map(|pipe| Box::new(pipe) as Box<dyn std::io::Read + Send>)); | |
| 205 | + let deadline = Instant::now() + timeout; | |
| 206 | + let code = loop { | |
| 207 | + match child.try_wait() { | |
| 208 | + Ok(Some(status)) => break status.code().unwrap_or(1), | |
| 209 | + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)), | |
| 210 | + _ => { | |
| 211 | + let _ = child.kill(); | |
| 212 | + let _ = child.wait(); | |
| 213 | + break 124; | |
| 214 | + } | |
| 215 | + } | |
| 216 | + }; | |
| 217 | + Output { stdout: stdout.join().unwrap_or_default(), stderr: stderr.join().unwrap_or_default(), code } | |
| 218 | + } | |
| 219 | + | |
| 220 | + /// Paths of helpers serve-sim bundles, when the hub has them. | |
| 221 | + #[derive(Clone, Debug, Default)] | |
| 222 | + pub struct Helpers { | |
| 223 | + pub ax_settings: Option<PathBuf>, | |
| 224 | + pub serve_sim_cli: Option<PathBuf>, | |
| 225 | + } | |
| 226 | + | |
| 227 | + /// A running hub's endpoints. | |
| 228 | + #[derive(Clone, Debug)] | |
| 229 | + pub struct Ready { | |
| 230 | + /// The hub's loopback origin, `http://127.0.0.1:<port>`. | |
| 231 | + pub origin: String, | |
| 232 | + pub node: PathBuf, | |
| 233 | + pub helpers: Helpers, | |
| 234 | + } | |
| 235 | + | |
| 236 | + /// The agent-device daemon's endpoint. | |
| 237 | + #[derive(Clone, Debug)] | |
| 238 | + pub struct AgentEndpoint { | |
| 239 | + pub base_url: String, | |
| 240 | + pub token: String, | |
| 241 | + } | |
| 242 | + | |
| 243 | + struct HubProcess { | |
| 244 | + child: Arc<Mutex<Child>>, | |
| 245 | + pid: u32, | |
| 246 | + origin: String, | |
| 247 | + started: Instant, | |
| 248 | + } | |
| 249 | + | |
| 250 | + impl HubProcess { | |
| 251 | + fn alive(&self) -> bool { | |
| 252 | + self.child.lock().map(|mut child| matches!(child.try_wait(), Ok(None))).unwrap_or(false) | |
| 253 | + } | |
| 254 | + | |
| 255 | + fn kill(&self) { | |
| 256 | + if let Ok(mut child) = self.child.lock() { | |
| 257 | + unsafe { | |
| 258 | + libc::kill(self.pid as i32, libc::SIGTERM); | |
| 259 | + } | |
| 260 | + let deadline = Instant::now() + Duration::from_secs(3); | |
| 261 | + while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline { | |
| 262 | + thread::sleep(Duration::from_millis(50)); | |
| 263 | + } | |
| 264 | + let _ = child.kill(); | |
| 265 | + let _ = child.wait(); | |
| 266 | + } | |
| 267 | + } | |
| 268 | + } | |
| 269 | + | |
| 270 | + #[derive(Default)] | |
| 271 | + struct Running { | |
| 272 | + hub: Option<HubProcess>, | |
| 273 | + node: Option<PathBuf>, | |
| 274 | + agent: Option<AgentEndpoint>, | |
| 275 | + restart_delay: Duration, | |
| 276 | + } | |
| 277 | + | |
| 278 | + /// Written next to the daemon's state, so a hub left behind by an app that | |
| 279 | + /// died without cleaning up is stopped on the next start. | |
| 280 | + #[derive(Serialize, Deserialize)] | |
| 281 | + struct HubStateFile { | |
| 282 | + pid: u32, | |
| 283 | + port: u16, | |
| 284 | + #[serde(rename = "entryPath")] | |
| 285 | + entry_path: String, | |
| 286 | + } | |
| 287 | + | |
| 288 | + #[derive(Deserialize)] | |
| 289 | + struct DaemonFile { | |
| 290 | + #[serde(rename = "httpPort")] | |
| 291 | + http_port: u16, | |
| 292 | + token: String, | |
| 293 | + pid: Option<i32>, | |
| 294 | + version: Option<String>, | |
| 295 | + } | |
| 296 | + | |
| 297 | + /// The state folder agent-device and the hub record live in. | |
| 298 | + pub fn state_dir() -> Option<PathBuf> { | |
| 299 | + Some(toolchain::base_dir()?.join("device").join("agent-device")) | |
| 300 | + } | |
| 301 | + | |
| 302 | + /// The phase callback ensure calls report progress through. | |
| 303 | + pub type OnPhase<'a> = &'a dyn Fn(HostStatus, Option<String>); | |
| 304 | + | |
| 305 | + pub struct LocalHost { | |
| 306 | + running: Arc<Mutex<Running>>, | |
| 307 | + /// Starts and stops one at a time. | |
| 308 | + lock: Mutex<()>, | |
| 309 | + } | |
| 310 | + | |
| 311 | + impl LocalHost { | |
| 312 | + pub fn new() -> Self { | |
| 313 | + Self { running: Arc::default(), lock: Mutex::new(()) } | |
| 314 | + } | |
| 315 | + | |
| 316 | + fn lock_running(&self) -> std::sync::MutexGuard<'_, Running> { | |
| 317 | + self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) | |
| 318 | + } | |
| 319 | + | |
| 320 | + /// The hub's endpoints if it runs, without starting anything. | |
| 321 | + pub fn current(&self) -> Option<Ready> { | |
| 322 | + let running = self.lock_running(); | |
| 323 | + let hub = running.hub.as_ref().filter(|hub| hub.alive())?; | |
| 324 | + Some(Ready { origin: hub.origin.clone(), node: running.node.clone()?, helpers: helpers() }) | |
| 325 | + } | |
| 326 | + | |
| 327 | + /// Whether the hub runs, and agent-device's version if its daemon does. | |
| 328 | + pub fn running_versions(&self) -> (bool, Option<String>) { | |
| 329 | + let hub = self.current().is_some(); | |
| 330 | + let agent = self.lock_running().agent.is_some().then(read_daemon_file).flatten(); | |
| 331 | + let agent = agent.filter(|file| file.pid.is_none_or(pid_alive)).and_then(|file| file.version); | |
| 332 | + (hub, agent) | |
| 333 | + } | |
| 334 | + | |
| 335 | + /// Installs the hub on first use and starts it; a running hub returns at once. | |
| 336 | + pub fn ensure_ready(&self, on_phase: OnPhase) -> Result<Ready> { | |
| 337 | + let _guard = self.lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 338 | + self.ensure_hub(on_phase) | |
| 339 | + } | |
| 340 | + | |
| 341 | + fn ensure_hub(&self, on_phase: OnPhase) -> Result<Ready> { | |
| 342 | + if let Some(ready) = self.current() { | |
| 343 | + return Ok(ready); | |
| 344 | + } | |
| 345 | + let node = toolchain::node().context( | |
| 346 | + "Local device support needs Node.js 22 or newer. Install it, then restart Vectrum Code.", | |
| 347 | + )?; | |
| 348 | + if !toolchain::is_installed(Tool::Hub) { | |
| 349 | + on_phase(HostStatus::Installing, Some(toolchain::install_message(Tool::Hub))); | |
| 350 | + } | |
| 351 | + let entry = toolchain::ensure(Tool::Hub).context("installing device support")?; | |
| 352 | + on_phase(HostStatus::Starting, None); | |
| 353 | + let hub = spawn_hub(&entry, &node)?; | |
| 354 | + let ready = Ready { origin: hub.origin.clone(), node: node.clone(), helpers: helpers() }; | |
| 355 | + let pid = hub.pid; | |
| 356 | + { | |
| 357 | + let mut running = self.lock_running(); | |
| 358 | + running.hub = Some(hub); | |
| 359 | + running.node = Some(node); | |
| 360 | + running.restart_delay = Duration::ZERO; | |
| 361 | + } | |
| 362 | + toolchain::prune(Tool::Hub); | |
| 363 | + supervise(self.running.clone(), pid, entry); | |
| 364 | + Ok(ready) | |
| 365 | + } | |
| 366 | + | |
| 367 | + /// Installs and starts agent-device once agent access is granted. | |
| 368 | + pub fn ensure_agent_ready(&self, on_phase: OnPhase) -> Result<(Ready, AgentEndpoint)> { | |
| 369 | + let _guard = self.lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 370 | + let ready = self.ensure_hub(on_phase)?; | |
| 371 | + if let Some(agent) = self.lock_running().agent.clone() { | |
| 372 | + return Ok((ready, agent)); | |
| 373 | + } | |
| 374 | + if !toolchain::is_installed(Tool::Agent) { | |
| 375 | + on_phase(HostStatus::Installing, Some(toolchain::install_message(Tool::Agent))); | |
| 376 | + } | |
| 377 | + let entry = toolchain::ensure(Tool::Agent).context("installing agent tools")?; | |
| 378 | + on_phase(HostStatus::Starting, None); | |
| 379 | + let agent = start_daemon(&entry, &ready.node)?; | |
| 380 | + toolchain::prune(Tool::Agent); | |
| 381 | + self.lock_running().agent = Some(agent.clone()); | |
| 382 | + Ok((ready, agent)) | |
| 383 | + } | |
| 384 | + | |
| 385 | + /// Stops only agent-device; watching through the hub carries on. | |
| 386 | + pub fn stop_agent(&self) { | |
| 387 | + let _guard = self.lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 388 | + let node = self.lock_running().node.clone(); | |
| 389 | + stop_daemon(node.as_deref()); | |
| 390 | + self.lock_running().agent = None; | |
| 391 | + } | |
| 392 | + | |
| 393 | + /// Stops the helpers. Simulators and emulators keep running; the user owns those. | |
| 394 | + pub fn stop(&self) { | |
| 395 | + let _guard = self.lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 396 | + let (hub, node) = { | |
| 397 | + let mut running = self.lock_running(); | |
| 398 | + running.agent = None; | |
| 399 | + (running.hub.take(), running.node.clone()) | |
| 400 | + }; | |
| 401 | + if let Some(hub) = hub { | |
| 402 | + hub.kill(); | |
| 403 | + } | |
| 404 | + if let Some(dir) = state_dir() { | |
| 405 | + let _ = std::fs::remove_file(dir.join("hub.json")); | |
| 406 | + } | |
| 407 | + stop_daemon(node.as_deref()); | |
| 408 | + } | |
| 409 | + } | |
| 410 | + | |
| 411 | + /// Restarts the hub when it dies under us, unless it was replaced or stopped. | |
| 412 | + fn supervise(running: Arc<Mutex<Running>>, pid: u32, entry: PathBuf) { | |
| 413 | + let _ = thread::Builder::new().name("device-hub-supervisor".into()).spawn(move || { | |
| 414 | + loop { | |
| 415 | + thread::sleep(Duration::from_millis(500)); | |
| 416 | + let delay = { | |
| 417 | + let mut guard = running.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 418 | + let Some(hub) = guard.hub.as_ref().filter(|hub| hub.pid == pid) else { return }; | |
| 419 | + if hub.alive() { | |
| 420 | + continue; | |
| 421 | + } | |
| 422 | + let uptime = hub.started.elapsed(); | |
| 423 | + let delay = if uptime >= HUB_STABLE_UPTIME { Duration::ZERO } else { guard.restart_delay }; | |
| 424 | + guard.restart_delay = if uptime >= HUB_STABLE_UPTIME { | |
| 425 | + Duration::ZERO | |
| 426 | + } else if guard.restart_delay.is_zero() { | |
| 427 | + Duration::from_secs(1) | |
| 428 | + } else { | |
| 429 | + (guard.restart_delay * 2).min(HUB_RESTART_MAX) | |
| 430 | + }; | |
| 431 | + delay | |
| 432 | + }; | |
| 433 | + thread::sleep(delay); | |
| 434 | + let mut guard = running.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 435 | + if guard.hub.as_ref().is_none_or(|hub| hub.pid != pid) { | |
| 436 | + return; | |
| 437 | + } | |
| 438 | + let Some(node) = guard.node.clone() else { return }; | |
| 439 | + match spawn_hub(&entry, &node) { | |
| 440 | + Ok(hub) => { | |
| 441 | + let next = hub.pid; | |
| 442 | + guard.hub = Some(hub); | |
| 443 | + drop(guard); | |
| 444 | + // Hand over to a supervisor for the replacement. | |
| 445 | + return supervise(running.clone(), next, entry); | |
| 446 | + } | |
| 447 | + Err(_) => { | |
| 448 | + guard.hub = None; | |
| 449 | + return; | |
| 450 | + } | |
| 451 | + } | |
| 452 | + } | |
| 453 | + }); | |
| 454 | + } | |
| 455 | + | |
| 456 | + fn helpers() -> Helpers { | |
| 457 | + let Some(dist) = toolchain::hub_package_dir().map(|dir| dir.join("vendor/serve-sim/dist")) else { | |
| 458 | + return Helpers::default(); | |
| 459 | + }; | |
| 460 | + let existing = |path: PathBuf| path.exists().then_some(path); | |
| 461 | + Helpers { | |
| 462 | + ax_settings: existing(dist.join("simax/serve-sim-ax-settings")), | |
| 463 | + serve_sim_cli: existing(dist.join("serve-sim.js")), | |
| 464 | + } | |
| 465 | + } | |
| 466 | + | |
| 467 | + fn pid_alive(pid: i32) -> bool { | |
| 468 | + unsafe { libc::kill(pid, 0) == 0 } | |
| 469 | + } | |
| 470 | + | |
| 471 | + /// Stops a hub a previous run left behind, if that pid is still that hub. | |
| 472 | + fn reap_stale_hub(state: &Path) { | |
| 473 | + let file = state.join("hub.json"); | |
| 474 | + let Some(previous) = std::fs::read_to_string(&file).ok().and_then(|text| serde_json::from_str::<HubStateFile>(&text).ok()) | |
| 475 | + else { | |
| 476 | + return; | |
| 477 | + }; | |
| 478 | + let cmdline = std::fs::read(format!("/proc/{}/cmdline", previous.pid)).unwrap_or_default(); | |
| 479 | + if pid_alive(previous.pid as i32) && String::from_utf8_lossy(&cmdline).contains(&previous.entry_path) { | |
| 480 | + unsafe { | |
| 481 | + libc::kill(previous.pid as i32, libc::SIGTERM); | |
| 482 | + } | |
| 483 | + } | |
| 484 | + let _ = std::fs::remove_file(file); | |
| 485 | + } | |
| 486 | + | |
| 487 | + fn free_port() -> Result<u16> { | |
| 488 | + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).context("reserving a port for the device hub")?; | |
| 489 | + Ok(listener.local_addr()?.port()) | |
| 490 | + } | |
| 491 | + | |
| 492 | + fn spawn_hub(entry: &Path, node: &Path) -> Result<HubProcess> { | |
| 493 | + let state = state_dir().context("no data folder")?; | |
| 494 | + std::fs::create_dir_all(&state)?; | |
| 495 | + reap_stale_hub(&state); | |
| 496 | + let port = free_port()?; | |
| 497 | + let origin = format!("http://127.0.0.1:{port}"); | |
| 498 | + let mut command = Command::new(node); | |
| 499 | + command | |
| 500 | + .arg(entry) | |
| 501 | + .args(["--port", &port.to_string(), "--host", "127.0.0.1", "--hide-sidebar", "--hide-boot-device"]) | |
| 502 | + .stdin(Stdio::null()) | |
| 503 | + .stdout(Stdio::piped()) | |
| 504 | + .stderr(Stdio::piped()); | |
| 505 | + apply_env(&mut command, &android_sdk()); | |
| 506 | + // The hub goes with the app if the app dies without stopping it. | |
| 507 | + unsafe { | |
| 508 | + command.pre_exec(|| { | |
| 509 | + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); | |
| 510 | + Ok(()) | |
| 511 | + }); | |
| 512 | + } | |
| 513 | + let mut child = command.spawn().context("starting the device hub")?; | |
| 514 | + let pid = child.id(); | |
| 515 | + // Its output goes to a log beside its state, for diagnosing a hub that won't start. | |
| 516 | + let log = Arc::new(Mutex::new(std::fs::File::create(state.join("hub.log")).ok())); | |
| 517 | + for pipe in [child.stdout.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>), child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>)].into_iter().flatten() { | |
| 518 | + let log = log.clone(); | |
| 519 | + thread::spawn(move || { | |
| 520 | + for line in BufReader::new(pipe).lines().map_while(Result::ok) { | |
| 521 | + if let Ok(mut log) = log.lock() | |
| 522 | + && let Some(file) = log.as_mut() | |
| 523 | + { | |
| 524 | + let _ = writeln!(file, "{line}"); | |
| 525 | + } | |
| 526 | + } | |
| 527 | + }); | |
| 528 | + } | |
| 529 | + let hub = HubProcess { child: Arc::new(Mutex::new(child)), pid, origin: origin.clone(), started: Instant::now() }; | |
| 530 | + let deadline = Instant::now() + HUB_READY_TIMEOUT; | |
| 531 | + let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(2)).build(); | |
| 532 | + loop { | |
| 533 | + if agent.get(&format!("{origin}/readyz")).call().is_ok() { | |
| 534 | + break; | |
| 535 | + } | |
| 536 | + if !hub.alive() || Instant::now() > deadline { | |
| 537 | + hub.kill(); | |
| 538 | + let tail = std::fs::read_to_string(state.join("hub.log")).unwrap_or_default(); | |
| 539 | + let tail: Vec<&str> = tail.lines().rev().take(5).collect(); | |
| 540 | + bail!( | |
| 541 | + "The device hub did not start.{}", | |
| 542 | + if tail.is_empty() { String::new() } else { format!("\n{}", tail.into_iter().rev().collect::<Vec<_>>().join("\n")) } | |
| 543 | + ); | |
| 544 | + } | |
| 545 | + thread::sleep(Duration::from_millis(150)); | |
| 546 | + } | |
| 547 | + let record = HubStateFile { pid, port, entry_path: entry.display().to_string() }; | |
| 548 | + let _ = std::fs::write(state.join("hub.json"), serde_json::to_string(&record).unwrap_or_default()); | |
| 549 | + Ok(hub) | |
| 550 | + } | |
| 551 | + | |
| 552 | + fn read_daemon_file() -> Option<DaemonFile> { | |
| 553 | + let text = std::fs::read_to_string(state_dir()?.join("daemon.json")).ok()?; | |
| 554 | + serde_json::from_str(&text).ok() | |
| 555 | + } | |
| 556 | + | |
| 557 | + fn daemon_command(node: &Path, entry: &Path, state: &Path) -> Command { | |
| 558 | + let mut command = Command::new(node); | |
| 559 | + command | |
| 560 | + .arg(entry) | |
| 561 | + .env("AGENT_DEVICE_STATE_DIR", state) | |
| 562 | + .env("AGENT_DEVICE_DAEMON_SERVER_MODE", "http") | |
| 563 | + // The daemon idles out after five minutes by default; the app owns | |
| 564 | + // its lifetime and stops it itself. | |
| 565 | + .env("AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS", "0") | |
| 566 | + .env("AGENT_DEVICE_NO_UPDATE_NOTIFIER", "1") | |
| 567 | + .stdin(Stdio::null()) | |
| 568 | + .stdout(Stdio::null()) | |
| 569 | + .stderr(Stdio::null()); | |
| 570 | + apply_env(&mut command, &android_sdk()); | |
| 571 | + command | |
| 572 | + } | |
| 573 | + | |
| 574 | + /// The first agent-device command in a state folder starts the daemon and | |
| 575 | + /// waits for it; `devices` is the cheapest. Its daemon.json says where it is. | |
| 576 | + fn start_daemon(entry: &Path, node: &Path) -> Result<AgentEndpoint> { | |
| 577 | + let state = state_dir().context("no data folder")?; | |
| 578 | + std::fs::create_dir_all(&state)?; | |
| 579 | + let endpoint = |file: DaemonFile| AgentEndpoint { | |
| 580 | + base_url: format!("http://127.0.0.1:{}", file.http_port), | |
| 581 | + token: file.token, | |
| 582 | + }; | |
| 583 | + if let Some(existing) = read_daemon_file() { | |
| 584 | + let health = ureq::AgentBuilder::new() | |
| 585 | + .timeout(Duration::from_secs(2)) | |
| 586 | + .build() | |
| 587 | + .get(&format!("http://127.0.0.1:{}/health", existing.http_port)) | |
| 588 | + .call(); | |
| 589 | + if health.is_ok_and(|response| response.status() == 200) { | |
| 590 | + return Ok(endpoint(existing)); | |
| 591 | + } | |
| 592 | + let _ = std::fs::remove_file(state.join("daemon.json")); | |
| 593 | + } | |
| 594 | + let mut command = daemon_command(node, entry, &state); | |
| 595 | + command.args(["devices", "--json"]); | |
| 596 | + if let Ok(mut child) = command.spawn() { | |
| 597 | + let deadline = Instant::now() + DAEMON_READY_TIMEOUT; | |
| 598 | + while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline { | |
| 599 | + thread::sleep(Duration::from_millis(100)); | |
| 600 | + } | |
| 601 | + let _ = child.kill(); | |
| 602 | + let _ = child.wait(); | |
| 603 | + } | |
| 604 | + let deadline = Instant::now() + DAEMON_READY_TIMEOUT; | |
| 605 | + loop { | |
| 606 | + if let Some(file) = read_daemon_file() { | |
| 607 | + return Ok(endpoint(file)); | |
| 608 | + } | |
| 609 | + if Instant::now() > deadline { | |
| 610 | + bail!("Agent tools did not start within {} ms.", DAEMON_READY_TIMEOUT.as_millis()); | |
| 611 | + } | |
| 612 | + thread::sleep(Duration::from_millis(100)); | |
| 613 | + } | |
| 614 | + } | |
| 615 | + | |
| 616 | + fn stop_daemon(node: Option<&Path>) { | |
| 617 | + let (Some(node), Some(entry), Some(state)) = (node, toolchain::entry_path(Tool::Agent), state_dir()) else { return }; | |
| 618 | + if !entry.is_file() || read_daemon_file().is_none() { | |
| 619 | + return; | |
| 620 | + } | |
| 621 | + let mut command = daemon_command(node, &entry, &state); | |
| 622 | + command.args(["daemon", "stop", "--state-dir"]).arg(&state); | |
| 623 | + if let Ok(mut child) = command.spawn() { | |
| 624 | + let deadline = Instant::now() + Duration::from_secs(10); | |
| 625 | + while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline { | |
| 626 | + thread::sleep(Duration::from_millis(100)); | |
| 627 | + } | |
| 628 | + let _ = child.kill(); | |
| 629 | + let _ = child.wait(); | |
| 630 | + } | |
| 631 | + } | |
| @@ -0,0 +1,1168 @@ | ||
| 1 | + //! iOS Simulators and Android Emulators next to a thread, as T3 Code's | |
| 2 | + //! Device hub has them. The app owns discovery, the streaming helper | |
| 3 | + //! (expo-device-hub) and the agent driver (agent-device); the Device panel | |
| 4 | + //! renders the hub's per-device streams, and agents reach devices through | |
| 5 | + //! the `device_*` tools and the `agent-device` CLI the app sets up for them. | |
| 6 | + //! | |
| 7 | + //! Discovery and boot go through the hub's JSON API: it normalizes both | |
| 8 | + //! platforms into one device shape, and it's the process that must know a | |
| 9 | + //! device booted before it can stream it. Sessions are the app's own | |
| 10 | + //! bookkeeping (which thread looks at which device), so the panel and the | |
| 11 | + //! tools agree, and a device an agent opens shows up in the panel. | |
| 12 | + //! | |
| 13 | + //! Everything here blocks; call it from a background thread. | |
| 14 | + | |
| 15 | + pub mod actions; | |
| 16 | + pub mod agent; | |
| 17 | + pub mod host; | |
| 18 | + pub mod stream; | |
| 19 | + pub mod toolchain; | |
| 20 | + | |
| 21 | + use std::{ | |
| 22 | + sync::{Mutex, OnceLock}, | |
| 23 | + time::Duration, | |
| 24 | + }; | |
| 25 | + | |
| 26 | + use anyhow::{Context as _, Result, anyhow, bail}; | |
| 27 | + use serde::{Deserialize, Serialize}; | |
| 28 | + use serde_json::json; | |
| 29 | + | |
| 30 | + use host::{LocalHost, Ready}; | |
| 31 | + use toolchain::Tool; | |
| 32 | + | |
| 33 | + /// The machine the app runs on, the only device host there is. | |
| 34 | + pub const LOCAL_HOST: &str = "local"; | |
| 35 | + | |
| 36 | + const BOOT_TIMEOUT: Duration = Duration::from_secs(180); | |
| 37 | + const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20); | |
| 38 | + const HUB_TIMEOUT: Duration = Duration::from_secs(15); | |
| 39 | + | |
| 40 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 41 | + #[serde(rename_all = "lowercase")] | |
| 42 | + pub enum Platform { | |
| 43 | + Ios, | |
| 44 | + Android, | |
| 45 | + } | |
| 46 | + | |
| 47 | + impl Platform { | |
| 48 | + pub fn key(self) -> &'static str { | |
| 49 | + match self { | |
| 50 | + Platform::Ios => "ios", | |
| 51 | + Platform::Android => "android", | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + pub fn label(self) -> &'static str { | |
| 56 | + match self { | |
| 57 | + Platform::Ios => "iOS", | |
| 58 | + Platform::Android => "Android", | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// The device list's heading for the platform. | |
| 63 | + pub fn group_label(self) -> &'static str { | |
| 64 | + match self { | |
| 65 | + Platform::Ios => "iOS Simulators", | |
| 66 | + Platform::Android => "Android Emulators", | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + fn vendor(self) -> &'static str { | |
| 71 | + match self { | |
| 72 | + Platform::Ios => "/vendor/serve-sim", | |
| 73 | + Platform::Android => "/vendor/serve-emu", | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + pub fn from_key(key: &str) -> Option<Self> { | |
| 78 | + match key { | |
| 79 | + "ios" => Some(Platform::Ios), | |
| 80 | + "android" => Some(Platform::Android), | |
| 81 | + _ => None, | |
| 82 | + } | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 87 | + #[serde(rename_all = "camelCase")] | |
| 88 | + pub struct DeviceSummary { | |
| 89 | + pub host_id: String, | |
| 90 | + /// Simulator udid or adb serial (an AVD's name while it isn't running). | |
| 91 | + pub id: String, | |
| 92 | + pub platform: Platform, | |
| 93 | + pub name: String, | |
| 94 | + /// "iOS 18.0", "Android 16.0". | |
| 95 | + pub version: String, | |
| 96 | + pub booted: bool, | |
| 97 | + pub physical: bool, | |
| 98 | + } | |
| 99 | + | |
| 100 | + /// Where the helper processes are in their lifecycle. Tools install on | |
| 101 | + /// first use, so a fresh setup spends a while installing before anything | |
| 102 | + /// can stream; the panel shows that instead of an empty list. | |
| 103 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 104 | + #[serde(rename_all = "lowercase")] | |
| 105 | + pub enum HostStatus { | |
| 106 | + #[default] | |
| 107 | + Disabled, | |
| 108 | + Idle, | |
| 109 | + Installing, | |
| 110 | + Starting, | |
| 111 | + Ready, | |
| 112 | + Failed, | |
| 113 | + } | |
| 114 | + | |
| 115 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 116 | + pub struct PlatformAvailability { | |
| 117 | + pub platform: Platform, | |
| 118 | + pub available: bool, | |
| 119 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 120 | + pub reason: Option<String>, | |
| 121 | + } | |
| 122 | + | |
| 123 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 124 | + #[serde(rename_all = "camelCase")] | |
| 125 | + pub struct ToolVersion { | |
| 126 | + #[serde(rename = "requiredVersion")] | |
| 127 | + pub required: String, | |
| 128 | + #[serde(rename = "installedVersions")] | |
| 129 | + pub installed: Vec<String>, | |
| 130 | + #[serde(rename = "runningVersion")] | |
| 131 | + pub running: Option<String>, | |
| 132 | + } | |
| 133 | + | |
| 134 | + impl ToolVersion { | |
| 135 | + /// The version to show: the running one, the pinned one if installed, or the newest installed. | |
| 136 | + pub fn shown(&self) -> Option<&str> { | |
| 137 | + self.running | |
| 138 | + .as_deref() | |
| 139 | + .or_else(|| self.installed.contains(&self.required).then_some(self.required.as_str())) | |
| 140 | + .or_else(|| self.installed.last().map(String::as_str)) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 145 | + #[serde(rename_all = "camelCase")] | |
| 146 | + pub struct HostSummary { | |
| 147 | + pub id: String, | |
| 148 | + pub kind: String, | |
| 149 | + pub label: String, | |
| 150 | + pub platforms: Vec<PlatformAvailability>, | |
| 151 | + pub hub: ToolVersion, | |
| 152 | + pub agent: ToolVersion, | |
| 153 | + pub hub_installed: bool, | |
| 154 | + pub agent_device_installed: bool, | |
| 155 | + } | |
| 156 | + | |
| 157 | + /// A device a thread looks at: one per thread and device. A device can be | |
| 158 | + /// open in several threads; they share its stream. | |
| 159 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 160 | + #[serde(rename_all = "camelCase")] | |
| 161 | + pub struct DeviceSession { | |
| 162 | + pub thread_id: String, | |
| 163 | + pub host_id: String, | |
| 164 | + pub device_id: String, | |
| 165 | + pub platform: Platform, | |
| 166 | + pub opened_at: String, | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Everything the Device panel renders from. | |
| 170 | + #[derive(Clone, Debug, Default)] | |
| 171 | + pub struct State { | |
| 172 | + pub enabled: bool, | |
| 173 | + pub agent_access: bool, | |
| 174 | + pub onboarding_completed: bool, | |
| 175 | + pub host_status: HostStatus, | |
| 176 | + pub host_detail: Option<String>, | |
| 177 | + pub hosts: Vec<HostSummary>, | |
| 178 | + pub devices: Vec<DeviceSummary>, | |
| 179 | + pub sessions: Vec<DeviceSession>, | |
| 180 | + /// Devices starting up, with the thread that asked. | |
| 181 | + pub booting: Vec<(DeviceSummary, String)>, | |
| 182 | + pub revision: u64, | |
| 183 | + } | |
| 184 | + | |
| 185 | + impl State { | |
| 186 | + pub fn device(&self, host: &str, id: &str) -> Option<&DeviceSummary> { | |
| 187 | + self.devices.iter().find(|device| device.host_id == host && device.id == id) | |
| 188 | + } | |
| 189 | + | |
| 190 | + pub fn local(&self) -> Option<&HostSummary> { | |
| 191 | + self.hosts.iter().find(|host| host.id == LOCAL_HOST) | |
| 192 | + } | |
| 193 | + | |
| 194 | + pub fn host_label(&self, host: &str) -> String { | |
| 195 | + self.hosts.iter().find(|summary| summary.id == host).map(|summary| summary.label.clone()).unwrap_or_else(|| "Device host".into()) | |
| 196 | + } | |
| 197 | + | |
| 198 | + pub fn busy(&self) -> bool { | |
| 199 | + matches!(self.host_status, HostStatus::Installing | HostStatus::Starting) | |
| 200 | + } | |
| 201 | + | |
| 202 | + /// The panel's "iOS"/"Android" lines: ready, or what's missing. | |
| 203 | + pub fn platform_status(&self, platform: Platform) -> (bool, String) { | |
| 204 | + let availability = self.hosts.iter().flat_map(|host| &host.platforms).find(|candidate| candidate.platform == platform); | |
| 205 | + let Some(availability) = availability.filter(|availability| availability.available) else { | |
| 206 | + let reason = availability.and_then(|availability| availability.reason.clone()); | |
| 207 | + return (false, reason.unwrap_or_else(|| format!("{} support was not detected.", platform.label()))); | |
| 208 | + }; | |
| 209 | + let _ = availability; | |
| 210 | + if self.host_status == HostStatus::Ready && !self.devices.iter().any(|device| device.platform == platform) { | |
| 211 | + return ( | |
| 212 | + false, | |
| 213 | + match platform { | |
| 214 | + Platform::Ios => "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components.", | |
| 215 | + Platform::Android => "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.", | |
| 216 | + } | |
| 217 | + .into(), | |
| 218 | + ); | |
| 219 | + } | |
| 220 | + ( | |
| 221 | + true, | |
| 222 | + match platform { | |
| 223 | + Platform::Ios => "Xcode and iOS Simulator are available.", | |
| 224 | + Platform::Android => "The Android SDK and Emulator are available.", | |
| 225 | + } | |
| 226 | + .into(), | |
| 227 | + ) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 232 | + pub enum TextSize { | |
| 233 | + Small, | |
| 234 | + #[default] | |
| 235 | + Default, | |
| 236 | + Large, | |
| 237 | + ExtraLarge, | |
| 238 | + } | |
| 239 | + | |
| 240 | + impl TextSize { | |
| 241 | + pub const ALL: [TextSize; 4] = [TextSize::Small, TextSize::Default, TextSize::Large, TextSize::ExtraLarge]; | |
| 242 | + | |
| 243 | + pub fn label(self) -> &'static str { | |
| 244 | + match self { | |
| 245 | + TextSize::Small => "Small", | |
| 246 | + TextSize::Default => "Default", | |
| 247 | + TextSize::Large => "Large", | |
| 248 | + TextSize::ExtraLarge => "Extra large", | |
| 249 | + } | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 254 | + pub enum ColorFilter { | |
| 255 | + None, | |
| 256 | + Grayscale, | |
| 257 | + RedGreen, | |
| 258 | + GreenRed, | |
| 259 | + BlueYellow, | |
| 260 | + } | |
| 261 | + | |
| 262 | + impl ColorFilter { | |
| 263 | + pub const ALL: [ColorFilter; 5] = | |
| 264 | + [ColorFilter::None, ColorFilter::Grayscale, ColorFilter::RedGreen, ColorFilter::GreenRed, ColorFilter::BlueYellow]; | |
| 265 | + | |
| 266 | + pub fn key(self) -> &'static str { | |
| 267 | + match self { | |
| 268 | + ColorFilter::None => "none", | |
| 269 | + ColorFilter::Grayscale => "grayscale", | |
| 270 | + ColorFilter::RedGreen => "red-green", | |
| 271 | + ColorFilter::GreenRed => "green-red", | |
| 272 | + ColorFilter::BlueYellow => "blue-yellow", | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + pub fn from_key(key: &str) -> Option<Self> { | |
| 277 | + Self::ALL.into_iter().find(|filter| filter.key() == key) | |
| 278 | + } | |
| 279 | + | |
| 280 | + pub fn label(self) -> &'static str { | |
| 281 | + match self { | |
| 282 | + ColorFilter::None => "None", | |
| 283 | + ColorFilter::Grayscale => "Grayscale", | |
| 284 | + ColorFilter::RedGreen => "Red / green (protanopia)", | |
| 285 | + ColorFilter::GreenRed => "Green / red (deuteranopia)", | |
| 286 | + ColorFilter::BlueYellow => "Blue / yellow (tritanopia)", | |
| 287 | + } | |
| 288 | + } | |
| 289 | + } | |
| 290 | + | |
| 291 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 292 | + pub enum Orientation { | |
| 293 | + Portrait, | |
| 294 | + LandscapeLeft, | |
| 295 | + PortraitUpsideDown, | |
| 296 | + LandscapeRight, | |
| 297 | + } | |
| 298 | + | |
| 299 | + impl Orientation { | |
| 300 | + pub const ALL: [Orientation; 4] = | |
| 301 | + [Orientation::Portrait, Orientation::LandscapeLeft, Orientation::PortraitUpsideDown, Orientation::LandscapeRight]; | |
| 302 | + | |
| 303 | + pub fn key(self) -> &'static str { | |
| 304 | + match self { | |
| 305 | + Orientation::Portrait => "portrait", | |
| 306 | + Orientation::LandscapeLeft => "landscape_left", | |
| 307 | + Orientation::PortraitUpsideDown => "portrait_upside_down", | |
| 308 | + Orientation::LandscapeRight => "landscape_right", | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + pub fn from_key(key: &str) -> Option<Self> { | |
| 313 | + Self::ALL.into_iter().find(|orientation| orientation.key() == key) | |
| 314 | + } | |
| 315 | + | |
| 316 | + pub fn label(self) -> &'static str { | |
| 317 | + match self { | |
| 318 | + Orientation::Portrait => "Portrait", | |
| 319 | + Orientation::LandscapeLeft => "Landscape left", | |
| 320 | + Orientation::PortraitUpsideDown => "Upside down", | |
| 321 | + Orientation::LandscapeRight => "Landscape right", | |
| 322 | + } | |
| 323 | + } | |
| 324 | + | |
| 325 | + pub fn is_landscape(self) -> bool { | |
| 326 | + matches!(self, Orientation::LandscapeLeft | Orientation::LandscapeRight) | |
| 327 | + } | |
| 328 | + | |
| 329 | + pub fn next(self) -> Self { | |
| 330 | + let ix = Self::ALL.iter().position(|orientation| *orientation == self).unwrap_or(0); | |
| 331 | + Self::ALL[(ix + 1) % Self::ALL.len()] | |
| 332 | + } | |
| 333 | + } | |
| 334 | + | |
| 335 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 336 | + pub enum Toggle { | |
| 337 | + ReduceMotion, | |
| 338 | + IncreaseContrast, | |
| 339 | + ReduceTransparency, | |
| 340 | + ShowBorders, | |
| 341 | + VoiceOver, | |
| 342 | + NetworkEnabled, | |
| 343 | + } | |
| 344 | + | |
| 345 | + /// Current values as read from the device; `None` is unsupported or unread. | |
| 346 | + #[derive(Clone, Debug, Default, PartialEq)] | |
| 347 | + pub struct Settings { | |
| 348 | + pub dark: Option<bool>, | |
| 349 | + pub text_size: Option<TextSize>, | |
| 350 | + pub reduce_motion: Option<bool>, | |
| 351 | + pub increase_contrast: Option<bool>, | |
| 352 | + pub reduce_transparency: Option<bool>, | |
| 353 | + pub show_borders: Option<bool>, | |
| 354 | + pub voice_over: Option<bool>, | |
| 355 | + pub liquid_glass_tinted: Option<bool>, | |
| 356 | + pub color_filter: Option<ColorFilter>, | |
| 357 | + pub network_enabled: Option<bool>, | |
| 358 | + } | |
| 359 | + | |
| 360 | + impl Settings { | |
| 361 | + pub fn toggle(&self, toggle: Toggle) -> Option<bool> { | |
| 362 | + match toggle { | |
| 363 | + Toggle::ReduceMotion => self.reduce_motion, | |
| 364 | + Toggle::IncreaseContrast => self.increase_contrast, | |
| 365 | + Toggle::ReduceTransparency => self.reduce_transparency, | |
| 366 | + Toggle::ShowBorders => self.show_borders, | |
| 367 | + Toggle::VoiceOver => self.voice_over, | |
| 368 | + Toggle::NetworkEnabled => self.network_enabled, | |
| 369 | + } | |
| 370 | + } | |
| 371 | + } | |
| 372 | + | |
| 373 | + #[derive(Clone, Debug, PartialEq)] | |
| 374 | + pub struct ForegroundApp { | |
| 375 | + pub id: String, | |
| 376 | + } | |
| 377 | + | |
| 378 | + /// One device's settings and foreground app, as last read. | |
| 379 | + #[derive(Clone, Debug, PartialEq)] | |
| 380 | + pub struct Detail { | |
| 381 | + pub settings: Settings, | |
| 382 | + pub foreground: Option<ForegroundApp>, | |
| 383 | + } | |
| 384 | + | |
| 385 | + /// One control in the Tools drawer or the rail. | |
| 386 | + #[derive(Clone, Debug, PartialEq)] | |
| 387 | + pub enum Action { | |
| 388 | + SetAppearance(bool), | |
| 389 | + SetTextSize(TextSize), | |
| 390 | + SetToggle { setting: Toggle, value: bool }, | |
| 391 | + SetLiquidGlass(bool), | |
| 392 | + SetColorFilter(ColorFilter), | |
| 393 | + SetOrientation(Orientation), | |
| 394 | + SetLocation { latitude: f64, longitude: f64 }, | |
| 395 | + ClearLocation, | |
| 396 | + /// `permission` is a shared name (`camera`), `decision` grant, revoke or reset. | |
| 397 | + SetPermission { app_id: String, permission: &'static str, decision: &'static str }, | |
| 398 | + OpenUrl(String), | |
| 399 | + LaunchApp(String), | |
| 400 | + TerminateApp(String), | |
| 401 | + SendPush { app_id: String, text: String }, | |
| 402 | + } | |
| 403 | + | |
| 404 | + impl Action { | |
| 405 | + pub fn name(&self) -> &'static str { | |
| 406 | + match self { | |
| 407 | + Action::SetAppearance(_) => "appearance", | |
| 408 | + Action::SetTextSize(_) => "text size", | |
| 409 | + Action::SetToggle { .. } => "setting", | |
| 410 | + Action::SetLiquidGlass(_) => "Liquid Glass", | |
| 411 | + Action::SetColorFilter(_) => "color filter", | |
| 412 | + Action::SetOrientation(_) => "orientation", | |
| 413 | + Action::SetLocation { .. } | Action::ClearLocation => "location", | |
| 414 | + Action::SetPermission { .. } => "permission", | |
| 415 | + Action::OpenUrl(_) => "open url", | |
| 416 | + Action::LaunchApp(_) => "launch", | |
| 417 | + Action::TerminateApp(_) => "terminate", | |
| 418 | + Action::SendPush { .. } => "push", | |
| 419 | + } | |
| 420 | + } | |
| 421 | + } | |
| 422 | + | |
| 423 | + /// What the service tells its listeners. | |
| 424 | + #[derive(Clone, Debug)] | |
| 425 | + pub enum Event { | |
| 426 | + Changed, | |
| 427 | + /// An agent opened a device in a thread: the panel shows it. | |
| 428 | + AgentOpened { thread: String, device: DeviceSummary }, | |
| 429 | + /// An agent closed a thread's devices (all of them without an id). | |
| 430 | + AgentClosed { thread: String, device: Option<String> }, | |
| 431 | + } | |
| 432 | + | |
| 433 | + #[derive(Deserialize)] | |
| 434 | + struct HubDevice { | |
| 435 | + id: String, | |
| 436 | + name: String, | |
| 437 | + version: String, | |
| 438 | + platform: Platform, | |
| 439 | + booted: bool, | |
| 440 | + #[serde(default)] | |
| 441 | + physical: bool, | |
| 442 | + } | |
| 443 | + | |
| 444 | + #[derive(Deserialize)] | |
| 445 | + struct HubDeviceList { | |
| 446 | + #[serde(default)] | |
| 447 | + simulators: Vec<HubDevice>, | |
| 448 | + #[serde(default)] | |
| 449 | + emulators: Vec<HubDevice>, | |
| 450 | + #[serde(default)] | |
| 451 | + errors: Vec<HubError>, | |
| 452 | + } | |
| 453 | + | |
| 454 | + #[derive(Deserialize)] | |
| 455 | + struct HubError { | |
| 456 | + message: String, | |
| 457 | + } | |
| 458 | + | |
| 459 | + #[derive(Deserialize)] | |
| 460 | + struct HubActionResult { | |
| 461 | + ok: bool, | |
| 462 | + id: Option<String>, | |
| 463 | + serial: Option<String>, | |
| 464 | + error: Option<String>, | |
| 465 | + } | |
| 466 | + | |
| 467 | + pub struct DeviceService { | |
| 468 | + host: LocalHost, | |
| 469 | + state: Mutex<State>, | |
| 470 | + listeners: Mutex<Vec<smol::channel::Sender<Event>>>, | |
| 471 | + /// Configure, readiness and session changes go one at a time. | |
| 472 | + lifecycle: Mutex<()>, | |
| 473 | + } | |
| 474 | + | |
| 475 | + /// Whether agents in a project get the device tools: device support and | |
| 476 | + /// agent access on, and a platform this machine can run. | |
| 477 | + pub fn agent_access_for(project: &std::path::Path) -> bool { | |
| 478 | + crate::settings::get_for(project, |settings| settings.enable_device_support && settings.enable_agent_device_access) | |
| 479 | + && host::availability(&host::android_sdk()).iter().any(|platform| platform.available) | |
| 480 | + } | |
| 481 | + | |
| 482 | + /// The app's one device service. | |
| 483 | + pub fn service() -> &'static DeviceService { | |
| 484 | + static SERVICE: OnceLock<DeviceService> = OnceLock::new(); | |
| 485 | + SERVICE.get_or_init(DeviceService::new) | |
| 486 | + } | |
| 487 | + | |
| 488 | + fn hub_agent(timeout: Duration) -> ureq::Agent { | |
| 489 | + ureq::AgentBuilder::new().timeout(timeout).build() | |
| 490 | + } | |
| 491 | + | |
| 492 | + /// GETs JSON from the hub. | |
| 493 | + fn get_json<T: serde::de::DeserializeOwned>(url: &str, timeout: Duration) -> Result<T> { | |
| 494 | + let body = hub_agent(timeout).get(url).call().map_err(|err| anyhow!("{err}"))?.into_string()?; | |
| 495 | + Ok(serde_json::from_str(&body)?) | |
| 496 | + } | |
| 497 | + | |
| 498 | + /// POSTs JSON to the hub and reads its JSON answer. | |
| 499 | + fn post_json<T: serde::de::DeserializeOwned>(url: &str, body: serde_json::Value, timeout: Duration) -> Result<T> { | |
| 500 | + let reply = hub_agent(timeout) | |
| 501 | + .post(url) | |
| 502 | + .set("Content-Type", "application/json") | |
| 503 | + .send_string(&body.to_string()) | |
| 504 | + .map_err(|err| anyhow!("{err}"))? | |
| 505 | + .into_string()?; | |
| 506 | + Ok(serde_json::from_str(&reply)?) | |
| 507 | + } | |
| 508 | + | |
| 509 | + fn now() -> String { | |
| 510 | + chrono::Local::now().to_rfc3339() | |
| 511 | + } | |
| 512 | + | |
| 513 | + impl DeviceService { | |
| 514 | + fn new() -> Self { | |
| 515 | + let (enabled, agent_access, onboarding_completed) = crate::settings::get(|settings| { | |
| 516 | + (settings.enable_device_support, settings.enable_agent_device_access, settings.device_onboarding_completed) | |
| 517 | + }); | |
| 518 | + let state = State { | |
| 519 | + enabled, | |
| 520 | + agent_access, | |
| 521 | + onboarding_completed, | |
| 522 | + host_status: if enabled { HostStatus::Idle } else { HostStatus::Disabled }, | |
| 523 | + ..State::default() | |
| 524 | + }; | |
| 525 | + let service = Self { host: LocalHost::new(), state: Mutex::new(state), listeners: Mutex::default(), lifecycle: Mutex::new(()) }; | |
| 526 | + let summary = service.summary(); | |
| 527 | + service.lock_state().hosts = vec![summary]; | |
| 528 | + service | |
| 529 | + } | |
| 530 | + | |
| 531 | + fn lock_state(&self) -> std::sync::MutexGuard<'_, State> { | |
| 532 | + self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) | |
| 533 | + } | |
| 534 | + | |
| 535 | + fn lock_lifecycle(&self) -> std::sync::MutexGuard<'_, ()> { | |
| 536 | + self.lifecycle.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) | |
| 537 | + } | |
| 538 | + | |
| 539 | + pub fn state(&self) -> State { | |
| 540 | + self.lock_state().clone() | |
| 541 | + } | |
| 542 | + | |
| 543 | + /// Events from now on. | |
| 544 | + pub fn subscribe(&self) -> smol::channel::Receiver<Event> { | |
| 545 | + let (tx, rx) = smol::channel::unbounded(); | |
| 546 | + self.listeners.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(tx); | |
| 547 | + rx | |
| 548 | + } | |
| 549 | + | |
| 550 | + fn broadcast(&self, event: Event) { | |
| 551 | + let mut listeners = self.listeners.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 552 | + listeners.retain(|listener| listener.try_send(event.clone()).is_ok()); | |
| 553 | + } | |
| 554 | + | |
| 555 | + fn publish(&self, update: impl FnOnce(&mut State)) -> State { | |
| 556 | + let state = { | |
| 557 | + let mut state = self.lock_state(); | |
| 558 | + update(&mut state); | |
| 559 | + state.revision += 1; | |
| 560 | + state.clone() | |
| 561 | + }; | |
| 562 | + self.broadcast(Event::Changed); | |
| 563 | + state | |
| 564 | + } | |
| 565 | + | |
| 566 | + fn set_host_status(&self, status: HostStatus, detail: Option<String>) { | |
| 567 | + self.publish(|state| { | |
| 568 | + state.host_status = status; | |
| 569 | + state.host_detail = detail; | |
| 570 | + }); | |
| 571 | + } | |
| 572 | + | |
| 573 | + /// The local host's platforms and tool versions, without installing anything. | |
| 574 | + fn summary(&self) -> HostSummary { | |
| 575 | + let sdk = host::android_sdk(); | |
| 576 | + let (hub_running, agent_running) = self.host.running_versions(); | |
| 577 | + HostSummary { | |
| 578 | + id: LOCAL_HOST.into(), | |
| 579 | + kind: "local".into(), | |
| 580 | + label: "This machine".into(), | |
| 581 | + platforms: host::availability(&sdk), | |
| 582 | + hub: toolchain::versions(Tool::Hub, hub_running.then_some(toolchain::HUB_VERSION)), | |
| 583 | + agent: toolchain::versions(Tool::Agent, agent_running.as_deref()), | |
| 584 | + hub_installed: toolchain::is_installed(Tool::Hub), | |
| 585 | + agent_device_installed: toolchain::is_installed(Tool::Agent), | |
| 586 | + } | |
| 587 | + } | |
| 588 | + | |
| 589 | + fn enabled(&self) -> bool { | |
| 590 | + crate::settings::get(|settings| settings.enable_device_support) | |
| 591 | + } | |
| 592 | + | |
| 593 | + fn agent_access(&self) -> bool { | |
| 594 | + crate::settings::get(|settings| settings.enable_device_support && settings.enable_agent_device_access) | |
| 595 | + } | |
| 596 | + | |
| 597 | + fn any_platform(&self) -> bool { | |
| 598 | + host::availability(&host::android_sdk()).iter().any(|platform| platform.available) | |
| 599 | + } | |
| 600 | + | |
| 601 | + /// The hub's endpoints, starting it if needed, once device support is on. | |
| 602 | + pub fn readiness(&self) -> Result<Ready> { | |
| 603 | + if !self.enabled() { | |
| 604 | + bail!("Device support is off. Enable it in the Device panel before installing or starting device tools."); | |
| 605 | + } | |
| 606 | + let _guard = self.lock_lifecycle(); | |
| 607 | + let on_phase = |status: HostStatus, detail: Option<String>| self.set_host_status(status, detail); | |
| 608 | + match self.host.ensure_ready(&on_phase) { | |
| 609 | + Ok(ready) => { | |
| 610 | + if self.lock_state().host_status != HostStatus::Ready { | |
| 611 | + let summary = self.summary(); | |
| 612 | + self.publish(|state| { | |
| 613 | + state.host_status = HostStatus::Ready; | |
| 614 | + state.hosts = vec![summary]; | |
| 615 | + }); | |
| 616 | + } | |
| 617 | + Ok(ready) | |
| 618 | + } | |
| 619 | + Err(err) => { | |
| 620 | + let detail = format!("{err:#}"); | |
| 621 | + self.set_host_status(HostStatus::Failed, Some(detail.clone())); | |
| 622 | + Err(anyhow!(detail)) | |
| 623 | + } | |
| 624 | + } | |
| 625 | + } | |
| 626 | + | |
| 627 | + /// The hub's origin if it runs, without starting it. | |
| 628 | + pub fn hub_origin(&self) -> Option<String> { | |
| 629 | + self.host.current().map(|ready| ready.origin) | |
| 630 | + } | |
| 631 | + | |
| 632 | + /// Starts agent-device, once agent access is on and a platform can run here. | |
| 633 | + fn agent_readiness(&self) -> Result<Option<(Ready, host::AgentEndpoint)>> { | |
| 634 | + if !self.agent_access() || !self.any_platform() { | |
| 635 | + return Ok(None); | |
| 636 | + } | |
| 637 | + let _guard = self.lock_lifecycle(); | |
| 638 | + let on_phase = |status: HostStatus, detail: Option<String>| self.set_host_status(status, detail); | |
| 639 | + match self.host.ensure_agent_ready(&on_phase) { | |
| 640 | + Ok(ready) => { | |
| 641 | + let summary = self.summary(); | |
| 642 | + self.publish(|state| { | |
| 643 | + state.host_status = HostStatus::Ready; | |
| 644 | + state.hosts = vec![summary]; | |
| 645 | + }); | |
| 646 | + Ok(Some(ready)) | |
| 647 | + } | |
| 648 | + Err(err) => { | |
| 649 | + let detail = format!("{err:#}"); | |
| 650 | + self.set_host_status(HostStatus::Failed, Some(detail.clone())); | |
| 651 | + Err(anyhow!(detail)) | |
| 652 | + } | |
| 653 | + } | |
| 654 | + } | |
| 655 | + | |
| 656 | + fn fetch_devices(&self, ready: &Ready) -> Result<(Vec<DeviceSummary>, Option<String>)> { | |
| 657 | + let list: HubDeviceList = get_json(&format!("{}/api/devices", ready.origin), HUB_TIMEOUT) | |
| 658 | + .map_err(|err| anyhow!("Device list failed: Could not communicate with device support. Try refreshing devices. ({err})"))?; | |
| 659 | + let mut devices: Vec<DeviceSummary> = list | |
| 660 | + .simulators | |
| 661 | + .into_iter() | |
| 662 | + .chain(list.emulators) | |
| 663 | + .map(|device| DeviceSummary { | |
| 664 | + host_id: LOCAL_HOST.into(), | |
| 665 | + id: device.id, | |
| 666 | + platform: device.platform, | |
| 667 | + name: device.name, | |
| 668 | + version: device.version, | |
| 669 | + booted: device.booted, | |
| 670 | + physical: device.physical, | |
| 671 | + }) | |
| 672 | + .collect(); | |
| 673 | + let sdk = host::android_sdk(); | |
| 674 | + if host::platform_reason(Platform::Android, &sdk).is_none() { | |
| 675 | + let avds = host::run("emulator", &["-list-avds"], Duration::from_secs(20), None).ok("list")?; | |
| 676 | + for name in avds.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with("INFO")) { | |
| 677 | + if !devices.iter().any(|device| device.platform == Platform::Android && device.name == name) { | |
| 678 | + devices.push(DeviceSummary { | |
| 679 | + host_id: LOCAL_HOST.into(), | |
| 680 | + id: name.into(), | |
| 681 | + name: name.into(), | |
| 682 | + platform: Platform::Android, | |
| 683 | + version: "Android".into(), | |
| 684 | + booted: false, | |
| 685 | + physical: false, | |
| 686 | + }); | |
| 687 | + } | |
| 688 | + } | |
| 689 | + } | |
| 690 | + // Off macOS the hub still tries Xcode; that's no news to anyone. | |
| 691 | + let ios_missing = host::platform_reason(Platform::Ios, &sdk).is_some(); | |
| 692 | + let errors: Vec<String> = list | |
| 693 | + .errors | |
| 694 | + .into_iter() | |
| 695 | + .map(|error| error.message) | |
| 696 | + .filter(|message| !(ios_missing && (message.contains("xcrun") || message.contains("apple-utils")))) | |
| 697 | + .collect(); | |
| 698 | + Ok((devices, (!errors.is_empty()).then(|| errors.join("\n")))) | |
| 699 | + } | |
| 700 | + | |
| 701 | + fn refresh(&self, ready: &Ready) -> Result<State> { | |
| 702 | + let (devices, detail) = self.fetch_devices(ready)?; | |
| 703 | + let summary = self.summary(); | |
| 704 | + let _guard = self.lock_lifecycle(); | |
| 705 | + if !self.enabled() { | |
| 706 | + return Ok(self.state()); | |
| 707 | + } | |
| 708 | + Ok(self.publish(|state| { | |
| 709 | + state.hosts = vec![summary]; | |
| 710 | + state.devices = devices; | |
| 711 | + state.host_status = HostStatus::Ready; | |
| 712 | + state.host_detail = detail; | |
| 713 | + })) | |
| 714 | + } | |
| 715 | + | |
| 716 | + /// Refreshes the devices, starting the hub, once device support is on | |
| 717 | + /// and this machine can run a platform. | |
| 718 | + pub fn list(&self) -> State { | |
| 719 | + if !self.enabled() { | |
| 720 | + return self.state(); | |
| 721 | + } | |
| 722 | + if !self.any_platform() { | |
| 723 | + let summary = self.summary(); | |
| 724 | + return self.publish(|state| { | |
| 725 | + state.hosts = vec![summary]; | |
| 726 | + state.host_status = HostStatus::Ready; | |
| 727 | + state.devices.clear(); | |
| 728 | + }); | |
| 729 | + } | |
| 730 | + if let Err(err) = self.readiness().and_then(|ready| self.refresh(&ready)) { | |
| 731 | + self.set_host_status(HostStatus::Failed, Some(format!("{err:#}"))); | |
| 732 | + } | |
| 733 | + self.state() | |
| 734 | + } | |
| 735 | + | |
| 736 | + /// Reads installed tool versions without installing or starting anything. | |
| 737 | + pub fn inspect(&self) -> State { | |
| 738 | + let summary = self.summary(); | |
| 739 | + self.publish(|state| state.hosts = vec![summary]) | |
| 740 | + } | |
| 741 | + | |
| 742 | + /// Installs a tool's pinned version without turning anything on. | |
| 743 | + pub fn update_tool(&self, tool: Tool) -> Result<State> { | |
| 744 | + toolchain::ensure(tool)?; | |
| 745 | + Ok(self.inspect()) | |
| 746 | + } | |
| 747 | + | |
| 748 | + /// Retries the host, agent tools included if access was granted. | |
| 749 | + pub fn retry(&self) -> State { | |
| 750 | + if !self.enabled() { | |
| 751 | + return self.state(); | |
| 752 | + } | |
| 753 | + let result = self.agent_readiness().and_then(|agent| match agent { | |
| 754 | + Some((ready, _)) => self.refresh(&ready).map(|_| ()), | |
| 755 | + None => self.readiness().and_then(|ready| self.refresh(&ready)).map(|_| ()), | |
| 756 | + }); | |
| 757 | + if let Err(err) = result { | |
| 758 | + self.set_host_status(HostStatus::Failed, Some(format!("{err:#}"))); | |
| 759 | + } | |
| 760 | + self.state() | |
| 761 | + } | |
| 762 | + | |
| 763 | + /// Turns device support, agent access or the finished setup on or off. | |
| 764 | + pub fn configure(&self, enabled: Option<bool>, agent_access: Option<bool>, onboarding: Option<bool>) -> Result<State> { | |
| 765 | + let (next_enabled, next_agent) = { | |
| 766 | + let _guard = self.lock_lifecycle(); | |
| 767 | + crate::settings::update(|settings| { | |
| 768 | + if let Some(enabled) = enabled { | |
| 769 | + settings.enable_device_support = enabled; | |
| 770 | + } | |
| 771 | + if let Some(agent) = agent_access { | |
| 772 | + settings.enable_agent_device_access = agent; | |
| 773 | + } | |
| 774 | + if let Some(done) = onboarding { | |
| 775 | + settings.device_onboarding_completed = done; | |
| 776 | + } | |
| 777 | + }); | |
| 778 | + let (next_enabled, next_agent) = | |
| 779 | + crate::settings::get(|settings| (settings.enable_device_support, settings.enable_agent_device_access)); | |
| 780 | + if !next_enabled { | |
| 781 | + self.host.stop(); | |
| 782 | + } else if agent_access == Some(false) { | |
| 783 | + self.host.stop_agent(); | |
| 784 | + } | |
| 785 | + let summary = self.summary(); | |
| 786 | + self.publish(|state| { | |
| 787 | + state.enabled = next_enabled; | |
| 788 | + state.agent_access = next_agent; | |
| 789 | + if let Some(done) = onboarding { | |
| 790 | + state.onboarding_completed = done; | |
| 791 | + } | |
| 792 | + state.host_status = if next_enabled { HostStatus::Idle } else { HostStatus::Disabled }; | |
| 793 | + state.host_detail = None; | |
| 794 | + state.hosts = vec![summary]; | |
| 795 | + if !next_enabled { | |
| 796 | + state.devices.clear(); | |
| 797 | + state.sessions.clear(); | |
| 798 | + state.booting.clear(); | |
| 799 | + } | |
| 800 | + }); | |
| 801 | + (next_enabled, next_agent) | |
| 802 | + }; | |
| 803 | + if next_enabled && next_agent && agent_access == Some(true) { | |
| 804 | + self.agent_readiness()?; | |
| 805 | + } | |
| 806 | + Ok(self.list()) | |
| 807 | + } | |
| 808 | + | |
| 809 | + /// Boots through the hub, so its list and its streaming helper both see | |
| 810 | + /// the device come up. An AVD changes id when it boots (name to serial), | |
| 811 | + /// so the id this returns is the one to use. | |
| 812 | + fn boot(&self, ready: &Ready, device: &DeviceSummary) -> Result<String> { | |
| 813 | + let body = json!({"platform": device.platform, "id": device.id, "name": device.name}); | |
| 814 | + let result: HubActionResult = post_json(&format!("{}/api/devices/boot", ready.origin), body, BOOT_TIMEOUT) | |
| 815 | + .map_err(|err| anyhow!("Device boot failed: Could not communicate with device support. ({err})"))?; | |
| 816 | + if !result.ok { | |
| 817 | + let error = result.error.unwrap_or_default(); | |
| 818 | + let lower = error.to_lowercase(); | |
| 819 | + let explanation = if (lower.contains("disk") || lower.contains("space")) && (lower.contains("insufficient") || lower.contains("not enough") || lower.contains("no space")) { | |
| 820 | + "There is not enough free disk space on this machine." | |
| 821 | + } else if lower.contains("timeout") || lower.contains("timed out") { | |
| 822 | + "The device did not become ready in time." | |
| 823 | + } else { | |
| 824 | + "The simulator or emulator could not start. Check its configuration." | |
| 825 | + }; | |
| 826 | + bail!("Device {} failed to boot: {explanation}", device.id); | |
| 827 | + } | |
| 828 | + if device.platform == Platform::Ios { | |
| 829 | + self.attach_ios(ready, &device.id)?; | |
| 830 | + } | |
| 831 | + Ok(result.serial.or(result.id).unwrap_or_else(|| device.id.clone())) | |
| 832 | + } | |
| 833 | + | |
| 834 | + /// Booting alone attaches no serve-sim helper to a simulator; the grid's | |
| 835 | + /// start does both and is harmless on a booted one. | |
| 836 | + fn attach_ios(&self, ready: &Ready, udid: &str) -> Result<()> { | |
| 837 | + post_json::<serde_json::Value>(&format!("{}/vendor/serve-sim/grid/api/start", ready.origin), json!({"udid": udid}), BOOT_TIMEOUT) | |
| 838 | + .map_err(|err| anyhow!("Device attach stream failed: {err}"))?; | |
| 839 | + Ok(()) | |
| 840 | + } | |
| 841 | + | |
| 842 | + /// Opens a device in a thread, booting it unless `boot` is false. | |
| 843 | + pub fn open(&self, thread: &str, device_id: &str, boot: bool) -> Result<DeviceSession> { | |
| 844 | + let ready = self.readiness()?; | |
| 845 | + let mut state = self.refresh(&ready)?; | |
| 846 | + let mut device = state.device(LOCAL_HOST, device_id).cloned().ok_or_else(|| anyhow!("Device {device_id} was not found on host {LOCAL_HOST}."))?; | |
| 847 | + if let Some(reason) = host::platform_reason(device.platform, &host::android_sdk()) { | |
| 848 | + bail!("{} devices are unavailable on host {LOCAL_HOST}: {reason}", device.platform.key()); | |
| 849 | + } | |
| 850 | + if !device.booted && boot { | |
| 851 | + let booting = (device.clone(), thread.to_string()); | |
| 852 | + self.publish(|state| { | |
| 853 | + state.booting.retain(|(entry, _)| entry.id != booting.0.id); | |
| 854 | + state.booting.push(booting.clone()); | |
| 855 | + }); | |
| 856 | + let booted = self.boot(&ready, &device); | |
| 857 | + self.publish(|state| state.booting.retain(|(entry, _)| entry.id != device.id)); | |
| 858 | + let booted = booted?; | |
| 859 | + state = self.refresh(&ready)?; | |
| 860 | + device = state | |
| 861 | + .device(LOCAL_HOST, &booted) | |
| 862 | + .or_else(|| state.device(LOCAL_HOST, &device.id)) | |
| 863 | + .cloned() | |
| 864 | + .ok_or_else(|| anyhow!("Device {booted} was not found on host {LOCAL_HOST}."))?; | |
| 865 | + } else if device.platform == Platform::Ios && device.booted { | |
| 866 | + // A simulator booted elsewhere has no helper attached yet. | |
| 867 | + self.attach_ios(&ready, &device.id)?; | |
| 868 | + } | |
| 869 | + let session = DeviceSession { | |
| 870 | + thread_id: thread.to_string(), | |
| 871 | + host_id: LOCAL_HOST.into(), | |
| 872 | + device_id: device.id.clone(), | |
| 873 | + platform: device.platform, | |
| 874 | + opened_at: now(), | |
| 875 | + }; | |
| 876 | + let _guard = self.lock_lifecycle(); | |
| 877 | + if !self.enabled() { | |
| 878 | + bail!("Device support was turned off while the device was opening."); | |
| 879 | + } | |
| 880 | + self.publish(|state| { | |
| 881 | + state.sessions.retain(|existing| !(existing.thread_id == thread && existing.device_id == session.device_id)); | |
| 882 | + state.sessions.push(session.clone()); | |
| 883 | + }); | |
| 884 | + Ok(session) | |
| 885 | + } | |
| 886 | + | |
| 887 | + /// Closes a thread's device (or all of them), powering it off on request. | |
| 888 | + pub fn close(&self, thread: &str, device_id: Option<&str>, shutdown: bool) -> Result<()> { | |
| 889 | + let closing: Vec<DeviceSession> = self | |
| 890 | + .lock_state() | |
| 891 | + .sessions | |
| 892 | + .iter() | |
| 893 | + .filter(|session| session.thread_id == thread && device_id.is_none_or(|id| session.device_id == id)) | |
| 894 | + .cloned() | |
| 895 | + .collect(); | |
| 896 | + if closing.is_empty() { | |
| 897 | + return Ok(()); | |
| 898 | + } | |
| 899 | + self.publish(|state| state.sessions.retain(|session| !closing.contains(session))); | |
| 900 | + if shutdown { | |
| 901 | + for session in closing { | |
| 902 | + self.shutdown_device(&session.device_id, session.platform)?; | |
| 903 | + } | |
| 904 | + } | |
| 905 | + Ok(()) | |
| 906 | + } | |
| 907 | + | |
| 908 | + fn shutdown_device(&self, device_id: &str, platform: Platform) -> Result<()> { | |
| 909 | + let ready = self.readiness()?; | |
| 910 | + let post = |path: &str, body: serde_json::Value| -> Result<()> { | |
| 911 | + let result: HubActionResult = post_json(&format!("{}{path}", ready.origin), body, HUB_TIMEOUT) | |
| 912 | + .map_err(|err| anyhow!("Device shutdown failed: Could not communicate with device support. ({err})"))?; | |
| 913 | + if !result.ok { | |
| 914 | + bail!("Device shutdown failed: The device hub could not complete the request."); | |
| 915 | + } | |
| 916 | + Ok(()) | |
| 917 | + }; | |
| 918 | + match platform { | |
| 919 | + // serve-sim closes its capture session before `simctl shutdown`; | |
| 920 | + // a simulator that's already off fails there, which is fine. | |
| 921 | + Platform::Ios => { | |
| 922 | + if let Err(err) = post("/vendor/serve-sim/grid/api/shutdown", json!({"udid": device_id})) { | |
| 923 | + 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); | |
| 924 | + if !off { | |
| 925 | + return Err(err); | |
| 926 | + } | |
| 927 | + } | |
| 928 | + } | |
| 929 | + Platform::Android => post("/api/devices/shutdown", json!({"platform": platform, "id": device_id}))?, | |
| 930 | + } | |
| 931 | + self.publish(|state| { | |
| 932 | + for device in state.devices.iter_mut().filter(|device| device.id == device_id) { | |
| 933 | + device.booted = false; | |
| 934 | + } | |
| 935 | + state.sessions.retain(|session| session.device_id != device_id); | |
| 936 | + }); | |
| 937 | + // Discovery can stall while an emulator saves its snapshot; the | |
| 938 | + // shutdown went through either way. | |
| 939 | + let _ = self.refresh(&ready); | |
| 940 | + Ok(()) | |
| 941 | + } | |
| 942 | + | |
| 943 | + fn resolve(&self, device_id: &str) -> Result<(Ready, DeviceSummary)> { | |
| 944 | + let ready = self.readiness()?; | |
| 945 | + let known = self.lock_state().device(LOCAL_HOST, device_id).cloned(); | |
| 946 | + let device = match known { | |
| 947 | + Some(device) => device, | |
| 948 | + None => self.refresh(&ready)?.device(LOCAL_HOST, device_id).cloned().ok_or_else(|| anyhow!("Device {device_id} was not found on host {LOCAL_HOST}."))?, | |
| 949 | + }; | |
| 950 | + Ok((ready, device)) | |
| 951 | + } | |
| 952 | + | |
| 953 | + /// A PNG of the device's screen. | |
| 954 | + pub fn screenshot(&self, device_id: &str) -> Result<(DeviceSummary, Vec<u8>)> { | |
| 955 | + let (ready, device) = self.resolve(device_id)?; | |
| 956 | + let url = format!("{}{}/api/screenshot?device={}", ready.origin, device.platform.vendor(), stream::urlencode(&device.id)); | |
| 957 | + let response = hub_agent(SCREENSHOT_TIMEOUT) | |
| 958 | + .post(&url) | |
| 959 | + .call() | |
| 960 | + .map_err(|err| anyhow!("Device screenshot failed: Could not communicate with device support. ({err})"))?; | |
| 961 | + let mut png = Vec::new(); | |
| 962 | + std::io::Read::read_to_end(&mut response.into_reader(), &mut png)?; | |
| 963 | + Ok((device, png)) | |
| 964 | + } | |
| 965 | + | |
| 966 | + /// The device's current settings and foreground app. | |
| 967 | + pub fn detail(&self, device_id: &str) -> Result<Detail> { | |
| 968 | + let (ready, device) = self.resolve(device_id)?; | |
| 969 | + let (settings, foreground) = actions::read(&ready, device.platform, &device.id); | |
| 970 | + Ok(Detail { settings, foreground }) | |
| 971 | + } | |
| 972 | + | |
| 973 | + /// Runs one action, then reads the device again. | |
| 974 | + pub fn action(&self, device_id: &str, action: &Action) -> Result<Detail> { | |
| 975 | + let (ready, device) = self.resolve(device_id)?; | |
| 976 | + actions::run(&ready, device.platform, &device.id, action)?; | |
| 977 | + let (settings, foreground) = actions::read(&ready, device.platform, &device.id); | |
| 978 | + Ok(Detail { settings, foreground }) | |
| 979 | + } | |
| 980 | + | |
| 981 | + /// The accessibility elements on screen, normalized to 0..1, for the overlay. | |
| 982 | + pub fn accessibility(&self, device_id: &str) -> Result<Vec<AxElement>> { | |
| 983 | + let origin = self.hub_origin().context("The device hub is not running.")?; | |
| 984 | + let platform = self.lock_state().device(LOCAL_HOST, device_id).map(|device| device.platform).unwrap_or(Platform::Android); | |
| 985 | + let id = stream::urlencode(device_id); | |
| 986 | + let url = match platform { | |
| 987 | + Platform::Ios => format!("{origin}/vendor/serve-sim/helper/{id}/ax"), | |
| 988 | + Platform::Android => format!("{origin}/vendor/serve-emu/api/accessibility?device={id}"), | |
| 989 | + }; | |
| 990 | + let payload: serde_json::Value = get_json(&url, HUB_TIMEOUT)?; | |
| 991 | + Ok(match platform { | |
| 992 | + Platform::Ios => flatten_ios_ax(&payload), | |
| 993 | + Platform::Android => flatten_android_ax(&payload), | |
| 994 | + }) | |
| 995 | + } | |
| 996 | + | |
| 997 | + /// The flags that pin agent-device to a thread's device: `--config` and `--session`. | |
| 998 | + pub fn agent_target(&self, thread: &str, device_id: &str) -> Result<Vec<String>> { | |
| 999 | + let Some((_, endpoint)) = self.agent_readiness()? else { | |
| 1000 | + bail!("Agent device access requires enabled device support, agent access, and an available simulator platform on this machine."); | |
| 1001 | + }; | |
| 1002 | + let config = agent::write_config(LOCAL_HOST, &endpoint)?; | |
| 1003 | + Ok(vec!["--config".into(), config.display().to_string(), "--session".into(), agent::session(thread, LOCAL_HOST, device_id)]) | |
| 1004 | + } | |
| 1005 | + | |
| 1006 | + /// The `agent-device` launcher, installing the CLI if needed. | |
| 1007 | + pub fn agent_cli(&self) -> Result<std::path::PathBuf> { | |
| 1008 | + let node = toolchain::node().context("Device automation needs Node.js 22 or newer.")?; | |
| 1009 | + let entry = toolchain::ensure(Tool::Agent)?; | |
| 1010 | + agent::ensure_shim(&entry, &node) | |
| 1011 | + } | |
| 1012 | + | |
| 1013 | + /// The devices open in a thread. | |
| 1014 | + pub fn sessions_for(&self, thread: &str) -> Vec<DeviceSession> { | |
| 1015 | + self.lock_state().sessions.iter().filter(|session| session.thread_id == thread).cloned().collect() | |
| 1016 | + } | |
| 1017 | + | |
| 1018 | + pub fn notify_agent_opened(&self, thread: &str, device: DeviceSummary) { | |
| 1019 | + self.broadcast(Event::AgentOpened { thread: thread.to_string(), device }); | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + pub fn notify_agent_closed(&self, thread: &str, device: Option<String>) { | |
| 1023 | + self.broadcast(Event::AgentClosed { thread: thread.to_string(), device }); | |
| 1024 | + } | |
| 1025 | + | |
| 1026 | + /// Stops the helpers when the app quits; devices keep running. | |
| 1027 | + pub fn stop_all(&self) { | |
| 1028 | + self.host.stop(); | |
| 1029 | + } | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + /// One accessibility element, normalized to the displayed screen. | |
| 1033 | + #[derive(Clone, Debug, PartialEq)] | |
| 1034 | + pub struct AxElement { | |
| 1035 | + pub label: String, | |
| 1036 | + pub x: f32, | |
| 1037 | + pub y: f32, | |
| 1038 | + pub width: f32, | |
| 1039 | + pub height: f32, | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + const AX_LIMIT: usize = 500; | |
| 1043 | + | |
| 1044 | + fn number(value: &serde_json::Value) -> f32 { | |
| 1045 | + value.as_f64().unwrap_or(0.) as f32 | |
| 1046 | + } | |
| 1047 | + | |
| 1048 | + /// serve-sim's nested tree: the root covers the screen; nodes with its frame are skipped. | |
| 1049 | + fn flatten_ios_ax(payload: &serde_json::Value) -> Vec<AxElement> { | |
| 1050 | + let Some(roots) = payload.as_array() else { return Vec::new() }; | |
| 1051 | + let root = roots.first().map(|root| &root["frame"]); | |
| 1052 | + let (screen_w, screen_h) = root.map(|frame| (number(&frame["width"]).max(1.), number(&frame["height"]).max(1.))).unwrap_or((1., 1.)); | |
| 1053 | + let mut elements = Vec::new(); | |
| 1054 | + fn visit(node: &serde_json::Value, screen: (f32, f32), elements: &mut Vec<AxElement>) { | |
| 1055 | + if elements.len() >= AX_LIMIT || !node["frame"].is_object() { | |
| 1056 | + return; | |
| 1057 | + } | |
| 1058 | + let frame = &node["frame"]; | |
| 1059 | + let (width, height) = (number(&frame["width"]), number(&frame["height"])); | |
| 1060 | + let covers = (width - screen.0).abs() < 0.5 && (height - screen.1).abs() < 0.5; | |
| 1061 | + if !covers && width > 0. && height > 0. { | |
| 1062 | + elements.push(AxElement { | |
| 1063 | + label: node["AXLabel"].as_str().unwrap_or_default().to_string(), | |
| 1064 | + x: number(&frame["x"]) / screen.0, | |
| 1065 | + y: number(&frame["y"]) / screen.1, | |
| 1066 | + width: width / screen.0, | |
| 1067 | + height: height / screen.1, | |
| 1068 | + }); | |
| 1069 | + } | |
| 1070 | + for child in node["children"].as_array().into_iter().flatten() { | |
| 1071 | + visit(child, screen, elements); | |
| 1072 | + } | |
| 1073 | + } | |
| 1074 | + for root in roots { | |
| 1075 | + visit(root, (screen_w, screen_h), &mut elements); | |
| 1076 | + } | |
| 1077 | + elements | |
| 1078 | + } | |
| 1079 | + | |
| 1080 | + /// uiautomator's pixel bounds; the first node is the whole window. Only | |
| 1081 | + /// nodes someone could point at are drawn. | |
| 1082 | + fn flatten_android_ax(payload: &serde_json::Value) -> Vec<AxElement> { | |
| 1083 | + let Some(nodes) = payload["nodes"].as_array() else { return Vec::new() }; | |
| 1084 | + let nodes: Vec<&serde_json::Value> = nodes.iter().filter(|node| node["bounds"].is_object()).collect(); | |
| 1085 | + let Some(root) = nodes.first().map(|node| &node["bounds"]) else { return Vec::new() }; | |
| 1086 | + let (screen_w, screen_h) = (number(&root["right"]).max(1.), number(&root["bottom"]).max(1.)); | |
| 1087 | + nodes | |
| 1088 | + .iter() | |
| 1089 | + .skip(1) | |
| 1090 | + .filter_map(|node| { | |
| 1091 | + let bounds = &node["bounds"]; | |
| 1092 | + let (left, top) = (number(&bounds["left"]), number(&bounds["top"])); | |
| 1093 | + let width = (number(&bounds["right"]) - left) / screen_w; | |
| 1094 | + let height = (number(&bounds["bottom"]) - top) / screen_h; | |
| 1095 | + let text = node["text"].as_str().unwrap_or_default(); | |
| 1096 | + let description = node["contentDescription"].as_str().unwrap_or_default(); | |
| 1097 | + let label = if text.is_empty() { description } else { text }; | |
| 1098 | + if width >= 0.95 && height >= 0.9 { | |
| 1099 | + return None; | |
| 1100 | + } | |
| 1101 | + if label.is_empty() && node["clickable"] != true { | |
| 1102 | + return None; | |
| 1103 | + } | |
| 1104 | + Some(AxElement { label: label.to_string(), x: left / screen_w, y: top / screen_h, width, height }) | |
| 1105 | + }) | |
| 1106 | + .take(AX_LIMIT) | |
| 1107 | + .collect() | |
| 1108 | + } | |
| 1109 | + | |
| 1110 | + /// Width and height from a PNG's header; 0×0 when it isn't one. | |
| 1111 | + pub fn png_size(png: &[u8]) -> (u32, u32) { | |
| 1112 | + if png.len() < 24 || &png[..8] != b"\x89PNG\r\n\x1a\n" || &png[12..16] != b"IHDR" { | |
| 1113 | + return (0, 0); | |
| 1114 | + } | |
| 1115 | + let read = |at: usize| u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]); | |
| 1116 | + (read(16), read(20)) | |
| 1117 | + } | |
| 1118 | + | |
| 1119 | + #[cfg(test)] | |
| 1120 | + mod tests { | |
| 1121 | + use super::*; | |
| 1122 | + | |
| 1123 | + #[::core::prelude::v1::test] | |
| 1124 | + fn flattens_android_accessibility() { | |
| 1125 | + let payload = json!({"nodes": [ | |
| 1126 | + {"bounds": {"left": 0, "top": 0, "right": 1080, "bottom": 2400}}, | |
| 1127 | + {"bounds": {"left": 0, "top": 0, "right": 1080, "bottom": 2400}, "text": "", "clickable": false}, | |
| 1128 | + {"bounds": {"left": 108, "top": 240, "right": 540, "bottom": 480}, "text": "Sign in"}, | |
| 1129 | + {"bounds": {"left": 0, "top": 0, "right": 10, "bottom": 10}, "text": ""}, | |
| 1130 | + ]}); | |
| 1131 | + let elements = flatten_android_ax(&payload); | |
| 1132 | + assert_eq!(elements.len(), 1); | |
| 1133 | + assert_eq!(elements[0].label, "Sign in"); | |
| 1134 | + assert!((elements[0].x - 0.1).abs() < 1e-4 && (elements[0].height - 0.1).abs() < 1e-4); | |
| 1135 | + } | |
| 1136 | + | |
| 1137 | + #[::core::prelude::v1::test] | |
| 1138 | + fn reads_png_sizes() { | |
| 1139 | + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); | |
| 1140 | + png.extend_from_slice(&576u32.to_be_bytes()); | |
| 1141 | + png.extend_from_slice(&1280u32.to_be_bytes()); | |
| 1142 | + assert_eq!(png_size(&png), (576, 1280)); | |
| 1143 | + assert_eq!(png_size(b"nope"), (0, 0)); | |
| 1144 | + } | |
| 1145 | + | |
| 1146 | + #[::core::prelude::v1::test] | |
| 1147 | + fn explains_missing_platforms() { | |
| 1148 | + let state = State { | |
| 1149 | + host_status: HostStatus::Ready, | |
| 1150 | + hosts: vec![HostSummary { | |
| 1151 | + id: LOCAL_HOST.into(), | |
| 1152 | + kind: "local".into(), | |
| 1153 | + label: "This machine".into(), | |
| 1154 | + platforms: vec![ | |
| 1155 | + PlatformAvailability { platform: Platform::Ios, available: false, reason: Some("iOS Simulators need macOS with Xcode.".into()) }, | |
| 1156 | + PlatformAvailability { platform: Platform::Android, available: true, reason: None }, | |
| 1157 | + ], | |
| 1158 | + hub: ToolVersion::default(), | |
| 1159 | + agent: ToolVersion::default(), | |
| 1160 | + hub_installed: true, | |
| 1161 | + agent_device_installed: false, | |
| 1162 | + }], | |
| 1163 | + ..State::default() | |
| 1164 | + }; | |
| 1165 | + assert_eq!(state.platform_status(Platform::Ios), (false, "iOS Simulators need macOS with Xcode.".into())); | |
| 1166 | + assert!(!state.platform_status(Platform::Android).0, "no AVD yet"); | |
| 1167 | + } | |
| 1168 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.