feat(dev): ask the phone instead of looking at it
The device tier could only take a screenshot and read what Go chose to log, and a screenshot cannot tell a dropped CSS declaration from a missing asset. This adds the third thing: the page's own answer, from the engine that is really rendering it. `make android-screenshot` grabs the screen, `make android-inspect` forwards the WebView's devtools socket, and `make android-eval EXPR=...` evaluates in the real page. Four details are load-bearing. Only a `debuggable` build opens that socket, so the debug build type takes `applicationIdSuffix ".dev"` and installs *beside* the release app -- the two carry different signing certificates, and Android's only remedy for a changed certificate is an uninstall, which takes the user's library with it. Playwright cannot drive a WebView (`connectOverCDP` calls `Browser.setDownloadBehavior`, which it answers "Browser context management is not supported"), so the eval is raw CDP over Node's built-in WebSocket. The socket name carries the pid, so it is resolved per launch rather than written down. And `exec-out`, not `shell`, for the screenshot: a pty translates LF and corrupts the PNG. What it immediately established is why it was worth having. The phone renders in Chrome 113 at 424x439 CSS px -- two years behind every browser the other tiers use, with no Popover API and no relaxed CSS nesting -- so a spec passing at that viewport says nothing about the device, and two conclusions drawn from version numbers alone were wrong. Both are corrected in NOTES.md and the plan.
This commit is contained in:
@@ -310,3 +310,41 @@ So when asking for a device run, ask about what the platform *adds* —
|
||||
system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
## Asking the device, not just looking at it
|
||||
|
||||
A real phone can be inspected, and that turns this tier from "reported
|
||||
symptoms" into evidence. Three commands:
|
||||
|
||||
```bash
|
||||
make android-screenshot # what the screen shows (.dev/ by default)
|
||||
make android-inspect # forward the WebView's devtools socket
|
||||
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
|
||||
```
|
||||
|
||||
Four things about it, each of which costs an hour if met cold:
|
||||
|
||||
- **Only a `debuggable` build has a devtools socket**, and a debug build
|
||||
carries `applicationIdSuffix ".dev"` so it installs **beside** the
|
||||
release app. That matters more than convenience: the two are signed by
|
||||
different certificates, and Android's only remedy for a changed
|
||||
certificate is an uninstall, which takes the user's library with it.
|
||||
Never uninstall to make room for a build.
|
||||
- **Playwright cannot drive it.** `connectOverCDP` calls
|
||||
`Browser.setDownloadBehavior`, a WebView answers "Browser context
|
||||
management is not supported", and the connection dies before the first
|
||||
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
|
||||
WebSocket for that reason.
|
||||
- **Wireless adb drops when the screen sleeps.** The symptoms are
|
||||
`device offline` mid-session and a `fetch failed` from the eval
|
||||
script. Plug in over USB for anything longer than a couple of probes.
|
||||
- **The socket name carries the pid**, which changes on every launch, so
|
||||
it is resolved rather than remembered.
|
||||
|
||||
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||
other tier runs a current Chromium or WebKit, so a spec that passes at
|
||||
that viewport says nothing about the phone — 113 has no Popover API and
|
||||
no relaxed CSS nesting, and a dropped CSS declaration renders as
|
||||
"present but wrong", which is the hardest failure to read from a
|
||||
picture. Get the version first; it reframes every other symptom.
|
||||
|
||||
@@ -3138,3 +3138,73 @@ cover the history behaviour, on Chromium locally and WebKit in CI.
|
||||
Not verified: the insets themselves, which need the next APK on the
|
||||
owner's phone. What to look for is one thing — the transport and the tab
|
||||
bar clear of the gesture bar, and the header clear of the status bar.
|
||||
|
||||
## The phone is a Chrome 113 WebView, and that reframes everything (2026-08-17)
|
||||
|
||||
The device is reachable over adb now, so the tier can be *asked* rather
|
||||
than reported on. `make android-inspect` + `make android-eval` are that:
|
||||
a debug build (`applicationIdSuffix ".dev"`, so it installs **beside**
|
||||
the release app rather than needing the uninstall that would take the
|
||||
library with it) opens `webview_devtools_remote_<pid>`, and raw CDP over
|
||||
Node's built-in WebSocket evaluates in the real page. **Playwright
|
||||
cannot do this** — `connectOverCDP` calls `Browser.setDownloadBehavior`
|
||||
and a WebView answers "Browser context management is not supported",
|
||||
killing the connection before the first evaluate.
|
||||
|
||||
Measured on the device (Light Phone III, TLP301):
|
||||
|
||||
| fact | value |
|
||||
| --- | --- |
|
||||
| Android | 14, SDK 34 |
|
||||
| screen | 1080x1240, density 408 |
|
||||
| WebView viewport | **424 x 439 CSS px**, DPR 2.55 |
|
||||
| WebView engine | **Chrome 113.0.5672.136** (mid-2023) |
|
||||
|
||||
**The first correction: the insets commit does not explain the report.**
|
||||
Edge-to-edge is forced for apps *running on* Android 15, and this phone
|
||||
is Android 14 — the screenshot shows the app correctly inset, with the
|
||||
status bar and the gesture bar outside it. `applyWindowInsets()` is
|
||||
right and stays (the next phone, or one OS update, is Android 15), but
|
||||
it is **pre-emptive, not the fix for "the controls are off screen"**.
|
||||
That was an inference from a version number, and the device disagreed.
|
||||
|
||||
**The second correction: the black `fill` proves nothing.** A wa-icon on
|
||||
the device has the right `color` (#ffd43b) and an `<svg>` in its shadow
|
||||
root, and `getComputedStyle(svg).fill` is black — but that is the *svg
|
||||
root*, and every vendored Font Awesome path carries
|
||||
`fill="currentColor"` itself, so the root's fill is irrelevant. Measuring
|
||||
the wrong node produced a diagnosis-shaped result. `__yjIconMisses` is
|
||||
empty, so no name is unbundled either. Why the icons do not appear in the
|
||||
screenshot is **still open**.
|
||||
|
||||
**What the engine version does explain, and what to check next.**
|
||||
Chrome 113 has `:has()`, `color-mix()` and `dialog.showModal()`, and
|
||||
lacks three things this app's dependencies use:
|
||||
|
||||
- **Relaxed CSS nesting** (Chrome 120): a nested rule starting with a
|
||||
bare element selector is dropped. `.x { svg { ... } }` parses to
|
||||
nothing; `.x { & svg { ... } }` parses. Any Web Awesome or app
|
||||
stylesheet written the modern way silently loses declarations here,
|
||||
and dropped declarations are exactly the failure that looks like
|
||||
"rendered but wrong".
|
||||
- **The Popover API** (Chrome 114). Web Awesome's popup calls
|
||||
`showPopover?.()` — optional, so nothing throws — but also sets
|
||||
`popover="manual"`, which on 113 is an unknown attribute doing
|
||||
nothing. Every context menu, dropdown and the whole menu keyboard
|
||||
model rides on that, so it is the first thing to test with a library
|
||||
present.
|
||||
- `light-dark()` and relative colour syntax (`rgb(from ...)`).
|
||||
|
||||
**The lesson for the tier: a device is an engine, not just a screen.**
|
||||
Every browser tier here runs a current Chromium or WebKit, and the phone
|
||||
that will actually run this app is two years behind — so "it renders at
|
||||
424x439 in Chromium" (checked, the transport is on screen) says nothing
|
||||
about whether it renders on the phone. The e2e tier cannot be fixed by
|
||||
resizing; the missing signal is version, and CDP against the device is
|
||||
the only place to get it.
|
||||
|
||||
Verified by execution: every number in the table, the four feature
|
||||
probes, and that the hardware back button no longer kills the app (the
|
||||
`.dev` build carries the history fix; pid survived a BACK press).
|
||||
Unverified: what happened to the icons and the transport controls, which
|
||||
is where this resumes.
|
||||
|
||||
@@ -360,9 +360,14 @@ awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||
navigation is a history entry now, and `navStack` is gone rather than
|
||||
kept beside it.
|
||||
- **The transport was under the gesture bar.** `targetSdk 35` is
|
||||
edge-to-edge by force; `applyWindowInsets()` in `MainActivity` pads
|
||||
by `systemBars | displayCutout | ime`.
|
||||
- **The transport was under the gesture bar** — or so the version
|
||||
number said. `applyWindowInsets()` in `MainActivity` is right and
|
||||
stays, but the phone is **Android 14**, where the system still insets
|
||||
the window: the fix is pre-emptive and the symptom has another cause.
|
||||
Still open, along with icons that do not appear at all. The phone's
|
||||
WebView is **Chrome 113**, which is the lead (no Popover API, no
|
||||
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
|
||||
how it gets asked.
|
||||
|
||||
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||
permission flow still need a device**, and so does confirming these two.
|
||||
|
||||
@@ -1998,14 +1998,29 @@ still not carried and its `includes:` entry is still dropped.
|
||||
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
|
||||
not leave that to the theme.** Android 15 lays every app out
|
||||
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
|
||||
scaffold's theme sets, and the WebView is `match_parent` — so the page's
|
||||
bottom band, which on a phone is the transport *and* the tab bar, was
|
||||
scaffold's theme sets, and the WebView is `match_parent`, so the page's
|
||||
bottom band — the transport and, on a phone, the tab bar — would be
|
||||
drawn under the gesture bar. `applyWindowInsets()` pads the container by
|
||||
`systemBars | displayCutout | ime` and returns the insets rather than
|
||||
consuming them; the window background is black to match the app's own
|
||||
ramp, since that padding is what shows through. **No browser tier can
|
||||
see this class of fault** — a viewport has no system bars, so the phone
|
||||
specs render a shell that fits at the moment the device is clipping it.
|
||||
ramp, since that padding is what shows through. It is **pre-emptive**:
|
||||
the phone this was checked against is Android 14, where the system still
|
||||
insets the window, and the enforcement applies to an app *running on*
|
||||
15. No browser tier can see this class of fault either way — a viewport
|
||||
has no system bars.
|
||||
|
||||
**And a device is an engine, not just a screen.** The phone this app was
|
||||
first run on renders in **Chrome 113** — two years behind every browser
|
||||
any other tier uses — at a 424x439 CSS px viewport. It has `:has()`,
|
||||
`color-mix()` and `dialog.showModal()`; it does **not** have relaxed CSS
|
||||
nesting (Chrome 120, so a nested rule beginning with a bare element
|
||||
selector is silently dropped), the Popover API (114, which Web Awesome's
|
||||
popups set `popover="manual"` for), `light-dark()` or relative colour
|
||||
syntax. So "it renders at that size in Chromium" is not evidence about
|
||||
the phone, and resizing a spec cannot recover the missing signal. `make
|
||||
android-inspect` forwards the WebView's devtools socket and `make
|
||||
android-eval` asks the real page — raw CDP, because `connectOverCDP`
|
||||
calls `Browser.setDownloadBehavior` and a WebView refuses it.
|
||||
|
||||
`build/config.yml`'s `version` is the
|
||||
*metadata* version and is not what the app reports — `main.version` is
|
||||
|
||||
@@ -78,6 +78,19 @@ android-launch: ## Force-stop, clear logcat, and start the app
|
||||
android-logs: ## Tail logcat, filtered to the app's own tags
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
|
||||
|
||||
# The only tier that can see the platform is the one you can look at.
|
||||
android-screenshot: ## Grab the device screen (OUT=<path>)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
|
||||
|
||||
# The page's own answer, from the engine that is really rendering it.
|
||||
# Needs the debug build installed (it is a sibling id, so it does not
|
||||
# disturb the release app): see scripts/android-eval.mjs.
|
||||
android-inspect: ## Forward the device WebView's devtools socket
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
|
||||
|
||||
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
|
||||
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
|
||||
|
||||
# "Did it start" is the wrong question — a crash-looping app starts
|
||||
# several times a second. This asserts the *same pid* is still there.
|
||||
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
|
||||
|
||||
@@ -83,6 +83,16 @@ android {
|
||||
}
|
||||
debug {
|
||||
debuggable true
|
||||
// Its own application id, so it installs *beside* the release
|
||||
// app rather than needing an uninstall to replace it. The two
|
||||
// are signed by different certificates (the release one comes
|
||||
// from a keystore CI holds), and Android's remedy for a
|
||||
// certificate change is an uninstall -- which takes the
|
||||
// user's library with it. This is also what makes the WebView
|
||||
// inspectable on a real phone: `debuggable` is what turns on
|
||||
// `setWebContentsDebuggingEnabled`, and `make android-inspect`
|
||||
// drives it.
|
||||
applicationIdSuffix ".dev"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ cd "$(dirname "$0")/.."
|
||||
AVD="${YJ_AVD:-yj-test}"
|
||||
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
|
||||
PKG="${YJ_ANDROID_PKG:-app.yellowjacket}"
|
||||
# Where `make android-inspect` forwards the WebView's devtools socket.
|
||||
CDP_PORT="${YJ_ANDROID_CDP_PORT:-9222}"
|
||||
# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved
|
||||
# relative to the *applicationId*, and the scaffold's activity lives in
|
||||
# the Java package `com.wails.app`, which is deliberately not the
|
||||
@@ -264,6 +266,67 @@ cmd_logs() {
|
||||
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
|
||||
}
|
||||
|
||||
# Forward the WebView's devtools socket, so the page can be asked things.
|
||||
#
|
||||
# Only a `debuggable` build opens that socket, and a debug build carries
|
||||
# `applicationIdSuffix ".dev"` precisely so it can be installed *beside*
|
||||
# the release app: the two are signed by different certificates, and
|
||||
# Android's remedy for a certificate change is an uninstall, which takes
|
||||
# the user's library with it. So this looks for the sibling first and the
|
||||
# release id second.
|
||||
#
|
||||
# The socket name carries the pid, which changes on every launch -- which
|
||||
# is why this resolves it rather than documenting a number.
|
||||
cmd_inspect() {
|
||||
need_sdk
|
||||
pick_device || die "no device -- plug a phone in (USB debugging on) or run 'make android-emulator'"
|
||||
|
||||
local pkg pid
|
||||
pid=""
|
||||
|
||||
for pkg in "$PKG.dev" "$PKG"; do
|
||||
pid=$("$ADB" shell pidof "$pkg" 2>/dev/null | tr -d '\r' | awk '{print $1}')
|
||||
[ -n "$pid" ] && break
|
||||
done
|
||||
|
||||
[ -n "$pid" ] || die "neither $PKG.dev nor $PKG is running; launch it first"
|
||||
|
||||
"$ADB" forward --remove-all >/dev/null 2>&1 || true
|
||||
"$ADB" forward "tcp:$CDP_PORT" "localabstract:webview_devtools_remote_$pid" >/dev/null \
|
||||
|| die "adb forward failed"
|
||||
|
||||
echo "android: $pkg (pid $pid) devtools on http://localhost:$CDP_PORT"
|
||||
echo " make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'"
|
||||
echo " (a release build has no devtools socket: build and install the debug one)"
|
||||
}
|
||||
|
||||
# What the phone is actually showing.
|
||||
#
|
||||
# This tier exists because no other one can see the platform: system
|
||||
# bars, the safe area, the keyboard, an OEM's permission dialog. All of
|
||||
# those are things you have to *look* at, and two of the three faults
|
||||
# found so far were found by reading a picture rather than an assertion
|
||||
# (`android-tier.md`).
|
||||
#
|
||||
# `exec-out` and not `shell`: `adb shell` runs the output through a pty
|
||||
# on some platforms, which translates LF and corrupts the PNG -- for
|
||||
# which the symptom is an image viewer refusing a file that downloaded
|
||||
# perfectly.
|
||||
cmd_screenshot() {
|
||||
need_sdk
|
||||
pick_device || die "no device \u2014 plug a phone in (USB debugging on) or run 'make android-emulator'"
|
||||
|
||||
local out="${1:-}"
|
||||
|
||||
[ -n "$out" ] || out=".dev/android-$(date +%Y%m%d-%H%M%S).png"
|
||||
mkdir -p "$(dirname "$out")"
|
||||
|
||||
"$ADB" exec-out screencap -p > "$out" || die "screencap failed"
|
||||
[ -s "$out" ] || die "screencap produced nothing (is the screen locked?)"
|
||||
|
||||
echo "android: screenshot -> $out"
|
||||
}
|
||||
|
||||
# Start the app and assert it is *still the same process* a few seconds
|
||||
# later. "It started" is not the question — a crash-looping app starts
|
||||
# continuously.
|
||||
@@ -316,9 +379,11 @@ stop) cmd_stop ;;
|
||||
install) cmd_install ;;
|
||||
launch) cmd_launch ;;
|
||||
logs) cmd_logs ;;
|
||||
screenshot) cmd_screenshot "${2:-}" ;;
|
||||
inspect) cmd_inspect ;;
|
||||
smoke) cmd_smoke "${2:-10}" ;;
|
||||
*)
|
||||
echo "usage: $0 {setup|start|stop|install|launch|logs|smoke [seconds]}" >&2
|
||||
echo "usage: $0 {setup|start|stop|install|launch|logs|screenshot [path]|inspect|smoke [seconds]}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Evaluate an expression inside the app's WebView on a real device.
|
||||
*
|
||||
* The device tier could only ever *look* at the app (a screenshot) or
|
||||
* read what Go chose to log. This is the third thing: the page's own
|
||||
* answer, from the engine that is actually rendering it — which is how
|
||||
* "the icons are missing" stops being a guess about assets and becomes a
|
||||
* computed style.
|
||||
*
|
||||
* Two facts make it work at all. A `debuggable` build calls
|
||||
* `WebView.setWebContentsDebuggingEnabled(true)`, which opens an abstract
|
||||
* unix socket per process (`webview_devtools_remote_<pid>`); `make
|
||||
* android-inspect` forwards it to localhost. And **Playwright cannot use
|
||||
* it** — `connectOverCDP` immediately calls `Browser.setDownloadBehavior`,
|
||||
* which a WebView answers with "Browser context management is not
|
||||
* supported", so the connection dies before the first evaluate. Raw CDP
|
||||
* over Node's built-in WebSocket is a dozen lines and has no such
|
||||
* opinion.
|
||||
*
|
||||
* Usage: node scripts/android-eval.mjs '<js expression>'
|
||||
* make android-eval EXPR='...'
|
||||
*
|
||||
* The expression is evaluated with `awaitPromise`, so an async probe is
|
||||
* fine. Return a string (`JSON.stringify(...)`) for anything structured:
|
||||
* `returnByValue` will not serialise a DOM node.
|
||||
*/
|
||||
const PORT = process.env.YJ_ANDROID_CDP_PORT ?? '9222';
|
||||
const expression = process.argv[2];
|
||||
|
||||
if (!expression) {
|
||||
console.error("usage: node scripts/android-eval.mjs '<js expression>'");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const endpoint = `http://localhost:${PORT}/json`;
|
||||
let targets;
|
||||
|
||||
try {
|
||||
targets = await (await fetch(endpoint)).json();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`android-eval: nothing on :${PORT} (${err.message})\n` +
|
||||
" run 'make android-inspect' first, and check the phone is " +
|
||||
'awake -- wireless adb drops when the screen sleeps',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const page = targets.find((t) => t.type === 'page');
|
||||
|
||||
if (!page) {
|
||||
console.error('android-eval: no page target; is the app in the foreground?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = () => reject(new Error('websocket refused'));
|
||||
});
|
||||
|
||||
const answer = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('evaluate timed out')), 20_000);
|
||||
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
|
||||
if (msg.id !== 1) return;
|
||||
|
||||
clearTimeout(timer);
|
||||
resolve(msg.result);
|
||||
};
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression, awaitPromise: true, returnByValue: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.close();
|
||||
|
||||
if (answer.exceptionDetails) {
|
||||
console.error(
|
||||
'android-eval: threw:',
|
||||
answer.exceptionDetails.exception?.description ??
|
||||
answer.exceptionDetails.text,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const value = answer.result?.value;
|
||||
|
||||
console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
||||
Reference in New Issue
Block a user