mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
fix(embed): don't let a readonly field's mirror be typed into
Audit of the branch, two findings. `readonly` stops the *user* editing a field, not scripts: the shim writes through the native value setter, so any text that reaches the host mirror is applied to the page and fires an `input` event no native browser would. Cut and Paste were refused at their call sites, but that misses a hardware keyboard (tablets, DeX, Chromebooks) — whose Ctrl+V goes straight to `onTextContextMenuItem`, bypassing the wrapper — and autofill. Configure the mirror as TYPE_NULL for a readonly field instead: `onCheckIsTextEditor()` is then false, so there is no InputConnection to type through at all, while selection and Copy — the half native does offer on a readonly field — keep working. The selection toolbar's item list was rebuilt on every recomposition of the tab layer, which recomposes on every IME inset change, bounds report and console line, for a toolbar only shown during a selection. Remembered on the readonly flag, so it allocates once and keeps a stable identity the overlay can skip on. Adds tools/ime-test/shim-events.mjs, a regression test that drives the shipped shim in headless Chromium and asserts the page→host envelopes. It fails on main (7 cases, including "no ime.wantkb — the keyboard could never come back") and passes here. A JVM unit test cannot cover this: the host parser runs on Android's org.json, which the unit tests stub out, so it would pass without parsing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
This commit is contained in:
+30
-24
@@ -533,33 +533,39 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// handle is dragged or the page scrolls; the handles hide only while scrolling.
|
||||
// A readonly field offers only the non-destructive half: its text can be selected and copied, but Cut
|
||||
// and Paste would silently do nothing (the page rejects the edit), so native never offers them there.
|
||||
// Remembered, not rebuilt per composition: this layer recomposes on every IME inset change, bounds
|
||||
// report and console line, while the toolbar it feeds is shown only during a selection. A fresh list
|
||||
// of fresh lambdas each time would allocate for nothing and, having a new identity every pass, stop
|
||||
// the overlay below from ever skipping.
|
||||
val fieldItems =
|
||||
listOfNotNull(
|
||||
if (sel.fieldReadOnly) {
|
||||
null
|
||||
} else {
|
||||
"Cut" to {
|
||||
imeView.cutSelection()
|
||||
remember(sel.fieldReadOnly, imeView) {
|
||||
listOfNotNull(
|
||||
if (sel.fieldReadOnly) {
|
||||
null
|
||||
} else {
|
||||
"Cut" to {
|
||||
imeView.cutSelection()
|
||||
Unit
|
||||
}
|
||||
},
|
||||
"Copy" to {
|
||||
imeView.copySelection()
|
||||
Unit
|
||||
}
|
||||
},
|
||||
"Copy" to {
|
||||
imeView.copySelection()
|
||||
Unit
|
||||
},
|
||||
if (sel.fieldReadOnly) {
|
||||
null
|
||||
} else {
|
||||
"Paste" to {
|
||||
imeView.pasteClipboard()
|
||||
},
|
||||
if (sel.fieldReadOnly) {
|
||||
null
|
||||
} else {
|
||||
"Paste" to {
|
||||
imeView.pasteClipboard()
|
||||
Unit
|
||||
}
|
||||
},
|
||||
"Select all" to {
|
||||
imeView.selectAllText()
|
||||
Unit
|
||||
}
|
||||
},
|
||||
"Select all" to {
|
||||
imeView.selectAllText()
|
||||
Unit
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val fieldHandles = sel.fieldHandles
|
||||
if (haveBounds && fieldHandles != null) {
|
||||
RangeSelectionOverlay(
|
||||
|
||||
+20
-9
@@ -379,15 +379,26 @@ class RemoteImeView(
|
||||
|
||||
private fun configureFor(focus: ImeEvent.Focus) {
|
||||
inputType =
|
||||
when (focus.inputType) {
|
||||
"password" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
|
||||
"email" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS
|
||||
"url" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||
"number" -> InputType.TYPE_CLASS_NUMBER
|
||||
"tel" -> InputType.TYPE_CLASS_PHONE
|
||||
else ->
|
||||
InputType.TYPE_CLASS_TEXT or
|
||||
(if (focus.multiline) InputType.TYPE_TEXT_FLAG_MULTI_LINE else InputType.TYPE_TEXT_VARIATION_NORMAL)
|
||||
// A readonly field's mirror must not be typeable AT ALL, not merely keyboard-less. `readonly`
|
||||
// stops the *user* editing the field, not scripts — the shim writes through the native value
|
||||
// setter, so anything that reaches this Editable is applied to the page and fires an `input`
|
||||
// event no native browser would. Refusing cut/paste at the call sites doesn't cover a hardware
|
||||
// keyboard (common on tablets/DeX), whose Ctrl+V goes straight to `onTextContextMenuItem`.
|
||||
// TYPE_NULL makes `onCheckIsTextEditor()` false, so there is no InputConnection to type through
|
||||
// — while selection and Copy, the half native does offer here, keep working.
|
||||
if (focus.readOnly) {
|
||||
InputType.TYPE_NULL
|
||||
} else {
|
||||
when (focus.inputType) {
|
||||
"password" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
|
||||
"email" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS
|
||||
"url" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||
"number" -> InputType.TYPE_CLASS_NUMBER
|
||||
"tel" -> InputType.TYPE_CLASS_PHONE
|
||||
else ->
|
||||
InputType.TYPE_CLASS_TEXT or
|
||||
(if (focus.multiline) InputType.TYPE_TEXT_FLAG_MULTI_LINE else InputType.TYPE_TEXT_VARIATION_NORMAL)
|
||||
}
|
||||
}
|
||||
imeOptions = editorActionFor(focus) or EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
package.json
|
||||
@@ -53,6 +53,33 @@ live only under `tools/`.
|
||||
- `MAINTHREAD BLOCKED` / `LONGTASK` = something is stalling the WebView thread.
|
||||
- `HEARTBEAT` lines changing while idle = spontaneous focus/selection drift.
|
||||
|
||||
## `shim-events.mjs` — automated regression test for the IME protocol
|
||||
|
||||
`index.html` and `perf.html` are manual probes; this one is a real test. It loads
|
||||
the shipped shim (`commons/.../napplet/shim.js`) into headless Chromium with the
|
||||
embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts
|
||||
the `ime.*` envelopes it emits — the doorbell fires on a tap in an
|
||||
already-focused field, the doorbell stays payload-free, a host `ime.resync` is
|
||||
answered with the field state and no geometry, `readonly` survives the round
|
||||
trip, and a tap inside a `contenteditable` counts.
|
||||
|
||||
```bash
|
||||
cd tools/ime-test
|
||||
npm i playwright-core # once; the browser itself is already on the box
|
||||
node shim-events.mjs # exits 0 on success, 1 with a per-case report
|
||||
node shim-events.mjs /path/to/other/shim.js # diff a candidate against it
|
||||
```
|
||||
|
||||
Set `CHROMIUM_PATH` if your Chromium lives somewhere other than
|
||||
`/opt/pw-browsers/chromium-1194/chrome-linux/chrome`.
|
||||
|
||||
**Why this and not a JVM unit test.** The host-side parser
|
||||
(`parseImeEvent`) runs on Android's `org.json`, which the unit tests stub out
|
||||
(`unitTests.isReturnDefaultValues = true` in `amethyst/build.gradle.kts`, and
|
||||
there is no Robolectric); a Kotlin test would "pass" without parsing anything.
|
||||
The half worth protecting is the page↔host contract, and that only exists in a
|
||||
browser.
|
||||
|
||||
## `perf.html` — why does the embed feel slower than the full-screen browser?
|
||||
|
||||
`index.html` profiles the IME relay. `perf.html` answers a different question:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Regression test for the embedded-WebView IME relay's page→host protocol.
|
||||
//
|
||||
// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium
|
||||
// with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*`
|
||||
// envelopes it emits. This is the only honest automated coverage for this code: the host-side parser runs
|
||||
// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so
|
||||
// a Kotlin test of it would pass without parsing anything.
|
||||
//
|
||||
// cd tools/ime-test && npm i playwright-core && node shim-events.mjs
|
||||
//
|
||||
// Exits 0 if every expectation holds, 1 otherwise. Override the browser with CHROMIUM_PATH, and the shim
|
||||
// under test with argv[2] (useful for diffing a candidate against the committed one).
|
||||
|
||||
import { chromium } from 'playwright-core'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const SHIM = process.argv[2] ?? resolve(HERE, '../../commons/src/commonMain/composeResources/files/napplet/shim.js')
|
||||
const CHROMIUM = process.env.CHROMIUM_PATH ?? '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'
|
||||
|
||||
const HTML = `<!doctype html><meta charset=utf-8><title>ime</title>
|
||||
<body style="margin:0;font:16px sans-serif">
|
||||
<p id="para">plain page text, not editable</p>
|
||||
<input id="inp" value="hello world" style="width:90%;height:40px">
|
||||
<input id="ro" value="read only field" readonly style="width:90%;height:40px">
|
||||
<div id="ce" contenteditable="true" style="border:1px solid #000;padding:8px">
|
||||
<span id="cespan">editable span text</span>
|
||||
</div>
|
||||
</body>`
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROMIUM, args: ['--no-sandbox'] })
|
||||
const context = await browser.newContext()
|
||||
|
||||
// Stand in for the native bridge the `:napplet` process installs: collect what the page sends, and keep the
|
||||
// reply channel so host→page ops (`ime.resync`) can be delivered exactly as the host delivers them.
|
||||
await context.addInitScript(() => {
|
||||
window.__sent = []
|
||||
window.__nappletDirectBridge = true
|
||||
window.__nappletImeProxy = true
|
||||
window.__nappletBridge = {
|
||||
postMessage(s) { window.__sent.push(s) },
|
||||
set onmessage(fn) { window.__imeIn = fn },
|
||||
get onmessage() { return window.__imeIn },
|
||||
}
|
||||
})
|
||||
await context.addInitScript({ content: readFileSync(SHIM, 'utf8') })
|
||||
await context.route('https://ime.test/**', (route) => route.fulfill({ contentType: 'text/html', body: HTML }))
|
||||
const page = await context.newPage()
|
||||
await page.goto('https://ime.test/')
|
||||
|
||||
const drain = async () => {
|
||||
const raw = await page.evaluate(() => { const s = window.__sent.slice(); window.__sent.length = 0; return s })
|
||||
return raw.map((s) => JSON.parse(s)).filter((m) => (m.type || '').startsWith('ime.'))
|
||||
}
|
||||
|
||||
const failures = []
|
||||
const results = []
|
||||
|
||||
// `expect` is a predicate over the messages one gesture produced, described in words for the report.
|
||||
const step = async (name, gesture, expectation) => {
|
||||
await gesture()
|
||||
await page.waitForTimeout(150)
|
||||
const msgs = await drain()
|
||||
const problem = expectation(msgs)
|
||||
const types = msgs.map((m) => m.type).join(', ') || '(nothing)'
|
||||
results.push([name, types, problem])
|
||||
if (problem) failures.push(`${name}: ${problem}\n got: ${types}`)
|
||||
}
|
||||
|
||||
const has = (msgs, type) => msgs.some((m) => m.type === type)
|
||||
const find = (msgs, type) => msgs.find((m) => m.type === type)
|
||||
|
||||
await step(
|
||||
'tap an unfocused field announces it and asks for the keyboard',
|
||||
() => page.click('#inp'),
|
||||
(m) => {
|
||||
const focus = find(m, 'ime.focus')
|
||||
if (!focus) return 'no ime.focus'
|
||||
if (focus.text !== 'hello world') return `ime.focus carried text "${focus.text}"`
|
||||
if (focus.readOnly !== false) return 'ime.focus said readOnly on an editable field'
|
||||
return has(m, 'ime.wantkb') ? null : 'no ime.wantkb doorbell'
|
||||
},
|
||||
)
|
||||
|
||||
// The regression this suite exists for: a tap on a field that never blurred fires no focus event, so before
|
||||
// `ime.wantkb` existed the host had no signal at all and the keyboard could not be brought back.
|
||||
await step(
|
||||
'tap the SAME already-focused field still rings the doorbell',
|
||||
() => page.click('#inp'),
|
||||
(m) => {
|
||||
if (!has(m, 'ime.wantkb')) return 'no ime.wantkb — the keyboard could never come back'
|
||||
if (has(m, 'ime.focus')) return 'unexpected ime.focus (page focus never moved)'
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
// The doorbell must stay payload-free: it fires on every tap in a field, so attaching the editing state
|
||||
// would put the whole field text on the wire per tap.
|
||||
await step(
|
||||
'the doorbell carries no payload',
|
||||
() => page.click('#inp'),
|
||||
(m) => {
|
||||
const kb = find(m, 'ime.wantkb')
|
||||
if (!kb) return 'no ime.wantkb'
|
||||
const extra = Object.keys(kb).filter((k) => k !== 'type' && k !== 'id')
|
||||
return extra.length ? `ime.wantkb carried ${extra.join(', ')}` : null
|
||||
},
|
||||
)
|
||||
|
||||
await step(
|
||||
'a host resync is answered with the focused field state',
|
||||
() => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })),
|
||||
(m) => {
|
||||
const re = find(m, 'ime.refocus')
|
||||
if (!re) return 'no ime.refocus'
|
||||
if (re.text !== 'hello world') return `ime.refocus carried text "${re.text}"`
|
||||
if (re.geom !== undefined) return 'ime.refocus carried geometry (forces a synchronous layout per send)'
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
await step(
|
||||
'tapping off the field blurs it',
|
||||
() => page.click('#para'),
|
||||
(m) => (has(m, 'ime.blur') ? null : 'no ime.blur'),
|
||||
)
|
||||
|
||||
await step(
|
||||
'a resync with nothing focused answers nothing',
|
||||
() => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })),
|
||||
(m) => (m.length ? 'answered a resync with no focused field' : null),
|
||||
)
|
||||
|
||||
await step(
|
||||
'a readonly field announces itself as readonly',
|
||||
() => page.click('#ro'),
|
||||
(m) => {
|
||||
const focus = find(m, 'ime.focus')
|
||||
if (!focus) return 'no ime.focus'
|
||||
return focus.readOnly === true ? null : 'ime.focus did not report readOnly'
|
||||
},
|
||||
)
|
||||
|
||||
await step(
|
||||
'readonly survives the resync round trip',
|
||||
() => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })),
|
||||
(m) => {
|
||||
const re = find(m, 'ime.refocus')
|
||||
if (!re) return 'no ime.refocus'
|
||||
return re.readOnly === true ? null : 'ime.refocus dropped readOnly — a restore would raise a keyboard'
|
||||
},
|
||||
)
|
||||
|
||||
// contenteditable taps land on a child node, so the doorbell has to test containment, not equality.
|
||||
await step(
|
||||
'a tap inside an already-focused contenteditable rings the doorbell',
|
||||
async () => {
|
||||
await page.click('#cespan')
|
||||
await page.waitForTimeout(150)
|
||||
await drain()
|
||||
await page.click('#cespan')
|
||||
},
|
||||
(m) => (has(m, 'ime.wantkb') ? null : 'no ime.wantkb from inside a contenteditable'),
|
||||
)
|
||||
|
||||
console.log(`\nshim: ${SHIM}\n`)
|
||||
for (const [name, types, problem] of results) {
|
||||
console.log(` ${problem ? 'FAIL' : 'ok '} ${name}\n ${types}`)
|
||||
}
|
||||
if (failures.length) {
|
||||
console.log(`\n${failures.length} failure(s):\n`)
|
||||
failures.forEach((f) => console.log(` - ${f}`))
|
||||
}
|
||||
await browser.close()
|
||||
process.exit(failures.length ? 1 : 0)
|
||||
Reference in New Issue
Block a user