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>