Skip to content

Commit 72c1dbf

Browse files
authored
Merge pull request #41 from reclaimprotocol/codex/test-ios-keyboard-liveview-fix
Codex/test ios keyboard liveview fix
2 parents ebe8ca8 + 10941c3 commit 72c1dbf

10 files changed

Lines changed: 661 additions & 50 deletions

images/minimal-vnc-desktop/host/popcorn-host.js

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@
326326
// pass { legacyGeometry: false }.
327327
var legacyGeometry = opts.legacyGeometry !== false;
328328
var legacyLogged = false;
329+
var legacyBaselineHeight = 0;
330+
var legacyOccluding = false;
329331
var embedded;
330332
try { embedded = global !== global.top; } catch (_) { embedded = true; }
331333
// Resolved stance: relay until proven otherwise when embedded under 'auto'.
@@ -339,6 +341,35 @@
339341
// once we have measured a real occlusion, our 0 means "dismissed" and must be
340342
// sent, or the keyboard could never be torn down through this bridge.
341343
var everOccluded = false;
344+
// Safari can eventually shrink window.innerHeight to the already-shrunk
345+
// visualViewport height while the keyboard remains docked. Keep the last
346+
// no-keyboard layout height for the active occlusion session; otherwise the
347+
// live innerHeight-vv.height delta collapses to zero after the first key.
348+
var layoutBaselineHeight = global.innerHeight || 0;
349+
var layoutBaselineWidth = global.innerWidth || 0;
350+
var measuredOccluding = false;
351+
// A fixed iframe declared as height:100% still follows Safari's shrinking
352+
// layout viewport. After the first character WebKit can collapse that box
353+
// to the area above the keyboard, exposing the embedder's background as a
354+
// large black band. Remember the pre-keyboard box and pin it in CSS pixels
355+
// for the keyboard session. Geometry/lift decides what PART is visible;
356+
// resizing the stream itself is both redundant and visibly destructive.
357+
var pinKeyboardHeight = opts.pinKeyboardHeight !== false;
358+
var frameBaselineHeight = 0;
359+
var frameBaselineWidth = 0;
360+
var framePinned = false;
361+
var frameRestoreHeight = '';
362+
var frameRestoreBottom = '';
363+
var frameRestoreTop = '';
364+
var frameRestoreTransform = '';
365+
try {
366+
var initialFrameRect = iframeEl.getBoundingClientRect();
367+
frameBaselineHeight = initialFrameRect.height || layoutBaselineHeight;
368+
frameBaselineWidth = initialFrameRect.width || layoutBaselineWidth;
369+
} catch (_) {
370+
frameBaselineHeight = layoutBaselineHeight;
371+
frameBaselineWidth = layoutBaselineWidth;
372+
}
342373
var blindLogged = false;
343374
var destroyed = false;
344375
var childHello = null;
@@ -388,26 +419,130 @@
388419
}
389420
}
390421

422+
function frameHeightNow() {
423+
try { return iframeEl.getBoundingClientRect().height || 0; } catch (_) { return 0; }
424+
}
425+
426+
function pinFrameForKeyboard(active) {
427+
if (!pinKeyboardHeight) return;
428+
var cs;
429+
try { cs = global.getComputedStyle(iframeEl); } catch (_) { return; }
430+
if (!cs || cs.position !== 'fixed') return;
431+
432+
if (active) {
433+
if (!framePinned) {
434+
var rect;
435+
try { rect = iframeEl.getBoundingClientRect(); } catch (_) { rect = null; }
436+
if (rect && rect.width && frameBaselineWidth && Math.abs(rect.width - frameBaselineWidth) > 8) {
437+
// A rotation is a new layout. Prefer the current box only when it has
438+
// not already collapsed below the pre-keyboard layout baseline.
439+
frameBaselineWidth = rect.width;
440+
if (rect.height >= layoutBaselineHeight - 8) frameBaselineHeight = rect.height;
441+
} else if (rect && rect.height > frameBaselineHeight) {
442+
frameBaselineHeight = rect.height;
443+
}
444+
frameBaselineHeight = Math.max(frameBaselineHeight, layoutBaselineHeight);
445+
if (!(frameBaselineHeight > 0)) return;
446+
frameRestoreHeight = iframeEl.style.height || '';
447+
frameRestoreBottom = iframeEl.style.bottom || '';
448+
frameRestoreTop = iframeEl.style.top || '';
449+
frameRestoreTransform = iframeEl.style.transform || '';
450+
iframeEl.style.height = Math.round(frameBaselineHeight) + 'px';
451+
// top + bottom + an explicit height is over-constrained. Say which two
452+
// edges own the box so WebKit cannot choose the shrunken bottom inset.
453+
iframeEl.style.bottom = 'auto';
454+
framePinned = true;
455+
}
456+
// iOS pans the visual viewport after the first key (offsetTop becomes
457+
// roughly the keyboard height). Fixed content remains in layout-viewport
458+
// coordinates, so without this compensation the entire 714px iframe moves
459+
// up ~337px and its bottom exposes the host background. Keep its top at
460+
// the visual viewport's top; the viewer's own lift still positions the
461+
// remote field within the visible 377px. Use an integer compositor
462+
// translation only when Safari actually pans. The normal proxy placement
463+
// keeps offsetTop at zero, so the iframe retains the transform-free layout
464+
// contract and its native raster scale.
465+
var vv = global.visualViewport;
466+
var visualTop = Math.round(vv ? (vv.offsetTop || 0) : 0);
467+
iframeEl.style.top = frameRestoreTop;
468+
iframeEl.style.transform = visualTop
469+
? 'translate3d(0,' + visualTop + 'px,0)'
470+
: frameRestoreTransform;
471+
return;
472+
}
473+
474+
if (!framePinned) {
475+
var idleHeight = frameHeightNow();
476+
if (idleHeight > 0) frameBaselineHeight = idleHeight;
477+
return;
478+
}
479+
iframeEl.style.height = frameRestoreHeight;
480+
iframeEl.style.bottom = frameRestoreBottom;
481+
iframeEl.style.top = frameRestoreTop;
482+
iframeEl.style.transform = frameRestoreTransform;
483+
framePinned = false;
484+
var restoredHeight = frameHeightNow();
485+
if (restoredHeight > 0) frameBaselineHeight = restoredHeight;
486+
}
487+
488+
function annotateGeometry(g) {
489+
var vv = global.visualViewport;
490+
g.rawInnerHeight = global.innerHeight || 0;
491+
g.rawViewportHeight = vv ? vv.height : 0;
492+
g.rawOffsetTop = vv ? (vv.offsetTop || 0) : 0;
493+
g.layoutBaselineHeight = layoutBaselineHeight;
494+
g.frameHeight = frameHeightNow();
495+
g.framePinned = framePinned ? 1 : 0;
496+
return g;
497+
}
498+
391499
// ---- MEASURE -----------------------------------------------------------
392500
// occludedBottom is the height the keyboard covers; visibleHeight is what's
393501
// left. Prefer the VirtualKeyboard API when this document actually has it
394502
// (Chromium, and only where permitted) since it reports the keyboard rect
395503
// explicitly; otherwise derive it from the visual viewport, which is the
396-
// only signal iOS Safari offers. offsetTop matters: iOS shifts the visual
397-
// viewport rather than only shrinking it, so height alone under-reports.
504+
// only signal iOS Safari offers. The keyboard reduces visualViewport.height;
505+
// offsetTop is the page's pan/scroll position and must NOT be subtracted from
506+
// the keyboard height. iOS raises offsetTop while keeping a lower-page field
507+
// visible during typing; subtracting it made a still-docked keyboard report
508+
// occludedBottom=0 and caused the viewer to expose a large black gap.
398509
function measure() {
399510
var innerH = global.innerHeight || 0;
511+
var innerW = global.innerWidth || 0;
512+
if (layoutBaselineWidth && Math.abs(innerW - layoutBaselineWidth) > 8) {
513+
// Rotation/posture change: relearn in the new width. If the keyboard is
514+
// already visible, vv.height will still be smaller and latch it below.
515+
layoutBaselineWidth = innerW;
516+
layoutBaselineHeight = innerH;
517+
measuredOccluding = false;
518+
} else {
519+
layoutBaselineWidth = innerW;
520+
if (innerH > layoutBaselineHeight) layoutBaselineHeight = innerH;
521+
}
400522
var vk = global.navigator && global.navigator.virtualKeyboard;
401523
if (vk && vk.boundingRect && vk.boundingRect.height > 0) {
524+
measuredOccluding = true;
402525
return { visibleHeight: innerH - vk.boundingRect.height, occludedBottom: vk.boundingRect.height };
403526
}
404527
var vv = global.visualViewport;
405528
if (vv) {
406-
var occluded = Math.max(0, innerH - vv.height - (vv.offsetTop || 0));
529+
// When both viewports agree and no keyboard session has been observed,
530+
// this is ordinary page/browser-chrome geometry. Relearn downward as
531+
// well so an expanded URL bar is not mistaken for a keyboard.
532+
if (!measuredOccluding && Math.abs(innerH - vv.height) < 50) {
533+
layoutBaselineHeight = innerH;
534+
return { visibleHeight: innerH, occludedBottom: 0 };
535+
}
536+
var occluded = Math.max(0, Math.max(layoutBaselineHeight, innerH) - vv.height);
407537
// Under ~50px is not a keyboard — it's a URL bar collapsing, a pinch, or
408538
// fractional rounding. Report it as "no keyboard" so the viewer clears
409539
// its lift instead of nudging the page around.
410-
if (occluded < 50) return { visibleHeight: innerH, occludedBottom: 0 };
540+
if (occluded < 50) {
541+
measuredOccluding = false;
542+
if (Math.abs(innerH - vv.height) < 50) layoutBaselineHeight = innerH;
543+
return { visibleHeight: innerH, occludedBottom: 0 };
544+
}
545+
measuredOccluding = true;
411546
return { visibleHeight: vv.height, occludedBottom: occluded };
412547
}
413548
return { visibleHeight: innerH, occludedBottom: 0 };
@@ -417,6 +552,8 @@
417552
if (relay) return; // a relaying frame forwards its parent's numbers, never its own
418553
if (destroyed) return;
419554
var g = measure();
555+
pinFrameForKeyboard(g.occludedBottom > 0);
556+
annotateGeometry(g);
420557
// A FALLBACK measurer (embedded, nobody above us proved it measures) must
421558
// never assert "there is no keyboard". Our visualViewport is a subframe's: it
422559
// does not reliably shrink when the keyboard opens, so occludedBottom:0 from
@@ -604,12 +741,22 @@
604741
if (legacyGeometry && d.type === 'parent-viewport') {
605742
var innerH = Number(d.innerHeight);
606743
var visH = Number(d.viewportHeight);
607-
var offTop = Number(d.offsetTop) || 0;
608744
if (isFinite(innerH) && innerH > 0 && isFinite(visH) && visH > 0) {
609-
var occ = Math.max(0, innerH - visH - offTop);
745+
// offsetTop is scroll position, not keyboard occlusion. This mirrors
746+
// measure() above for the legacy portal message shape.
747+
if (!legacyBaselineHeight || innerH > legacyBaselineHeight) legacyBaselineHeight = innerH;
748+
if (!legacyOccluding && Math.abs(innerH - visH) < 50) legacyBaselineHeight = innerH;
749+
var occ = Math.max(0, Math.max(legacyBaselineHeight, innerH) - visH);
610750
// Same sub-threshold rule as measure(): under ~50px is a collapsing URL
611751
// bar or rounding, not a keyboard.
612-
if (occ < 50) { occ = 0; visH = innerH; }
752+
if (occ < 50) {
753+
occ = 0;
754+
visH = innerH;
755+
legacyOccluding = false;
756+
if (Math.abs(innerH - Number(d.viewportHeight)) < 50) legacyBaselineHeight = innerH;
757+
} else {
758+
legacyOccluding = true;
759+
}
613760
if (!legacyLogged) {
614761
legacyLogged = true;
615762
logInfo('translating the legacy parent-viewport message into POPCORN_HOST_GEOMETRY');
@@ -619,7 +766,8 @@
619766
if (graceTimer) { global.clearTimeout(graceTimer); graceTimer = null; }
620767
if (!relay) { stopMeasuring(); relay = true; logInfo('upstream host is measuring (legacy bridge) — switching to relay'); }
621768
}
622-
post('POPCORN_HOST_GEOMETRY', { visibleHeight: visH, occludedBottom: occ });
769+
pinFrameForKeyboard(occ > 0);
770+
post('POPCORN_HOST_GEOMETRY', annotateGeometry({ visibleHeight: visH, occludedBottom: occ }));
623771
}
624772
return;
625773
}
@@ -631,6 +779,9 @@
631779
if (graceTimer) { global.clearTimeout(graceTimer); graceTimer = null; }
632780
if (!relay) { stopMeasuring(); relay = true; logInfo('upstream host is measuring — switching to relay'); }
633781
}
782+
if (d.type === 'POPCORN_HOST_GEOMETRY') {
783+
pinFrameForKeyboard(Number(d.occludedBottom) > 0);
784+
}
634785
post(d.type, d);
635786
}
636787
}
@@ -732,6 +883,7 @@
732883
stopHelloRetry();
733884
if (graceTimer) { global.clearTimeout(graceTimer); graceTimer = null; }
734885
stopMeasuring();
886+
pinFrameForKeyboard(false);
735887
if (resizeWatch) { try { resizeWatch.disconnect(); } catch (_) {} resizeWatch = null; }
736888
listeners = {};
737889
},

images/minimal-vnc-desktop/kbd-autofocus.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,17 @@ import { createFbScaleWatch, FBSCALE_MODE } from './kbd/fbscale.js';
347347
// Bring the proxy on-screen near the tap before focusing (it's parked
348348
// off-screen while the keyboard is down so miss-taps can't hit it).
349349
const tapXY = tap.lastTapXY();
350+
// On iOS, putting the real DOM input at a bottom-page remote field makes
351+
// WebKit pan the TOP-LEVEL visual viewport by roughly the keyboard height on
352+
// the first character. A cross-origin LiveView cannot cancel that pan before
353+
// its first compositor frame, which is the 309px black flash seen in the
354+
// simulator. The proxy only needs to be rendered and focusable inside the
355+
// gesture; it does not need to sit under the remote field. Keep it in the
356+
// top safe strip so Safari has no reason to auto-scroll. Android retains the
357+
// under-finger placement used by its input/IME paths.
358+
const proxyY = isIOS ? 24 : (tapXY.y || Math.round(window.innerHeight / 2));
350359
moveProxyTo(tapXY.x || Math.round(window.innerWidth / 2),
351-
tapXY.y || Math.round(window.innerHeight / 2));
360+
proxyY);
352361

353362
try { proxy.removeAttribute('readonly'); } catch (_) {}
354363
applyProxyImeHints();

images/minimal-vnc-desktop/kbd/host-bridge.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,13 @@ function staleWindowMs() {
8080
// Absolute malformed-input guard; host geometry can exceed this iframe's height.
8181
const MAX_HOST_OCCLUSION_PX = 4096;
8282

83-
let geom = null; // { visibleHeight, occludedBottom, at }
83+
let geom = null; // newest raw { visibleHeight, occludedBottom, at }
84+
// A host's visualViewport can briefly report zero occlusion while iOS pans the
85+
// viewport around a focused field. kbd-detect owns the decision about whether
86+
// that zero is a dismissal; while it is deciding, keep exposing the last
87+
// accepted positive sample so field updates cannot indirectly drop the lift.
88+
let heldGeom = null; // accepted positive geometry during zero confirmation
89+
let lastGeometryDiag = '';
8490
// The boot HELLO can race a framework mounting its parent-side bridge. Keep the
8591
// last capability payload so an authenticated parent can explicitly ask us to
8692
// repeat it once its listener is ready.
@@ -119,11 +125,23 @@ export function onLifecycleAck(handler) {
119125
* occludedBottom is the keyboard's height (0 when dismissed).
120126
*/
121127
export function hostGeometry() {
122-
if (!geom) return null;
123-
if (nowMs() - geom.at > staleWindowMs()) return null;
124-
return geom;
128+
const effective = heldGeom || geom;
129+
if (!effective) return null;
130+
if (nowMs() - effective.at > staleWindowMs()) return null;
131+
return effective;
125132
}
126133

134+
/** Keep exposing a previously accepted positive sample while a host zero is
135+
* being confirmed. Only kbd-detect should call this: it has the keyboard/input
136+
* state needed to distinguish a transient iOS viewport pan from dismissal. */
137+
export function holdHostGeometry(g) {
138+
if (!g || !(g.occludedBottom > 0) || !(g.visibleHeight > 0)) return;
139+
heldGeom = g;
140+
}
141+
142+
/** Reveal the newest raw host sample again after confirmation or cancellation. */
143+
export function releaseHostGeometryHold() { heldGeom = null; }
144+
127145
/**
128146
* Age of the newest host sample in ms, or -1 when none has ever arrived. Reads
129147
* the raw sample rather than hostGeometry(), so it still answers AFTER the
@@ -251,6 +269,19 @@ function onMessage(e) {
251269
// only). Storing geom anyway would still make hostGeometryActive() true and
252270
// feed currentVisibleBottom a keyboard rect that means nothing here.
253271
if (!handlers.onGeometry) return;
272+
const raw = [
273+
Number(d.rawInnerHeight), Number(d.rawViewportHeight), Number(d.rawOffsetTop),
274+
Number(d.layoutBaselineHeight), Number(d.frameHeight), Number(d.framePinned),
275+
];
276+
if (raw.every(Number.isFinite)) {
277+
const key = raw.map((n) => Math.round(n)).join(',');
278+
if (key !== lastGeometryDiag) {
279+
lastGeometryDiag = key;
280+
dbg('host geom raw inner=' + Math.round(raw[0]) + ' vv=' + Math.round(raw[1]) +
281+
' top=' + Math.round(raw[2]) + ' base=' + Math.round(raw[3]) +
282+
' frame=' + Math.round(raw[4]) + ' pinned=' + Math.round(raw[5]));
283+
}
284+
}
254285
if (ob > 0 && !hostEverOccluded) {
255286
hostEverOccluded = true;
256287
dbg('host-bridge: embedder proved it can see the keyboard -> local detectors stand down');

0 commit comments

Comments
 (0)