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

commitd101ef581ac9d72e24e6b9ae46205d02559aa38e
parentef30db2dad
authorLucas Galante <[email protected]>
date2026-07-21 21:01
feat: โ‹ฏ menu โ€” view-mode switch + restart compositor

The ๐Ÿ–ฅ bar toggle becomes a โ‹ฏ menu (switcher-panel styling): one entry
flips trackpad โ†” live window view, one runs restart-compositor (client
confirm() first; red-tinted). Server side, a new 'cmd' WS message with
per-name whitelisting โ€” only restart-compositor is mapped, nothing
passes through raw. Overlay touch-routing generalized to cover both
panels.

Co-Authored-By: Claude Fable 5 <[email protected]>

 index.html  | 61 +++++++++++++++++++++++++++++++++++++++++++++++--------------
 src/main.rs |  5 +++++
 2 files changed, 52 insertions(+), 14 deletions(-)

diff --git a/index.html b/index.html
index 9fce00f..59bae55 100644
--- a/index.html
+++ b/index.html
@@ -32,13 +32,13 @@
             object-fit: contain; background: #0c0d12; border-radius: 12px; z-index: 2; }
   #pad.view #screen { display: block; }
   #pad.view .hint { display: none; }
-  #switcher { display: none; position: absolute; inset: 8px; overflow-y: auto;
+  #switcher, #menu { display: none; position: absolute; inset: 8px; overflow-y: auto;
               background: rgba(20,21,28,0.96); border: 1px solid #2e3140;
               border-radius: 10px; z-index: 5; -webkit-overflow-scrolling: touch; }
-  #switcher.show { display: block; }
-  #switcher .win { padding: 14px 14px; border-bottom: 1px solid #23252f;
+  #switcher.show, #menu.show { display: block; }
+  #switcher .win, #menu .win { padding: 14px 14px; border-bottom: 1px solid #23252f;
                    display: flex; gap: 10px; align-items: baseline; }
-  #switcher .win .t { flex: 1; overflow: hidden; text-overflow: ellipsis;
+  #switcher .win .t, #menu .win .t { flex: 1; overflow: hidden; text-overflow: ellipsis;
                       white-space: nowrap; font-size: 14px; }
   #switcher .win .a { color: #6a6d7c; font-size: 11px; }
   #switcher .win.focused { background: #1d3a44; }
@@ -66,13 +66,14 @@
   <button data-k="108">โ†“</button>
   <button data-k="106">โ†’</button>
   <button id="wbtn">โ˜ฐ</button>
-  <button id="vbtn">๐Ÿ–ฅ</button>
+  <button id="mbtn">โ‹ฏ</button>
   <button id="kbtn">โŒจ</button>
 </div>
 <div id="preview"></div>
 <div id="pad"><div class="hint">drag = move ยท tap = click ยท 2-finger drag = scroll<br>2-finger tap = right click ยท hold = drag</div>
   <img id="screen" draggable="false">
   <div id="switcher"></div>
+  <div id="menu"></div>
 </div>
 <div id="clicks">
   <button id="lclick">left</button>
@@ -114,9 +115,16 @@ function send(s) { if (authed && ws && ws.readyState === 1) ws.send(s); }
 
 // โ”€โ”€ Trackpad โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 const pad = document.getElementById("pad");
-// forward decls used by the pad handlers; the panel is wired further down
-function swContains(el) { return document.getElementById("switcher").contains(el); }
-function swHide() { document.getElementById("switcher").classList.remove("show"); }
+// forward decls used by the pad handlers; the panels are wired further down.
+// "sw" now means any overlay panel (window switcher or the โ‹ฏ menu).
+function swContains(el) {
+  return document.getElementById("switcher").contains(el)
+      || document.getElementById("menu").contains(el);
+}
+function swHide() {
+  document.getElementById("switcher").classList.remove("show");
+  document.getElementById("menu").classList.remove("show");
+}
 // SCROLL is finger-px โ†’ wl axis units (~surface px): 0.8 โ‰ˆ natural 1:1 feel.
 const ACCEL = 1.6, SCROLL = 0.8;
 // Touch state: one finger moves, two fingers scroll; short still touches
@@ -226,13 +234,13 @@ pad.addEventListener("touchcancel", () => { touches.clear(); clearTimeout(dragTi
 document.getElementById("lclick").addEventListener("click", () => send("b left click"));
 document.getElementById("rclick").addEventListener("click", () => send("b right click"));
 
-// โ”€โ”€ View-mode toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
-const vbtn = document.getElementById("vbtn");
-vbtn.addEventListener("click", () => {
-  viewMode = !viewMode;
-  vbtn.classList.toggle("on", viewMode);
+// โ”€โ”€ View mode + the โ‹ฏ menu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+const mbtn = document.getElementById("mbtn");
+function setViewMode(on) {
+  if (viewMode === on) return;
+  viewMode = on;
+  mbtn.classList.toggle("on", viewMode);
   pad.classList.toggle("view", viewMode);
-  swHide();
   if (viewMode) {
     const pin = localStorage.getItem("cce-remote-pin") || "";
     screenEl.src = "/stream?pin=" + encodeURIComponent(pin);
@@ -242,6 +250,31 @@ vbtn.addEventListener("click", () => {
     clearInterval(rectTimer);
     screenEl.src = "data:,"; // closes the stream connection
   }
+}
+const menuEl = document.getElementById("menu");
+function menuRow(label, danger, fn) {
+  const row = document.createElement("div");
+  row.className = "win";
+  const t = document.createElement("span");
+  t.className = "t";
+  t.textContent = label;
+  if (danger) t.style.color = "#e08a8a";
+  row.appendChild(t);
+  row.addEventListener("click", () => { swHide(); fn(); });
+  menuEl.appendChild(row);
+}
+mbtn.addEventListener("click", () => {
+  if (menuEl.classList.contains("show")) { swHide(); return; }
+  swHide();
+  menuEl.innerHTML = "";
+  menuRow(viewMode ? "Switch to trackpad" : "Show focused window (live view)", false,
+    () => setViewMode(!viewMode));
+  menuRow("Restart compositor", true, () => {
+    if (window.confirm("Restart the compositor? Every app in the session will close, then the session relaunches.")) {
+      send("cmd restart-compositor");
+    }
+  });
+  menuEl.classList.add("show");
 });
 
 // โ”€โ”€ Window switcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
diff --git a/src/main.rs b/src/main.rs
index cd0b5af..d559bd2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -135,6 +135,11 @@ fn translate(frame: &str) -> Option<String> {
             }
             format!("focus-window {target}")
         }
+        // Named commands, individually whitelisted โ€” never pass-through.
+        "cmd" => match it.next()? {
+            "restart-compositor" => "restart-compositor".to_string(),
+            _ => return None,
+        },
         _ => return None,
     };
     Some(cmd)