Compare commits

...
Sign in to create a new pull request.

7 commits

Author SHA1 Message Date
7d82c6dc93 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.
2026-08-21 06:00:16 +03:00
18e110d432 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.
2026-08-21 05:33:07 +03:00
ec8ac35d88 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.
2026-08-21 05:01:12 +03:00
a9af660e45 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.
2026-08-20 07:41:10 +03:00
ce899827a9 feat(test): extend protocol for touch, long-press, paste, IME composition
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.
2026-08-19 00:50:17 +03:00
ec2c0aaf15 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.
2026-08-16 14:38:41 +03:00
7de0f215d9 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.
2026-08-16 14:36:10 +03:00
15 changed files with 878 additions and 37 deletions

View file

@ -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));
}
}
}

View file

@ -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;
@ -85,6 +87,11 @@ pub struct TestConfig {
pub startup_pause: Duration,
pub action_delay: Duration,
pub keep_open: Duration,
pub android: bool,
pub device_serial: Option<String>,
pub adb_path: Option<String>,
pub android_port: u16,
pub android_native_activity: bool,
}
impl TestConfig {
@ -126,6 +133,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 +169,13 @@ impl TestConnection {
Self::Remote(connection) => connection.recv_timeout(timeout),
}
}
fn studio_addr(&self) -> Option<String> {
match self {
Self::InProcess(connection) => connection.studio_addr(),
Self::Remote(_) => None,
}
}
}
struct TestAppInner {
@ -208,7 +227,9 @@ impl TestApp {
}
fn start_once(config: TestConfig) -> TestResult<Self> {
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)?
@ -454,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<str>) {
if let Err(err) = self.try_paste_text(text) {
panic_for_error(err);
}
}
pub fn try_paste_text(&self, text: impl AsRef<str>) -> 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<str>) {
if let Err(err) = self.try_ime_composition(text) {
panic_for_error(err);
}
}
pub fn try_ime_composition(&self, text: impl AsRef<str>) -> 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,
@ -623,6 +766,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 });
}
@ -964,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<str>) -> Self {
if let Err(err) = self.try_paste(text) {
panic_for_error(err);
}
self
}
pub fn try_paste(&self, text: impl AsRef<str>) -> TestResult<()> {
self.try_click()?;
self.app.try_paste_text(text)
}
pub fn ime_composition(self, text: impl AsRef<str>) -> Self {
if let Err(err) = self.try_ime_composition(text) {
panic_for_error(err);
}
self
}
pub fn try_ime_composition(&self, text: impl AsRef<str>) -> TestResult<()> {
self.try_click()?;
self.app.try_ime_composition(text)
}
pub fn snapshot(&self) -> WidgetSnapshot {
match self.try_snapshot() {
Ok(widget) => widget,
@ -1511,6 +1734,380 @@ 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<String> {
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<String> {
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<String> {
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]);
// 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(())
}
fn adb_enable_package(config: &TestConfig, package: &str) -> TestResult<()> {
let _ = adb_exec(config, &["shell", "pm", "enable", package]);
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);
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<std::path::PathBuf> {
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::<u16>().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: 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));
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)

View file

@ -59,24 +59,50 @@ pub enum HttpServerRequest {
},
}
pub fn start_http_server(http_server: HttpServer) -> Option<std::thread::JoinHandle<()>> {
/// 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<HttpServerHandle> {
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;
let _read_thread = std::thread::spawn(move || {
@ -108,7 +134,10 @@ pub fn start_http_server(http_server: HttpServer) -> Option<std::thread::JoinHan
}
})
};
Some(listen_thread)
Some(HttpServerHandle {
thread: listen_thread,
shutdown: shutdown_tx,
})
}
fn handle_post(

View file

@ -11,7 +11,7 @@ pub mod web_socket_parser;
pub use crate::backend::{EventSink, NetworkBackend, UnsupportedBackend};
pub use crate::http_server::{
start_http_server, HttpServer, HttpServerRequest, HttpServerResponse,
start_http_server, HttpServer, HttpServerHandle, HttpServerRequest, HttpServerResponse,
};
pub use crate::runtime::{NetworkConfig, NetworkRuntime};
pub use crate::socket_stream::SocketStream;

View file

@ -5,7 +5,7 @@ use std::time::Duration;
use makepad_live_id::LiveId;
use crate::backend::{default_backend, EventSink, NetworkBackend};
use crate::http_server::HttpServer;
use crate::http_server::{HttpServer, HttpServerHandle};
use crate::types::{HttpRequest, NetworkError, NetworkResponse, WsSend};
#[derive(Default)]
@ -62,7 +62,7 @@ impl NetworkRuntime {
pub fn start_http_server(
&self,
http_server: HttpServer,
) -> Option<std::thread::JoinHandle<()>> {
) -> Option<HttpServerHandle> {
crate::http_server::start_http_server(http_server)
}

View file

@ -604,6 +604,84 @@ impl Cx {
StudioToApp::Custom(data) => {
self.call_event_handler(&Event::Custom(data));
}
StudioToApp::TouchUpdate(remote_touch) => {
let touches: Vec<crate::event::finger::TouchPoint> = 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

View file

@ -299,7 +299,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();

View file

@ -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<jni_sys::jstring> {
@ -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 {

View file

@ -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<RemoteTouchPoint>,
}
#[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<RemoteGameInput>),
TouchUpdate(RemoteTouchUpdate),
LongPress(RemoteLongPress),
TextPaste(RemoteTextPaste),
IMEComposition(RemoteIMEComposition),
/// Application-defined event. Delivered to the app as `Event::Custom`.
Custom(String),
#[default]

View file

@ -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" }

View file

@ -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));
}
}

View file

@ -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<JoinHandle<()>>,
pub http_thread: Option<JoinHandle<()>>,
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<HubEvent>,
) -> Result<GatewayHandle, String> {
let (request_tx, request_rx) = mpsc::channel::<HttpServerRequest>();
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,
})
}

View file

@ -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

View file

@ -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"]

View file

@ -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;