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 9a8c947d7..a2c8d74d5 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; @@ -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, + pub adb_path: Option, + 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 { + 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 { - 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) { + 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, @@ -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) -> 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, @@ -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 { + 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]); + // 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 { + 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: 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) diff --git a/platform/network/src/http_server.rs b/platform/network/src/http_server.rs index 0af6e2cb1..188f7790f 100644 --- a/platform/network/src/http_server.rs +++ b/platform/network/src/http_server.rs @@ -59,24 +59,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; let _read_thread = std::thread::spawn(move || { @@ -108,7 +134,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/cx_shared.rs b/platform/src/os/cx_shared.rs index 14208cf6c..51f1072bd 100644 --- a/platform/src/os/cx_shared.rs +++ b/platform/src/os/cx_shared.rs @@ -604,6 +604,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/src/os/linux/android/android.rs b/platform/src/os/linux/android/android.rs index 4171b68c7..0a07dbffe 100644 --- a/platform/src/os/linux/android/android.rs +++ b/platform/src/os/linux/android/android.rs @@ -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(); diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs index c5a2b82e8..dd5afb70a 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/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] 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 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 641589d83..743954573 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;