Vectrum
Sign In

Add a Device panel like T3 Code's Device hub

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>
JjohannSocommitted 1 parent 06f3e7acommit653d0e2

Showing 30 changed files with 6999 additions and 63 deletions.

+50−0Cargo.lockView File
@@ -4068,6 +4068,15 @@ dependencies = [
40684068 "unicode-ident",
40694069 ]
40704070
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+
40714080 [[package]]
40724081 name = "ndk-sys"
40734082 version = "0.6.0+11769913"
@@ -4741,6 +4750,27 @@ dependencies = [
47414750 "libc",
47424751 ]
47434752
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+
47444774 [[package]]
47454775 name = "openssl-probe"
47464776 version = "0.2.1"
@@ -5895,6 +5925,15 @@ version = "1.0.23"
58955925 source = "registry+https://github.com/rust-lang/crates.io-index"
58965926 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
58975927
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+
58985937 [[package]]
58995938 name = "same-file"
59005939 version = "1.0.6"
@@ -7408,6 +7447,7 @@ dependencies = [
74087447 "gpui-kit",
74097448 "image",
74107449 "libc",
7450+ "openh264",
74117451 "serde",
74127452 "serde_json",
74137453 "smol",
@@ -7906,6 +7946,16 @@ dependencies = [
79067946 "libc",
79077947 ]
79087948
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+
79097959 [[package]]
79107960 name = "winapi"
79117961 version = "0.3.9"
+1−0Cargo.tomlView File
@@ -17,6 +17,7 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha
1717 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
1818 image = { version = "0.25", default-features = false, features = ["jpeg"] }
1919 alacritty_terminal = "0.26"
20+ openh264 = "0.9"
2021
2122 [profile.dev]
2223 debug = "line-tables-only"
+29−2README.mdView File
@@ -105,7 +105,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
105105 switcher, **Commit** (asks the agent to commit, or commit and push), the size of the
106106 uncommitted changes, and the thread's cost.
107107 - 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
109110 terminal there holds the thread's terminals instead of the drawer under the conversation.
110111 - A subagents panel (Ctrl+Alt+S, or S in the right panel), in the project panel's
111112 place: every subagent the thread started, the working ones first with what each is
@@ -138,6 +139,26 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
138139 its own headless Chrome session, which its agent drives with the `agent-browser` CLI
139140 while the panel shows it live. Click, scroll and type in the page yourself, or go to a
140141 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.
141162 - A terminal drawer under the conversation, like T3 Code's (Ctrl+J, or the panel toggle in
142163 the chat header): each thread has its own terminals, which start in the thread's folder
143164 (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.
219240 `secrets.json` only you can read), and its models: favorites, which ones the picker
220241 shows, their order, and custom models. **Add provider** walks through kind, identity and
221242 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.
223246 - **Source Control**: the git and GitHub CLI found on this machine, and how worktree
224247 branches are named (a static prefix, a prefix the model picks, or your instructions).
225248 - **Storage**: where new worktrees go, and the app's folders.
@@ -238,6 +261,10 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
238261 (`curl -fsSL https://jcode.sh/install | bash`, then `jcode login`)
239262 - Optional: agent-browser for the browser panel
240263 (`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`.
241268 - GPUI's Linux system libraries: Vulkan, Wayland/X11, xkbcommon, fontconfig, freetype
242269 (on Arch/CachyOS: `vulkan-icd-loader` plus your GPU's Vulkan driver, `wayland`,
243270 `libxkbcommon-x11`, `libxcb`, `fontconfig`, `freetype2`)
+4−0assets/licenses.tsvView File
@@ -318,6 +318,8 @@ object 0.37.3 Apache-2.0 OR MIT https://github.com/gimli-rs/object
318318 once_cell 1.21.4 MIT OR Apache-2.0 https://github.com/matklad/once_cell
319319 oo7 0.6.0 MIT https://github.com/linux-credentials/oo7
320320 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
321323 option-ext 0.2.0 MPL-2.0 https://github.com/soc/option-ext.git
322324 ordered-float 5.5.0 MIT https://github.com/reem/rust-ordered-float
323325 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
414416 rustversion 1.0.23 MIT OR Apache-2.0 https://github.com/dtolnay/rustversion
415417 rustybuzz 0.20.1 MIT https://github.com/harfbuzz/rustybuzz
416418 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
417420 same-file 1.0.6 Unlicense OR MIT https://github.com/BurntSushi/same-file
418421 schemars 1.2.2 MIT https://github.com/GREsau/schemars
419422 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
566569 wgpu-hal 29.0.4 MIT OR Apache-2.0 https://github.com/gfx-rs/wgpu
567570 wgpu-naga-bridge 29.0.4 MIT OR Apache-2.0 https://github.com/gfx-rs/wgpu
568571 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
569573 winnow 0.7.15 MIT https://github.com/winnow-rs/winnow
570574 winnow 1.0.4 MIT https://github.com/winnow-rs/winnow
571575 writeable 0.6.4 Unicode-3.0 https://github.com/unicode-org/icu4x
+11−5src/browser.rsView File
@@ -47,24 +47,30 @@ pub fn session_name(thread_id: &str) -> String {
4747
4848 /// Where `agent-browser` is: PATH, then the usual npm, bun and Homebrew bin folders.
4949 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> {
5057 let on_path = std::env::var_os("PATH")
5158 .into_iter()
5259 .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));
5561 let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
5662 let mut fallbacks: Vec<PathBuf> = [".npm-global/bin", ".local/bin", ".bun/bin", ".volta/bin", ".local/share/pnpm"]
5763 .iter()
58- .map(|dir| home.join(dir).join(BINARY))
64+ .map(|dir| home.join(dir).join(name))
5965 .collect();
6066 fallbacks.extend(
6167 ["/home/linuxbrew/.linuxbrew/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
6268 .iter()
63- .map(|dir| Path::new(dir).join(BINARY)),
69+ .map(|dir| Path::new(dir).join(name)),
6470 );
6571 // nvm keeps one bin directory per Node version.
6672 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)));
6874 }
6975 on_path.chain(fallbacks).find(|path| is_executable(path))
7076 }
+13−6src/claude.rsView File
@@ -153,9 +153,11 @@ pub struct LaunchConfig {
153153 pub resume: Option<String>,
154154 /// The thread's agent-browser session, shared with the browser panel.
155155 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,
159161 }
160162
161163 /// Everything the background reader threads report back to the UI.
@@ -180,17 +182,22 @@ impl AgentProcess {
180182 // environment; the thread tells it about the browser in a message.
181183 Provider::Jcode => jcode_command(config)?,
182184 };
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);
184187 match config.instance.kind {
185188 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() {
187190 let allowed = format!("mcp__{}", orchestrator::SERVER);
188191 command.args(["--mcp-config", &mcp, "--allowedTools", &allowed]);
189192 }
190193 }
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),
192195 Provider::Jcode => {}
193196 }
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+ }
194201 // Claude and Pi take extra system prompt with this flag.
195202 if config.instance.kind != Provider::Jcode
196203 && let Some(session) = &config.browser_session
+388−0src/device/actions.rsaddedView File
@@ -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+ }
+183−0src/device/agent.rsaddedView File
@@ -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(&current));
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+ }
+631−0src/device/host.rsaddedView File
@@ -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(&current))).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+ }
+1168−0src/device/mod.rsaddedView File
@@ -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+ }
+912−0src/device/stream.rsaddedView File

Large diffs are not rendered by default.

+222−0src/device/toolchain.rsaddedView File

Large diffs are not rendered by default.

+2−0src/main.rsView File

Large diffs are not rendered by default.

+186−36src/orchestrator.rsView File

Large diffs are not rendered by default.

+11−4src/session.rsView File

Large diffs are not rendered by default.

+16−0src/settings.rsView File

Large diffs are not rendered by default.

+19−0src/ui/chat/timeline.rsView File

Large diffs are not rendered by default.

+789−0src/ui/device/mod.rsaddedView File

Large diffs are not rendered by default.

+330−0src/ui/device/setup.rsaddedView File

Large diffs are not rendered by default.

+1255−0src/ui/device/view.rsaddedView File

Large diffs are not rendered by default.

+7−0src/ui/icons.rsView File

Large diffs are not rendered by default.

+1−0src/ui/mod.rsView File

Large diffs are not rendered by default.

+192−2src/ui/settings/integrations.rsView File

Large diffs are not rendered by default.

+21−0src/ui/settings/licenses.rsView File

Large diffs are not rendered by default.

+5−0src/ui/settings/mod.rsView File

Large diffs are not rendered by default.

+4−0src/ui/settings/search.rsView File

Large diffs are not rendered by default.

+34−3src/ui/workspace.rsView File

Large diffs are not rendered by default.

+5−1src/ui/workspace/delegation.rsView File

Large diffs are not rendered by default.

+500−0src/ui/workspace/devices.rsaddedView File

Large diffs are not rendered by default.

+10−4src/ui/workspace/surfaces.rsView File

Large diffs are not rendered by default.