Skip to content

Open the live view's soft keyboard from the remote browser's focus - #464

Merged
masnwilliams merged 13 commits into
mainfrom
hypeship/live-view-focus-signal
Oct 15, 2026
Merged

masnwilliams merged 13 commits into
mainfrom
hypeship/live-view-focus-signal

Conversation

@masnwilliams

@masnwilliams masnwilliams commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #448 (base hypeship/live-view-touch-input).

Summary

On touch devices, the live view decides whether to raise the soft keyboard from the remote cursor shape, which Chromium also shows over selectable text. That guess cannot tell a text field from a paragraph, picks the wrong keyboard, and misses closes after a submit that navigates to a page with a search box in the same spot. This PR has the browser VM report which editable element has focus, and where the fields on screen are. The live view uses that report to raise the keyboard only for real fields, give it the field's keyboard and AutoFill, and close it when the field loses focus.

Design

In the VM: server/lib/livefocus. It runs only while a live view is subscribed, and holds its own DevTools connection. The browsersurface tracker is used with WithoutLocations, so it enables no Page domain. Each frame of every page and out-of-process iframe gets watcher.js in an isolated world (Page.createIsolatedWorld). The server long-polls that watcher with Runtime.callFunctionOn(awaitPromise).

  • Each watcher reports its document's focused editable element: input type or textarea or contenteditable, inputmode, autocomplete, enterkeyhint, the page's own autocapitalize/autocorrect/spellcheck, a one-time-code flag, and its rect. It also reports whether its document has focus.
  • A top frame also reports the editable fields on screen that a tap at their center would reach (checked with elementFromPoint), including fields in same-origin iframes. The server converts these rects to screen pixels using the window bounds (Browser.getWindowForTarget) and page zoom.
  • The one-time-code flag is set by autocomplete="one-time-code", by one-character boxes, or by name, label or placeholder hints such as code, OTP or verification on short or numeric inputs. Zip, postal and promo codes are excluded.
  • A navigation ends the pending poll ("Inspected target navigated or closed" or "Execution context was destroyed"). That clears the frame's focus and puts a new watcher in the next document. A new child frame shows up in its parent's report and triggers a frame-tree rescan.
  • Reports are sent on focusin and focusout, on window focus and blur, on visibility changes, scrolls, resizes and DOM mutations (throttled), and on a 1s tick. Only changes are sent.

Stealth. Nothing runs in the page's main world, and no Runtime, Page or DOM domain is enabled (the real-Chromium test asserts this through a recording proxy). The page cannot see the isolated world's globals, listeners or observers, and page changes to globals or prototypes do not reach it. The watcher never logs, and catches its own errors. If a watcher goes 30s without a poll, it removes its listeners and observer, so a dropped connection leaves nothing running in the page. A CDP client of the customer's own that has Runtime enabled will see one extra isolated-world context per document while a touch live view is open. Playwright and Puppeteer ignore isolated worlds they did not create.

Dismissing the keyboard: POST /live-view/focus/blur?seq=N. Hide, the keyboard's own Done, and an iOS dismissal that leaves the overlay focused (seen as the keyboard leaving the visual viewport) all blur the remote's focused field too, the way Safari's Done does. The blur runs from the focused frame's isolated world, so the page and the live view agree and the next tap is a fresh focus. Each watcher counts focus changes and mouse presses and reports the count as the focused field's seq. The live view sends back the count it last saw, and a blur sent before a later tap is dropped, even a tap on the field that already had focus. Closing the keyboard for the page's own reasons (a blur, a navigation, the clipboard panel), refocusing, or leaving for another app does not blur the remote field.

Delivery: GET /live-view/focus, server-sent events from the kernel-images API. This is outside the OpenAPI spec, like the PTY attach endpoint. The live view page is served at /browser/live?jwt=…, and the browser proxy already serves the kernel-images API at /browser/kernel/* with the same session token, streaming included. So the client derives the URL from its own location, with no neko change. The other route was neko's data channel. neko's legacy protocol, which this client uses, drops custom broadcasts, so that route needs a neko fork change and release, plus a privileged neko session for the API to send through. The stream sends the current state, then each change, then a ping every 15s. The client opens it only on touch devices, while hosting with input enabled and the page visible. Without the proxy route (an older image, or a local run-docker.sh live view) or with ?focusSignal=off, the stream is unavailable and the client keeps the cursor-shape behavior.

Client.

  • remote-focus.ts holds the stream state, hit-tests taps against the field rects (in stream pixels), and ignores rects from before a client scroll until new ones arrive.
  • focus-keyboard.ts decides when the keyboard opens and closes, under iOS's rule that focus() must run inside the touch:
    • A tap on a field from the last rects, or on the focused field wherever it is, raises the keyboard inside the tap, set up for that field. If no field has focus by the end of a 1s wait, it closes. If the overlay kept its focus with no keyboard on screen, the tap focuses it afresh, because a focus it already has would not raise the keyboard.
    • On iOS, any other tap on the page holds focus with inputmode="none", as Make live view usable on touch devices #448 does. A focus report within the wait then raises the keyboard. This covers labels and buttons that focus a field.
    • A keyboard raised for a field closes 300ms after the field's blur, unless focus returns. That includes a submit that navigates or reloads, and a search box in the same place on the next page does not keep it open. A chat box that keeps focus after Enter keeps it open.
    • When the page moves focus to another field, such as the next box of a code, the open keyboard switches layout.
  • The cursor-shape guess (TapKeyboard) stays for two cases. One is taps outside the page area, which is browser UI such as the address bar, where no page document has focus. The other is a live view without the stream, or with it down or stale; a keyboard raised then is not tied to a field until a focus report claims it. A keyboard raised for the address bar closes when the page gets keyboard focus back (pageFocused), which happens when the address bar navigates. The Enter-then-cursor check runs only without the stream.
  • field-keyboard.ts copies the field onto the overlay:
    • The layout goes through inputmode (email, tel, url, numeric, decimal, search). type="email" or type="number" would sanitize the overlay's sentinel and break its selection APIs.
    • Code fields get autocomplete="one-time-code", for iOS SMS code AutoFill. Email and tel fields get the owner's own details, and name and address fields get contact AutoFill. Credentials never get AutoFill.
    • Plain text gets autocorrect, capitals and spellcheck. A page that turns these off is followed. Google's search box sets autocorrect="off", for example. Password, username, code, email, url, tel and number fields get all three off.
    • iOS reads these attributes when the overlay gains focus. So a field that needs other attributes refocuses the overlay inside the tap, while a held focus or a page-driven focus change only switches the layout.
  • soft-keyboard.ts tells whether a soft keyboard is actually up from how much of the layout viewport (innerHeight) the visual viewport leaves visible. The keyboard covers the layout viewport without resizing it. Safari's toolbars resize both, so they are not mistaken for a keyboard, and no learned baseline is needed, through a rotation included. keyboardShown is the one source for the extra keys row, the keyboard control's Hide label and what that control does. On a phone, or once a keyboard has been seen, it is a focused overlay with the keyboard measured up, however the focus came about. Otherwise, as with a tablet's hardware keyboard or inside a frame, it goes by the focus.
  • On touch devices, focusing the overlay no longer reads the phone clipboard. On iOS that read shows the native Paste callout and holds back the keyboard; the Paste control reads the clipboard inside its own tap.
  • typed-text.ts: for autocorrect fields, printable keys and Backspace go into the overlay instead of being sent by the Guacamole keyboard, and each change to the overlay's text is diffed against what was sent. iOS autocorrect (insertReplacementText), predictive text, dictation and composing keyboards then reach the remote as backspaces and replacement text, whatever order the events come in. The overlay starts over on a tap, on Enter or another non-text key, and on a paste or shortcut. Other fields keep Make live view usable on touch devices #448's per-key path.

Desktop is unchanged: the stream is never opened, and the overlay's attributes and typing path are as before.

Testing

  • Go: go test -race ./lib/livefocus ./cmd/api/.... Unit tests cover:
    • the merge (focused frame, shown tab, zoom, cross-origin frames, pageFocused), subscriptions and linger, and the SSE stream;
    • against a fake browser: a frame that fails for a while is watched once it works, a frame gone from the tree is dropped, and an out-of-process iframe is never watched through its parent's session.
  • KERNEL_LIVEFOCUS_CHROME_E2E=1 go test ./lib/livefocus runs against headless Chromium with --site-per-process, 5 runs under -race. It covers:
    • fields and attributes, including a one-character code box and a same-origin iframe field inside border and padding;
    • leaving out button, covered, disabled and off-screen fields;
    • focus and blur from real clicks, and focus in a cross-origin iframe (no rect), with no further createIsolatedWorld calls while it is shown;
    • clearing focus on navigation;
    • the page's main world unchanged, and Runtime.enable, Page.enable, DOM.enable, addScriptToEvaluateOnNewDocument and addBinding never sent.
  • Client: bun test tests (229 tests). The simulated iOS overlay also covers the first keyboard after loading the live view (including Safari's toolbars appearing after the load, and a keyboard that comes up after the focus), no clipboard read on a touch focus, a keyboard iOS shows for a held focus, rotation with the keyboard up or going, and a hardware keyboard. A simulated iOS overlay covers tap, Hide, and a re-tap of the same field (with and without the remote blur landing), a dismissal that keeps the overlay focused, the keyboard's own Done versus leaving the page, a focus iOS shows no keyboard for, and the address bar with Hide and a re-tap. New tests cover the focus keyboard state machine, field-to-keyboard mapping, stream state and hit-testing, the autocorrect diff, and the video component's focus, refocus and tap routing.
  • On a 390x844 headful Kernel browser, with the API and live view swapped in:
    • The stream through the browser proxy reported focus about 33ms after a CDP click, with the click itself about 16ms of that.
    • Driving the live view from Chromium with the iPhone 13 profile (iOS user agent, touch), every check passed:
      • each field type got its keyboard attributes and typed text reached the remote;
      • an insertReplacementText edit replaced the word on the remote;
      • a chat box stayed open after Enter;
      • a search on html.duckduckgo.com closed the keyboard though the results page has a search box;
      • tapping paragraph text or a button opened no keyboard;
      • an address-bar URL plus Enter opened and then closed the keyboard;
      • Hide blurred the remote field through the proxy, and a re-tap focused it again and typing reached it.
    • Desktop typing (Shift, Backspace) was unchanged and opened no stream.

Not verified: a physical iPhone or Android device (the real keyboard, AutoFill and autocorrect behavior, and the inputmode switch on a held focus). The CI workflows only run for PRs against main, so they did not run on this stacked PR; the tests above were run locally.

Known gaps

  • A frame whose watcher cannot start, such as one showing an error page, is retried with backoff (up to 5s) for as long as it is in its session's frame tree.
  • Fields inside cross-origin iframes and shadow DOM report focus but have no pre-sent rects. On iOS those taps use the held focus, so the keyboard keeps the default attributes (no autocorrect, code AutoFill offered).
  • Rect conversion assumes an unscaled screen (device scale factor 1) and no pinch zoom inside the remote page.
  • Browser UI other than the address bar (find bar, prompt dialogs) uses the cursor-shape guess.

Note

Medium Risk
Adds always-on-while-subscribed CDP instrumentation and new HTTP routes that blur remote fields; behavior is complex on iOS but gated to touch/proxy sessions with seq-checked blurs and cursor fallback.

Overview
Touch live views no longer rely only on the remote cursor shape to decide when to show the soft keyboard. The VM exposes SSE focus state (GET /live-view/focus) and a remote blur action (POST /live-view/focus/blur?seq=…) via new livefocus CDP watchers in isolated worlds, reporting focused editables, on-screen field rects, and page vs browser UI focus.

The chromium-headful client subscribes on touch devices (browser proxy + JWT), routes taps through focus-keyboard and remote-focus hit-testing, maps fields to overlay attributes (field-keyboard), detects real keyboard visibility (soft-keyboard / keyboardShown), and syncs autocorrect through typed-text. Hiding the keyboard or iOS dismissing it blurs the remote field when appropriate; cursor-shape tap-keyboard remains as fallback when the stream is off or stale.

Extensive Go and Bun tests cover merge logic, CDP connection edge cases, keyboard state machines, and simulated iOS overlay flows.

Reviewed by Cursor Bugbot for commit 6966a49. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread images/chromium-headful/client/src/components/video.vue
Comment thread server/lib/livefocus/watcher.go

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread server/lib/livefocus/watcher.go

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread images/chromium-headful/client/src/components/video.vue
Comment thread server/lib/livefocus/watcher.js

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread images/chromium-headful/client/src/utils/soft-keyboard.ts Outdated

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread images/chromium-headful/client/src/utils/soft-keyboard.ts Outdated
@masnwilliams

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@masnwilliams
masnwilliams requested a review from Sayan- October 15, 2026 19:22

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread images/chromium-headful/client/src/components/video.vue Outdated
@masnwilliams

Copy link
Copy Markdown
Contributor Author

Reviewer guide

This PR is stacked on #448. To merge: merge #448, retarget this PR to main, then merge it.

What it does

When a live view is opened on a touch device, the VM reports which editable element has focus and where the fields are. The live view uses that to raise the soft keyboard only for real fields, set up for each field, and to close it when the field loses focus. The cursor-shape guess from #448 remains for taps on browser UI (the address bar) and for when the stream is unavailable.

Where to look

  1. server/lib/livefocus/watcher.js, the script that runs in the page. It runs in an isolated world, reads focus and field rects, and removes its own listeners if it goes 30s without being polled.
  2. server/lib/livefocus/watcher.go:
    • lifecycle: started by the first subscriber, stopped 30s after the last one leaves;
    • frame discovery through browsersurface with WithoutLocations;
    • per-frame long polling, with retries and dropping of frames that are gone;
    • Blur(seq).
  3. server/lib/livefocus/state.go: merges frame reports, picks the shown tab and the focused field, and converts rects to screen pixels.
  4. server/cmd/api/main.go: two routes outside the OpenAPI spec:
    • GET /live-view/focus (SSE);
    • POST /live-view/focus/blur?seq=N.
  5. Client:
    • focus-keyboard.ts: when the keyboard opens and closes;
    • remote-focus.ts: stream state and tap hit-testing;
    • field-keyboard.ts: field to overlay attributes;
    • typed-text.ts: autocorrect edits;
    • soft-keyboard.ts: whether a keyboard is on screen;
    • the call sites in video.vue.

Release-relevant properties, and how each is checked

  • Stealth:
    • Nothing is added to the page's main world.
    • No Runtime.enable, Page.enable or DOM.enable, and no addScriptToEvaluateOnNewDocument or addBinding.
    • The real-Chromium test (KERNEL_LIVEFOCUS_CHROME_E2E=1) asserts all of this through a recording CDP proxy, and also checks that the page's own globals are unchanged.
    • Visible effects:
      • a CDP client that has Runtime enabled sees one extra isolated-world context per document while a touch live view is open (Playwright and Puppeteer ignore worlds they did not create);
      • a Hide or Done on the phone blurs the focused field, which the page sees as an ordinary blur.
  • Runs only while a touch live view is connected:
    • The client opens the stream only on touch devices, while it has input and is visible.
    • The server starts its CDP connection on the first subscriber and stops it 30s after the last one leaves (watcher_test.go).
    • POST .../blur uses the existing connection and never starts one.
  • Auth:
    • Both routes are on the kernel-images API router, reached through the browser proxy's /browser/kernel/* with the session token, like the rest of that API.
    • Checked against a live session:
      • no token: 400;
      • bad token: 401;
      • SSE with a token: 200;
      • blur without seq: 400;
      • blur: 204;
      • GET on the blur route: 405.
  • Desktop: the stream is never opened, and the overlay's attributes, key path and clipboard sync are as in Make live view usable on touch devices #448. The only desktop-visible changes in the stack are from Make live view usable on touch devices #448:
    • IME composition commits are now typed (on main they were dropped);
    • .neko-main no longer has a 360px minimum width.
  • Scale-to-zero and session activity: an open stream counts as an in-flight request, like the live view's own websocket. The client closes it when the page is hidden, input is lost, or the neko connection drops.

Tested

  • CI on the combined code. The workflows only run for PRs against main, so they were run with workflow_dispatch on a branch that merges main, Make live view usable on touch devices #448 and this PR (client tests, server tests and image builds). All passed:
    • Live View client tests;
    • server unit;
    • headful and headless image builds;
    • server e2e.
  • Go:
    • merge and conversion;
    • subscriptions and linger;
    • the SSE handler;
    • a fake browser covering retries, dropped frames, out-of-process iframes and blur routing;
    • real Chromium (opt-in) covering:
      • focus and blur, and rects in same-origin iframes;
      • focus in cross-origin iframes;
      • navigation;
      • blur with a stale seq;
      • the stealth assertions above.
  • Client: bun test tests. A simulated iOS overlay runs the component's real methods against these scenarios:
    • tap, Hide and re-tap;
    • native dismissals;
    • the first keyboard after load, including Safari's toolbars appearing;
    • the address bar;
    • rotation;
    • a hardware keyboard.
  • On a headful 390x844 session, driving the live view from Chromium with an iPhone profile. All of these ran through the real proxy:
    • keyboard attributes per field type;
    • an autocorrect-style replacement;
    • a chat box staying open after Enter;
    • a search submit that navigates closing it;
    • the address bar opening and closing;
    • the remote blur.
  • On a physical iPhone: earlier builds, plus a timestamped trace of keyboard and viewport events that confirmed the last two fixes.

Emulation-only or not verified

  • Real iOS keyboard behaviour:

    • when the keyboard shows and hides;
    • inputmode changes on a held focus;
    • one-time-code AutoFill;
    • autocorrect edits.

    These are covered in simulation and on earlier device builds, with the final build's device retest pending.

  • Android has not been tried on a device.

  • Fields in cross-origin iframes and shadow DOM report focus but have no pre-sent rects, so on iOS they use the held-focus path with default keyboard attributes.

  • Rect conversion assumes an unscaled screen (device scale factor 1).

Base automatically changed from hypeship/live-view-touch-input to main October 15, 2026 22:28
@masnwilliams
masnwilliams force-pushed the hypeship/live-view-focus-signal branch from 2174597 to ce0bdb0 Compare October 15, 2026 22:29

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ce0bdb0. Configure here.

Comment thread server/lib/livefocus/watcher.go
Comment thread images/chromium-headful/client/src/utils/remote-focus.ts

@tnsardesai tnsardesai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Future follow-ups (not for this PR)

  • Non-blocking design issue — Untagged login fields now get auto-capitals and autocorrect — images/chromium-headful/client/src/utils/field-keyboard.ts:L65-77, L117-132

    • Problem: fieldKind only spots username and email fields from autocomplete="username", type="email", inputmode="email" or autocomplete="email". Many sign-in forms use <input type="text" name="email|login|username|user"> with none of these. Those fields count as plain text and get autocapitalize="sentences", autocorrect="on" and keepText. Before this PR the overlay always used autocorrect="off", autocapitalize="off" and spellcheck="false".
    • Impact: on iOS the first character of a username or email gets capitalized, and autocorrect can rewrite unusual usernames. That can break or slow down signing in through a touch live view.
    • Fix: in watcher.js describe, add a login flag for text inputs that look like login fields. Match name, id, placeholder or label text against something like /user|login|e-?mail|account|sign.?in/, or flag inputs that share a form with an input[type=password]. Then have fieldKind map login to username, or to email when the hint says email, before it falls back to text.
  • Non-blocking question — Does the next letter get capitalized after every tap in a plain-text field? — images/chromium-headful/client/src/components/video.vue:L1206, L353

    • Problem: in keepText mode the overlay resets to the bare ' ' sentinel on every tap, arrow or other non-text key, and shortcut, and plain text now gets autocapitalize="sentences". If iOS treats a field holding only whitespace as the start of a sentence, the next letter gets capitalized even when the remote caret is mid-sentence. Tapping into existing text to insert a word would produce, for example, "The".
    • Impact: words inserted into existing text would come out capitalized and need fixing by hand. This hasn't been checked on a physical device.
    • Fix: check it on an iPhone. If it happens, use a sentinel that reads as mid-sentence, such as 'a ', for keepText. TypedText already strips the sentinel by prefix and treats it as one character. Another option is to handle this together with the login-field follow-up above.

@tnsardesai

Copy link
Copy Markdown
Contributor

LGTM

Future follow-ups (not for this PR)

* **Non-blocking design issue — Untagged login fields now get auto-capitals and autocorrect** — `images/chromium-headful/client/src/utils/field-keyboard.ts:L65-77`, `L117-132`
* **Non-blocking question — Does the next letter get capitalized after every tap in a plain-text field?** — `images/chromium-headful/client/src/components/video.vue:L1206`, `L353`

fwiw I haven't tried testing e2e on an ios device

@masnwilliams
masnwilliams merged commit 744b944 into main Oct 15, 2026
12 checks passed
@masnwilliams
masnwilliams deleted the hypeship/live-view-focus-signal branch October 15, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants