@myerscarpenter/quest-dev 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.github/workflows/docs.yml +45 -0
  3. package/.github/workflows/publish.yml +11 -1
  4. package/README.md +27 -0
  5. package/build/cast/decoder.d.ts +48 -0
  6. package/build/cast/decoder.d.ts.map +1 -0
  7. package/build/cast/decoder.js +152 -0
  8. package/build/cast/decoder.js.map +1 -0
  9. package/build/cast/session.d.ts +87 -0
  10. package/build/cast/session.d.ts.map +1 -0
  11. package/build/cast/session.js +565 -0
  12. package/build/cast/session.js.map +1 -0
  13. package/build/commands/logcat.d.ts.map +1 -1
  14. package/build/commands/logcat.js +7 -6
  15. package/build/commands/logcat.js.map +1 -1
  16. package/build/commands/screenshot.d.ts.map +1 -1
  17. package/build/commands/screenshot.js +17 -20
  18. package/build/commands/screenshot.js.map +1 -1
  19. package/build/commands/stay-awake.d.ts +2 -15
  20. package/build/commands/stay-awake.d.ts.map +1 -1
  21. package/build/commands/stay-awake.js +14 -77
  22. package/build/commands/stay-awake.js.map +1 -1
  23. package/build/daemon/cast-manager.d.ts +42 -0
  24. package/build/daemon/cast-manager.d.ts.map +1 -0
  25. package/build/daemon/cast-manager.js +243 -0
  26. package/build/daemon/cast-manager.js.map +1 -0
  27. package/build/daemon/client.d.ts +40 -0
  28. package/build/daemon/client.d.ts.map +1 -0
  29. package/build/daemon/client.js +133 -0
  30. package/build/daemon/client.js.map +1 -0
  31. package/build/daemon/daemon.d.ts +20 -0
  32. package/build/daemon/daemon.d.ts.map +1 -0
  33. package/build/daemon/daemon.js +130 -0
  34. package/build/daemon/daemon.js.map +1 -0
  35. package/build/daemon/deploy.d.ts +44 -0
  36. package/build/daemon/deploy.d.ts.map +1 -0
  37. package/build/daemon/deploy.js +230 -0
  38. package/build/daemon/deploy.js.map +1 -0
  39. package/build/daemon/logcat-manager.d.ts +39 -0
  40. package/build/daemon/logcat-manager.d.ts.map +1 -0
  41. package/build/daemon/logcat-manager.js +194 -0
  42. package/build/daemon/logcat-manager.js.map +1 -0
  43. package/build/daemon/server.d.ts +19 -0
  44. package/build/daemon/server.d.ts.map +1 -0
  45. package/build/daemon/server.js +482 -0
  46. package/build/daemon/server.js.map +1 -0
  47. package/build/daemon/stay-awake-manager.d.ts +22 -0
  48. package/build/daemon/stay-awake-manager.d.ts.map +1 -0
  49. package/build/daemon/stay-awake-manager.js +74 -0
  50. package/build/daemon/stay-awake-manager.js.map +1 -0
  51. package/build/index.js +272 -45
  52. package/build/index.js.map +1 -1
  53. package/build/public/dashboard.js +749 -0
  54. package/build/public/index.html +12 -0
  55. package/build/public/style.css +106 -0
  56. package/build/utils/adb.d.ts +6 -0
  57. package/build/utils/adb.d.ts.map +1 -1
  58. package/build/utils/adb.js +62 -66
  59. package/build/utils/adb.js.map +1 -1
  60. package/build/utils/casting-apk.d.ts +40 -0
  61. package/build/utils/casting-apk.d.ts.map +1 -0
  62. package/build/utils/casting-apk.js +252 -0
  63. package/build/utils/casting-apk.js.map +1 -0
  64. package/build/utils/config.d.ts +5 -3
  65. package/build/utils/config.d.ts.map +1 -1
  66. package/build/utils/config.js +18 -38
  67. package/build/utils/config.js.map +1 -1
  68. package/build/utils/exec.d.ts +5 -0
  69. package/build/utils/exec.d.ts.map +1 -1
  70. package/build/utils/exec.js +17 -0
  71. package/build/utils/exec.js.map +1 -1
  72. package/build/utils/filename.d.ts +7 -1
  73. package/build/utils/filename.d.ts.map +1 -1
  74. package/build/utils/filename.js +17 -2
  75. package/build/utils/filename.js.map +1 -1
  76. package/build/utils/filename.test.js +33 -1
  77. package/build/utils/filename.test.js.map +1 -1
  78. package/build/utils/jpeg-comment.d.ts +14 -0
  79. package/build/utils/jpeg-comment.d.ts.map +1 -0
  80. package/build/utils/jpeg-comment.js +28 -0
  81. package/build/utils/jpeg-comment.js.map +1 -0
  82. package/build/utils/test-properties.d.ts +34 -0
  83. package/build/utils/test-properties.d.ts.map +1 -0
  84. package/build/utils/test-properties.js +73 -0
  85. package/build/utils/test-properties.js.map +1 -0
  86. package/package.json +11 -5
  87. package/packages/cast2-protocol/README.md +86 -0
  88. package/packages/cast2-protocol/docs/_config.yml +4 -0
  89. package/packages/cast2-protocol/docs/feature-flags.md +102 -0
  90. package/packages/cast2-protocol/docs/index.md +24 -0
  91. package/packages/cast2-protocol/docs/open-investigations.md +149 -0
  92. package/packages/cast2-protocol/docs/protocol.md +602 -0
  93. package/packages/cast2-protocol/package.json +46 -0
  94. package/packages/cast2-protocol/src/constants.ts +65 -0
  95. package/packages/cast2-protocol/src/index.ts +7 -0
  96. package/packages/cast2-protocol/src/mgik.ts +69 -0
  97. package/packages/cast2-protocol/src/mud.ts +294 -0
  98. package/packages/cast2-protocol/src/pose.ts +99 -0
  99. package/packages/cast2-protocol/src/resolutions.ts +34 -0
  100. package/packages/cast2-protocol/src/types.ts +64 -0
  101. package/packages/cast2-protocol/src/xrsp.ts +73 -0
  102. package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
  103. package/packages/cast2-protocol/tests/mud.test.ts +295 -0
  104. package/packages/cast2-protocol/tests/pose.test.ts +173 -0
  105. package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
  106. package/packages/cast2-protocol/tsconfig.json +20 -0
  107. package/pnpm-workspace.yaml +2 -0
  108. package/src/cast/decoder.ts +178 -0
  109. package/src/cast/session.ts +708 -0
  110. package/src/commands/logcat.ts +6 -5
  111. package/src/commands/screenshot.ts +19 -13
  112. package/src/commands/stay-awake.ts +22 -91
  113. package/src/daemon/adbkit-apkreader.d.ts +14 -0
  114. package/src/daemon/cast-manager.ts +282 -0
  115. package/src/daemon/client.ts +166 -0
  116. package/src/daemon/daemon.ts +169 -0
  117. package/src/daemon/deploy.ts +307 -0
  118. package/src/daemon/logcat-manager.ts +229 -0
  119. package/src/daemon/server.ts +595 -0
  120. package/src/daemon/stay-awake-manager.ts +83 -0
  121. package/src/index.ts +326 -56
  122. package/src/public/dashboard.js +288 -0
  123. package/src/public/index.html +12 -0
  124. package/src/public/style.css +106 -0
  125. package/src/utils/adb.ts +70 -57
  126. package/src/utils/casting-apk.ts +276 -0
  127. package/src/utils/config.ts +18 -36
  128. package/src/utils/exec.ts +20 -0
  129. package/src/utils/filename.test.ts +41 -1
  130. package/src/utils/filename.ts +18 -2
  131. package/src/utils/jpeg-comment.ts +30 -0
  132. package/src/utils/test-properties.ts +94 -0
  133. package/tests/cast/auto-layer.test.ts +87 -0
  134. package/tests/cast/decoder.test.ts +82 -0
  135. package/tests/cast/session-restart.test.ts +107 -0
  136. package/tests/config.test.ts +17 -22
  137. package/tests/daemon/api-status.test.ts +82 -0
  138. package/tests/daemon/cast-manager.test.ts +69 -0
  139. package/tests/daemon/mjpeg-stream.test.ts +144 -0
  140. package/tests/daemon/pose-endpoint.test.ts +63 -0
  141. package/tests/daemon/start-guard.test.ts +77 -0
  142. package/vitest.config.ts +10 -0
@@ -0,0 +1,288 @@
1
+ import { h, render } from "preact";
2
+ import { useState, useEffect, useRef, useCallback } from "preact/hooks";
3
+ import htm from "htm";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ // --- API helper ---
8
+ async function api(method, path, body) {
9
+ const opts = { method };
10
+ if (body) {
11
+ opts.headers = { "Content-Type": "application/json" };
12
+ opts.body = JSON.stringify(body);
13
+ }
14
+ const r = await fetch(path, opts);
15
+ if (!r.ok) {
16
+ let msg;
17
+ try { const j = await r.json(); msg = j.error ?? r.statusText; } catch { msg = r.statusText; }
18
+ throw new Error(`HTTP ${r.status}: ${msg}`);
19
+ }
20
+ return r.json();
21
+ }
22
+
23
+ // Helper: pick button class based on whether it matches current server state
24
+ function bc(active) { return active ? "" : "secondary"; }
25
+
26
+ // --- App ---
27
+ function App() {
28
+ // Single source of truth: server status (polled every second)
29
+ const [s, setS] = useState({ connected: false, running: false });
30
+ const [toast, setToast] = useState(null);
31
+ const toastTimer = useRef(null);
32
+
33
+ const active = s.connected && s.running;
34
+ const pose = s.pose || {};
35
+
36
+ const showToast = useCallback((msg) => {
37
+ setToast(msg);
38
+ clearTimeout(toastTimer.current);
39
+ toastTimer.current = setTimeout(() => setToast(null), 2000);
40
+ }, []);
41
+
42
+ // SSE — single source of truth for all state
43
+ useEffect(() => {
44
+ let es;
45
+ function connect() {
46
+ es = new EventSource("/cast/events");
47
+ es.addEventListener("status", (e) => {
48
+ setS(JSON.parse(e.data));
49
+ });
50
+ es.addEventListener("toast", (e) => {
51
+ showToast(JSON.parse(e.data).message);
52
+ });
53
+ es.onerror = () => {
54
+ setS((prev) => ({ ...prev, connected: false, running: false }));
55
+ es.close();
56
+ setTimeout(connect, 2000);
57
+ };
58
+ }
59
+ connect();
60
+ return () => es?.close();
61
+ }, [showToast]);
62
+
63
+ // Double-buffered frame refresh: fetch next frame while current displays
64
+ const imgRef = useRef(null);
65
+ useEffect(() => {
66
+ if (!active) return;
67
+ let run = true;
68
+ // Two offscreen buffers, alternating
69
+ const buf = [new Image(), new Image()];
70
+ let idx = 0;
71
+ function fetch_next() {
72
+ if (!run) return;
73
+ const img = buf[idx];
74
+ idx = 1 - idx;
75
+ img.onload = () => {
76
+ if (!run) return;
77
+ if (imgRef.current) imgRef.current.src = img.src;
78
+ requestAnimationFrame(fetch_next);
79
+ };
80
+ img.onerror = () => { if (run) setTimeout(fetch_next, 500); };
81
+ img.src = "/cast/screenshot?t=" + Date.now();
82
+ }
83
+ fetch_next();
84
+ return () => { run = false; };
85
+ }, [active]);
86
+
87
+ // Keyboard + mouse (imperative)
88
+ const overlayRef = useRef(null);
89
+ const crosshairRef = useRef(null);
90
+
91
+ useEffect(() => {
92
+ const keysDown = new Set();
93
+ let keyLoop = false, dragging = false, dx0, dy0;
94
+
95
+ function spd() { return parseFloat(document.getElementById("move-speed")?.value) || 0.3; }
96
+ function look() { return parseFloat(document.getElementById("look-speed")?.value) || 0.15; }
97
+
98
+ function tick() {
99
+ if (keysDown.size === 0) { keyLoop = false; return; }
100
+ const m = spd(), l = look();
101
+ let fwd = 0, str = 0, yaw = 0, pit = 0, up = 0;
102
+ if (keysDown.has("w") || keysDown.has("arrowup")) fwd += m;
103
+ if (keysDown.has("s") || keysDown.has("arrowdown")) fwd -= m;
104
+ if (keysDown.has("a") || keysDown.has("arrowleft")) str -= m;
105
+ if (keysDown.has("d") || keysDown.has("arrowright")) str += m;
106
+ if (keysDown.has("q")) up -= m;
107
+ if (keysDown.has("e")) up += m;
108
+ if (keysDown.has("j")) yaw -= l;
109
+ if (keysDown.has("l")) yaw += l;
110
+ if (keysDown.has("i")) pit += l;
111
+ if (keysDown.has("k")) pit -= l;
112
+ if (fwd || str || yaw || pit || up)
113
+ api("POST", "/cast/pose", { dz: fwd, dx: str, dy: up, d_yaw: yaw, d_pitch: pit }).catch((e) => showToast(e.message));
114
+ setTimeout(tick, 80);
115
+ }
116
+
117
+ const kd = (e) => {
118
+ if (e.target.tagName === "INPUT") return;
119
+ if (e.repeat) return;
120
+ keysDown.add(e.key.toLowerCase());
121
+ if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].includes(e.key)) e.preventDefault();
122
+ if (e.key === "p" || e.key === "P") { doScreenshot(); return; }
123
+ if (e.key === " " || e.key === "Enter") { doClick(); return; }
124
+ if (e.key === "Escape") { doResetPose(); return; }
125
+ if (!keyLoop) { keyLoop = true; tick(); }
126
+ };
127
+ const ku = (e) => { if (e.target.tagName === "INPUT") return; keysDown.delete(e.key.toLowerCase()); };
128
+ const md = (e) => { if (e.button !== 0) return; dragging = true; dx0 = e.clientX; dy0 = e.clientY; if (crosshairRef.current) crosshairRef.current.style.display = "block"; };
129
+ const mm = (e) => {
130
+ if (!dragging) return;
131
+ const dx = e.clientX - dx0, dy = e.clientY - dy0;
132
+ dx0 = e.clientX; dy0 = e.clientY;
133
+ const sn = look();
134
+ const yaw = dx * sn * 0.02, pitch = dy * sn * 0.02;
135
+ if (yaw || pitch) api("POST", "/cast/pose", { d_yaw: yaw, d_pitch: pitch }).catch((e) => showToast(e.message));
136
+ };
137
+ const mu = () => { dragging = false; if (crosshairRef.current) crosshairRef.current.style.display = "none"; };
138
+ const ctx = (e) => { e.preventDefault(); doClick(); };
139
+
140
+ document.addEventListener("keydown", kd);
141
+ document.addEventListener("keyup", ku);
142
+ window.addEventListener("mousemove", mm);
143
+ window.addEventListener("mouseup", mu);
144
+ const ov = overlayRef.current;
145
+ if (ov) { ov.addEventListener("mousedown", md); ov.addEventListener("contextmenu", ctx); }
146
+ return () => {
147
+ document.removeEventListener("keydown", kd);
148
+ document.removeEventListener("keyup", ku);
149
+ window.removeEventListener("mousemove", mm);
150
+ window.removeEventListener("mouseup", mu);
151
+ if (ov) { ov.removeEventListener("mousedown", md); ov.removeEventListener("contextmenu", ctx); }
152
+ };
153
+ }, []);
154
+
155
+ // Actions
156
+ const doClick = async () => {
157
+ try { const r = await api("POST", "/cast/click"); if (r?.ok) showToast("Click sent"); }
158
+ catch (e) { showToast("Click failed: " + e.message); }
159
+ };
160
+ const doScreenshot = () => {
161
+ const a = document.createElement("a");
162
+ a.href = "/cast/screenshot";
163
+ a.download = "quest-" + new Date().toISOString().replace(/[:.]/g, "-") + ".jpg";
164
+ a.click();
165
+ showToast("Screenshot saved");
166
+ };
167
+ const doResetPose = async () => {
168
+ try { await api("POST", "/cast/reset-view"); showToast("Back to HMD view"); }
169
+ catch (e) { showToast("Reset failed: " + e.message); }
170
+ };
171
+
172
+ // Derive display state from server
173
+ const curRes = s.width && s.height ? s.width + "x" + s.height : "";
174
+ const curEye = s.eye || "left";
175
+
176
+ return html`
177
+ <header>
178
+ <div id="status-dot" class=${active ? "ok" : ""}></div>
179
+ <h1>Quest Cast</h1>
180
+ ${active ? html`
181
+ <div id="status-bar">
182
+ <span><span class="val">${s.fps ?? "--"}</span> fps</span>
183
+ <span><span class="val">${s.width ? s.width + "\u00d7" + s.height : "--"}</span></span>
184
+ <span><span class="val">${s.frame_count?.toLocaleString() ?? "--"}</span> frames</span>
185
+ <span><span class="val">${s.bytes ? (s.bytes / 1048576).toFixed(1) : "--"}</span> MB</span>
186
+ <button onclick=${() => { if (confirm("Stop casting?")) api("POST", "/cast/stop"); }}>Stop</button>
187
+ </div>
188
+ ` : html`
189
+ <div id="header-cast-btn">
190
+ <button onclick=${() => api("POST", "/cast/start")}>Cast</button>
191
+ </div>
192
+ `}
193
+ </header>
194
+
195
+ <div class="main">
196
+ <div class="video-pane">
197
+ <${VideoPane} active=${active} imgRef=${imgRef} crosshairRef=${crosshairRef} overlayRef=${overlayRef} />
198
+ </div>
199
+
200
+ <div class="sidebar">
201
+ <${Panel} title="Movement">
202
+ <div class="btn-row" style="justify-content:center;">
203
+ <div style="display:grid; grid-template-columns: repeat(3,40px); grid-template-rows: repeat(2,34px); gap:4px;">
204
+ <div></div>
205
+ <button class="secondary" onclick=${() => api("POST", "/cast/pose", { dz: parseFloat(document.getElementById("move-speed")?.value) || 0.3 })}>\u25B2</button>
206
+ <div></div>
207
+ <button class="secondary" onclick=${() => api("POST", "/cast/pose", { dx: -(parseFloat(document.getElementById("move-speed")?.value) || 0.3) })}>\u25C0</button>
208
+ <button class="secondary" onclick=${() => api("POST", "/cast/pose", { dz: -(parseFloat(document.getElementById("move-speed")?.value) || 0.3) })}>\u25BC</button>
209
+ <button class="secondary" onclick=${() => api("POST", "/cast/pose", { dx: parseFloat(document.getElementById("move-speed")?.value) || 0.3 })}>\u25B6</button>
210
+ </div>
211
+ </div>
212
+ <div class="speed-row">
213
+ <label>Speed</label>
214
+ <input type="number" id="move-speed" value="0.3" min="0.01" max="2" step="0.05" />
215
+ <label>Look</label>
216
+ <input type="number" id="look-speed" value="0.15" min="0.01" max="1" step="0.01" />
217
+ </div>
218
+ <//>
219
+
220
+ <${Panel} title="Offset from HMD">
221
+ <div class="pose-readout">
222
+ <span>${pose.x?.toFixed(2) ?? "0"}, ${pose.y?.toFixed(2) ?? "0"}, ${pose.z?.toFixed(2) ?? "0"}</span>
223
+ <span>${pose.yaw_deg?.toFixed(1) ?? "0"}\u00b0 yaw, ${pose.pitch_deg?.toFixed(1) ?? "0"}\u00b0 pitch</span>
224
+ <span>loop: ${s.pose_loop ? "~27 Hz" : "off"}</span>
225
+ </div>
226
+ <div class="btn-row">
227
+ <button class="secondary" style="flex:1;" onclick=${async () => { const r = await api("POST", "/cast/pose", { x: 0, y: 0, z: 0, yaw: 0, pitch: 0 }); if (r?.ok) showToast("Origin"); }}>Origin</button>
228
+ <button class=${s.pose_loop ? "" : "secondary"} style="flex:1;" onclick=${doResetPose}>${s.pose_loop ? "Back to HMD" : "Reset"}</button>
229
+ </div>
230
+ <//>
231
+
232
+ <${Panel} title="Display">
233
+ <div class="btn-row">
234
+ <button class=${bc(curRes === "2064x1162")} onclick=${() => api("POST", "/cast/config", { resolution: "native" })}>Native</button>
235
+ <button class=${bc(curRes === "1920x1080")} onclick=${() => api("POST", "/cast/config", { resolution: "1080p" })}>1080p</button>
236
+ <button class=${bc(curRes === "1280x720")} onclick=${() => api("POST", "/cast/config", { resolution: "720p" })}>720p</button>
237
+ </div>
238
+ <div class="btn-row" style="margin-top:6px;">
239
+ <button class=${bc(curEye === "left")} onclick=${() => api("POST", "/cast/eye", { mode: "left" })}>L Eye</button>
240
+ <button class=${bc(curEye === "right")} onclick=${() => api("POST", "/cast/eye", { mode: "right" })}>R Eye</button>
241
+ <button class=${bc(curEye === "stereo")} onclick=${() => api("POST", "/cast/eye", { mode: "stereo" })}>Stereo</button>
242
+ </div>
243
+ <//>
244
+
245
+ <${Panel} title="Actions">
246
+ <div class="btn-row">
247
+ <button style="flex:1;" onclick=${doClick}>Click</button>
248
+ <button class="secondary" style="flex:1;" onclick=${() => api("POST", "/cast/home")}>Home</button>
249
+ </div>
250
+ <div class="btn-row" style="margin-top:6px;">
251
+ <button style="flex:1;" onclick=${doScreenshot}>Screenshot</button>
252
+ <button class="secondary" style="flex:1;" onclick=${() => window.open("/cast/stream", "_blank")}>MJPEG</button>
253
+ </div>
254
+ <//>
255
+
256
+ <${Panel} title="Keys">
257
+ <div class="keybind-hint">
258
+ <b>WASD</b> / <b>Arrows</b> move \u00a0 <b>IJKL</b> look \u00a0 <b>QE</b> up/down<br />
259
+ <b>Drag</b> free look \u00a0 <b>Right-click</b> click<br />
260
+ <b>Space</b> click \u00a0 <b>P</b> screenshot \u00a0 <b>Esc</b> back to HMD<br />
261
+ Movement overrides HMD tracking from origin
262
+ </div>
263
+ <//>
264
+ </div>
265
+ </div>
266
+
267
+ ${toast && html`<div class="toast show">${toast}</div>`}
268
+ `;
269
+ }
270
+
271
+ function VideoPane({ active, imgRef, crosshairRef, overlayRef }) {
272
+ if (!active) {
273
+ return html`<div class="video-placeholder">
274
+ <button onclick=${() => api("POST", "/cast/start")}>Start Casting</button>
275
+ </div>`;
276
+ }
277
+ return html`
278
+ <img id="live-frame" ref=${imgRef} alt="Quest view" />
279
+ <div id="crosshair" ref=${crosshairRef}></div>
280
+ <div id="drag-overlay" ref=${overlayRef}></div>
281
+ `;
282
+ }
283
+
284
+ function Panel({ title, children }) {
285
+ return html`<div class="panel"><h2>${title}</h2>${children}</div>`;
286
+ }
287
+
288
+ render(html`<${App} />`, document.body);
@@ -0,0 +1,12 @@
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">
6
+ <title>Quest Cast</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <script src="/dashboard.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,106 @@
1
+ :root {
2
+ --bg: #0e0e10; --surface: #18181b; --border: #2d2d33;
3
+ --text: #efeff1; --dim: #898991; --accent: #6d5bff;
4
+ --accent2: #9147ff; --green: #00c853; --red: #f44;
5
+ }
6
+ * { box-sizing: border-box; margin: 0; padding: 0; }
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
9
+ background: var(--bg); color: var(--text);
10
+ display: flex; flex-direction: column; height: 100vh; overflow: hidden;
11
+ user-select: none;
12
+ }
13
+ header {
14
+ display: flex; align-items: center; gap: 12px;
15
+ padding: 8px 16px; background: var(--surface); border-bottom: 1px solid var(--border);
16
+ flex-shrink: 0;
17
+ }
18
+ header h1 { font-size: 16px; font-weight: 600; }
19
+ #header-cast-btn { margin-left: auto; }
20
+ #status-bar {
21
+ display: flex; align-items: center; gap: 16px; margin-left: auto; font-size: 12px; color: var(--dim);
22
+ }
23
+ #status-bar .val { color: var(--text); font-variant-numeric: tabular-nums; }
24
+ #header-stop-btn { margin-left: 8px; padding: 4px 14px; font-size: 12px; }
25
+ #status-dot {
26
+ width: 8px; height: 8px; border-radius: 50%; background: var(--red);
27
+ align-self: center;
28
+ }
29
+ #status-dot.ok { background: var(--green); }
30
+ .main { display: flex; flex: 1; overflow: hidden; }
31
+ .video-pane {
32
+ flex: 1; display: flex; align-items: center; justify-content: center;
33
+ background: #000; position: relative; min-width: 0;
34
+ }
35
+ #live-frame {
36
+ max-width: 100%; max-height: 100%; object-fit: contain;
37
+ image-rendering: auto;
38
+ }
39
+ #crosshair {
40
+ position: absolute; pointer-events: none;
41
+ width: 24px; height: 24px;
42
+ border: 2px solid rgba(255,255,255,0.4); border-radius: 50%;
43
+ top: 50%; left: 50%; transform: translate(-50%,-50%);
44
+ display: none;
45
+ }
46
+ #crosshair::before, #crosshair::after {
47
+ content: ''; position: absolute; background: rgba(255,255,255,0.4);
48
+ }
49
+ #crosshair::before { width: 1px; height: 8px; top: 50%; left: 50%; transform: translate(-50%,-50%); }
50
+ #crosshair::after { width: 8px; height: 1px; top: 50%; left: 50%; transform: translate(-50%,-50%); }
51
+ .sidebar {
52
+ width: 260px; flex-shrink: 0; background: var(--surface);
53
+ border-left: 1px solid var(--border); display: flex; flex-direction: column;
54
+ overflow-y: auto;
55
+ }
56
+ .panel { padding: 10px 12px; border-bottom: 1px solid var(--border); }
57
+ .panel h2 {
58
+ font-size: 10px; text-transform: uppercase; letter-spacing: .08em;
59
+ color: var(--dim); margin-bottom: 6px;
60
+ }
61
+ button {
62
+ background: var(--accent); color: #fff; border: none; border-radius: 6px;
63
+ padding: 5px 10px; font-size: 11px; cursor: pointer; font-weight: 500;
64
+ transition: opacity .15s;
65
+ }
66
+ button:hover { opacity: .85; }
67
+ button:active { opacity: .7; }
68
+ button.secondary { background: var(--border); }
69
+ button.danger { background: var(--red); }
70
+ .btn-row { display: flex; gap: 4px; }
71
+ .speed-row {
72
+ display: flex; align-items: center; gap: 6px; margin-top: 6px;
73
+ font-size: 10px; color: var(--dim);
74
+ }
75
+ .speed-row input {
76
+ width: 44px; background: var(--bg); color: var(--text);
77
+ border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px;
78
+ font-size: 10px; text-align: center;
79
+ }
80
+ .pose-readout {
81
+ font-size: 10px; font-family: monospace; line-height: 1.5; color: var(--dim);
82
+ margin-bottom: 6px;
83
+ }
84
+ .pose-readout span { display: block; }
85
+ .pose-readout .val { color: var(--text); }
86
+ .keybind-hint {
87
+ font-size: 10px; color: var(--dim); line-height: 1.6;
88
+ }
89
+ .toast {
90
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
91
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
92
+ padding: 8px 16px; font-size: 13px; opacity: 0; transition: opacity .3s;
93
+ pointer-events: none; z-index: 100;
94
+ }
95
+ .toast.show { opacity: 1; }
96
+ #drag-overlay {
97
+ position: absolute; inset: 0; cursor: grab; z-index: 2;
98
+ }
99
+ #drag-overlay:active { cursor: grabbing; }
100
+ .video-placeholder {
101
+ display: flex; align-items: center; justify-content: center;
102
+ width: 100%; height: 100%;
103
+ }
104
+ .video-placeholder button {
105
+ font-size: 16px; padding: 12px 28px; border-radius: 8px;
106
+ }
package/src/utils/adb.ts CHANGED
@@ -9,38 +9,48 @@ import { verbose } from './verbose.js';
9
9
 
10
10
  const CDP_PORT = 9223; // Chrome DevTools Protocol port (Quest browser default)
11
11
 
12
+ /** Escape a string for safe use in adb shell commands. */
13
+ function shellEscape(s: string): string {
14
+ return "'" + s.replace(/'/g, "'\\''") + "'";
15
+ }
16
+
17
+ /** Global ADB device target. When set, all adb commands use -s <device>. */
18
+ let targetDevice: string | undefined;
19
+
20
+ /** Set the global ADB device target (IP:port or serial). */
21
+ export function setAdbDevice(device: string | undefined): void {
22
+ targetDevice = device;
23
+ }
24
+
25
+ /** Get the global ADB device target. */
26
+ export function getAdbDevice(): string | undefined {
27
+ return targetDevice;
28
+ }
29
+
30
+ /** Build ADB args with -s <device> prefix when a target device is set. */
31
+ export function adbArgs(...args: string[]): string[] {
32
+ if (targetDevice) {
33
+ return ["-s", targetDevice, ...args];
34
+ }
35
+ return args;
36
+ }
37
+
12
38
  /**
13
39
  * Get browser process PID
14
40
  */
15
41
  async function getBrowserPID(packageName: string): Promise<number | null> {
16
42
  try {
17
- const result = await execCommandFull('adb', ['shell', `ps | grep ${packageName}`]);
18
- verbose('getBrowserPID ps output:', result.stdout?.trim());
19
- if (!result.stdout) return null;
20
-
21
- // Parse ps output: USER PID PPID ... NAME
22
- // Only match the main process (exact package name at end of line), not
23
- // child processes like :sandboxed_process0 or :privileged_process0
24
- const lines = result.stdout.trim().split('\n');
25
- for (const line of lines) {
26
- if (line.includes('grep')) continue; // Skip grep itself
27
- const parts = line.trim().split(/\s+/);
28
- if (parts.length >= 2) {
29
- // Check that the process name is exactly the package (last column)
30
- const processName = parts[parts.length - 1];
31
- if (processName === packageName) {
32
- const pid = parseInt(parts[1], 10);
33
- verbose('getBrowserPID found PID:', pid, 'for', packageName);
34
- return pid;
35
- }
36
- }
37
- }
38
- verbose('getBrowserPID: no exact match for', packageName);
43
+ const result = await execCommandFull('adb', adbArgs('shell', 'pidof', shellEscape(packageName)));
44
+ verbose('getBrowserPID pidof output:', result.stdout?.trim());
45
+ if (!result.stdout?.trim()) return null;
46
+ const pid = parseInt(result.stdout.trim().split(/\s+/)[0], 10);
47
+ if (isNaN(pid)) return null;
48
+ verbose('getBrowserPID found PID:', pid, 'for', packageName);
49
+ return pid;
39
50
  } catch (e) {
40
51
  verbose('getBrowserPID error:', e);
41
52
  return null;
42
53
  }
43
- return null;
44
54
  }
45
55
 
46
56
  /**
@@ -55,10 +65,9 @@ async function detectCDPSocket(packageName: string): Promise<string> {
55
65
  const socketName = `chrome_devtools_remote_${pid}`;
56
66
  for (let attempt = 0; attempt < 5; attempt++) {
57
67
  try {
58
- const result = await execCommandFull('adb', [
59
- 'shell',
60
- `cat /proc/net/unix | grep ${socketName}`
61
- ]);
68
+ const result = await execCommandFull('adb', adbArgs(
69
+ 'shell', 'cat', '/proc/net/unix',
70
+ ));
62
71
  if (result.stdout.includes(socketName)) {
63
72
  verbose('detectCDPSocket: found PID-specific socket:', socketName, `(attempt ${attempt + 1})`);
64
73
  return socketName;
@@ -132,9 +141,24 @@ async function restartADBServer(): Promise<boolean> {
132
141
  */
133
142
  export async function checkADBDevices(retryCount = 0): Promise<boolean> {
134
143
  try {
144
+ const target = getAdbDevice();
135
145
  const output = await execCommand('adb', ['devices']);
136
146
  const lines = output.trim().split('\n').slice(1); // Skip header
137
- const devices = lines.filter(line => line.trim() && !line.includes('List of devices'));
147
+ let devices = lines.filter(line => line.trim() && !line.includes('List of devices'));
148
+
149
+ // If a target device is configured, check it's in the list
150
+ if (target) {
151
+ const targetOnline = devices.some(line => line.includes(target) && line.includes('device'));
152
+ if (!targetOnline) {
153
+ console.error(`Error: Configured device ${target} not found or offline`);
154
+ console.error('');
155
+ console.error('Check connection: adb connect ' + target);
156
+ console.error('');
157
+ process.exit(1);
158
+ }
159
+ // Filter to just the target device for the count
160
+ devices = devices.filter(line => line.includes(target));
161
+ }
138
162
 
139
163
  if (devices.length === 0) {
140
164
  console.error('Error: No ADB devices connected');
@@ -204,19 +228,19 @@ export async function ensurePortForwarding(
204
228
  const cdpPort = getCDPPortForSocket(cdpSocket);
205
229
 
206
230
  // Check reverse forwarding (Quest -> Host for dev server)
207
- const reverseList = await execCommand('adb', ['reverse', '--list']);
231
+ const reverseList = await execCommand('adb', adbArgs('reverse', '--list'));
208
232
  const reverseExists = reverseList.includes(`tcp:${port}`);
209
233
 
210
234
  if (reverseExists) {
211
235
  console.log(`ADB reverse port forwarding already set up: Quest:${port} -> Host:${port}`);
212
236
  } else {
213
- await execCommand('adb', ['reverse', `tcp:${port}`, `tcp:${port}`]);
237
+ await execCommand('adb', adbArgs('reverse', `tcp:${port}`, `tcp:${port}`));
214
238
  console.log(`ADB reverse port forwarding set up: Quest:${port} -> Host:${port}`);
215
239
  }
216
240
 
217
241
  // Check forward forwarding (Host -> Quest for CDP)
218
242
  // First check if ADB already has this forwarding set up
219
- const forwardList = await execCommand('adb', ['forward', '--list']);
243
+ const forwardList = await execCommand('adb', adbArgs('forward', '--list'));
220
244
  const forwardExists = forwardList.includes(`tcp:${cdpPort}`) && forwardList.includes(cdpSocket);
221
245
 
222
246
  if (forwardExists) {
@@ -236,7 +260,7 @@ export async function ensurePortForwarding(
236
260
  process.exit(1);
237
261
  }
238
262
 
239
- await execCommand('adb', ['forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`]);
263
+ await execCommand('adb', adbArgs('forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`));
240
264
  console.log(`ADB forward port forwarding set up: Host:${cdpPort} -> Quest:${cdpSocket} (CDP)`);
241
265
  }
242
266
  } catch (error) {
@@ -250,21 +274,10 @@ export async function ensurePortForwarding(
250
274
  */
251
275
  export async function isBrowserRunning(browser: string = 'com.oculus.browser'): Promise<boolean> {
252
276
  try {
253
- const result = await execCommandFull('adb', ['shell', `ps | grep ${browser}`]);
254
- verbose('isBrowserRunning ps output:', result.stdout?.trim());
255
- // Check for exact process name match (not _zygote or :sandboxed_process)
256
- const lines = result.stdout?.trim().split('\n') || [];
257
- for (const line of lines) {
258
- if (line.includes('grep')) continue;
259
- const parts = line.trim().split(/\s+/);
260
- const processName = parts[parts.length - 1];
261
- if (processName === browser) {
262
- verbose('isBrowserRunning:', browser, 'YES (PID', parts[1] + ')');
263
- return true;
264
- }
265
- }
266
- verbose('isBrowserRunning:', browser, 'NO (zygote/child only)');
267
- return false;
277
+ const result = await execCommandFull('adb', adbArgs('shell', 'pidof', shellEscape(browser)));
278
+ const running = result.code === 0 && result.stdout.trim().length > 0;
279
+ verbose('isBrowserRunning:', browser, running ? 'YES' : 'NO');
280
+ return running;
268
281
  } catch (error) {
269
282
  verbose('isBrowserRunning error:', error);
270
283
  return false;
@@ -277,7 +290,7 @@ export async function isBrowserRunning(browser: string = 'com.oculus.browser'):
277
290
  export async function launchBrowser(url: string, browser: string = 'com.oculus.browser'): Promise<boolean> {
278
291
  console.log('Launching browser...');
279
292
  try {
280
- await execCommand('adb', [
293
+ await execCommand('adb', adbArgs(
281
294
  'shell',
282
295
  'am',
283
296
  'start',
@@ -286,7 +299,7 @@ export async function launchBrowser(url: string, browser: string = 'com.oculus.b
286
299
  '-d',
287
300
  url,
288
301
  browser
289
- ]);
302
+ ));
290
303
  console.log(`Browser launched with URL: ${url}`);
291
304
  return true;
292
305
  } catch (error) {
@@ -315,7 +328,7 @@ export async function ensureCDPForwarding(
315
328
  const cdpPort = getCDPPortForSocket(cdpSocket);
316
329
 
317
330
  // Check forward forwarding (Host -> Quest for CDP)
318
- const forwardList = await execCommand('adb', ['forward', '--list']);
331
+ const forwardList = await execCommand('adb', adbArgs('forward', '--list'));
319
332
  const forwardExists = forwardList.includes(`tcp:${cdpPort}`) && forwardList.includes(cdpSocket);
320
333
 
321
334
  if (forwardExists) {
@@ -335,7 +348,7 @@ export async function ensureCDPForwarding(
335
348
  process.exit(1);
336
349
  }
337
350
 
338
- await execCommand('adb', ['forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`]);
351
+ await execCommand('adb', adbArgs('forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`));
339
352
  console.log(`ADB forward port forwarding set up: Host:${cdpPort} -> Quest:${cdpSocket} (CDP)`);
340
353
  }
341
354
  } catch (error) {
@@ -357,7 +370,7 @@ export async function refreshCDPForwarding(
357
370
  const cdpPort = getCDPPortForSocket(cdpSocket);
358
371
 
359
372
  // Check if forwarding already points to the correct socket
360
- const forwardList = await execCommand('adb', ['forward', '--list']);
373
+ const forwardList = await execCommand('adb', adbArgs('forward', '--list'));
361
374
  verbose('refreshCDPForwarding: detected socket:', cdpSocket, 'port:', cdpPort);
362
375
  verbose('refreshCDPForwarding: current forwards:', forwardList.trim());
363
376
 
@@ -371,10 +384,10 @@ export async function refreshCDPForwarding(
371
384
  // Remove existing forwarding on CDP port and re-create with correct socket
372
385
  if (forwardList.includes(`tcp:${cdpPort}`)) {
373
386
  verbose('refreshCDPForwarding: removing stale forwarding on port', cdpPort);
374
- await execCommandFull('adb', ['forward', '--remove', `tcp:${cdpPort}`]);
387
+ await execCommandFull('adb', adbArgs('forward', '--remove', `tcp:${cdpPort}`));
375
388
  }
376
389
 
377
- await execCommand('adb', ['forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`]);
390
+ await execCommand('adb', adbArgs('forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`));
378
391
  console.log(`CDP forwarding updated: Host:${cdpPort} -> Quest:${cdpSocket}`);
379
392
  } catch (error) {
380
393
  // Non-fatal: CDP may still work with existing forwarding
@@ -387,7 +400,7 @@ export async function refreshCDPForwarding(
387
400
  * After reboot, user must click notification to allow file access
388
401
  */
389
402
  export async function checkUSBFileTransfer(): Promise<void> {
390
- const result = await execCommandFull('adb', ['shell', 'ls', '/sdcard/']);
403
+ const result = await execCommandFull('adb', adbArgs('shell', 'ls', '/sdcard/'));
391
404
 
392
405
  if (result.code !== 0 ||
393
406
  result.stdout.includes('Permission denied') ||
@@ -408,7 +421,7 @@ export async function checkUSBFileTransfer(): Promise<void> {
408
421
  * Screenshots cannot be taken when the display is off
409
422
  */
410
423
  export async function checkQuestAwake(): Promise<void> {
411
- const result = await execCommandFull('adb', ['shell', 'dumpsys', 'power']);
424
+ const result = await execCommandFull('adb', adbArgs('shell', 'dumpsys', 'power'));
412
425
 
413
426
  if (result.stdout.includes('mWakefulness=Asleep')) {
414
427
  console.error('Error: Quest display is off');
@@ -428,7 +441,7 @@ export interface BatteryInfo {
428
441
  * Get Quest battery info as structured data
429
442
  */
430
443
  export async function getBatteryInfo(): Promise<BatteryInfo> {
431
- const result = await execCommandFull('adb', ['shell', 'dumpsys', 'battery']);
444
+ const result = await execCommandFull('adb', adbArgs('shell', 'dumpsys', 'battery'));
432
445
 
433
446
  if (result.code !== 0) {
434
447
  throw new Error('Failed to get battery status');