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

index.html (29.9K)

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