@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,595 @@
1
+ /**
2
+ * Unified Fastify server for the quest-dev daemon.
3
+ * Hosts all endpoint groups: core, stay-awake, logcat, deploy, cast.
4
+ */
5
+
6
+ import Fastify, { type FastifyInstance } from "fastify";
7
+ import fastifyStatic from "@fastify/static";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { loadPin, loadConfig } from "../utils/config.js";
11
+ import { getBatteryInfo } from "../utils/adb.js";
12
+ import { execCommand } from "../utils/exec.js";
13
+ import { adbArgs } from "../utils/adb.js";
14
+ import { EYE_LEFT, EYE_RIGHT, EYE_STEREO, RESOLUTIONS, resolveResolution } from "@myerscarpenter/cast2-protocol";
15
+ import type { StayAwakeManager } from "./stay-awake-manager.js";
16
+ import type { LogcatManager } from "./logcat-manager.js";
17
+ import type { CastManager } from "./cast-manager.js";
18
+ import { deploy, type DeployResult } from "./deploy.js";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ export interface DaemonServerOptions {
23
+ port: number;
24
+ host: string;
25
+ stayAwake: StayAwakeManager;
26
+ logcat: LogcatManager;
27
+ castManager: CastManager;
28
+ onActivity: () => void;
29
+ onShutdown: () => void;
30
+ }
31
+
32
+ export async function createDaemonServer(
33
+ options: DaemonServerOptions,
34
+ ): Promise<FastifyInstance> {
35
+ const { port, host, stayAwake, logcat, castManager, onActivity, onShutdown } =
36
+ options;
37
+ const config = loadConfig();
38
+
39
+ const app = Fastify({ logger: false });
40
+
41
+ // Static file serving for dashboard
42
+ const publicDir = join(__dirname, "..", "public");
43
+ await app.register(fastifyStatic, {
44
+ root: publicDir,
45
+ prefix: "/",
46
+ decorateReply: false,
47
+ });
48
+
49
+ // Activity tracking: reset idle timer on every request
50
+ app.addHook("onRequest", async () => {
51
+ onActivity();
52
+ });
53
+
54
+ // --- Core endpoints ---
55
+
56
+ app.get("/help", async (_req, reply) => {
57
+ const help = `quest-dev daemon — REST API
58
+
59
+ GET endpoints
60
+ /help This help screen
61
+ /status JSON: uptime, pid, stay_awake, cast, logcat, battery
62
+ /stay-awake/status JSON: stay-awake state
63
+ /logcat/status JSON: logcat capture state
64
+ /cast/help Cast-specific API reference
65
+ /cast/status JSON: cast session state
66
+ /cast/screenshot Latest frame as JPEG
67
+ /cast/stream MJPEG stream (multipart/x-mixed-replace)
68
+ /cast/layers JSON: available layers and active layer ID
69
+ /cast/events SSE stream: state changes and toast notifications
70
+
71
+ POST endpoints (JSON body)
72
+ /shutdown Shut down daemon
73
+ /stay-awake/enable Enable stay-awake (body: { pin? })
74
+ /stay-awake/disable Disable stay-awake
75
+ /logcat/start Start logcat capture (body: { tag? })
76
+ /logcat/stop Stop logcat capture
77
+ /cast/start Start casting (body: { resolution?, listen_port? })
78
+ /cast/stop Stop casting
79
+ /cast/restart Restart cast session
80
+ /cast/reset-view Reset camera offset to headset
81
+ /cast/home Press Home button
82
+ /cast/resolutions List available resolution presets
83
+ /cast/config Set resolution (body: { resolution } or { width, height })
84
+ /cast/eye Set eye mode (body: { mode })
85
+ /cast/pose Set/nudge camera offset from headset
86
+ /cast/click Tap at coordinates (body: { x, y })
87
+ `;
88
+ return reply.type("text/plain").send(help);
89
+ });
90
+
91
+ app.get("/status", async () => {
92
+ let battery = null;
93
+ try {
94
+ const info = await getBatteryInfo();
95
+ battery = { level: info.level, state: info.state };
96
+ } catch {
97
+ // Device might be unavailable
98
+ }
99
+
100
+ const logcatStatus = logcat.status();
101
+
102
+ return {
103
+ uptime: Math.round(process.uptime()),
104
+ pid: process.pid,
105
+ stay_awake: stayAwake.isEnabled,
106
+ cast: { active: castManager.isActive },
107
+ logcat: {
108
+ capturing: logcatStatus.capturing,
109
+ file: logcatStatus.file,
110
+ size: logcatStatus.size,
111
+ },
112
+ battery,
113
+ };
114
+ });
115
+
116
+ app.post("/shutdown", async () => {
117
+ castManager.cleanup();
118
+ setTimeout(() => onShutdown(), 100);
119
+ return { ok: true, message: "shutting down" };
120
+ });
121
+
122
+ // --- Stay-Awake endpoints ---
123
+
124
+ app.post<{ Body: { pin?: string } }>("/stay-awake/enable", async (req) => {
125
+ let pin: string;
126
+ try {
127
+ pin = loadPin(req.body?.pin);
128
+ } catch {
129
+ return { ok: false, error: "PIN required" };
130
+ }
131
+ try {
132
+ await stayAwake.enable(pin);
133
+ return { ok: true };
134
+ } catch (error) {
135
+ return { ok: false, error: (error as Error).message };
136
+ }
137
+ });
138
+
139
+ app.post("/stay-awake/disable", async () => {
140
+ await stayAwake.disable();
141
+ return { ok: true };
142
+ });
143
+
144
+ app.get("/stay-awake/status", async () => {
145
+ const props = await stayAwake.status();
146
+ return {
147
+ enabled: stayAwake.isEnabled,
148
+ properties: props,
149
+ };
150
+ });
151
+
152
+ // --- Logcat endpoints ---
153
+
154
+ app.post<{ Body: { filter?: string } }>("/logcat/start", async (req) => {
155
+ const filter = req.body?.filter;
156
+ const result = await logcat.start(filter);
157
+ return { ok: true, file: result.file, pid: result.pid };
158
+ });
159
+
160
+ app.post("/logcat/stop", async () => {
161
+ logcat.stop();
162
+ return { ok: true };
163
+ });
164
+
165
+ app.get("/logcat/status", async () => {
166
+ return logcat.status();
167
+ });
168
+
169
+ app.get<{ Querystring: { lines?: string } }>("/logcat/read", async (req) => {
170
+ const lineCount = parseInt(req.query.lines || "50", 10);
171
+ const lines = logcat.readTail(lineCount);
172
+ return { lines };
173
+ });
174
+
175
+ // --- Deploy endpoint ---
176
+
177
+ app.post<{ Body: { apk_path: string; crash_wait_ms?: number } }>(
178
+ "/deploy",
179
+ async (req) => {
180
+ const { apk_path, crash_wait_ms } = req.body ?? {};
181
+ if (!apk_path) {
182
+ return { ok: false, error: "apk_path required" };
183
+ }
184
+
185
+ let pin: string | undefined;
186
+ try {
187
+ pin = loadPin();
188
+ } catch {
189
+ // No PIN configured
190
+ }
191
+
192
+ const result: DeployResult = await deploy(
193
+ { apkPath: apk_path, crashWaitMs: crash_wait_ms, pin },
194
+ stayAwake,
195
+ logcat,
196
+ );
197
+ return result;
198
+ },
199
+ );
200
+
201
+ // --- Cast endpoints ---
202
+
203
+ app.post<{
204
+ Body: { listen_port?: number; resolution?: string; width?: number; height?: number };
205
+ }>("/cast/start", async (req) => {
206
+ if (castManager.isActive) {
207
+ return { ok: true, already_running: true };
208
+ }
209
+ try {
210
+ await castManager.start({
211
+ listenPort: req.body?.listen_port,
212
+ resolution: req.body?.resolution,
213
+ width: req.body?.width,
214
+ height: req.body?.height,
215
+ });
216
+ return { ok: true };
217
+ } catch (error) {
218
+ return { ok: false, error: (error as Error).message };
219
+ }
220
+ });
221
+
222
+ app.post("/cast/stop", async () => {
223
+ const session = castManager.getSession();
224
+ if (!session?.connected) return { error: "cast not active" };
225
+ await castManager.stop();
226
+ castManager.broadcastToast("Casting stopped");
227
+ return { ok: true };
228
+ });
229
+
230
+ app.post("/cast/restart", async () => {
231
+ try {
232
+ await castManager.restart();
233
+ return { ok: true, msg: "restarting cast session" };
234
+ } catch (error) {
235
+ return { ok: false, error: (error as Error).message };
236
+ }
237
+ });
238
+
239
+ app.get("/cast/events", async (_req, reply) => {
240
+ reply.raw.writeHead(200, {
241
+ "Content-Type": "text/event-stream",
242
+ "Cache-Control": "no-cache",
243
+ Connection: "keep-alive",
244
+ });
245
+ // Send full status snapshot immediately
246
+ const init = castManager.getStatus();
247
+ reply.raw.write(`event: status\ndata: ${JSON.stringify(init)}\n\n`);
248
+ castManager.addSSEClient(reply.raw);
249
+ _req.raw.on("close", () => castManager.removeSSEClient(reply.raw));
250
+ });
251
+
252
+ app.get("/cast/help", async (_req, reply) => {
253
+ const help = `quest-dev cast — REST API
254
+
255
+ GET endpoints
256
+ /cast/help This help screen
257
+ /cast/screenshot Latest frame as JPEG (503 if no frame)
258
+ /cast/stream MJPEG stream (multipart/x-mixed-replace)
259
+ /cast/status JSON: connected, running, resolution, fps, frame_count, pose
260
+ /cast/layers JSON: available layers and active layer ID
261
+ /cast/events SSE stream: state changes and toast notifications
262
+
263
+ GET endpoints (continued)
264
+ /cast/resolutions List available resolution presets
265
+
266
+ POST endpoints (JSON body)
267
+ /cast/start Start casting (optional: { resolution, listen_port })
268
+ /cast/stop Stop casting (daemon stays running)
269
+ /cast/restart Restart cast session
270
+ /cast/config Set resolution
271
+ { resolution: "720p" } or { width, height }
272
+ /cast/eye Set eye mode
273
+ { mode: "left" | "right" | "stereo" }
274
+ /cast/pose Set or nudge camera offset from headset (not world space)
275
+ Offset: { x, y, z, yaw, pitch } (relative to HMD)
276
+ Delta: { dx, dy, dz, d_yaw, d_pitch }
277
+ /cast/pose-loop Toggle periodic pose refresh (~27 Hz)
278
+ { active: true | false } (omit to toggle)
279
+ /cast/click Tap at normalised screen coordinates
280
+ { x: 0.5, y: 0.5, layer, hold_ms: 50 }
281
+ /cast/gaze Gaze-based interaction
282
+ { action: "enable" }
283
+ { action: "click", yaw, pitch, dwell_ms: 1200 }
284
+ /cast/mud Send raw MUD payload
285
+ { type: 0, payload_hex: "..." }
286
+ /cast/home Press the Home button (ADB keyevent)
287
+ /cast/reset-view Reset camera offset to headset origin, stop pose loop
288
+ `;
289
+ return reply.type("text/plain").send(help);
290
+ });
291
+
292
+ app.get("/cast/screenshot", async (_req, reply) => {
293
+ const session = castManager.getSession();
294
+ if (!session) return reply.code(503).send({ error: "cast not active" });
295
+ const jpeg = session.getScreenshot();
296
+ if (jpeg) {
297
+ return reply.type("image/jpeg").send(jpeg);
298
+ }
299
+ return reply.code(503).send({ error: "no frame available" });
300
+ });
301
+
302
+ app.get("/cast/stream", async (_req, reply) => {
303
+ const session = castManager.getSession();
304
+ if (!session?.running) {
305
+ return reply.code(503).send({ error: "cast not active" });
306
+ }
307
+ reply.raw.writeHead(200, {
308
+ "Content-Type": "multipart/x-mixed-replace; boundary=frame",
309
+ "Cache-Control": "no-cache",
310
+ Connection: "close",
311
+ });
312
+
313
+ let cleaned = false;
314
+ const cleanup = () => {
315
+ if (cleaned) return;
316
+ cleaned = true;
317
+ clearInterval(interval);
318
+ try { reply.raw.end(); } catch { /* ignore */ }
319
+ };
320
+
321
+ const interval = setInterval(() => {
322
+ const s = castManager.getSession();
323
+ if (!s?.running) {
324
+ cleanup();
325
+ return;
326
+ }
327
+ try {
328
+ const jpeg = s.getScreenshot();
329
+ if (jpeg) {
330
+ reply.raw.write(
331
+ `--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`,
332
+ );
333
+ reply.raw.write(jpeg);
334
+ reply.raw.write("\r\n");
335
+ }
336
+ } catch {
337
+ cleanup();
338
+ }
339
+ }, 200);
340
+
341
+ _req.raw.on("close", cleanup);
342
+ });
343
+
344
+ app.get("/cast/status", async () => {
345
+ return castManager.getStatus();
346
+ });
347
+
348
+ app.get("/cast/layers", async () => {
349
+ const session = castManager.getSession();
350
+ if (!session) return { layers: {}, active_layer_id: 0 };
351
+ const layers: Record<string, unknown> = {};
352
+ for (const [id, info] of session.layers) {
353
+ layers[String(id)] = info;
354
+ }
355
+ return { layers, active_layer_id: session.layerId };
356
+ });
357
+
358
+ // --- Cast POST endpoints ---
359
+
360
+ app.post<{ Body: { pitch?: number; yaw?: number } }>(
361
+ "/cast/rotate",
362
+ async (req) => {
363
+ const session = castManager.getSession();
364
+ if (!session?.connected) return { error: "cast not active" };
365
+ const { pitch = 0, yaw = 0 } = req.body ?? {};
366
+ session.sendRotation(pitch, yaw);
367
+ return { ok: true, pitch, yaw };
368
+ },
369
+ );
370
+
371
+ app.post<{
372
+ Body: {
373
+ forward?: number;
374
+ strafe?: number;
375
+ yaw?: number;
376
+ pitch?: number;
377
+ };
378
+ }>("/cast/move", async (req) => {
379
+ const session = castManager.getSession();
380
+ if (!session?.connected) return { error: "cast not active" };
381
+ const { forward = 0, strafe = 0, yaw = 0, pitch = 0 } = req.body ?? {};
382
+ session.sendRotation(pitch, yaw, forward, strafe);
383
+ return { ok: true };
384
+ });
385
+
386
+ app.get("/cast/resolutions", async () => {
387
+ return Object.fromEntries(
388
+ Object.entries(RESOLUTIONS).map(([key, r]) => [key, `${r.width}x${r.height}`]),
389
+ );
390
+ });
391
+
392
+ app.post<{ Body: { resolution?: string; width?: number; height?: number } }>(
393
+ "/cast/config",
394
+ async (req) => {
395
+ const session = castManager.getSession();
396
+ if (!session?.connected) return { error: "cast not active" };
397
+ const { width, height } = resolveResolution(
398
+ req.body?.resolution,
399
+ req.body?.width ?? session.width,
400
+ req.body?.height ?? session.height,
401
+ );
402
+ session.sendDisplayConfig(width, height);
403
+ castManager.broadcastToast(`Resolution: ${width}\u00d7${height}`);
404
+ castManager.broadcastStatus();
405
+ return { ok: true, width, height };
406
+ },
407
+ );
408
+
409
+ app.post<{ Body: { mode?: string } }>("/cast/eye", async (req) => {
410
+ const session = castManager.getSession();
411
+ if (!session?.connected) return { error: "cast not active" };
412
+ const mode = req.body?.mode ?? "left";
413
+ const eyeMap: Record<string, number> = {
414
+ left: EYE_LEFT,
415
+ right: EYE_RIGHT,
416
+ stereo: EYE_STEREO,
417
+ both: EYE_STEREO,
418
+ };
419
+ const eye = eyeMap[mode] ?? EYE_LEFT;
420
+ session.sendDisplayConfig(session.width, session.height, eye);
421
+ castManager.broadcastToast(`Eye mode: ${mode}`);
422
+ castManager.broadcastStatus();
423
+ return { ok: true, mode };
424
+ });
425
+
426
+ app.post<{
427
+ Body: { x?: number; y?: number; layer?: number; hold_ms?: number };
428
+ }>("/cast/click", async (req) => {
429
+ const session = castManager.getSession();
430
+ if (!session?.connected) return { error: "cast not active" };
431
+ const { x = 0.5, y = 0.5, layer, hold_ms = 50 } = req.body ?? {};
432
+ await session.sendClick(x, y, layer, hold_ms);
433
+ const layerInfo = session.layers.get(layer ?? session.layerId);
434
+ return {
435
+ ok: true,
436
+ x,
437
+ y,
438
+ layer: layer ?? session.layerId,
439
+ layer_name: layerInfo?.layer ?? "",
440
+ };
441
+ });
442
+
443
+ app.post<{
444
+ Body: {
445
+ x?: number;
446
+ y?: number;
447
+ z?: number;
448
+ yaw?: number;
449
+ pitch?: number;
450
+ dx?: number;
451
+ dy?: number;
452
+ dz?: number;
453
+ d_yaw?: number;
454
+ d_pitch?: number;
455
+ };
456
+ }>("/cast/pose", async (req) => {
457
+ const session = castManager.getSession();
458
+ if (!session?.connected) return { error: "cast not active" };
459
+ const data = req.body ?? {};
460
+
461
+ // Direct offset from headset (not world-space) — takes priority over deltas
462
+ if (
463
+ "x" in data ||
464
+ "y" in data ||
465
+ "z" in data ||
466
+ "yaw" in data ||
467
+ "pitch" in data
468
+ ) {
469
+ session.setPoseOffset({
470
+ x: data.x,
471
+ y: data.y,
472
+ z: data.z,
473
+ yaw: data.yaw,
474
+ pitch: data.pitch,
475
+ });
476
+ } else if (
477
+ // Incremental deltas — only when no offset fields present
478
+ "dx" in data ||
479
+ "dy" in data ||
480
+ "dz" in data ||
481
+ "d_yaw" in data ||
482
+ "d_pitch" in data
483
+ ) {
484
+ session.applyPoseDelta({
485
+ dForward: data.dz ?? 0,
486
+ dStrafe: data.dx ?? 0,
487
+ dUp: data.dy ?? 0,
488
+ dYaw: data.d_yaw ?? 0,
489
+ dPitch: data.d_pitch ?? 0,
490
+ });
491
+ }
492
+
493
+ castManager.broadcastStatus();
494
+ return { ok: true };
495
+ });
496
+
497
+ app.post<{
498
+ Body: {
499
+ action?: string;
500
+ yaw?: number;
501
+ pitch?: number;
502
+ dwell_ms?: number;
503
+ };
504
+ }>("/cast/gaze", async (req) => {
505
+ const session = castManager.getSession();
506
+ if (!session?.connected) return { error: "cast not active" };
507
+ const data = req.body ?? {};
508
+ const action = data.action ?? "enable";
509
+
510
+ if (action === "enable") {
511
+ session.ensureInputForwarding();
512
+ return { ok: true, action: "enable" };
513
+ }
514
+
515
+ if (action === "click") {
516
+ session.ensureInputForwarding();
517
+ const yaw = data.yaw ?? session.pose.yaw;
518
+ const pitch = data.pitch ?? session.pose.pitch;
519
+ const dwellMs = data.dwell_ms ?? 1200;
520
+
521
+ session.setPoseOffset({ yaw, pitch });
522
+ const steps = Math.floor(dwellMs / 16);
523
+ for (let i = 0; i < steps; i++) {
524
+ session.sendPose(session.pose);
525
+ await new Promise((r) => setTimeout(r, 16));
526
+ }
527
+
528
+ return {
529
+ ok: true,
530
+ action: "click",
531
+ yaw: Math.round(yaw * 10000) / 10000,
532
+ pitch: Math.round(pitch * 10000) / 10000,
533
+ dwell_ms: dwellMs,
534
+ };
535
+ }
536
+
537
+ return { error: `unknown action: ${action}` };
538
+ });
539
+
540
+ app.post<{ Body: { active?: boolean } }>(
541
+ "/cast/pose-loop",
542
+ async (req) => {
543
+ const session = castManager.getSession();
544
+ if (!session?.connected) return { error: "cast not active" };
545
+ const active = req.body?.active ?? !session.poseLoopActive;
546
+ if (active) {
547
+ session.startPoseLoop();
548
+ } else {
549
+ session.stopPoseLoop();
550
+ }
551
+ castManager.broadcastStatus();
552
+ return { ok: true, pose_loop: session.poseLoopActive };
553
+ },
554
+ );
555
+
556
+ app.post("/cast/reset-view", async () => {
557
+ const session = castManager.getSession();
558
+ if (!session?.connected) return { error: "cast not active" };
559
+ session.resetView();
560
+ castManager.broadcastToast("View reset");
561
+ castManager.broadcastStatus();
562
+ return { ok: true, mode: "normal" };
563
+ });
564
+
565
+ app.post("/cast/home", async () => {
566
+ try {
567
+ await execCommand("adb", adbArgs(
568
+ "shell",
569
+ "input",
570
+ "keyevent",
571
+ "KEYCODE_HOME",
572
+ ));
573
+ castManager.broadcastToast("Home");
574
+ return { ok: true };
575
+ } catch {
576
+ return { error: "failed to send home key" };
577
+ }
578
+ });
579
+
580
+ app.post<{ Body: { type?: number; payload_hex?: string } }>(
581
+ "/cast/mud",
582
+ async (req) => {
583
+ const session = castManager.getSession();
584
+ if (!session?.connected) return { error: "cast not active" };
585
+ const typeId = req.body?.type ?? 0;
586
+ const payloadHex = req.body?.payload_hex ?? "";
587
+ const payload = payloadHex ? Buffer.from(payloadHex, "hex") : undefined;
588
+ session.sendMud(typeId, payload);
589
+ return { ok: true, type: typeId, payload_len: payload?.length ?? 0 };
590
+ },
591
+ );
592
+
593
+ await app.listen({ port, host });
594
+ return app;
595
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Stay-awake manager for the daemon process.
3
+ * Extracted from stay-awake.ts — manages test properties lifecycle.
4
+ */
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import {
8
+ buildSetPropertyArgs,
9
+ setTestProperties,
10
+ getTestProperties,
11
+ formatTestProperties,
12
+ type TestProperties,
13
+ } from "../utils/test-properties.js";
14
+ import { execCommand } from "../utils/exec.js";
15
+ import { verbose } from "../utils/verbose.js";
16
+ import { adbArgs, getAdbDevice } from "../utils/adb.js";
17
+
18
+ export class StayAwakeManager {
19
+ private enabled = false;
20
+ private pin: string | undefined;
21
+
22
+ /** Whether stay-awake is currently enabled */
23
+ get isEnabled(): boolean {
24
+ return this.enabled;
25
+ }
26
+
27
+ /** Enable test properties (guardian, dialogs, autosleep, proximity) */
28
+ async enable(pin: string): Promise<void> {
29
+ if (this.enabled) {
30
+ verbose("stay-awake already enabled");
31
+ return;
32
+ }
33
+ this.pin = pin;
34
+ await setTestProperties(pin, true);
35
+ this.enabled = true;
36
+ // Wake screen
37
+ try {
38
+ await execCommand("adb", adbArgs("shell", "input", "keyevent", "KEYCODE_WAKEUP"));
39
+ } catch {
40
+ // Non-fatal
41
+ }
42
+ console.log("Stay-awake enabled (guardian, dialogs, autosleep disabled)");
43
+ }
44
+
45
+ /** Disable test properties (restore Quest to normal) */
46
+ async disable(): Promise<void> {
47
+ if (!this.enabled || !this.pin) {
48
+ verbose("stay-awake not enabled, nothing to disable");
49
+ return;
50
+ }
51
+ try {
52
+ await setTestProperties(this.pin, false);
53
+ console.log("Stay-awake disabled (guardian, dialogs, autosleep restored)");
54
+ } catch (error) {
55
+ console.error("Failed to disable stay-awake:", (error as Error).message);
56
+ }
57
+ this.enabled = false;
58
+ }
59
+
60
+ /** Synchronous cleanup for signal handlers */
61
+ cleanupSync(): void {
62
+ if (!this.enabled || !this.pin) return;
63
+ try {
64
+ const args = buildSetPropertyArgs(this.pin, false);
65
+ const device = getAdbDevice();
66
+ const adb = device ? ["-s", device, ...args] : args;
67
+ execFileSync("adb", adb, { stdio: "ignore" });
68
+ } catch {
69
+ // Best-effort
70
+ }
71
+ this.enabled = false;
72
+ }
73
+
74
+ /** Get current test property status */
75
+ async status(): Promise<TestProperties> {
76
+ return getTestProperties();
77
+ }
78
+
79
+ /** Format status for display */
80
+ formatStatus(props: TestProperties): string {
81
+ return formatTestProperties(props);
82
+ }
83
+ }