From 493d23a7630f487d29912dd73f2cbb5b639b74ca Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:41:39 -0700 Subject: [PATCH 01/11] Put the F10/F12/Shift+F12 dev overlays behind an opt-in (#1209) * remote: honor MAKEPAD_REMOTE in requested(), and hush the close notices when the bridge is off `requested()` only scanned argv, while `requested_bind()` also reads MAKEPAD_REMOTE. So `MAKEPAD_REMOTE=1` started the bridge but everything keyed off `requested()` still said no. Just delegate, so there's one answer to "did this process ask for the remote bridge". Also stop printing `[makepad-remote] user closed window ...` to stdout from every app on every window close -- that line is for the agent driving the app, so only print it when the bridge is actually up. The log ring still gets it either way, for /log. * devtools: put the F10/F12/Shift+F12 overlays behind an opt-in Three dev tools are currently wired into every app with no way to turn them off, each on a bare function key: F10 the exploded draw-list view. Intercepted in Cx::call_event_handler *before* the app's handler, and once it's up it also eats Escape, the arrows, +/-/0, I and H -- no modifier needed -- plus every drag outside the flat band. F12 the design tweaker, a child of every Window. Once it's up it swallows every pointer event over the body, so the app looks frozen to the mouse. Shift+F12 the screen recorder, which starts writing mp4s to disk. None of that is something a shipped app wants a user to find by accident, and there was no flag, env var or property to stop it. So: one gate, platform/src/devtools.rs. `--devtools`, or MAKEPAD_DEVTOOLS=1, and --remote implies it since the /snap + /click loop drives the tweaker. An explicit MAKEPAD_DEVTOOLS=0 wins over all of it, which also keeps the off path testable under --remote. Only the hotkeys are gated, not the tools. Cx::sploded_toggle, set_tweak_on and ScreenCap::toggle are untouched and still public, so an app that wants any of this puts it on a key of its own choosing -- that's the app deciding, rather than a key nobody knew was bound. Gating F10 and F12 is enough to reach all of it: everything else these two claim sits behind `sploded.active` / `tweak_is_on()`, and with the hotkeys gated the only remaining ways in are the /tweak routes (already --remote, which implies devtools) and an app's own call. * text_input: drop the Ctrl+Enter submit clause again `|| mods.control` made Ctrl+Enter submit a multiline input. On Linux/Windows that's already what is_primary() means, so it changed nothing; on macOS it turned Ctrl+Enter from "insert a newline" into "send", which is a surprise in the middle of a chat app's composer. The comment right above it already described the old behavior, so this puts the code back in line with it. --- platform/src/devtools.rs | 92 +++++++++++++++++++++++++++++++++++++++ platform/src/lib.rs | 1 + platform/src/remote.rs | 30 +++++++++---- platform/src/sploded.rs | 10 ++++- widgets/src/screen_cap.rs | 12 ++++- widgets/src/text_input.rs | 4 -- widgets/src/tweaker.rs | 16 +++++-- 7 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 platform/src/devtools.rs diff --git a/platform/src/devtools.rs b/platform/src/devtools.rs new file mode 100644 index 000000000..7bc62a137 --- /dev/null +++ b/platform/src/devtools.rs @@ -0,0 +1,92 @@ +//! The opt-in switch for makepad's in-app developer overlays. +//! +//! Three of them exist, and each one binds a bare function key and then claims +//! input the app never sees: +//! +//! * **F10** — the exploded draw-list view ([`crate::sploded`]). Intercepted in +//! `Cx::call_event_handler` *before* the app's handler, and once it is up it +//! also claims Escape, the arrow keys, `+`/`-`/`0`, `I` and `H` — no modifier +//! required — plus every pointer drag outside the declared flat band. +//! * **F12** — the design tweaker (`makepad_widgets::tweaker`), a child of every +//! `Window`. Once it is up it swallows every pointer event over the body. +//! * **Shift+F12** — the screen recorder (`makepad_widgets::screen_cap`), which +//! writes mp4 files next to the running process. +//! +//! These are development tools, so they stay off unless a developer asks for +//! them. A shipped app is not a place to discover that a stray F10 tilts the +//! whole UI into 3D and stops Escape from closing anything. +//! +//! Turn them on with `--devtools` on the command line, or `MAKEPAD_DEVTOOLS=1` +//! in the environment. `--remote` implies them: the remote control surface's +//! `/snap` + `/click` loop drives the tweaker, so a remote-driven app has +//! already opted in to being instrumented. An explicit `MAKEPAD_DEVTOOLS=0` +//! wins over all of it, which is also how the off path stays testable under +//! `--remote`. +//! +//! Only the *hotkeys* are gated, not the tools. An app that wants one of these +//! on its own terms still calls `Cx::sploded_toggle`, `tweaker::set_tweak_on` +//! or `ScreenCap::toggle` directly — that is the app deciding, rather than a +//! key nobody knew was bound. + +use std::sync::OnceLock; + +/// Whether this process opted into the developer overlays. +/// +/// Scans argv and the environment once and caches the answer, so the hot event +/// path pays an atomic load. See the module docs for what this gates. +pub fn enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + decide( + std::env::args().any(|a| a == "--devtools"), + std::env::var("MAKEPAD_DEVTOOLS").ok().as_deref(), + crate::remote::requested(), + ) + }) +} + +/// The whole decision, with the process pulled out so it can be tested. +/// +/// `MAKEPAD_DEVTOOLS` takes the usual off-ish spellings, so it can sit in a +/// shell profile as `0` instead of having to be unset — and because it is the +/// one explicit signal, an off spelling also overrides `--devtools` and +/// `--remote`. +fn decide(flag: bool, env: Option<&str>, remote: bool) -> bool { + if let Some(env) = env { + let env = env.trim().to_ascii_lowercase(); + return !matches!(env.as_str(), "" | "0" | "off" | "false" | "no"); + } + flag || remote +} + +#[cfg(test)] +mod tests { + use super::decide; + + #[test] + fn off_by_default() { + assert!(!decide(false, None, false)); + } + + #[test] + fn the_flag_or_remote_turns_it_on() { + assert!(decide(true, None, false)); + assert!(decide(false, None, true)); + } + + #[test] + fn the_env_var_turns_it_on() { + for on in ["1", "yes", "true", "on", " 1 "] { + assert!(decide(false, Some(on), false), "{on:?} should enable"); + } + } + + #[test] + fn an_off_spelling_wins_over_the_flag_and_remote() { + // So `MAKEPAD_DEVTOOLS=0` can live in a profile, and so the gated-off + // path is still reachable under --remote. + for off in ["0", "off", "false", "no", "", " ", "OFF"] { + assert!(!decide(true, Some(off), true), "{off:?} should disable"); + } + } +} diff --git a/platform/src/lib.rs b/platform/src/lib.rs index 8c8f2753a..43762968d 100644 --- a/platform/src/lib.rs +++ b/platform/src/lib.rs @@ -78,6 +78,7 @@ pub mod display_context; #[macro_use] mod app_main; pub mod remote; +pub mod devtools; pub mod pixel_probe; pub mod screen_capture; pub mod audio_output_tap; diff --git a/platform/src/remote.rs b/platform/src/remote.rs index 08a2c2c26..ec4d744b5 100644 --- a/platform/src/remote.rs +++ b/platform/src/remote.rs @@ -46,11 +46,14 @@ mod imp { static ACTIVE: AtomicBool = AtomicBool::new(false); - /// True when this process was started with `--remote` (any form). Pure - /// argv scan, usable before the bridge itself is up — the platform's - /// focus policy reads it while the first window is being created. + /// True when this process asked for the remote bridge, in any of the forms + /// [`requested_bind`] accepts — including `MAKEPAD_REMOTE`, which a plain + /// argv scan used to miss, so `MAKEPAD_REMOTE=1` started the bridge while + /// everything keyed off this said no. Pure argv + env, usable before the + /// bridge itself is up: the platform's focus policy reads it while the + /// first window is being created. pub fn requested() -> bool { - std::env::args().any(|a| a == "--remote" || a.starts_with("--remote=")) + requested_bind().is_some() } static NEXT_ID: AtomicU64 = AtomicU64::new(1); static LIVE_CONNS: AtomicUsize = AtomicUsize::new(0); @@ -458,9 +461,14 @@ mod imp { /// "the user dismissed this" apart from "the app crashed", and remember it /// so requests aimed at that window get the real reason. pub fn note_user_closed_window(window_id: usize, title: &str) { + // Only chatter when the bridge is actually up: this line is for the + // agent driving the app, and a shipped app should not print + // `[makepad-remote] ...` to stdout every time a window closes. let line = format!("[makepad-remote] user closed window {window_id} ({title:?})"); - println!("{line}"); - let _ = std::io::stdout().flush(); + if is_active() { + println!("{line}"); + let _ = std::io::stdout().flush(); + } push_log_line(line); if let Ok(mut closed) = closed_windows().lock() { if !closed.iter().any(|(id, _)| *id == window_id) { @@ -473,8 +481,10 @@ mod imp { /// away. Not a crash. pub fn note_user_closed_last_window() { let line = "[makepad-remote] app exit: user closed the last window".to_string(); - println!("{line}"); - let _ = std::io::stdout().flush(); + if is_active() { + println!("{line}"); + let _ = std::io::stdout().flush(); + } push_log_line(line); } @@ -2128,6 +2138,10 @@ mod imp { use crate::cx::Cx; pub fn start_if_requested() {} + /// There is no remote bridge on these targets, so nothing ever asked for one. + pub fn requested() -> bool { + false + } pub fn is_active() -> bool { false } diff --git a/platform/src/sploded.rs b/platform/src/sploded.rs index 1a389f939..2d12f9913 100644 --- a/platform/src/sploded.rs +++ b/platform/src/sploded.rs @@ -11,7 +11,8 @@ //! scrollbar thumb by dragging is therefore deliberately NOT possible while //! exploded — a drag is the orbit — and that is the coexistence rule. //! -//! F10 tilts the window into an isometric stack that renders **the component +//! F10 — once the dev overlays are switched on ([`crate::devtools`]) — tilts +//! the window into an isometric stack that renders **the component //! nesting structure**: one plane per nesting level, siblings sharing a plane, //! children lifting toward the viewer and their parents staying at the bottom //! of the stack. The point is to see — and click — the fully-covered parent @@ -727,7 +728,12 @@ impl Cx { } match event { Event::KeyDown(e) => { - if e.key_code == KeyCode::F10 { + // F10 is only ours when the app opted into the dev overlays + // (`--devtools` / `MAKEPAD_DEVTOOLS=1` / `--remote`). Otherwise + // it is the app's key like any other. `sploded_toggle` still + // works either way, so an app can put the mode on a key of its + // own choosing. + if e.key_code == KeyCode::F10 && crate::devtools::enabled() { if e.is_repeat { return true; } diff --git a/widgets/src/screen_cap.rs b/widgets/src/screen_cap.rs index 6eeba72b7..ed1c20cba 100644 --- a/widgets/src/screen_cap.rs +++ b/widgets/src/screen_cap.rs @@ -1,5 +1,9 @@ //! ScreenCap — SHIFT+F12 records the window to an mp4, picture and sound. //! +//! The hotkey needs the dev overlays switched on +//! (`makepad_platform::devtools`: `--devtools`, `MAKEPAD_DEVTOOLS=1`, or +//! `--remote`); an app that wants its own recording key calls [`ScreenCap::toggle`]. +//! //! One widget, hardcoded into [`crate::window::Window`] the way the tweaker //! and the nav control are, so every Makepad app can record itself without //! wiring anything up. Shift+F12 starts, Shift+F12 stops. While it records, @@ -43,6 +47,7 @@ use crate::makepad_draw::audio::AudioBuffer; use crate::{makepad_derive_widget::*, makepad_draw::*, widget::*}; use makepad_platform::audio_output_tap::{add_audio_output_tap, remove_audio_output_tap}; +use makepad_platform::devtools; use makepad_platform::screen_capture::{ add_screen_capture, remove_screen_capture, ScreenCaptureOptions, }; @@ -321,7 +326,12 @@ impl ScreenCap { impl Widget for ScreenCap { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { if let Event::KeyDown(ke) = event { - if ke.key_code == self.hotkey + // The hotkey only exists once the dev overlays are switched on + // (`--devtools` / `MAKEPAD_DEVTOOLS=1` / `--remote`). A shipped app + // should not have a key that starts writing mp4s to disk; one that + // wants a recorder can call `toggle` from its own binding. + if devtools::enabled() + && ke.key_code == self.hotkey && ke.modifiers.shift == self.hotkey_shift && !ke.is_repeat { diff --git a/widgets/src/text_input.rs b/widgets/src/text_input.rs index e13d184ba..1d57de616 100644 --- a/widgets/src/text_input.rs +++ b/widgets/src/text_input.rs @@ -2653,12 +2653,8 @@ impl Widget for TextInput { // In multiline mode, other modifier combos (Alt+Enter, or Ctrl+Enter // on macOS) insert a newline below when not read-only. let has_physical_keyboard = cx.keyboard.has_physical_keyboard(); - // Ctrl+Enter submits on every platform — on macOS `primary` - // is Cmd, and a person who reaches for Ctrl+Enter to send - // must not get a newline instead. let should_submit = !self.is_multiline || mods.is_primary() - || mods.control || (has_physical_keyboard && self.submit_on_enter && !mods.any()); if should_submit { cx.hide_text_ime(); diff --git a/widgets/src/tweaker.rs b/widgets/src/tweaker.rs index 822436ce9..6a4e8379f 100644 --- a/widgets/src/tweaker.rs +++ b/widgets/src/tweaker.rs @@ -1,7 +1,10 @@ //! The TWEAKER — the design-feedback overlay every `--remote` app grows. //! //! Hardcoded into `Window` (like the caption bar: zero app wiring), inert -//! unless the remote bridge is live, zero cost while off. Turned on (F12 or +//! unless the remote bridge is live, zero cost while off. The F12 key needs +//! the dev overlays switched on (`makepad_platform::devtools`: `--devtools`, +//! `MAKEPAD_DEVTOOLS=1`, or `--remote`); [`set_tweak_on`] is always there for +//! an app that wants to open the panel itself. Turned on (F12 or //! `GET /tweak?on=1`), a person points at the UI and live-edits it while the //! AI watches the same session through the bridge: //! @@ -29,6 +32,7 @@ use crate::{ check_box::{CheckBox, CheckBoxAction}, fab_controls::{format_hex, parse_hex, rgb_to_hsv, FabColorPick, FabColorPickAction, FabValueInput, FabValueInputAction}, + makepad_draw::makepad_platform::devtools, makepad_draw::makepad_platform::sploded::{SPLODED_SPREAD_DEFAULT, SPLODED_SPREAD_MAX, SPLODED_SPREAD_MIN}, file_tree::{FileTree, FileTreeAction}, label::Label, @@ -616,13 +620,19 @@ pub fn window_intercept( // F12 toggles the mode, bridge or no bridge: the design surface is // in-process and owes the remote nothing. Only the HTTP endpoints and // the AI vibecode loop need --remote; without it they simply are not - // there, and the panel still is. + // there, and the panel still is. It does need the dev overlays to be + // switched on though (`--devtools` / `MAKEPAD_DEVTOOLS=1` / `--remote`) — + // in a shipped app F12 belongs to the app, and `set_tweak_on` is still + // there for one that wants to open the panel itself. // // SHIFT+F12 is not ours: that is the screen recorder // (widgets/src/screen_cap.rs), and it must not drag the design surface // into every recording. if let Event::KeyDown(key_event) = event { - if key_event.key_code == KeyCode::F12 && !key_event.modifiers.shift { + if key_event.key_code == KeyCode::F12 + && !key_event.modifiers.shift + && devtools::enabled() + { let flip = { let mut s = session().lock().unwrap(); if s.toggle_event_id != cx.event_id() { From 4c94531456c2187e6b9d6fc19899052ba69008fe Mon Sep 17 00:00:00 2001 From: andodeki Date: Sun, 16 Aug 2026 14:36:10 +0300 Subject: [PATCH 02/11] feat(test): Android makepad_test via adb + in-process hub (legacy Java path) Adds the Android test runtime to makepad_test: builds the APK with cargo-makepad's standard Java path, installs and launches via adb with makepad.STUDIO_* intent extras (incl. STUDIO_BUILD), connects the app to an in-process hub over adb reverse, and waits for startup + responsiveness. Adds clean in-process hub shutdown (HttpServerHandle + GatewayHandle Drop) and the STUDIO_BUILD intent parsing on the app side. No native-activity or NDK APK compilation code is included. --- examples/counter/src/main.rs | 23 +- libs/makepad_test/src/runtime.rs | 367 ++++++++++++++++++- platform/network/src/http_server.rs | 45 ++- platform/network/src/lib.rs | 2 +- platform/network/src/runtime.rs | 4 +- platform/src/os/linux/android/android_jni.rs | 12 + studio/hub/Cargo.toml | 4 + studio/hub/src/bin/hub_server.rs | 38 ++ studio/hub/src/gateway.rs | 30 +- tools/web_server/src/main.rs | 2 +- 10 files changed, 492 insertions(+), 35 deletions(-) create mode 100644 studio/hub/src/bin/hub_server.rs diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index 1e63fa5d5..449f5b598 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -6,15 +6,8 @@ app_main!(App); script_mod! { use mod.prelude.widgets.* - let state = { - counter: 0 - } - mod.state = state startup() do #(App::script_component(vm)){ ui: Root{ - on_startup:||{ // right now render isnt called automatically yet - ui.main_view.render() - } main_window := Window{ window.inner_size: vec2(420, 220) body +: { @@ -24,11 +17,9 @@ script_mod! { flow: Down spacing: 12 align: Center - on_render: ||{ - counter_label := Label{ - text: "Count: " + state.counter - draw_text.text_style.font_size: 24 - } + counter_label := Label{ + text: "Count: 0" + draw_text.text_style.font_size: 24 } } increment_button := Button{ @@ -44,15 +35,15 @@ script_mod! { pub struct App { #[live] ui: WidgetRef, + #[rust] + counter: i32, } impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { if self.ui.button(cx, ids!(increment_button)).clicked(actions) { - script_eval!(cx,{ - mod.state.counter += 1 - ui.main_view.render() - }); + self.counter += 1; + self.ui.label(cx, ids!(counter_label)).set_text(cx, &format!("Count: {}", self.counter)); } } } diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index 13c28ed75..ecad4edbf 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -85,6 +85,11 @@ pub struct TestConfig { pub startup_pause: Duration, pub action_delay: Duration, pub keep_open: Duration, + pub android: bool, + pub device_serial: Option, + pub adb_path: Option, + pub android_port: u16, + pub android_native_activity: bool, } impl TestConfig { @@ -126,6 +131,11 @@ impl TestConfig { startup_pause: env_duration_ms("MAKEPAD_TEST_STARTUP_DELAY_MS"), action_delay: env_duration_ms("MAKEPAD_TEST_ACTION_DELAY_MS"), keep_open: env_duration_ms("MAKEPAD_TEST_KEEP_OPEN_MS"), + android: android_test_enabled(), + device_serial: android_device_serial(), + adb_path: android_adb_path(), + android_port: android_hub_port(), + android_native_activity: env_truthy("MAKEPAD_TEST_NATIVE_ACTIVITY"), }) } @@ -157,6 +167,13 @@ impl TestConnection { Self::Remote(connection) => connection.recv_timeout(timeout), } } + + fn studio_addr(&self) -> Option { + match self { + Self::InProcess(connection) => connection.studio_addr(), + Self::Remote(_) => None, + } + } } struct TestAppInner { @@ -208,7 +225,9 @@ impl TestApp { } fn start_once(config: TestConfig) -> TestResult { - let (connection, build_id) = if visible_mode_enabled() { + let (connection, build_id) = if config.android { + start_android_app(&config)? + } else if visible_mode_enabled() { start_visible_app(&config)? } else { start_headless_app(&config)? @@ -623,6 +642,10 @@ impl TestApp { if inner.build_stopped.is_some() { return; } + if inner.config.android { + let full_package = android_full_package_name(&inner.config.package_name); + let _ = adb_force_stop(&inner.config, &full_package); + } let build_id = inner.build_id; let _ = inner.connection.send(ClientToHub::ClearBuild { build_id }); } @@ -1537,6 +1560,348 @@ fn env_duration_ms(name: &str) -> Duration { .unwrap_or(Duration::ZERO) } +// --------------------------------------------------------------------------- +// Android test helpers +// --------------------------------------------------------------------------- + +const DEFAULT_ANDROID_PORT: u16 = 8001; +const ANDROID_STARTUP_TIMEOUT: Duration = Duration::from_secs(120); +const ANDROID_LAUNCH_TIMEOUT: Duration = Duration::from_secs(60); + +fn android_test_enabled() -> bool { + env_truthy("MAKEPAD_TEST_ANDROID") +} + +fn android_device_serial() -> Option { + std::env::var("MAKEPAD_TEST_DEVICE") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn android_hub_port() -> u16 { + std::env::var("MAKEPAD_TEST_ANDROID_PORT") + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(DEFAULT_ANDROID_PORT) +} + +fn android_adb_path() -> Option { + std::env::var("MAKEPAD_TEST_ADB") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn adb_command(config: &TestConfig) -> std::process::Command { + let adb = config + .adb_path + .as_deref() + .unwrap_or("adb"); + let mut cmd = std::process::Command::new(adb); + if let Some(ref serial) = config.device_serial { + cmd.arg("-s").arg(serial); + } + cmd +} + +fn adb_exec(config: &TestConfig, args: &[&str]) -> TestResult { + let output = adb_command(config) + .args(args) + .output() + .map_err(|err| TestError::new(format!("failed to run adb: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TestError::new(format!( + "adb {} failed: {}", + args.join(" "), + stderr.trim() + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn android_full_package_name(package_name: &str) -> String { + let underscore = package_name.replace('-', "_"); + format!("dev.makepad.{underscore}") +} + +fn adb_forward(config: &TestConfig, port: u16) -> TestResult<()> { + let _ = adb_exec(config, &["reverse", "--remove", &format!("tcp:{port}")]); + adb_exec( + config, + &["reverse", &format!("tcp:{port}"), &format!("tcp:{port}")], + )?; + Ok(()) +} + +fn adb_install(config: &TestConfig, apk_path: &std::path::Path) -> TestResult<()> { + let output = adb_command(config) + .args(["install", "-r"]) + .arg(apk_path) + .output() + .map_err(|err| TestError::new(format!("failed to run adb install: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TestError::new(format!("adb install failed: {}", stderr.trim()))); + } + Ok(()) +} + +fn adb_launch( + config: &TestConfig, + package: &str, + build_id: u64, + crate_name: &str, + port: u16, +) -> TestResult<()> { + // Native-activity builds launch `android.app.NativeActivity` directly (no + // Java Activity). Legacy Java builds launch the generated `MakepadApp` + // subclass that bridges into `MakepadNative.activityOnCreate`. + let activity = if config.android_native_activity { + format!("{package}/android.app.NativeActivity") + } else { + format!("{package}/.MakepadApp") + }; + let studio_host = format!("127.0.0.1:{port}"); + let output = adb_command(config) + .args([ + "shell", "am", "start", + "-n", &activity, + "-e", "makepad.STUDIO_HOST", &studio_host, + "-e", "makepad.STUDIO_BUILD", &build_id.to_string(), + "-e", "makepad.STUDIO_CRATE", crate_name, + ]) + .output() + .map_err(|err| TestError::new(format!("failed to run adb shell am start: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TestError::new(format!("adb launch failed: {}", stderr.trim()))); + } + Ok(()) +} + +fn adb_force_stop(config: &TestConfig, package: &str) -> TestResult<()> { + let _ = adb_exec(config, &["shell", "am", "force-stop", package]); + Ok(()) +} + +fn resolve_workspace_root(manifest_dir: &std::path::Path) -> std::path::PathBuf { + if let Ok(env_root) = std::env::var("MAKEPAD_WORKSPACE_ROOT") { + let root = std::path::PathBuf::from(env_root); + if root.join("Cargo.toml").exists() { + return root; + } + } + let mut dir = manifest_dir.to_path_buf(); + loop { + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() { + if let Ok(content) = std::fs::read_to_string(&cargo_toml) { + if content.contains("tools/cargo_makepad") { + // skip Cargo.toml files that reference the tool as a path + } + let has_workspace = content.contains("[workspace]") + || content.contains("workspace.members") + || content.contains("workspace.package"); + if has_workspace && dir.join("tools/cargo_makepad").exists() { + return dir; + } + } + } + if !dir.pop() { + break; + } + } + manifest_dir.to_path_buf() +} + +fn build_android_apk(config: &TestConfig) -> TestResult { + let workspace_root = resolve_workspace_root(&config.manifest_dir); + let cargo_makepad = workspace_root + .join("target") + .join("release") + .join("cargo-makepad"); + if !cargo_makepad.exists() { + return Err(TestError::new(format!( + "cargo-makepad not found at {}. Run: cargo build --release -p cargo-makepad", + cargo_makepad.display() + ))); + } + let mut args = vec!["android"]; + if config.android_native_activity { + args.push("--native-activity"); + } + args.extend_from_slice(&["build", "-p", &config.package_name]); + let output = std::process::Command::new(&cargo_makepad) + .args(&args) + .current_dir(&workspace_root) + .output() + .map_err(|err| TestError::new(format!("failed to run cargo makepad: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(TestError::new(format!( + "android build failed:\nstdout: {}\nstderr: {}", + stdout.trim(), + stderr.trim() + ))); + } + let apk_dir_name = config.package_name.replace('-', "_"); + let apk_dir = workspace_root + .join("target") + .join("android") + .join("makepad-android-apk") + .join(&apk_dir_name) + .join("apk"); + let apk_name = format!("{}.apk", &apk_dir_name); + let apk_path = apk_dir.join(&apk_name); + if !apk_path.exists() { + return Err(TestError::new(format!( + "APK not found at {}", + apk_path.display() + ))); + } + Ok(apk_path) +} + +fn wait_for_android_app_started( + connection: &TestConnection, + build_id: QueryId, + timeout: Duration, +) -> TestResult<()> { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + return Err(TestError::new( + "timed out waiting for Android app to connect to hub", + )); + } + let slice = cmp::min( + POLL_INTERVAL, + deadline.saturating_duration_since(Instant::now()), + ); + let Some(msg) = connection.recv_timeout(slice) else { + continue; + }; + match msg { + HubToClient::AppStarted { + build_id: msg_build_id, + } if msg_build_id == build_id => { + return Ok(()); + } + HubToClient::Error { message } => return Err(TestError::new(message)), + _ => {} + } + } +} + +fn wait_for_android_app_responsive( + connection: &mut TestConnection, + build_id: QueryId, + timeout: Duration, +) -> TestResult<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let query_id = connection.send(ClientToHub::WidgetTreeDump { build_id })?; + let attempt_deadline = Instant::now() + ACTION_TIMEOUT; + let mut replied = false; + while Instant::now() < attempt_deadline { + if Instant::now() >= deadline { + break; + } + let slice = cmp::min( + POLL_INTERVAL, + attempt_deadline.saturating_duration_since(Instant::now()), + ); + let Some(msg) = connection.recv_timeout(slice) else { + continue; + }; + if let HubToClient::WidgetTreeDump { + query_id: id, dump: _, .. + } = &msg + { + if *id == query_id { + replied = true; + break; + } + } + } + if replied { + return Ok(()); + } + thread::sleep(POLL_INTERVAL); + } + Err(TestError::new( + "timed out waiting for Android app to become responsive", + )) +} + +fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId)> { + let hub_port = config.android_port; + let listen_address = SocketAddr::from((Ipv4Addr::LOCALHOST, hub_port)); + let mut connection = TestConnection::InProcess( + StudioHub::start_in_process(HubConfig { + listen_address, + mounts: vec![MountConfig { + name: config.mount_name.clone(), + path: config.manifest_dir.clone(), + }], + enable_in_process_gateway: true, + ..Default::default() + }) + .map_err(TestError::new)?, + ); + + let build_id = QueryId(1); + let full_package = android_full_package_name(&config.package_name); + // The hub may have bound a fallback port (e.g. when a real Studio already + // owns `android_port`). Route adb and the app at the port actually bound, + // never a hardcoded one, or the app would dial a dead listener. + let hub_port = connection + .studio_addr() + .and_then(|addr| addr.rsplit_once(':').map(|(_, p)| p.to_string())) + .and_then(|p| p.parse::().ok()) + .unwrap_or(config.android_port); + + eprintln!("[makepad-test] Android: forwarding ADB port {hub_port}"); + adb_forward(config, hub_port)?; + + eprintln!("[makepad-test] Android: building APK for {}", config.package_name); + let apk_path = build_android_apk(config)?; + + eprintln!("[makepad-test] Android: installing APK"); + adb_install(config, &apk_path)?; + + eprintln!("[makepad-test] Android: force-stopping previous instance"); + let _ = adb_force_stop(config, &full_package); + thread::sleep(Duration::from_secs(1)); + + eprintln!("[makepad-test] Android: launching app"); + adb_launch(config, &full_package, build_id.0, &config.package_name, hub_port)?; + + thread::sleep(Duration::from_secs(2)); + + eprintln!("[makepad-test] Android: waiting for app to connect to hub"); + wait_for_android_app_started(&connection, build_id, ANDROID_STARTUP_TIMEOUT)?; + + eprintln!("[makepad-test] Android: app connected"); + + // The websocket connects on a background thread before the app's event + // loop is up, so a cold start can answer the handshake well before it can + // service hub requests. Legacy Java starts are the slowest: the first + // frame (and with it the main loop that drains requests) only comes after + // the SurfaceView surface materializes, which on a first launch after + // install can exceed the per-request action timeout. Settle until the app + // actually answers a request so the test's first query does not lose that + // boot race. + eprintln!("[makepad-test] Android: waiting for app to become responsive"); + wait_for_android_app_responsive(&mut connection, build_id, ANDROID_STARTUP_TIMEOUT)?; + + eprintln!("[makepad-test] Android: app responsive"); + Ok((connection, build_id)) +} + fn now_seconds() -> f64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/platform/network/src/http_server.rs b/platform/network/src/http_server.rs index ea8171321..fe7d561a7 100644 --- a/platform/network/src/http_server.rs +++ b/platform/network/src/http_server.rs @@ -92,24 +92,50 @@ pub enum HttpServerRequest { }, } -pub fn start_http_server(http_server: HttpServer) -> Option> { +/// Handle to a running HTTP server. Sending on `shutdown` (or dropping the +/// sender) makes the accept loop exit so the listen port is released and the +/// thread can be joined. +pub struct HttpServerHandle { + pub thread: std::thread::JoinHandle<()>, + pub shutdown: mpsc::Sender<()>, +} + +pub fn start_http_server(http_server: HttpServer) -> Option { let listener = if let Ok(listener) = TcpListener::bind(http_server.listen_address) { listener } else { println!("Cannot bind http server port"); return None; }; + if listener.set_nonblocking(true).is_err() { + println!("Cannot set http server non-blocking"); + return None; + } + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); let listen_thread = { std::thread::spawn(move || { let mut connection_counter = 0u64; - for tcp_stream in listener.incoming() { - let mut tcp_stream = if let Ok(tcp_stream) = tcp_stream { - tcp_stream - } else { - println!("Incoming stream failure"); - continue; + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + let mut tcp_stream = match listener.accept() { + Ok((tcp_stream, _)) => tcp_stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No pending connection; poll the shutdown channel + // periodically so a drop releases the port promptly. + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + } + Err(_) => { + println!("Incoming stream failure"); + continue; + } }; + if tcp_stream.set_nonblocking(false).is_err() { + continue; + } let http_server = http_server.clone(); connection_counter += 1; // Shed over the cap INLINE (no thread): the pile of leaked @@ -156,7 +182,10 @@ pub fn start_http_server(http_server: HttpServer) -> Option Option> { + ) -> Option { crate::http_server::start_http_server(http_server) } diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs index 3ae6eeba7..56e51c608 100644 --- a/platform/src/os/linux/android/android_jni.rs +++ b/platform/src/os/linux/android/android_jni.rs @@ -331,6 +331,7 @@ unsafe fn get_intent_string_extra( const MAKEPAD_PREFS_NAME: &str = "makepad"; const MAKEPAD_STUDIO_HOST_PREF_KEY: &str = "studio_host"; const MAKEPAD_STUDIO_CRATE_PREF_KEY: &str = "studio_crate"; +const MAKEPAD_STUDIO_BUILD_PREF_KEY: &str = "studio_build"; const ANDROID_MODE_PRIVATE: i32 = 0; unsafe fn new_jstring(env: *mut jni_sys::JNIEnv, value: &str) -> Option { @@ -453,6 +454,8 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) .filter(|v| !v.trim().is_empty()); let intent_studio_crate = get_intent_string_extra(env, activity, "makepad.STUDIO_CRATE") .filter(|v| !v.trim().is_empty()); + let intent_studio_build = get_intent_string_extra(env, activity, "makepad.STUDIO_BUILD") + .filter(|v| !v.trim().is_empty()); if let Some(studio_host) = intent_studio_host { let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY, &studio_host); @@ -471,6 +474,15 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) { std::env::set_var("STUDIO_CRATE", &studio_crate); } + + if let Some(studio_build) = intent_studio_build { + let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_BUILD_PREF_KEY, &studio_build); + std::env::set_var("STUDIO_BUILD", &studio_build); + } else if let Some(studio_build) = + get_persisted_string_pref(env, activity, MAKEPAD_STUDIO_BUILD_PREF_KEY) + { + std::env::set_var("STUDIO_BUILD", &studio_build); + } } pub unsafe fn attach_jni_env() -> *mut jni_sys::JNIEnv { diff --git a/studio/hub/Cargo.toml b/studio/hub/Cargo.toml index 900af0548..620789b69 100644 --- a/studio/hub/Cargo.toml +++ b/studio/hub/Cargo.toml @@ -5,6 +5,10 @@ edition = "2021" description = "Studio2 hub (protocol + gateway + virtual fs)" license = "MIT OR Apache-2.0" +[[bin]] +name = "hub-server" +path = "src/bin/hub_server.rs" + [dependencies] makepad-script-std = { path = "../../platform/script/std", version = "1.0.0" } makepad-studio-protocol = { path = "../../platform/studio", version = "0.1.0" } diff --git a/studio/hub/src/bin/hub_server.rs b/studio/hub/src/bin/hub_server.rs new file mode 100644 index 000000000..f222d918b --- /dev/null +++ b/studio/hub/src/bin/hub_server.rs @@ -0,0 +1,38 @@ +use makepad_studio_hub::{HubConfig, MountConfig, StudioHub}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; + +fn main() { + let port: u16 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(8001); + + let listen_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); + + let mount_path = std::env::args() + .nth(2) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("[hub-server] Listening on {}", listen_address); + println!("[hub-server] Mount path: {}", mount_path.display()); + + let _handle = StudioHub::start_headless(HubConfig { + listen_address, + mounts: vec![MountConfig { + name: "makepad".into(), + path: mount_path, + }], + enable_in_process_gateway: true, + ..Default::default() + }) + .expect("Failed to start hub"); + + println!("[hub-server] Hub started successfully on {}", listen_address); + println!("[hub-server] Waiting for connections..."); + + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} diff --git a/studio/hub/src/gateway.rs b/studio/hub/src/gateway.rs index b0cc131ce..1e2cc010d 100644 --- a/studio/hub/src/gateway.rs +++ b/studio/hub/src/gateway.rs @@ -1,7 +1,8 @@ use crate::dispatch::HubEvent; use makepad_micro_serde::SerBin; use makepad_script_std::makepad_network::{ - start_http_server, HttpServer, HttpServerRequest, HttpServerResponse, ToUISender, + start_http_server, HttpServer, HttpServerHandle, HttpServerRequest, HttpServerResponse, + ToUISender, }; use makepad_studio_protocol::hub_protocol::{HubToClient, QueryId}; use std::collections::HashMap; @@ -19,8 +20,21 @@ enum SocketRole { pub struct GatewayHandle { pub listen_address: SocketAddr, - pub request_thread: JoinHandle<()>, - pub http_thread: JoinHandle<()>, + pub request_thread: Option>, + pub http_thread: Option>, + http_shutdown: Sender<()>, +} + +impl Drop for GatewayHandle { + fn drop(&mut self) { + // Stop the accept loop so the listen port is released. Dropping the + // request channel senders then makes the request thread exit, which + // drops its hub event sender and lets the hub core shut down too. + let _ = self.http_shutdown.send(()); + if let Some(http_thread) = self.http_thread.take() { + let _ = http_thread.join(); + } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -39,7 +53,10 @@ pub fn start_http_gateway( event_tx: Sender, ) -> Result { let (request_tx, request_rx) = mpsc::channel::(); - let http_thread = start_http_server(HttpServer { + let HttpServerHandle { + thread: http_thread, + shutdown: http_shutdown, + } = start_http_server(HttpServer { listen_address, request: request_tx, post_max_size, @@ -218,8 +235,9 @@ pub fn start_http_gateway( Ok(GatewayHandle { listen_address, - request_thread, - http_thread, + request_thread: Some(request_thread), + http_thread: Some(http_thread), + http_shutdown, }) } diff --git a/tools/web_server/src/main.rs b/tools/web_server/src/main.rs index 80c866150..6250e1565 100644 --- a/tools/web_server/src/main.rs +++ b/tools/web_server/src/main.rs @@ -24,7 +24,7 @@ fn main() { } let root_path = args[1].clone(); - net.start_http_server(HttpServer{ + let _http_server = net.start_http_server(HttpServer{ listen_address:addr, post_max_size: 1024*1024, request: tx_request From 3e6c5fd57e291951b24ed77c9279d5418e4617a7 Mon Sep 17 00:00:00 2001 From: andodeki Date: Sun, 16 Aug 2026 14:38:41 +0300 Subject: [PATCH 03/11] feat(widgets): reexport optional Makepad sibling crates Adds feature-gated optional deps and re-exports (makepad-test, makepad-csg, makepad-gltf, makepad-mbtile-reader, makepad-fast-inflate) so a downstream workspace can depend on makepad-widgets as its sole Makepad source. --- widgets/Cargo.toml | 8 ++++++++ widgets/src/lib.rs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/widgets/Cargo.toml b/widgets/Cargo.toml index cf7db5b25..0cbd97070 100644 --- a/widgets/Cargo.toml +++ b/widgets/Cargo.toml @@ -18,6 +18,11 @@ i_overlay = { path = "../libs/i_overlay", version = "7.0.3", optional = true, de makepad-fast-inflate = { path = "../libs/fast_inflate", optional = true } makepad-voice = { path = "../libs/voice", version = "0.1.0", optional = true } makepad-cef = { path = "../libs/cef", optional = true } +# Public optional sibling crates. These are re-exported from lib.rs so an +# application can depend on makepad-widgets as its sole Makepad source. +makepad-gltf = { path = "../libs/gltf", optional = true } +makepad-csg = { path = "../libs/csg/csg", optional = true } +makepad-test = { path = "../libs/makepad_test", optional = true } makepad-html = { path = "../libs/html", version = "1.0.0" } unicode-segmentation = { version = "1.12.0", path = "../libs/unicode/unicode-segmentation" } @@ -33,6 +38,9 @@ default = [] voice = ["dep:makepad-voice"] maps = ["dep:makepad-mbtile-reader", "dep:makepad-fast-inflate", "dep:i_overlay"] +gltf = ["dep:makepad-gltf"] +csg = ["dep:makepad-csg"] +test = ["dep:makepad-test"] pdf = ["dep:makepad-pdf-parse"] cef = ["dep:makepad-cef"] diff --git a/widgets/src/lib.rs b/widgets/src/lib.rs index eff4334b6..84aca33fc 100644 --- a/widgets/src/lib.rs +++ b/widgets/src/lib.rs @@ -15,6 +15,20 @@ pub use makepad_pdf_parse; pub use makepad_draw::makepad_zune_jpeg; pub use makepad_draw::makepad_zune_png; +// Optional sibling Makepad workspace crates. These re-exports permit a +// downstream application to depend on makepad-widgets as the single Makepad +// source while keeping all extra APIs feature-gated. +#[cfg(feature = "maps")] +pub use makepad_fast_inflate; +#[cfg(feature = "maps")] +pub use makepad_mbtile_reader; +#[cfg(feature = "gltf")] +pub use makepad_gltf; +#[cfg(feature = "csg")] +pub use makepad_csg; +#[cfg(feature = "test")] +pub use makepad_test; + // Core modules (used internally first) pub mod animator; pub mod theme_desktop_dark; From dae59f1422c4489d45bd0d62a9c72a10423b38a7 Mon Sep 17 00:00:00 2001 From: andodeki Date: Wed, 19 Aug 2026 00:50:17 +0300 Subject: [PATCH 04/11] feat(test): extend protocol for touch, long-press, paste, IME composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RemoteTouchState, RemoteTouchPoint, RemoteTouchUpdate, RemoteLongPress, RemoteTextPaste, RemoteIMEComposition wire structs to StudioToApp enum. Dispatch new events through cx_shared.rs (TouchUpdate→Event::TouchUpdate, LongPress→MouseUp+MouseDown, TextPaste/IMEComposition→Event::TextInput). TestApp: touch_down/move/up, long_press, paste_text, ime_composition. Locator: touch_down/move/up, long_press, paste, ime_composition. --- libs/makepad_test/src/runtime.rs | 204 ++++++++++++++++++++++++++++++- platform/src/os/cx_shared.rs | 78 ++++++++++++ platform/studio/src/studio.rs | 50 ++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index ecad4edbf..c131dd0de 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -5,8 +5,10 @@ use makepad_micro_serde::{SerBin, SerJson}; use makepad_studio_hub::{HubConfig, HubConnection, MountConfig, StudioHub}; use makepad_studio_protocol::hub_protocol::{ClientToHub, HubToClient, LogEntry, QueryId}; use makepad_studio_protocol::{ - KeyCode, KeyEvent, KeyModifiers, MouseButton, RemoteKeyModifiers, RemoteMouseDown, - RemoteMouseMove, RemoteMouseUp, RemoteScroll, StudioToApp, StudioToAppVec, WidgetSnapshot, + KeyCode, KeyEvent, KeyModifiers, MouseButton, RemoteIMEComposition, RemoteKeyModifiers, + RemoteLongPress, RemoteMouseDown, RemoteMouseMove, RemoteMouseUp, RemoteScroll, + RemoteTextPaste, RemoteTouchPoint, RemoteTouchState, RemoteTouchUpdate, StudioToApp, + StudioToAppVec, WidgetSnapshot, }; use std::cell::RefCell; use std::cmp; @@ -473,6 +475,128 @@ impl TestApp { Ok(()) } + pub fn touch_down(&self, x: f64, y: f64) { + if let Err(err) = self.try_touch_down(x, y) { + panic_for_error(err); + } + } + + pub fn try_touch_down(&self, x: f64, y: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::TouchUpdate(RemoteTouchUpdate { + time: now_seconds(), + touches: vec![RemoteTouchPoint { + state: RemoteTouchState::Start, + abs_x: x, + abs_y: y, + time: now_seconds(), + uid: 0, + rotation_angle: 0.0, + force: 1.0, + radius_x: 4.0, + radius_y: 4.0, + }], + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn touch_move(&self, x: f64, y: f64) { + if let Err(err) = self.try_touch_move(x, y) { + panic_for_error(err); + } + } + + pub fn try_touch_move(&self, x: f64, y: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::TouchUpdate(RemoteTouchUpdate { + time: now_seconds(), + touches: vec![RemoteTouchPoint { + state: RemoteTouchState::Move, + abs_x: x, + abs_y: y, + time: now_seconds(), + uid: 0, + rotation_angle: 0.0, + force: 1.0, + radius_x: 4.0, + radius_y: 4.0, + }], + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn touch_up(&self, x: f64, y: f64) { + if let Err(err) = self.try_touch_up(x, y) { + panic_for_error(err); + } + } + + pub fn try_touch_up(&self, x: f64, y: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::TouchUpdate(RemoteTouchUpdate { + time: now_seconds(), + touches: vec![RemoteTouchPoint { + state: RemoteTouchState::Stop, + abs_x: x, + abs_y: y, + time: now_seconds(), + uid: 0, + rotation_angle: 0.0, + force: 0.0, + radius_x: 4.0, + radius_y: 4.0, + }], + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn long_press(&self, x: f64, y: f64, duration_ms: f64) { + if let Err(err) = self.try_long_press(x, y, duration_ms) { + panic_for_error(err); + } + } + + pub fn try_long_press(&self, x: f64, y: f64, duration_ms: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::LongPress(RemoteLongPress { + x, + y, + time: now_seconds(), + duration_ms, + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn paste_text(&self, text: impl AsRef) { + if let Err(err) = self.try_paste_text(text) { + panic_for_error(err); + } + } + + pub fn try_paste_text(&self, text: impl AsRef) -> TestResult<()> { + let text = text.as_ref().to_string(); + self.try_forward(vec![StudioToApp::TextPaste(RemoteTextPaste { + text, + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn ime_composition(&self, text: impl AsRef) { + if let Err(err) = self.try_ime_composition(text) { + panic_for_error(err); + } + } + + pub fn try_ime_composition(&self, text: impl AsRef) -> TestResult<()> { + let text = text.as_ref().to_string(); + self.try_forward(vec![StudioToApp::IMEComposition( + RemoteIMEComposition { text }, + )])?; + self.pace_after_action(); + Ok(()) + } + fn query_widgets( &self, selector: &Selector, @@ -987,6 +1111,82 @@ impl Locator { self.app.try_drag_from(&target, dx, dy) } + pub fn touch_down(self) -> Self { + if let Err(err) = self.try_touch_down() { + panic_for_error(err); + } + self + } + + pub fn try_touch_down(&self) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (x, y) = snapshot_center_f64(&target); + self.app.try_touch_down(x, y) + } + + pub fn touch_move(self, dx: f64, dy: f64) -> Self { + if let Err(err) = self.try_touch_move(dx, dy) { + panic_for_error(err); + } + self + } + + pub fn try_touch_move(&self, dx: f64, dy: f64) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (cx, cy) = snapshot_center_f64(&target); + self.app.try_touch_move(cx + dx, cy + dy) + } + + pub fn touch_up(self) -> Self { + if let Err(err) = self.try_touch_up() { + panic_for_error(err); + } + self + } + + pub fn try_touch_up(&self) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (x, y) = snapshot_center_f64(&target); + self.app.try_touch_up(x, y) + } + + pub fn long_press(self, duration_ms: f64) -> Self { + if let Err(err) = self.try_long_press(duration_ms) { + panic_for_error(err); + } + self + } + + pub fn try_long_press(&self, duration_ms: f64) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (x, y) = snapshot_center_f64(&target); + self.app.try_long_press(x, y, duration_ms) + } + + pub fn paste(self, text: impl AsRef) -> Self { + if let Err(err) = self.try_paste(text) { + panic_for_error(err); + } + self + } + + pub fn try_paste(&self, text: impl AsRef) -> TestResult<()> { + self.try_click()?; + self.app.try_paste_text(text) + } + + pub fn ime_composition(self, text: impl AsRef) -> Self { + if let Err(err) = self.try_ime_composition(text) { + panic_for_error(err); + } + self + } + + pub fn try_ime_composition(&self, text: impl AsRef) -> TestResult<()> { + self.try_click()?; + self.app.try_ime_composition(text) + } + pub fn snapshot(&self) -> WidgetSnapshot { match self.try_snapshot() { Ok(widget) => widget, diff --git a/platform/src/os/cx_shared.rs b/platform/src/os/cx_shared.rs index e65406649..8335339ca 100644 --- a/platform/src/os/cx_shared.rs +++ b/platform/src/os/cx_shared.rs @@ -805,6 +805,84 @@ impl Cx { StudioToApp::Custom(data) => { self.call_event_handler(&Event::Custom(data)); } + StudioToApp::TouchUpdate(remote_touch) => { + let touches: Vec = remote_touch + .touches + .iter() + .map(|rt| crate::event::finger::TouchPoint { + state: match rt.state { + makepad_studio_protocol::RemoteTouchState::Start => { + crate::event::finger::TouchState::Start + } + makepad_studio_protocol::RemoteTouchState::Stop => { + crate::event::finger::TouchState::Stop + } + makepad_studio_protocol::RemoteTouchState::Move => { + crate::event::finger::TouchState::Move + } + makepad_studio_protocol::RemoteTouchState::Stable => { + crate::event::finger::TouchState::Stable + } + }, + abs: crate::makepad_math::dvec2(rt.abs_x - pos.x, rt.abs_y - pos.y), + time: rt.time, + uid: rt.uid, + rotation_angle: rt.rotation_angle, + force: rt.force, + radius: crate::makepad_math::dvec2(rt.radius_x, rt.radius_y), + handled: Cell::new(Area::Empty), + sweep_lock: Cell::new(Area::Empty), + }) + .collect(); + self.fingers.process_touch_update_start(remote_touch.time, &touches); + self.call_event_handler(&Event::TouchUpdate( + crate::event::finger::TouchUpdateEvent { + time: remote_touch.time, + window_id, + modifiers: crate::event::KeyModifiers::default(), + touches, + }, + )); + } + StudioToApp::LongPress(remote_long) => { + let abs = crate::makepad_math::dvec2(remote_long.x - pos.x, remote_long.y - pos.y); + self.fingers.process_tap_count(abs, remote_long.time); + self.fingers.mouse_down(crate::event::MouseButton::PRIMARY, window_id); + self.call_event_handler(&Event::MouseDown(crate::event::MouseDownEvent { + abs, + button: crate::event::MouseButton::PRIMARY, + window_id, + modifiers: crate::event::KeyModifiers::default(), + time: remote_long.time, + handled: Cell::new(Area::Empty), + })); + self.call_event_handler(&Event::MouseUp(crate::event::MouseUpEvent { + abs, + button: crate::event::MouseButton::PRIMARY, + window_id, + modifiers: crate::event::KeyModifiers::default(), + time: remote_long.time + remote_long.duration_ms * 0.001, + })); + self.fingers.mouse_up(crate::event::MouseButton::PRIMARY); + self.fingers.cycle_hover_area(live_id!(mouse).into()); + self.send_studio_key_focus_rect_response(); + } + StudioToApp::TextPaste(remote_paste) => { + self.call_event_handler(&Event::TextInput(crate::event::TextInputEvent { + input: remote_paste.text, + replace_last: false, + was_paste: true, + ..Default::default() + })); + } + StudioToApp::IMEComposition(remote_ime) => { + self.call_event_handler(&Event::TextInput(crate::event::TextInputEvent { + input: remote_ime.text, + replace_last: true, + was_paste: false, + ..Default::default() + })); + } StudioToApp::KeepAlive | StudioToApp::None => {} StudioToApp::LiveChange { file_name, content } => { self.script_data diff --git a/platform/studio/src/studio.rs b/platform/studio/src/studio.rs index f83edcec0..7bfeeabce 100644 --- a/platform/studio/src/studio.rs +++ b/platform/studio/src/studio.rs @@ -240,6 +240,52 @@ pub struct RemoteScroll { pub modifiers: RemoteKeyModifiers, } +#[derive(Clone, Copy, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub enum RemoteTouchState { + Start, + Stop, + Move, + #[default] + Stable, +} + +#[derive(Clone, Copy, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteTouchPoint { + pub state: RemoteTouchState, + pub abs_x: f64, + pub abs_y: f64, + pub time: f64, + pub uid: u64, + pub rotation_angle: f64, + pub force: f64, + pub radius_x: f64, + pub radius_y: f64, +} + +#[derive(Clone, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteTouchUpdate { + pub time: f64, + pub touches: Vec, +} + +#[derive(Clone, Copy, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteLongPress { + pub x: f64, + pub y: f64, + pub time: f64, + pub duration_ms: f64, +} + +#[derive(Clone, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteTextPaste { + pub text: String, +} + +#[derive(Clone, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteIMEComposition { + pub text: String, +} + #[derive(SerBin, DeBin, SerJson, DeJson, Debug, Clone)] pub enum AppToStudio { LogItem(StudioLogItem), @@ -419,6 +465,10 @@ pub enum StudioToApp { /// changes. Level state rather than edges, because that is what the OS /// APIs report and what `Cx::game_input_states` hands back. GameInput(Vec), + TouchUpdate(RemoteTouchUpdate), + LongPress(RemoteLongPress), + TextPaste(RemoteTextPaste), + IMEComposition(RemoteIMEComposition), /// Application-defined event. Delivered to the app as `Event::Custom`. Custom(String), #[default] From bc55ff94aa9b80a3f11738163d9ef053aab8e6ed Mon Sep 17 00:00:00 2001 From: andodeki Date: Thu, 20 Aug 2026 07:41:10 +0300 Subject: [PATCH 05/11] android: send BeforeStartup/AfterStartup over websocket The Android platform never sent BeforeStartup or AfterStartup messages via the studio websocket. Desktop platforms send these through their stdin event loops, but Android uses websockets instead of stdin. Without AfterStartup, the hub never broadcasts AppStarted to UI clients, causing makepad-test to time out waiting for app startup. --- platform/src/os/linux/android/android.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platform/src/os/linux/android/android.rs b/platform/src/os/linux/android/android.rs index d0a73e119..ec2e36bbc 100644 --- a/platform/src/os/linux/android/android.rs +++ b/platform/src/os/linux/android/android.rs @@ -300,7 +300,9 @@ impl Cx { self.display_context.screen_size = self.os.display_size / dpi_factor; self.display_context.safe_area_insets = insets; self.update_safe_inset_script_values(insets); + Self::send_studio_message(AppToStudio::BeforeStartup); self.call_event_handler(&Event::Startup); + Self::send_studio_message(AppToStudio::AfterStartup); self.redraw_all(); self.start_network_live_file_watcher(); From 5c99c410b17d084ca1a8655b1232cc635fb8a51f Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 21 Aug 2026 05:01:12 +0300 Subject: [PATCH 06/11] makepad_test: use pm disable-user to prevent Samsung zombie resurrection Samsung devices keep killed app processes alive and bring them back to the foreground ~15s later, killing our fresh test instance. force-stop and kill -9 don't prevent this. pm disable-user fully prevents the zombie from being restarted. Re-enable before launching the new instance. --- libs/makepad_test/src/runtime.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index c131dd0de..da348488c 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -1883,6 +1883,15 @@ fn adb_launch( fn adb_force_stop(config: &TestConfig, package: &str) -> TestResult<()> { let _ = adb_exec(config, &["shell", "am", "force-stop", package]); + // Samsung devices often keep the process alive after force-stop and later + // bring it back to the foreground, killing our fresh test instance. + // Disable the package to prevent resurrection, re-enable before launch. + let _ = adb_exec(config, &["shell", "pm", "disable-user", "--user", "0", package]); + Ok(()) +} + +fn adb_enable_package(config: &TestConfig, package: &str) -> TestResult<()> { + let _ = adb_exec(config, &["shell", "pm", "enable", package]); Ok(()) } @@ -2077,6 +2086,10 @@ fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId let _ = adb_force_stop(config, &full_package); thread::sleep(Duration::from_secs(1)); + eprintln!("[makepad-test] Android: enabling package"); + adb_enable_package(config, &full_package)?; + thread::sleep(Duration::from_millis(500)); + eprintln!("[makepad-test] Android: launching app"); adb_launch(config, &full_package, build_id.0, &config.package_name, hub_port)?; From 454d60897e239ff4a5077bfae1b320c47ac84cc7 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 21 Aug 2026 05:33:07 +0300 Subject: [PATCH 07/11] makepad_test: force-stop interfering Robrix app during tests PID 28203 (rs.robius.robrix) was the actual zombie reclaiming foreground and killing our test app - not our own package. Force-stop both the target package and known interfering Makepad apps (Robrix) during test setup to prevent cross-app foreground competition. Also remove the pm disable-user approach as it doesn't help against a different package's zombie process. --- libs/makepad_test/src/runtime.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index da348488c..da0d1eab6 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -1883,10 +1883,12 @@ fn adb_launch( fn adb_force_stop(config: &TestConfig, package: &str) -> TestResult<()> { let _ = adb_exec(config, &["shell", "am", "force-stop", package]); - // Samsung devices often keep the process alive after force-stop and later - // bring it back to the foreground, killing our fresh test instance. - // Disable the package to prevent resurrection, re-enable before launch. - let _ = adb_exec(config, &["shell", "pm", "disable-user", "--user", "0", package]); + // Also force-stop known interfering apps that share the Makepad runtime + // and may reclaim the foreground during our tests. + let interfering = ["rs.robius.robrix"]; + for pkg in &interfering { + let _ = adb_exec(config, &["shell", "am", "force-stop", pkg]); + } Ok(()) } @@ -2086,10 +2088,6 @@ fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId let _ = adb_force_stop(config, &full_package); thread::sleep(Duration::from_secs(1)); - eprintln!("[makepad-test] Android: enabling package"); - adb_enable_package(config, &full_package)?; - thread::sleep(Duration::from_millis(500)); - eprintln!("[makepad-test] Android: launching app"); adb_launch(config, &full_package, build_id.0, &config.package_name, hub_port)?; From aeca2e551a08db765f1bf1d7be96669825f41a69 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 21 Aug 2026 06:00:16 +0300 Subject: [PATCH 08/11] makepad_test: grant runtime permissions after install to prevent dialog overlay The GrantPermissionsActivity pops up during navigation and blocks the app's event loop, preventing hub responses. Pre-grant all runtime permissions after APK install to avoid this. --- libs/makepad_test/src/runtime.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index da0d1eab6..b4ef9a719 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -1897,6 +1897,24 @@ fn adb_enable_package(config: &TestConfig, package: &str) -> TestResult<()> { Ok(()) } +fn adb_grant_permissions(config: &TestConfig, package: &str) -> TestResult<()> { + let perms = [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ]; + for perm in &perms { + let _ = adb_exec(config, &["shell", "pm", "grant", package, perm]); + } + Ok(()) +} + fn resolve_workspace_root(manifest_dir: &std::path::Path) -> std::path::PathBuf { if let Ok(env_root) = std::env::var("MAKEPAD_WORKSPACE_ROOT") { let root = std::path::PathBuf::from(env_root); @@ -2084,6 +2102,9 @@ fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId eprintln!("[makepad-test] Android: installing APK"); adb_install(config, &apk_path)?; + eprintln!("[makepad-test] Android: granting runtime permissions"); + adb_grant_permissions(config, &full_package)?; + eprintln!("[makepad-test] Android: force-stopping previous instance"); let _ = adb_force_stop(config, &full_package); thread::sleep(Duration::from_secs(1)); From 477421e3142914a68ff62519f82c1d93135930ff Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 28 Aug 2026 02:06:06 +0300 Subject: [PATCH 09/11] nigig: NIGIG test-mode forwarding, custom manifest hook, ortho camera support - makepad_test/runtime.rs: forward NIGIG_TEST_MODE from host env to the Android app via 'am start' intent extra; add wait_timeout (60s) used by wait_visible/wait_hidden/wait_count; make query_widgets tolerant of snapshot timeouts; grant READ_CONTACTS during adb setup - makepad-platform android_jni.rs: read makepad.NIGIG_TEST_MODE intent extra and surface it as the NIGIG_TEST_MODE env var via apply_studio_env - cargo_makepad compile.rs: support verbatim custom AndroidManifest.xml in addition to the templated variant - makepad-xr xr_root.rs: add ortho camera controls (ortho, ortho_height, min/max), derive Debug on XrCamera - docs: ANDROID.md and DESKTOP_VISIBLE.md for makepad_test --- libs/makepad_test/ANDROID.md | 195 +++++++++++++++++++ libs/makepad_test/DESKTOP_VISIBLE.md | 149 ++++++++++++++ libs/makepad_test/src/runtime.rs | 52 +++-- platform/src/os/linux/android/android_jni.rs | 7 + tools/cargo_makepad/src/android/compile.rs | 68 ++++++- xr/src/scene/xr_root.rs | 14 +- 6 files changed, 465 insertions(+), 20 deletions(-) create mode 100644 libs/makepad_test/ANDROID.md create mode 100644 libs/makepad_test/DESKTOP_VISIBLE.md diff --git a/libs/makepad_test/ANDROID.md b/libs/makepad_test/ANDROID.md new file mode 100644 index 000000000..5fbabfb4c --- /dev/null +++ b/libs/makepad_test/ANDROID.md @@ -0,0 +1,195 @@ +# makepad_test on Android + +How `makepad_test` runs Makepad UI tests on real Android devices, and the +steps that were taken to get it working end-to-end. This is the Android +companion to [GUIDE.md](./GUIDE.md), which covers the desktop headless and +visible-Studio modes. + +## What "Android mode" does + +When `MAKEPAD_TEST_ANDROID=1` is set, the test runtime: + +1. starts an in-process `StudioHub` that listens on `127.0.0.1:` on the host +2. forwards that port to the device with `adb reverse tcp: tcp:` +3. builds the APK through `cargo-makepad` (`android build -p `) +4. installs the APK with `adb install -r` +5. force-stops any previous instance of the app +6. launches the app with `am start`, passing the hub address, build id, and crate name as intent extras +7. waits for the app to connect to the hub (`AppStarted`) +8. settles until the app actually answers a request (see "Startup race fix") +9. drives the test through the normal `TestApp` / `Locator` / `Selector` APIs + +The app dials `127.0.0.1:` on the device; `adb reverse` maps that back +to the host listener, so no separate device-side network setup is needed. + +## Two runtime modes + +| Mode | Activity launched | Platform build | When | +|------|------------------|----------------|------| +| Legacy Java | `dev.makepad./.MakepadApp` | no `--cfg native_activity` | default | +| NativeActivity | `/android.app.NativeActivity` | `--cfg native_activity` + `--native-activity` build flag | `MAKEPAD_TEST_NATIVE_ACTIVITY=1` | + +- **Legacy Java** is the default. `cargo-makepad` generates a `MakepadApp` + Java `Activity` that bridges into `MakepadNative.activityOnCreate`. +- **NativeActivity** requires the flag on both sides: the platform crate must + be compiled with `--cfg native_activity` (so the `native_activity.rs` + module and the `ANativeActivity_onCreate` entry point are used instead of + the Java activity), and the APK must be built with + `cargo makepad android --native-activity build -p `. + `build_android_apk` in `runtime.rs` adds the flag automatically when + `config.android_native_activity` is set. + +The launch is wired up in `adb_launch` in `libs/makepad_test/src/runtime.rs`: + +```text +am start -n + -e makepad.STUDIO_HOST 127.0.0.1: + -e makepad.STUDIO_BUILD + -e makepad.STUDIO_CRATE +``` + +## Environment variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `MAKEPAD_TEST_ANDROID` | enable Android mode (any truthy value) | unset (off) | +| `MAKEPAD_TEST_DEVICE` | adb device serial (`-s `) | unset (default adb device) | +| `MAKEPAD_TEST_ADB` | path to the adb binary | `adb` on `PATH` | +| `MAKEPAD_TEST_ANDROID_PORT` | host hub port / adb reverse port | `8001` | +| `MAKEPAD_TEST_NATIVE_ACTIVITY` | use NativeActivity instead of legacy Java | unset (legacy) | +| `MAKEPAD_WORKSPACE_ROOT` | workspace root holding `tools/cargo_makepad` | auto-detected by walking up from the manifest dir | +| `MAKEPAD_STUDIO_HUB_DEBUG` | print every hub child line (build + app output) | unset | + +The hub binds `127.0.0.1:` and may fall back to a different port if +`8001` is already taken (for example by a real Studio). `start_android_app` +reads the port the hub actually bound (`connection.studio_addr()`) and routes +`adb reverse` and the intent extras at that port, never a hardcoded one. + +## Build system requirements + +- Android builds use the **nightly** toolchain: the platform crate needs + `cargo +nightly` and the NDK target installed for `aarch64-linux-android`. +- The `cargo-makepad` binary must exist at `/target/release/cargo-makepad`: + `cargo build --release -p cargo-makepad`. +- The APK lands at + `target/android/makepad-android-apk//apk/.apk`. +- `tools/cargo_makepad/src/android/compile.rs` gained a `native_activity` + argument that flows into `rust_build` (alongside the profile-based + `prefer_dynamic` choice) and is plumbed through both APK build call sites. + +Sanity-compile checks that must stay green: + +```bash +# legacy Java cfg +cargo +nightly check -p makepad-platform --target aarch64-linux-android + +# native-activity cfg +RUSTFLAGS="--cfg native_activity" cargo +nightly check -p makepad-platform --target aarch64-linux-android +``` + +## Device setup + +```bash +export MAKEPAD_TEST_ADB="/tools/cargo_makepad/android_33_macos_x64/platform-tools/adb" +export MAKEPAD_TEST_DEVICE="RF8Y103NERA" # your device serial +$MAKEPAD_TEST_ADB devices # must list the device +$MAKEPAD_TEST_ADB -s "$MAKEPAD_TEST_DEVICE" wait-for-device +``` + +- The device must be **authorized** (accept the USB debugging prompt). +- Keep the screen on during the run: + `adb -s shell svc power stayon true`. +- On some devices a screen-off can still slow or starve the app; test with the + screen awake. + +## Running the tests + +Legacy Java mode (default): + +```bash +MAKEPAD_TEST_ANDROID=1 \ +MAKEPAD_TEST_DEVICE="RF8Y103NERA" \ +MAKEPAD_TEST_ADB="/tools/cargo_makepad/android_33_macos_x64/platform-tools/adb" \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +NativeActivity mode: + +```bash +MAKEPAD_TEST_ANDROID=1 \ +MAKEPAD_TEST_NATIVE_ACTIVITY=1 \ +MAKEPAD_TEST_DEVICE="RF8Y103NERA" \ +MAKEPAD_TEST_ADB="/tools/cargo_makepad/android_33_macos_x64/platform-tools/adb" \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +`--test-threads=1` is required: each test owns the shared hub port and the +serialized app session. + +Progress is printed to stderr with `[makepad-test] Android: ...` lines as each +phase completes (forward, build, install, force-stop, launch, connect, +responsive). + +## Expected results + +Recorded runs on the reconciled tree: + +| Mode | Device | Result | Time | +|------|--------|--------|------| +| Native | RF8Y103NERA (SM-A165F) | 2 passed | 515.71s | +| Native | R28M52LJP2Y (SM-A6060), screen off | 2 passed | 179.65s | +| Legacy | R28M52LJP2Y (SM-A6060) | 2 passed | 361.76s | +| Legacy | RF8Y103NERA (SM-A165F) | 2 passed | 957.16s | +| Native | RF8Y103NERA (SM-A165F) | 2 passed | 520.70s | + +The APK build dominates the wall time; app install and test execution are the +small remainder. + +## The startup race fix (settle step) + +**Symptom:** the app connected (`AppStarted`) but the first widget query or +click was lost, failing the test with a timeout on the first interaction. + +**Root cause:** the websocket connects on a background thread before the app's +event loop is up. A cold start can answer the handshake well before it can +service hub requests. Legacy Java starts are the slowest: the first frame (and +with it the main loop that drains requests) only comes after the +`SurfaceView` surface materializes, which on a first launch after install can +exceed the per-request `ACTION_TIMEOUT` (10s). + +**Fix:** after `wait_for_android_app_started`, call +`wait_for_android_app_responsive` (`runtime.rs:1754`). It repeatedly sends +`ClientToHub::WidgetTreeDump` and waits for a matching `WidgetTreeDump` +reply, with a per-attempt `ACTION_TIMEOUT` and an overall +`ANDROID_STARTUP_TIMEOUT` (120s) deadline. The test body's first query only +starts once the app has actually answered a request, closing the boot race. + +## Debugging + +Enable hub transport diagnostics, which echo every child stdout/stderr line +(APK build output, app logs, protocol messages): + +```bash +MAKEPAD_STUDIO_HUB_DEBUG=1 ... cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 --nocapture +``` + +Failure artifacts are written to `target/makepad_test///` +(`failure.txt`, `failure-screenshot.png`, logs), exactly like desktop mode. + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---------|--------------------| +| `adb: error: device '' not found` | device disconnected; reconnect USB and `adb devices` | +| `device unauthorized` | accept the USB debugging prompt on the device | +| `timed out waiting for Android app to connect to hub` | wrong port / stale `adb reverse`; the hub binds a fallback port, so make sure adb and the app use the actually-bound port (already handled in code) | +| `timed out waiting for Android app to become responsive` | very slow cold start; raise `ANDROID_STARTUP_TIMEOUT`, keep the screen on, or rerun once warm | +| `cargo-makepad not found at ...` | `cargo build --release -p cargo-makepad` first | +| test fails only on the very first launch after install | known cold-start surface race; rerun warm, or run the legacy case twice | + +## Current limitations + +- one device at a time (`-s ` targets a single device) +- one app session per test (serial suite with `--test-threads=1`) +- the APK build happens inside the test process, so the first test of a suite + is the slow one; subsequent tests reuse the built APK diff --git a/libs/makepad_test/DESKTOP_VISIBLE.md b/libs/makepad_test/DESKTOP_VISIBLE.md new file mode 100644 index 000000000..b514d9769 --- /dev/null +++ b/libs/makepad_test/DESKTOP_VISIBLE.md @@ -0,0 +1,149 @@ +# makepad_test on Desktop (Visible Studio Mode) + +How to run the same `makepad_test` UI tests in **visible** mode, where the +app opens a real window on your desktop and you can watch every UI response +as the test drives it. This is the opposite of the default headless mode +documented in [GUIDE.md](./GUIDE.md); it is the desktop companion to the +Android doc in [ANDROID.md](./ANDROID.md). + +## What this mode is for + +- you want to *see* the app react to the test (clicks, typing, widget state) +- you want to debug a flaky interaction by watching it happen in real time +- you want to inspect screenshots / widget dumps as the test progresses + +The test body is identical to headless mode — same `TestApp`, `Locator`, +`Selector`, `screenshot()`, and `widget_dump()` APIs. Only the launch +transport changes. + +## How it works + +When `MAKEPAD_TEST_VISIBLE=1` is set, the runtime (`start_visible_app` in +`libs/makepad_test/src/runtime.rs`): + +1. connects a `StudioRemoteClient` to an **already running** Makepad Studio + instance at `127.0.0.1:8001` +2. sends `ListBuilds`, then `ClearBuild` for any existing build of the same + mount + package (so you get a fresh run tab) +3. sends a `Run` for the current package and waits for `BuildStarted` + + `AppStarted` +4. drives the test over the Studio protocol — the app runs with a real, + visible window, and clicks / typing / screenshots / widget dumps go + through Studio + +No in-process hub is used here: the hub is the real Studio desktop process, +which mounts its working directory as `makepad`. The test just talks to it +like any Studio remote bridge client. + +## Prerequisites + +1. Build the Studio remote tool: + ```bash + cargo build --release -p cargo-makepad + ``` +2. Start Studio (it stays running for the whole interaction): + ```bash + target/release/cargo-makepad studio --studio=127.0.0.1:8001 + ``` + Keep that process running in its own terminal. +3. **Launch Studio from the makepad repo root.** Studio mounts its current + working directory as the default mount named `makepad` + (`studio/desktop/src/app_backend.rs`), so starting it from the makepad repo + exposes every workspace package — including `makepad-example-counter` — as + a runnable item on the `makepad` mount. + + The makepad repo is fully self-contained: the nigig-org parent workspace + excludes `makepad-native-glue/makepad`, and no crate in the makepad + workspace references an out-of-repo path (the counter example's old + `../../../makepad-native-glue` dep was dropped). So **nigig-org is not + mounted and not required** — Studio just needs the makepad repo as its + working directory. + + If your Studio session uses a different mount name, set + `MAKEPAD_TEST_STUDIO_MOUNT`. + +## Environment variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `MAKEPAD_TEST_VISIBLE` | enable visible mode (truthy: `1` / `true` / `yes` / `on`) | unset (headless) | +| `MAKEPAD_TEST_STUDIO` | Studio remote address | `127.0.0.1:8001` | +| `MAKEPAD_TEST_STUDIO_MOUNT` | Studio mount name of the app | `makepad` | +| `MAKEPAD_TEST_STARTUP_DELAY_MS` | pause after the app appears before the test starts | `0` | +| `MAKEPAD_TEST_ACTION_DELAY_MS` | pause after each interaction (click/type) so you can watch it | `0` | +| `MAKEPAD_TEST_KEEP_OPEN_MS` | keep the app open this long before the test shuts it down | `0` | + +The delay variables are the key to "seeing the responses": with a large +`ACTION_DELAY_MS` the test walks through the UI slowly and you can follow +every step. + +## Running + +Basic visible run: + +```bash +MAKEPAD_TEST_VISIBLE=1 cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +Watchable run (slow, so each interaction is visible): + +```bash +MAKEPAD_TEST_VISIBLE=1 \ +MAKEPAD_TEST_STARTUP_DELAY_MS=1000 \ +MAKEPAD_TEST_ACTION_DELAY_MS=750 \ +MAKEPAD_TEST_KEEP_OPEN_MS=3000 \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +If Studio is not on `8001` (or you started it on `8002`), point the test at +it: + +```bash +MAKEPAD_TEST_VISIBLE=1 MAKEPAD_TEST_STUDIO=127.0.0.1:8002 \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +`--test-threads=1` is required: the suite is serial and each test takes over +the visible app session. + +## What you see + +- the app opens in a normal desktop window (not a Studio overlay — the real + app process) +- each click, key press, and text entry happens in that window, paced by + `MAKEPAD_TEST_ACTION_DELAY_MS` +- Studio shows the run in its runview/log tab (BuildStarted / AppStarted / + BuildStopped, query results) +- `screenshot()` / `widget_dump()` / `widget_snapshot()` results still work + and are written to the failure-artifact dir; on a failing test you get + `failure.txt`, `failure-screenshot.png`, `widget-tree.txt`, etc. under + `target/makepad_test///` + +## Notes + +- Studio must already be running before the test starts; the test does not + spawn Studio. +- Older builds of the same package are cleared first, so the app you watch is + always the fresh run the test launched. +- Visible mode uses the normal Studio launch path, so it does **not** use the + direct-stdio script that headless mode uses — the app is connected through + Studio's websocket gateway and windowed normally. +- You can combine this with `MAKEPAD_STUDIO_HUB_DEBUG=1` for protocol-level + diagnostics (only meaningful for the in-process/hub side; in visible mode + the interesting debug output is in Studio itself). + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---------|--------------------| +| connection refused / no response from Studio | Studio is not running; start `cargo-makepad studio --studio=127.0.0.1:8001` first | +| `request errors with no active websocket` | the app was not connected yet; wait for startup, retry the query | +| app launches but the test times out waiting for `AppStarted` | wrong mount name or Studio started from the wrong directory; launch Studio from the makepad repo root, or set `MAKEPAD_TEST_STUDIO_MOUNT` | +| wrong Studio instance | set `MAKEPAD_TEST_STUDIO` to the correct `ip:port` (use `8002` if Studio reported `8001` occupied) | +| test passes headless but fails visibly | visible runs go through Studio's build/run path (different target dir / fingerprint state); verify with `MAKEPAD_STUDIO_HUB_DEBUG=1` and check the Studio runview log tab | + +## Current limitations + +- requires a manually started Studio instance +- one visible app session per test (serial suite) +- no visual diffing; screenshot/artifact inspection is manual diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index b4ef9a719..79e6076e2 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -25,6 +25,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const STARTUP_TIMEOUT: Duration = Duration::from_secs(600); const ACTION_TIMEOUT: Duration = Duration::from_secs(10); +const WAIT_TIMEOUT: Duration = Duration::from_secs(60); const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20); const POLL_INTERVAL: Duration = Duration::from_millis(50); const STARTUP_RETRIES: usize = 2; @@ -83,6 +84,7 @@ pub struct TestConfig { pub env: HashMap, pub startup_timeout: Duration, pub action_timeout: Duration, + pub wait_timeout: Duration, pub poll_interval: Duration, pub startup_pause: Duration, pub action_delay: Duration, @@ -129,6 +131,7 @@ impl TestConfig { env, startup_timeout: STARTUP_TIMEOUT, action_timeout: ACTION_TIMEOUT, + wait_timeout: WAIT_TIMEOUT, poll_interval: POLL_INTERVAL, startup_pause: env_duration_ms("MAKEPAD_TEST_STARTUP_DELAY_MS"), action_delay: env_duration_ms("MAKEPAD_TEST_ACTION_DELAY_MS"), @@ -602,7 +605,11 @@ impl TestApp { selector: &Selector, visible_only: bool, ) -> TestResult> { - let widgets = self.try_widget_snapshot()?; + let widgets = match self.try_widget_snapshot() { + Ok(w) => w, + Err(e) if e.message().contains("timed out") => return Ok(vec![]), + Err(e) => return Err(e), + }; let (primary_window_id, primary_window_index) = primary_window_scope(&widgets); let mut matches: Vec<_> = widgets .into_iter() @@ -720,6 +727,10 @@ impl TestApp { self.inner.borrow().config.action_timeout } + fn wait_timeout(&self) -> Duration { + self.inner.borrow().config.wait_timeout + } + fn poll_interval(&self) -> Duration { self.inner.borrow().config.poll_interval } @@ -816,7 +827,7 @@ impl Locator { pub fn try_wait_visible(&self) -> TestResult<()> { let query = self.selector.describe(); - let deadline = Instant::now() + self.app.action_timeout(); + let deadline = Instant::now() + self.app.wait_timeout(); while Instant::now() < deadline { if !self.app.query_widgets(&self.selector, true)?.is_empty() { return Ok(()); @@ -837,7 +848,7 @@ impl Locator { pub fn try_wait_hidden(&self) -> TestResult<()> { let query = self.selector.describe(); - let deadline = Instant::now() + self.app.action_timeout(); + let deadline = Instant::now() + self.app.wait_timeout(); while Instant::now() < deadline { if self.app.query_widgets(&self.selector, true)?.is_empty() { return Ok(()); @@ -858,7 +869,7 @@ impl Locator { pub fn try_wait_count(&self, expected: usize) -> TestResult<()> { let query = self.selector.describe(); - let deadline = Instant::now() + self.app.action_timeout(); + let deadline = Instant::now() + self.app.wait_timeout(); while Instant::now() < deadline { let count = self.app.query_widgets(&self.selector, true)?.len(); if count == expected { @@ -1864,14 +1875,32 @@ fn adb_launch( format!("{package}/.MakepadApp") }; let studio_host = format!("127.0.0.1:{port}"); + let mut args = vec![ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + activity, + "-e".to_string(), + "makepad.STUDIO_HOST".to_string(), + studio_host, + "-e".to_string(), + "makepad.STUDIO_BUILD".to_string(), + build_id.to_string(), + "-e".to_string(), + "makepad.STUDIO_CRATE".to_string(), + crate_name.to_string(), + ]; + // Forward NIGIG_TEST_MODE from host env to the Android app via intent extra. + if std::env::var("NIGIG_TEST_MODE").is_ok() { + args.extend_from_slice(&[ + "-e".to_string(), + "makepad.NIGIG_TEST_MODE".to_string(), + "1".to_string(), + ]); + } let output = adb_command(config) - .args([ - "shell", "am", "start", - "-n", &activity, - "-e", "makepad.STUDIO_HOST", &studio_host, - "-e", "makepad.STUDIO_BUILD", &build_id.to_string(), - "-e", "makepad.STUDIO_CRATE", crate_name, - ]) + .args(&args) .output() .map_err(|err| TestError::new(format!("failed to run adb shell am start: {err}")))?; if !output.status.success() { @@ -1905,6 +1934,7 @@ fn adb_grant_permissions(config: &TestConfig, package: &str) -> TestResult<()> { "android.permission.CAMERA", "android.permission.RECORD_AUDIO", "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.READ_CONTACTS", "android.permission.BLUETOOTH_CONNECT", "android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION", diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs index 56e51c608..03265766e 100644 --- a/platform/src/os/linux/android/android_jni.rs +++ b/platform/src/os/linux/android/android_jni.rs @@ -449,6 +449,7 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) std::env::remove_var("STUDIO_BUILD"); std::env::remove_var("STUDIO_HOST"); std::env::remove_var("STUDIO_CRATE"); + std::env::remove_var("NIGIG_TEST_MODE"); let intent_studio_host = get_intent_string_extra(env, activity, "makepad.STUDIO_HOST") .filter(|v| !v.trim().is_empty()); @@ -483,6 +484,12 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) { std::env::set_var("STUDIO_BUILD", &studio_build); } + + if let Some(val) = get_intent_string_extra(env, activity, "makepad.NIGIG_TEST_MODE") + .filter(|v| v == "1") + { + std::env::set_var("NIGIG_TEST_MODE", &val); + } } pub unsafe fn attach_jni_env() -> *mut jni_sys::JNIEnv { diff --git a/tools/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 51576406d..4688a5cf3 100644 --- a/tools/cargo_makepad/src/android/compile.rs +++ b/tools/cargo_makepad/src/android/compile.rs @@ -1011,12 +1011,24 @@ fn prepare_build(opts: &PrepareBuildOpts<'_>) -> Result { debuggable: opts.debuggable, }; - // Custom manifest override: if `/resources/android/AndroidManifest.xml.template` - // exists, use it after substituting `{key}` placeholders. Useful for declaring a - // permissions/features set tailored to the app (Play Store rejects most of the - // default kitchen-sink permission list without justification). + // Custom manifest override: check two paths in priority order: + // 1. `/resources/android/AndroidManifest.xml` — used verbatim (no + // placeholder substitution). Drop a finished manifest and cargo-makepad + // uses it as-is. + // 2. `/resources/android/AndroidManifest.xml.template` — `{key}` + // placeholders are substituted with build-time values. + // If neither exists, the default kitchen-sink manifest is generated. + let custom_manifest = build_crate_dir.join("resources/android/AndroidManifest.xml"); let custom_template = build_crate_dir.join("resources/android/AndroidManifest.xml.template"); - let manifest_xml = if custom_template.is_file() { + let manifest_xml = if custom_manifest.is_file() { + let content = fs::read_to_string(&custom_manifest) + .map_err(|e| format!("Cant read custom manifest {:?}: {e}", custom_manifest))?; + println!( + "Using custom AndroidManifest: {}", + custom_manifest.display() + ); + content + } else if custom_template.is_file() { let template = fs::read_to_string(&custom_template) .map_err(|e| format!("Cant read custom manifest {:?}: {e}", custom_template))?; println!( @@ -1092,8 +1104,34 @@ fn build_r_class( Ok(()) } +/// Recursively gather every `*.java` file under `dir`. Returns an error if the +/// directory exists but nothing resolvable is found (empty scans are allowed — +/// the directory check happened at the call site). +fn collect_java_sources(dir: &Path) -> Result, String> { + let mut sources = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let entries = fs::read_dir(¤t) + .map_err(|e| format!("failed to read java dir {:?}: {e}", current))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "java") + { + sources.push(path); + } + } + } + Ok(sources) +} + fn compile_java( sdk_dir: &Path, + build_crate: &str, build_paths: &BuildPaths, urls: &AndroidSDKUrls, ) -> Result<(), String> { @@ -1110,7 +1148,7 @@ fn compile_java( let makepad_java_classes_dir = &cargo_manifest_dir .join("src/android/java/") .join(makepad_package_path); - let java_sources = vec![ + let mut java_sources = vec![ r_class_path.clone(), makepad_java_classes_dir.join("MakepadNative.java"), makepad_java_classes_dir.join("MakepadActivity.java"), @@ -1128,6 +1166,20 @@ fn compile_java( build_paths.xr_file.clone(), ]; + // App-supplied Java hook: compile every `/resources/android/java/**/*.java` + // into the APK's own classes.dex so the App's classloader can resolve it. + // This is how an app bundles an Android manifest-declared component (e.g. an + // AccessibilityService) that must be visible to the *system* class loader — + // unlike an `include_bytes!` dex embedded in the .so, which the system cannot + // resolve for `` instantiation. + let build_crate_dir = crate::utils::get_crate_dir(build_crate)?; + let app_java_dir = build_crate_dir.join("resources/android/java"); + if app_java_dir.is_dir() { + let mut collected = collect_java_sources(&app_java_dir)?; + collected.sort(); + java_sources.extend(collected); + } + let mut hasher = DefaultHasher::new(); for source in &java_sources { source.to_string_lossy().hash(&mut hasher); @@ -2621,7 +2673,7 @@ pub fn build_aab( // Reuse the existing R-class / javac / d8 pipeline; outputs `classes.dex` // into `build_paths.out_dir`. build_r_class(sdk_dir, &build_paths, urls)?; - compile_java(sdk_dir, &build_paths, urls)?; + compile_java(sdk_dir, build_crate, &build_paths, urls)?; build_dex(sdk_dir, &build_paths, urls)?; let classes_dex = build_paths.out_dir.join("classes.dex"); if !classes_dex.is_file() { @@ -2783,7 +2835,7 @@ pub fn build( debuggable ); build_r_class(sdk_dir, &build_paths, urls)?; - compile_java(sdk_dir, &build_paths, urls)?; + compile_java(sdk_dir, build_crate, &build_paths, urls)?; build_dex(sdk_dir, &build_paths, urls)?; build_unaligned_apk(sdk_dir, &build_paths, urls)?; let build_dir = add_rust_library( diff --git a/xr/src/scene/xr_root.rs b/xr/src/scene/xr_root.rs index 13c1dc954..da3fdaa65 100644 --- a/xr/src/scene/xr_root.rs +++ b/xr/src/scene/xr_root.rs @@ -56,7 +56,7 @@ script_mod! { } } -#[derive(Script, ScriptHook, Clone)] +#[derive(Script, ScriptHook, Clone, Debug)] pub struct XrCamera { #[live(28.0)] pub fov_y: f32, @@ -82,6 +82,14 @@ pub struct XrCamera { pub orbit_last_abs: Option, #[rust] pub viewport_rect: Option, + #[live(false)] + pub ortho: bool, + #[live(2.0)] + pub ortho_height: f32, + #[live(0.02)] + pub ortho_height_min: f32, + #[live(80_000.0)] + pub ortho_height_max: f32, } #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -107,6 +115,10 @@ impl Default for XrCamera { orbit_pitch: 0.0, orbit_last_abs: None, viewport_rect: None, + ortho: false, + ortho_height: 2.0, + ortho_height_min: 0.02, + ortho_height_max: 80_000.0, } } } From 5ec5b65e8df94b26c37134ba0e2bc2ce853ceb67 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 28 Aug 2026 10:18:12 +0300 Subject: [PATCH 10/11] nigig: carry workspace.dependencies into android wrapper manifest The generated android wrapper re-creates a standalone workspace and only forwarded [patch.*] sections from the workspace root manifest, so deps declared via [workspace.dependencies] + workspace = true failed to inherit in wrapped crate builds. Extract the [workspace.dependencies] section the same way patches are handled and inject it into the wrapper manifest. --- tools/cargo_makepad/src/android/compile.rs | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tools/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 4688a5cf3..6a0111b51 100644 --- a/tools/cargo_makepad/src/android/compile.rs +++ b/tools/cargo_makepad/src/android/compile.rs @@ -454,6 +454,51 @@ fn extract_workspace_patch_sections(workspace_manifest: &str) -> String { out } +fn extract_workspace_dependencies_section(workspace_manifest: &str) -> String { + let mut out = String::new(); + let mut current_section: Option = None; + let mut current_body = Vec::new(); + + let flush_section = + |out: &mut String, current_section: &mut Option, current_body: &mut Vec| { + let Some(section) = current_section.take() else { + current_body.clear(); + return; + }; + if section != "[workspace.dependencies]" { + current_body.clear(); + return; + } + + if !out.is_empty() { + out.push('\n'); + } + out.push_str(§ion); + out.push('\n'); + for line in current_body.iter() { + out.push_str(line); + out.push('\n'); + } + current_body.clear(); + }; + + for raw_line in workspace_manifest.lines() { + let trimmed = raw_line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') && !raw_line.starts_with(' ') { + flush_section(&mut out, &mut current_section, &mut current_body); + current_section = Some(trimmed.to_string()); + continue; + } + + if current_section.is_some() { + current_body.push(raw_line.to_string()); + } + } + + flush_section(&mut out, &mut current_section, &mut current_body); + out +} + fn strip_generated_wrapper_args(args: &[String], build_crate: &str) -> Vec { let mut out = Vec::new(); let mut skip_next = false; @@ -556,6 +601,15 @@ fn generate_android_wrapper_manifest( &workspace_root, )); } + + let workspace_deps = extract_workspace_dependencies_section(&workspace_manifest); + if !workspace_deps.trim().is_empty() { + wrapper_manifest.push('\n'); + wrapper_manifest.push_str(&rewrite_wrapper_manifest_paths( + &workspace_deps, + &workspace_root, + )); + } } let wrapper_manifest_path = wrapper_dir.join("Cargo.toml"); From 531114e841ac0faadc38db07df460ca49cb4f94d Mon Sep 17 00:00:00 2001 From: andodeki Date: Thu, 3 Sep 2026 03:29:46 +0300 Subject: [PATCH 11/11] nigig: fix makepad-ai-hub ServiceHandle http_thread type (join network HttpServerHandle) --- libs/ai/hub/src/server.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/ai/hub/src/server.rs b/libs/ai/hub/src/server.rs index d036724c3..2614a6d29 100644 --- a/libs/ai/hub/src/server.rs +++ b/libs/ai/hub/src/server.rs @@ -16,7 +16,8 @@ use crate::registry::{ModelSpec, Registry}; use crate::residency::{self, ResidencyConfig}; use makepad_micro_serde::{DeJson, SerJson}; use makepad_network::{ - start_http_server, HttpServer, HttpServerHeaders, HttpServerRequest, HttpServerResponse, + start_http_server, HttpServer, HttpServerHandle, HttpServerHeaders, HttpServerRequest, + HttpServerResponse, }; use std::collections::HashMap; use std::fs; @@ -43,7 +44,7 @@ pub struct ServiceConfig { pub struct ServiceHandle { pub addr: SocketAddr, - pub http_thread: JoinHandle<()>, + pub http_thread: HttpServerHandle, pub route_thread: JoinHandle<()>, pub worker_thread: JoinHandle<()>, /// One per chat lane. They take from the chat admission class only, so a