git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

index.html (26K)

  1 <!DOCTYPE html>
  2 <html lang="en">
  3 <head>
  4 <meta charset="utf-8">
  5 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
  6 <meta name="apple-mobile-web-app-capable" content="yes">
  7 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  8 <title>cce remote</title>
  9 <style>
 10   * { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; }
 11   html, body { height: 100%; overflow: hidden; background: #14151c; color: #ccccd4;
 12                font-family: ui-monospace, Menlo, monospace; }
 13   body { display: flex; flex-direction: column;
 14          padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
 15   #bar { display: flex; gap: 6px; padding: 8px; flex-wrap: wrap; }
 16   #bar button { flex: 1 1 auto; min-width: 44px; padding: 10px 6px; font-size: 13px;
 17                 font-family: inherit; color: #ccccd4; background: #23252f;
 18                 border: 1px solid #2e3140; border-radius: 8px; }
 19   #bar button.on { background: #25626c; border-color: #337fc0; }
 20   #pad { flex: 1; margin: 0 8px; background: #191b23; border: 1px solid #2e3140;
 21          border-radius: 12px; touch-action: none; position: relative;
 22          /* clip the zoomed/panned view (its wrapper is what scales) to the
 23             frame — the clip must be on this NON-transformed ancestor */
 24          overflow: hidden; }
 25   #pad .hint { position: absolute; inset: 0; display: flex; align-items: center;
 26                justify-content: center; color: #4a4d5c; font-size: 12px; text-align: center;
 27                pointer-events: none; }
 28   #clicks { display: flex; gap: 8px; padding: 8px; }
 29   #clicks button { flex: 1; padding: 16px; font-size: 13px; font-family: inherit;
 30                    color: #ccccd4; background: #23252f; border: 1px solid #2e3140;
 31                    border-radius: 10px; }
 32   #clicks button:active, #bar button:active { background: #2c5f69; }
 33   #kb { position: absolute; opacity: 0.02; width: 1px; height: 1px; left: -10px; top: -10px; }
 34   /* Zoom/pan transform lives on the WRAPPER: getBoundingClientRect then
 35      reflects it, so the cursor ring stays correctly placed while zoomed.
 36      (Historically this also dodged an iOS bug where a transformed
 37      multipart-MJPEG <img> stopped repainting; the live view is a canvas
 38      now, but the structure stays.) */
 39   #screenwrap { display: none; position: absolute; inset: 0; z-index: 2;
 40                 transform-origin: 0 0; border-radius: 12px; overflow: hidden; }
 41   #pad.view #screenwrap { display: block; }
 42   #screen { display: block; width: 100%; height: 100%;
 43             object-fit: contain; background: #0c0d12; }
 44   #vcursor { display: none; position: absolute; width: 14px; height: 14px; z-index: 3;
 45              pointer-events: none; border: 2px solid #7dffff; border-radius: 50%;
 46              box-shadow: 0 0 4px #000; margin: -7px 0 0 -7px; }
 47   #pad.view #vcursor.on { display: block; }
 48   #pad.view #screen { display: block; }
 49   #pad.view .hint { display: none; }
 50   #switcher, #menu { display: none; position: absolute; inset: 8px; overflow-y: auto;
 51               background: rgba(20,21,28,0.96); border: 1px solid #2e3140;
 52               border-radius: 10px; z-index: 5; -webkit-overflow-scrolling: touch; }
 53   #switcher.show, #menu.show { display: block; }
 54   #switcher .win, #menu .win { padding: 14px 14px; border-bottom: 1px solid #23252f;
 55                    display: flex; gap: 10px; align-items: baseline; }
 56   #switcher .win .t, #menu .win .t { flex: 1; overflow: hidden; text-overflow: ellipsis;
 57                       white-space: nowrap; font-size: 14px; }
 58   #switcher .win .a { color: #6a6d7c; font-size: 11px; }
 59   #switcher .win.focused { background: #1d3a44; }
 60   #switcher .win.focused .t { color: #7dffff; }
 61   #switcher .empty { padding: 20px; color: #4a4d5c; text-align: center; font-size: 12px; }
 62   #preview { display: none; margin: 0 8px 6px; padding: 8px 10px; min-height: 36px;
 63              background: #191b23; border: 1px solid #2e3140; border-radius: 8px;
 64              font-size: 15px; color: #cfe8e8; white-space: pre-wrap; word-break: break-all; }
 65   #preview.show { display: block; }
 66   #preview.show::after { content: "▍"; color: #7dffff; animation: blink 1s steps(1) infinite; }
 67   @keyframes blink { 50% { opacity: 0; } }
 68   #status { position: fixed; top: 4px; right: 10px; font-size: 10px; color: #4a4d5c; }
 69 </style>
 70 </head>
 71 <body>
 72 <div id="status">·</div>
 73 <div id="bar">
 74   <button data-k="1">esc</button>
 75   <button data-k="15">tab</button>
 76   <button data-mod="29">ctrl</button>
 77   <button data-mod="56">alt</button>
 78   <button data-mod="125">sup</button>
 79   <button data-k="105">←</button>
 80   <button data-k="103">↑</button>
 81   <button data-k="108">↓</button>
 82   <button data-k="106">→</button>
 83   <button id="wbtn">☰</button>
 84   <button id="mbtn">⋯</button>
 85   <button id="kbtn">⌨</button>
 86 </div>
 87 <div id="preview"></div>
 88 <div id="pad"><div class="hint">drag = move · tap = click · 2-finger drag = scroll<br>2-finger tap = right click · hold = drag</div>
 89   <div id="screenwrap"><canvas id="screen" width="0" height="0"></canvas></div>
 90   <div id="vcursor"></div>
 91   <div id="switcher"></div>
 92   <div id="menu"></div>
 93 </div>
 94 <div id="clicks">
 95   <button id="lclick">left</button>
 96   <button id="rclick">right</button>
 97 </div>
 98 <textarea id="kb" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
 99 <script>
100 "use strict";
101 // ── WebSocket + pairing ──────────────────────────────────────────────
102 // First frame must be `auth <pin>` (the PIN cce-remote prints at startup).
103 // Remembered in localStorage after the first successful pairing.
104 let ws = null, authed = false, pageVer = null;
105 const status = document.getElementById("status");
106 function askPin(msg) {
107   const pin = window.prompt(msg || "cce-remote pairing PIN (shown in the server terminal):");
108   if (pin) localStorage.setItem("cce-remote-pin", pin.trim());
109   return pin ? pin.trim() : null;
110 }
111 function connect() {
112   authed = false;
113   ws = new WebSocket("ws://" + location.host + "/ws");
114   ws.onopen = () => {
115     const pin = localStorage.getItem("cce-remote-pin") || askPin();
116     if (pin) ws.send("auth " + pin); else ws.close();
117   };
118   ws.onmessage = e => {
119     if (typeof e.data === "string" && e.data.startsWith("windows ")) { renderSwitcher(e.data.slice(8)); }
120     else if (typeof e.data === "string" && e.data.startsWith("ploc ")) { updateCursorMarker(e.data.slice(5)); }
121     else if (typeof e.data === "string" && e.data.startsWith("ver ")) {
122       // The server names the page version it serves. First connect pins it;
123       // a DIFFERENT version on a later reconnect means the service restarted
124       // onto new code — reload to pick it up. (The input WS retries every 1s,
125       // so this fires moments after a restart, automatically.)
126       const v = e.data.slice(4);
127       if (pageVer === null) pageVer = v;
128       else if (pageVer !== v) location.reload();
129     }
130     else if (e.data === "auth ok") { authed = true; status.textContent = "●"; status.style.color = "#5fbf6f"; }
131     else if (e.data === "auth fail") {
132       localStorage.removeItem("cce-remote-pin");
133       askPin("Wrong PIN — try again:");
134       // server closed on failure; onclose reconnects with the new PIN
135     }
136   };
137   ws.onclose = () => { authed = false; status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
138 }
139 connect();
140 function send(s) { if (authed && ws && ws.readyState === 1) ws.send(s); }
141 
142 // ── Trackpad ─────────────────────────────────────────────────────────
143 const pad = document.getElementById("pad");
144 // forward decls used by the pad handlers; the panels are wired further down.
145 // "sw" now means any overlay panel (window switcher or the ⋯ menu).
146 function swContains(el) {
147   return document.getElementById("switcher").contains(el)
148       || document.getElementById("menu").contains(el);
149 }
150 function swHide() {
151   document.getElementById("switcher").classList.remove("show");
152   document.getElementById("menu").classList.remove("show");
153 }
154 // SCROLL is finger-px → wl axis units (~surface px): 0.8 ≈ natural 1:1 feel.
155 const ACCEL = 1.6, SCROLL = 0.8;
156 // Touch state: one finger moves, two fingers scroll; short still touches
157 // click; a long-press starts a held drag (release on lift).
158 let touches = new Map(), moved = 0, startT = 0, maxFingers = 0, startX = 0, startY = 0;
159 let dragging = false, dragTimer = null, pendDx = 0, pendDy = 0, pendSy = 0, flushT = null;
160 
161 // ── Window view mode: live view of the focused window. Input is identical
162 // to the trackpad — the picture is feedback, not a touchscreen.
163 // Frames arrive as JPEG binaries on a dedicated /wstream WebSocket, drawn to
164 // the canvas; winRect, which places the cursor ring, is kept fresh by polling
165 // `wl` over the input WS (the stream follows focus server-side).
166 //
167 // Delivery is ACK-CLOCKED: after rendering a frame we send "n", and only then
168 // does the server send its NEWEST frame. At most one frame is ever in flight,
169 // so a slow link costs frame rate, never accumulating lag — the old MJPEG
170 // path queued frames in the TCP buffer and grew seconds behind. The ack also
171 // times the whole pipeline for the server's resolution/quality adaptation,
172 // which is why it is sent after drawImage, not on receipt.
173 let viewMode = false, winRect = null, rectTimer = null, cursorTimer = null;
174 const screenEl = document.getElementById("screen");
175 const screenCtx = screenEl.getContext("2d");
176 let sws = null, lastFrameAt = 0, watchdog = null;
177 function streamStart() {
178   if (sws) { const old = sws; sws = null; try { old.close(); } catch (_) {} }
179   const pin = localStorage.getItem("cce-remote-pin") || "";
180   const s = new WebSocket("ws://" + location.host + "/wstream");
181   s.binaryType = "arraybuffer";
182   sws = s;
183   lastFrameAt = Date.now();
184   let paired = false;
185   s.onopen = () => s.send("auth " + pin);
186   s.onmessage = async e => {
187     if (typeof e.data === "string") {
188       if (e.data === "auth ok") { paired = true; s.send("n"); }
189       return;
190     }
191     lastFrameAt = Date.now();
192     try {
193       const bmp = await createImageBitmap(new Blob([e.data], { type: "image/jpeg" }));
194       if (screenEl.width !== bmp.width || screenEl.height !== bmp.height) {
195         screenEl.width = bmp.width;
196         screenEl.height = bmp.height;
197       }
198       screenCtx.drawImage(bmp, 0, 0);
199       bmp.close();
200     } catch (_) {}
201     if (sws === s && s.readyState === 1) s.send("n");
202   };
203   // Reconnect only if this connection ever paired: a stale PIN would
204   // otherwise retry-loop failures into the server's rate limiter and lock
205   // the phone's address out of the INPUT socket too.
206   s.onclose = () => { if (viewMode && sws === s && paired) setTimeout(streamStart, 600); };
207 }
208 function streamStop() {
209   if (sws) { const old = sws; sws = null; try { old.close(); } catch (_) {} }
210   screenEl.width = 0;
211   screenEl.height = 0;
212 }
213 function updateRect(wins) {
214   const f = wins.find(w => w.focused && w.mode !== "Status");
215   if (f) winRect = { id: f.id, x: f.x, y: f.y, w: f.w, h: f.h };
216 }
217 // View zoom/pan (two-finger pinch/drag in view mode): a pure client-side
218 // transform of the streamed image. Uniform scale+translate means
219 // getBoundingClientRect reflects it, so placeMarker stays correct zoomed.
220 let vZoom = 1, vPanX = 0, vPanY = 0, pinch = null;
221 const screenWrap = document.getElementById("screenwrap");
222 function applyViewTransform() {
223   screenWrap.style.transform = "translate(" + vPanX + "px," + vPanY + "px) scale(" + vZoom + ")";
224 }
225 function resetViewTransform() { vZoom = 1; vPanX = 0; vPanY = 0; pinch = null; applyViewTransform(); }
226 function clampPan() {
227   const r = pad.getBoundingClientRect();
228   vPanX = Math.min(Math.max(vPanX, r.width - r.width * vZoom), 0);
229   vPanY = Math.min(Math.max(vPanY, r.height - r.height * vZoom), 0);
230   if (vZoom <= 1.001) { vZoom = 1; vPanX = 0; vPanY = 0; }
231 }
232 
233 // The compositor's frames carry no cursor (they're surface textures), so
234 // view mode draws its own marker: poll pointer-location over the WS and map
235 // that layout position through the contain-fit letterbox onto the canvas.
236 const vcursor = document.getElementById("vcursor");
237 let ptr = null; // last known pointer position in layout px (from ploc + prediction)
238 function placeMarker(lx, ly) {
239   if (!viewMode || !winRect || !screenEl.width) { vcursor.classList.remove("on"); return; }
240   const u = (lx - winRect.x) / winRect.w, v = (ly - winRect.y) / winRect.h;
241   if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) { vcursor.classList.remove("on"); return; }
242   const box = screenEl.getBoundingClientRect();
243   const scale = Math.min(box.width / screenEl.width, box.height / screenEl.height);
244   const dw = screenEl.width * scale, dh = screenEl.height * scale;
245   const padBox = pad.getBoundingClientRect();
246   vcursor.style.left = (box.left - padBox.left + (box.width - dw) / 2 + u * dw) + "px";
247   vcursor.style.top = (box.top - padBox.top + (box.height - dh) / 2 + v * dh) + "px";
248   vcursor.classList.add("on");
249 }
250 // Server truth (poll): reconcile the predicted position.
251 function updateCursorMarker(coords) {
252   const [lx, ly] = coords.split(" ").map(Number);
253   ptr = { x: lx, y: ly };
254   placeMarker(lx, ly);
255 }
256 // Client prediction: move the marker with the finger immediately, so pointer
257 // motion feels instant even though the window frame is damage-gated and won't
258 // repaint just because the cursor moved. Reconciled by the next ploc poll.
259 function predictMarker(dLx, dLy) {
260   if (!ptr || !winRect) return;
261   ptr.x = Math.min(Math.max(ptr.x + dLx, winRect.x), winRect.x + winRect.w);
262   ptr.y = Math.min(Math.max(ptr.y + dLy, winRect.y), winRect.y + winRect.h);
263   placeMarker(ptr.x, ptr.y);
264 }
265 
266 function flush() {
267   flushT = null;
268   if (pendDx || pendDy) { send("m " + (pendDx * ACCEL).toFixed(1) + " " + (pendDy * ACCEL).toFixed(1)); pendDx = pendDy = 0; }
269   if (pendSy) { send("s " + pendSy.toFixed(3) + " 0"); pendSy = 0; }
270 }
271 function queueFlush() { if (!flushT) flushT = setTimeout(flush, 12); }
272 
273 pad.addEventListener("touchstart", e => {
274   // Touches on the window-switcher panel are its own (row taps, list
275   // scrolling) — never trackpad input. Any other pad touch dismisses it.
276   if (swContains(e.target)) return;
277   swHide();
278   e.preventDefault();
279   for (const t of e.changedTouches) touches.set(t.identifier, { x: t.clientX, y: t.clientY });
280   maxFingers = Math.max(maxFingers, touches.size);
281   if (touches.size === 1) {
282     moved = 0; startT = Date.now(); pinch = null;
283     const t0 = e.changedTouches[0];
284     startX = t0.clientX; startY = t0.clientY;
285     dragTimer = setTimeout(() => {
286       if (moved >= 8) return;
287       dragging = true; send("b left down"); navigator.vibrate && navigator.vibrate(15);
288     }, 350);
289   } else { clearTimeout(dragTimer); }
290 }, { passive: false });
291 
292 pad.addEventListener("touchmove", e => {
293   if (swContains(e.target)) return;
294   e.preventDefault();
295   if (touches.size === 1) {
296     const t = e.changedTouches[0], p = touches.get(t.identifier);
297     if (!p) return;
298     // one-finger drag = relative pointer motion, in BOTH modes
299     const rdx = t.clientX - p.x, rdy = t.clientY - p.y;
300     pendDx += rdx; pendDy += rdy; queueFlush();
301     // view mode: predict the cursor marker instantly (same accel as the send)
302     if (viewMode) predictMarker(rdx * ACCEL, rdy * ACCEL);
303     moved += Math.abs(rdx) + Math.abs(rdy);
304     touches.set(t.identifier, { x: t.clientX, y: t.clientY });
305   } else if (touches.size === 2) {
306     let dy = 0, n = 0;
307     for (const t of e.changedTouches) {
308       const p = touches.get(t.identifier);
309       if (p) { dy += t.clientY - p.y; n++; touches.set(t.identifier, { x: t.clientX, y: t.clientY }); }
310     }
311     if (n) moved += Math.abs(dy);
312     if (viewMode) {
313       // two fingers in view mode: pinch-zoom + pan of the view
314       const pts = [...touches.values()];
315       if (pts.length === 2) {
316         const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
317         const cx = (pts[0].x + pts[1].x) / 2, cy = (pts[0].y + pts[1].y) / 2;
318         if (pinch && pinch.dist > 0) {
319           const nz = Math.min(8, Math.max(1, vZoom * (dist / pinch.dist)));
320           const applied = nz / vZoom;
321           const r = pad.getBoundingClientRect();
322           const px = cx - r.left, py = cy - r.top;
323           // zoom about the pinch centroid, then follow its drag
324           vPanX = px - (px - vPanX) * applied + (cx - pinch.cx);
325           vPanY = py - (py - vPanY) * applied + (cy - pinch.cy);
326           vZoom = nz;
327           clampPan();
328           applyViewTransform();
329         }
330         pinch = { dist, cx, cy };
331       }
332     } else if (n) {
333       // trackpad mode: two-finger drag scrolls (natural direction)
334       pendSy += (-dy / n) * SCROLL;
335       queueFlush();
336     }
337   }
338 }, { passive: false });
339 
340 pad.addEventListener("touchend", e => {
341   if (swContains(e.target)) return;
342   e.preventDefault();
343   for (const t of e.changedTouches) touches.delete(t.identifier);
344   if (touches.size < 2) pinch = null;
345   if (touches.size > 0) return;
346   clearTimeout(dragTimer);
347   flush();
348   if (dragging) { send("b left up"); dragging = false; }
349   else if (moved < 10 && Date.now() - startT < 300) {
350     // Identical in both modes: a tap clicks where the cursor IS — it never
351     // warps the pointer to the tapped spot. View mode is the trackpad with a
352     // picture behind it, not a touchscreen.
353     send(maxFingers >= 2 ? "b right click" : "b left click");
354   }
355   maxFingers = 0;
356 }, { passive: false });
357 pad.addEventListener("touchcancel", () => { touches.clear(); clearTimeout(dragTimer); if (dragging) { send("b left up"); dragging = false; } maxFingers = 0; });
358 
359 document.getElementById("lclick").addEventListener("click", () => send("b left click"));
360 document.getElementById("rclick").addEventListener("click", () => send("b right click"));
361 
362 // ── View mode + the ⋯ menu ───────────────────────────────────────────
363 const mbtn = document.getElementById("mbtn");
364 function setViewMode(on) {
365   if (viewMode === on) return;
366   viewMode = on;
367   mbtn.classList.toggle("on", viewMode);
368   pad.classList.toggle("view", viewMode);
369   resetViewTransform();
370   if (viewMode) {
371     streamStart();
372     send("wl"); // prime winRect
373     rectTimer = setInterval(() => send("wl"), 2000);
374     cursorTimer = setInterval(() => send("pl"), 120);
375     // Reconnect if no frame arrives for a while (the frame sources force
376     // keepalive frames every ≤20s, so ~30s of silence means the connection
377     // is dead, not idle).
378     watchdog = setInterval(() => {
379       if (viewMode && Date.now() - lastFrameAt > 30000) streamStart();
380     }, 5000);
381   } else {
382     clearInterval(rectTimer);
383     clearInterval(cursorTimer);
384     clearInterval(watchdog);
385     vcursor.classList.remove("on");
386     streamStop();
387   }
388 }
389 const menuEl = document.getElementById("menu");
390 function menuRow(label, danger, fn) {
391   const row = document.createElement("div");
392   row.className = "win";
393   const t = document.createElement("span");
394   t.className = "t";
395   t.textContent = label;
396   if (danger) t.style.color = "#e08a8a";
397   row.appendChild(t);
398   row.addEventListener("click", () => { swHide(); fn(); });
399   menuEl.appendChild(row);
400 }
401 mbtn.addEventListener("click", () => {
402   if (menuEl.classList.contains("show")) { swHide(); return; }
403   swHide();
404   menuEl.innerHTML = "";
405   menuRow(viewMode ? "Switch to trackpad" : "Show focused window (live view)", false,
406     () => setViewMode(!viewMode));
407   menuRow("Reload page", false, () => location.reload());
408   menuRow("Restart compositor", true, () => {
409     if (window.confirm("Restart the compositor? Every app in the session will close, then the session relaunches.")) {
410       send("cmd restart-compositor");
411     }
412   });
413   menuEl.classList.add("show");
414 });
415 
416 // ── Window switcher ──────────────────────────────────────────────────
417 const switcher = document.getElementById("switcher");
418 let wantSwitcher = false;
419 document.getElementById("wbtn").addEventListener("click", () => {
420   if (switcher.classList.contains("show")) { switcher.classList.remove("show"); return; }
421   wantSwitcher = true;
422   send("wl"); // reply renders + shows the panel
423 });
424 function renderSwitcher(json) {
425   let wins = [];
426   try { wins = JSON.parse(json); } catch (_) {}
427   updateRect(wins); // every wl reply refreshes the tap-mapping rect
428   if (!wantSwitcher) return;
429   wantSwitcher = false;
430   // real app windows only: no status-bar modules, no child surfaces
431   wins = wins.filter(w => w.mode !== "Status" && !w.has_parent && !w.minimized);
432   switcher.innerHTML = "";
433   if (!wins.length) {
434     switcher.innerHTML = '<div class="empty">no windows</div>';
435   }
436   for (const w of wins) {
437     const row = document.createElement("div");
438     row.className = "win" + (w.focused ? " focused" : "");
439     const t = document.createElement("span"); t.className = "t"; t.textContent = w.title || w.app_id;
440     const a = document.createElement("span"); a.className = "a"; a.textContent = w.app_id;
441     row.append(t, a);
442     row.addEventListener("click", () => {
443       send("wf " + w.id);
444       swHide();
445     });
446     switcher.appendChild(row);
447   }
448   switcher.classList.add("show");
449 }
450 
451 // ── Bar keys + sticky modifiers ──────────────────────────────────────
452 for (const b of document.querySelectorAll("#bar button[data-k]"))
453   b.addEventListener("click", () => send("k " + b.dataset.k));
454 for (const b of document.querySelectorAll("#bar button[data-mod]"))
455   b.addEventListener("click", () => {
456     const on = b.classList.toggle("on");
457     send((on ? "kd " : "ku ") + b.dataset.mod);
458   });
459 
460 // ── Keyboard: iOS software keyboard via a hidden textarea ────────────
461 // beforeinput is the reliable path on iOS (keydown often reports 229).
462 const kb = document.getElementById("kb");
463 document.getElementById("kbtn").addEventListener("click", () => {
464   if (document.activeElement === kb) { kb.blur(); } else { kb.focus(); }
465 });
466 // iOS never fires deleteContentBackward on an EMPTY field — keep sentinel
467 // padding in the textarea (cursor at the end) so backspace always has
468 // something to "delete". beforeinput preventDefault keeps it untouched;
469 // the re-arm below repairs any drift (autocorrect ghosts etc.).
470 const SENTINEL = "········";
471 function armKb() { kb.value = SENTINEL; kb.setSelectionRange(SENTINEL.length, SENTINEL.length); }
472 // Local echo of what's been typed — cleared on Enter or keyboard close.
473 // (The target app shows the real text; this is feedback for eyes-on-phone.)
474 const preview = document.getElementById("preview");
475 let typed = "";
476 // The iOS keyboard overlays the page without any CSS-visible layout change;
477 // visualViewport is the only honest signal (its height shrinks by the
478 // keyboard, and offsetTop tracks iOS sliding a standalone page). While the
479 // keyboard is up, fit the WHOLE layout into the visible strip above it: the
480 // flex column reflows, so the trackpad / live view shrinks but stays fully
481 // visible — its bottom edge, and the click buttons, ride the keyboard top.
482 // The 0.85 guard keeps ordinary browser-chrome changes from counting as a
483 // keyboard. Tap mapping and the cursor marker need no adjustment: both read
484 // getBoundingClientRect, which reflects the reflow.
485 const vv = window.visualViewport;
486 function fitViewport() {
487   const short = vv && vv.height < window.innerHeight * 0.85;
488   document.body.style.height = short ? vv.height + "px" : "";
489   // The keyboard covers the home-indicator area, so the safe-area bottom
490   // padding (~34px in standalone) would hover the click buttons that far
491   // above the keyboard. It exists to dodge the indicator; the keyboard is
492   // dodging it for us.
493   document.body.style.paddingBottom = short ? "0px" : "";
494   document.body.style.transform = short && vv.offsetTop > 0
495     ? "translateY(" + vv.offsetTop + "px)"
496     : "";
497 }
498 if (vv) {
499   vv.addEventListener("resize", fitViewport);
500   vv.addEventListener("scroll", fitViewport);
501 }
502 function updPreview() {
503   preview.textContent = typed;
504   preview.classList.toggle("show", document.activeElement === kb);
505 }
506 kb.addEventListener("focus", () => { armKb(); updPreview(); });
507 kb.addEventListener("blur", () => { typed = ""; updPreview(); });
508 // US-layout char → [evdev keycode, needs shift]
509 const KEYS = {};
510 "abcdefghijklmnopqrstuvwxyz".split("").forEach((c, i) => {
511   KEYS[c] = [[30,48,46,32,18,33,34,35,23,36,37,38,50,49,24,25,16,19,31,20,22,47,17,45,21,44][i], false];
512   KEYS[c.toUpperCase()] = [KEYS[c][0], true];
513 });
514 "1234567890".split("").forEach((c, i) => { KEYS[c] = [2 + i, false]; });
515 Object.assign(KEYS, {
516   " ": [57,false], "-": [12,false], "=": [13,false], "[": [26,false], "]": [27,false],
517   "\\": [43,false], ";": [39,false], "'": [40,false], "`": [41,false], ",": [51,false],
518   ".": [52,false], "/": [53,false],
519   "!": [2,true], "@": [3,true], "#": [4,true], "$": [5,true], "%": [6,true], "^": [7,true],
520   "&": [8,true], "*": [9,true], "(": [10,true], ")": [11,true], "_": [12,true], "+": [13,true],
521   "{": [26,true], "}": [27,true], "|": [43,true], ":": [39,true], "\"": [40,true],
522   "~": [41,true], "<": [51,true], ">": [52,true], "?": [53,true],
523 });
524 function typeChar(ch) {
525   const k = KEYS[ch];
526   if (!k) return;
527   if (k[1]) { send("kd 42"); send("k " + k[0]); send("ku 42"); }
528   else { send("k " + k[0]); }
529   typed += ch;
530 }
531 kb.addEventListener("beforeinput", e => {
532   e.preventDefault();
533   switch (e.inputType) {
534     case "insertText":
535     case "insertReplacementText":
536       for (const ch of (e.data || "")) typeChar(ch);
537       break;
538     case "insertLineBreak": send("k 28"); typed = ""; break;
539     case "deleteContentBackward": send("k 14"); typed = typed.slice(0, -1); break;
540     case "deleteWordBackward": {
541       // one backspace per removed char, so desktop and preview stay in sync
542       const rest = typed.replace(/\S+\s*$/, "");
543       const n = Math.max(1, typed.length - rest.length);
544       for (let i = 0; i < n; i++) send("k 14");
545       typed = rest;
546       break;
547     }
548     case "deleteContentForward": send("k 111"); break;
549   }
550   updPreview();
551 });
552 // repair sentinel drift while the keyboard is up
553 setInterval(() => { if (document.activeElement === kb && kb.value !== SENTINEL) armKb(); }, 1000);
554 </script>
555 </body>
556 </html>