Compare commits
11 commits
7bddebb391
...
531114e841
| Author | SHA1 | Date | |
|---|---|---|---|
| 531114e841 | |||
| 5ec5b65e8d | |||
| 477421e314 | |||
| aeca2e551a | |||
| 454d60897e | |||
| 5c99c410b1 | |||
| bc55ff94aa | |||
| dae59f1422 | |||
| 3e6c5fd57e | |||
| 4c94531456 | |||
|
|
493d23a763 |
27 changed files with 1540 additions and 70 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
195
libs/makepad_test/ANDROID.md
Normal file
195
libs/makepad_test/ANDROID.md
Normal file
|
|
@ -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:<port>` on the host
|
||||
2. forwards that port to the device with `adb reverse tcp:<port> tcp:<port>`
|
||||
3. builds the APK through `cargo-makepad` (`android build -p <package>`)
|
||||
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:<port>` 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.<pkg>/.MakepadApp` | no `--cfg native_activity` | default |
|
||||
| NativeActivity | `<pkg>/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 <pkg>`.
|
||||
`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 <activity>
|
||||
-e makepad.STUDIO_HOST 127.0.0.1:<port>
|
||||
-e makepad.STUDIO_BUILD <build_id>
|
||||
-e makepad.STUDIO_CRATE <package>
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `MAKEPAD_TEST_ANDROID` | enable Android mode (any truthy value) | unset (off) |
|
||||
| `MAKEPAD_TEST_DEVICE` | adb device serial (`-s <serial>`) | 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:<port>` 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 `<workspace>/target/release/cargo-makepad`:
|
||||
`cargo build --release -p cargo-makepad`.
|
||||
- The APK lands at
|
||||
`target/android/makepad-android-apk/<pkg_underscored>/apk/<pkg_underscored>.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="<repo>/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 <serial> 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="<repo>/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="<repo>/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/<package>/<test>/`
|
||||
(`failure.txt`, `failure-screenshot.png`, logs), exactly like desktop mode.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|---------|--------------------|
|
||||
| `adb: error: device '<serial>' 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 <serial>` 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
|
||||
149
libs/makepad_test/DESKTOP_VISIBLE.md
Normal file
149
libs/makepad_test/DESKTOP_VISIBLE.md
Normal file
|
|
@ -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/<package>/<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
|
||||
|
|
@ -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;
|
||||
|
|
@ -23,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;
|
||||
|
|
@ -81,10 +84,16 @@ pub struct TestConfig {
|
|||
pub env: HashMap<String, String>,
|
||||
pub startup_timeout: Duration,
|
||||
pub action_timeout: Duration,
|
||||
pub wait_timeout: Duration,
|
||||
pub poll_interval: Duration,
|
||||
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 {
|
||||
|
|
@ -122,10 +131,16 @@ 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"),
|
||||
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 +172,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 +230,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,12 +478,138 @@ 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,
|
||||
visible_only: bool,
|
||||
) -> TestResult<Vec<WidgetSnapshot>> {
|
||||
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()
|
||||
|
|
@ -577,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
|
||||
}
|
||||
|
|
@ -623,6 +777,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 });
|
||||
}
|
||||
|
|
@ -669,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(());
|
||||
|
|
@ -690,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(());
|
||||
|
|
@ -711,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 {
|
||||
|
|
@ -964,6 +1122,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,
|
||||
|
|
@ -1537,6 +1771,399 @@ 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 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(&args)
|
||||
.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.READ_CONTACTS",
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -92,24 +92,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;
|
||||
// Shed over the cap INLINE (no thread): the pile of leaked
|
||||
|
|
@ -156,7 +182,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(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
92
platform/src/devtools.rs
Normal file
92
platform/src/devtools.rs
Normal file
|
|
@ -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<bool> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -805,6 +805,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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
@ -448,11 +449,14 @@ 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());
|
||||
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 +475,21 @@ 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);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
38
studio/hub/src/bin/hub_server.rs
Normal file
38
studio/hub/src/bin/hub_server.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> = None;
|
||||
let mut current_body = Vec::new();
|
||||
|
||||
let flush_section =
|
||||
|out: &mut String, current_section: &mut Option<String>, current_body: &mut Vec<String>| {
|
||||
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<String> {
|
||||
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");
|
||||
|
|
@ -1011,12 +1065,24 @@ fn prepare_build(opts: &PrepareBuildOpts<'_>) -> Result<BuildPaths, String> {
|
|||
debuggable: opts.debuggable,
|
||||
};
|
||||
|
||||
// Custom manifest override: if `<crate>/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. `<crate>/resources/android/AndroidManifest.xml` — used verbatim (no
|
||||
// placeholder substitution). Drop a finished manifest and cargo-makepad
|
||||
// uses it as-is.
|
||||
// 2. `<crate>/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 +1158,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<Vec<PathBuf>, 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 +1202,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 +1220,20 @@ fn compile_java(
|
|||
build_paths.xr_file.clone(),
|
||||
];
|
||||
|
||||
// App-supplied Java hook: compile every `<crate>/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 `<service>` 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 +2727,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 +2889,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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<DVec2>,
|
||||
#[rust]
|
||||
pub viewport_rect: Option<Rect>,
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue