/** * Unified Fastify server for the quest-dev daemon. * Hosts all endpoint groups: core, stay-awake, logcat, deploy, cast. */ import Fastify, { type FastifyInstance, type FastifyReply } from "fastify"; import fastifyStatic from "@fastify/static"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { loadPin, loadConfig, tryLoadPin } from "../utils/config.js"; import { getBatteryInfo, ensureAdbHealthy } from "../utils/adb.js"; import { execCommand } from "../utils/exec.js"; import { adbArgs } from "../utils/adb.js"; import { EYE_LEFT, EYE_RIGHT, EYE_STEREO, RESOLUTIONS, resolveResolution } from "@myerscarpenter/cast2-protocol"; import type { StayAwakeManager } from "./stay-awake-manager.js"; import type { LogcatManager } from "./logcat-manager.js"; import type { CastManager } from "./cast-manager.js"; import { deploy, type DeployEvent } from "./deploy.js"; import { getPackageVersion } from "../utils/version.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export interface DaemonServerOptions { port: number; host: string; stayAwake: StayAwakeManager; logcat: LogcatManager; castManager: CastManager; onActivity: () => void; onShutdown: () => void; } export async function createDaemonServer( options: DaemonServerOptions, ): Promise { const { port, host, stayAwake, logcat, castManager, onActivity, onShutdown } = options; const config = loadConfig(); const app = Fastify({ logger: false }); // Return 503 with a JSON error body. Use this (instead of `return { error }`) // for any "can't service the request right now" condition. Returning 200 on // failure lets clients like `curl -o file.jpg` silently save the error body // as an image, which has previously corrupted downstream tools. const castNotActive = (reply: FastifyReply) => reply.code(503).send({ error: "cast not active" }); // Static file serving for dashboard const publicDir = join(__dirname, "..", "public"); await app.register(fastifyStatic, { root: publicDir, prefix: "/", decorateReply: false, }); // Activity tracking: reset idle timer on every request app.addHook("onRequest", async () => { onActivity(); }); // --- Core endpoints --- app.get("/help", async (_req, reply) => { const help = `quest-dev daemon — REST API GET endpoints /help This help screen /status JSON: uptime, pid, stay_awake, cast, logcat, battery /stay-awake/status JSON: stay-awake state /logcat/status JSON: logcat capture state /cast/help Cast-specific API reference /cast/status JSON: cast session state /cast/screenshot Latest frame as JPEG /cast/stream MJPEG stream (multipart/x-mixed-replace) /cast/layers JSON: available layers and active layer ID /cast/events SSE stream: state changes and toast notifications POST endpoints (JSON body) /shutdown Shut down daemon /stay-awake/enable Enable stay-awake (body: { pin? }) /stay-awake/disable Disable stay-awake /logcat/start Start logcat capture (body: { tag? }) /logcat/stop Stop logcat capture /cast/start Start casting (body: { resolution?, listen_port? }) /cast/stop Stop casting /cast/restart Restart cast session /cast/reset-view Reset camera offset to headset /cast/home Press Home button /cast/resolutions List available resolution presets /cast/config Set resolution (body: { resolution } or { width, height }) /cast/eye Set eye mode (body: { mode }) /cast/pose Set/nudge camera offset from headset /cast/click Tap at coordinates (body: { x, y }) `; return reply.type("text/plain").send(help); }); app.get("/status", async () => { let battery = null; try { const info = await getBatteryInfo(); battery = { level: info.level, state: info.state }; } catch { // Device might be unavailable } const logcatStatus = logcat.status(); return { version: getPackageVersion(), uptime: Math.round(process.uptime()), pid: process.pid, stay_awake: stayAwake.isEnabled, cast: { active: castManager.isActive }, logcat: { capturing: logcatStatus.capturing, file: logcatStatus.file, size: logcatStatus.size, }, battery, }; }); app.post("/shutdown", async () => { castManager.cleanup(); setTimeout(() => onShutdown(), 100); return { ok: true, message: "shutting down" }; }); // --- Stay-Awake endpoints --- app.post<{ Body: { pin?: string } }>("/stay-awake/enable", async (req, reply) => { let pin: string; try { pin = loadPin(req.body?.pin); } catch { return reply.code(400).send({ ok: false, error: "PIN required" }); } const health = await ensureAdbHealthy(); if (health.kind === 'failed') { return reply.code(500).send({ ok: false, error: `ADB unresponsive: ${health.error}` }); } try { await stayAwake.turnOn(pin); return { ok: true }; } catch (error) { return reply.code(500).send({ ok: false, error: (error as Error).message }); } }); app.post("/stay-awake/disable", async () => { await stayAwake.turnOff(); return { ok: true }; }); app.get("/stay-awake/status", async () => { const props = await stayAwake.status(); return { enabled: stayAwake.isEnabled, properties: props, }; }); // --- Logcat endpoints --- app.post<{ Body: { filter?: string } }>("/logcat/start", async (req) => { const filter = req.body?.filter; const result = await logcat.start(filter); return { ok: true, file: result.file, pid: result.pid }; }); app.post("/logcat/stop", async () => { logcat.stop(); return { ok: true }; }); app.get("/logcat/status", async () => { return logcat.status(); }); app.get<{ Querystring: { lines?: string } }>("/logcat/read", async (req) => { const lineCount = parseInt(req.query.lines || "50", 10); const lines = logcat.readTail(lineCount); return { lines }; }); // --- Deploy endpoint --- app.post<{ Body: { apk_path: string; crash_wait_ms?: number; debugging_port?: number } }>( "/deploy", async (req, reply) => { const { apk_path, crash_wait_ms, debugging_port } = req.body ?? {}; if (!apk_path) { return reply.code(400).send({ ok: false, error: "apk_path required" }); } const pin = tryLoadPin(); if (pin === null) { return reply.code(400).send({ ok: false, error: "PIN required for deploy. Stay-awake must be enabled to prevent " + "the Quest from sleeping mid-install. Save it with: " + "quest-dev config --pin ", }); } reply.raw.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", Connection: "close", }); const writeEvent = (e: DeployEvent) => { try { reply.raw.write(JSON.stringify(e) + "\n"); } catch { /* socket gone */ } }; try { await deploy( { apkPath: apk_path, pin, crashWaitMs: crash_wait_ms, onEvent: writeEvent, adb: (args) => execCommand("adb", adbArgs(...args)), debuggingPort: debugging_port, }, stayAwake, logcat, ); } catch (err) { writeEvent({ type: "done", ok: false, package: "", crashed: false, error: String(err) }); } finally { try { reply.raw.end(); } catch { /* ignore */ } } return reply; }, ); // --- Cast endpoints --- app.post<{ Body: { listen_port?: number; resolution?: string; width?: number; height?: number }; }>("/cast/start", async (req, reply) => { if (castManager.isActive) { return { ok: true, already_running: true }; } try { await castManager.start({ listenPort: req.body?.listen_port, resolution: req.body?.resolution, width: req.body?.width, height: req.body?.height, }); return { ok: true }; } catch (error) { return reply.code(500).send({ ok: false, error: (error as Error).message }); } }); app.post("/cast/stop", async (_req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); await castManager.stop(); castManager.broadcastToast("Casting stopped"); return { ok: true }; }); app.post("/cast/restart", async (_req, reply) => { try { await castManager.restart(); return { ok: true, msg: "restarting cast session" }; } catch (error) { return reply.code(500).send({ ok: false, error: (error as Error).message }); } }); app.get("/cast/events", async (_req, reply) => { reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); // Send full status snapshot immediately const init = castManager.getStatus(); reply.raw.write(`event: status\ndata: ${JSON.stringify(init)}\n\n`); castManager.addSSEClient(reply.raw); _req.raw.on("close", () => castManager.removeSSEClient(reply.raw)); }); app.get("/cast/help", async (_req, reply) => { const help = `quest-dev cast — REST API GET endpoints /cast/help This help screen /cast/screenshot Latest frame as JPEG (503 if no frame) /cast/stream MJPEG stream (multipart/x-mixed-replace) /cast/status JSON: connected, running, resolution, fps, frame_count, pose /cast/layers JSON: available layers and active layer ID /cast/events SSE stream: state changes and toast notifications GET endpoints (continued) /cast/resolutions List available resolution presets POST endpoints (JSON body) /cast/start Start casting (optional: { resolution, listen_port }) /cast/stop Stop casting (daemon stays running) /cast/restart Restart cast session /cast/config Set resolution { resolution: "720p" } or { width, height } /cast/eye Set eye mode { mode: "left" | "right" | "stereo" } /cast/pose Set or nudge camera offset from headset (not world space) Offset: { x, y, z, yaw, pitch } (relative to HMD) Delta: { dx, dy, dz, d_yaw, d_pitch } /cast/pose-loop Toggle periodic pose refresh (~27 Hz) { active: true | false } (omit to toggle) /cast/click Tap at normalised screen coordinates { x: 0.5, y: 0.5, layer, hold_ms: 50 } /cast/gaze Gaze-based interaction { action: "enable" } { action: "click", yaw, pitch, dwell_ms: 1200 } /cast/mud Send raw MUD payload { type: 0, payload_hex: "..." } /cast/home Press the Home button (ADB keyevent) /cast/reset-view Reset camera offset to headset origin, stop pose loop `; return reply.type("text/plain").send(help); }); app.get("/cast/screenshot", async (_req, reply) => { const session = castManager.getSession(); if (!session) return reply.code(503).send({ error: "cast not active" }); const jpeg = session.getScreenshot(); if (jpeg) { return reply.type("image/jpeg").send(jpeg); } return reply.code(503).send({ error: "no frame available" }); }); app.get("/cast/stream", async (_req, reply) => { const session = castManager.getSession(); if (!session?.running) { return reply.code(503).send({ error: "cast not active" }); } reply.raw.writeHead(200, { "Content-Type": "multipart/x-mixed-replace; boundary=frame", "Cache-Control": "no-cache", Connection: "close", }); let cleaned = false; const cleanup = () => { if (cleaned) return; cleaned = true; clearInterval(interval); try { reply.raw.end(); } catch { /* ignore */ } }; const interval = setInterval(() => { const s = castManager.getSession(); if (!s?.running) { cleanup(); return; } try { const jpeg = s.getScreenshot(); if (jpeg) { reply.raw.write( `--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`, ); reply.raw.write(jpeg); reply.raw.write("\r\n"); } } catch { cleanup(); } }, 200); _req.raw.on("close", cleanup); }); app.get("/cast/status", async () => { return castManager.getStatus(); }); app.get("/cast/layers", async () => { const session = castManager.getSession(); if (!session) return { layers: {}, active_layer_id: 0 }; const layers: Record = {}; for (const [id, info] of session.layers) { layers[String(id)] = info; } return { layers, active_layer_id: session.layerId }; }); // --- Cast POST endpoints --- app.post<{ Body: { pitch?: number; yaw?: number } }>( "/cast/rotate", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const { pitch = 0, yaw = 0 } = req.body ?? {}; session.sendRotation(pitch, yaw); return { ok: true, pitch, yaw }; }, ); app.post<{ Body: { forward?: number; strafe?: number; yaw?: number; pitch?: number; }; }>("/cast/move", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const { forward = 0, strafe = 0, yaw = 0, pitch = 0 } = req.body ?? {}; session.sendRotation(pitch, yaw, forward, strafe); return { ok: true }; }); app.get("/cast/resolutions", async () => { return Object.fromEntries( Object.entries(RESOLUTIONS).map(([key, r]) => [key, `${r.width}x${r.height}`]), ); }); app.post<{ Body: { resolution?: string; width?: number; height?: number } }>( "/cast/config", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const { width, height } = resolveResolution( req.body?.resolution, req.body?.width ?? session.width, req.body?.height ?? session.height, ); session.sendDisplayConfig(width, height); castManager.broadcastToast(`Resolution: ${width}\u00d7${height}`); castManager.broadcastStatus(); return { ok: true, width, height }; }, ); app.post<{ Body: { mode?: string } }>("/cast/eye", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const mode = req.body?.mode ?? "left"; const eyeMap: Record = { left: EYE_LEFT, right: EYE_RIGHT, stereo: EYE_STEREO, both: EYE_STEREO, }; if (!(mode in eyeMap)) { return reply.code(400).send({ error: `bad mode: ${mode} (want left|right|stereo|both)` }); } const eye = eyeMap[mode]; session.sendDisplayConfig(session.width, session.height, eye); castManager.broadcastToast(`Eye mode: ${mode}`); castManager.broadcastStatus(); return { ok: true, mode }; }); app.post<{ Body: { x?: number; y?: number; layer?: number; hold_ms?: number }; }>("/cast/click", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const { x = 0.5, y = 0.5, layer, hold_ms = 50 } = req.body ?? {}; await session.sendClick(x, y, layer, hold_ms); const layerInfo = session.layers.get(layer ?? session.layerId); return { ok: true, x, y, layer: layer ?? session.layerId, layer_name: layerInfo?.layer ?? "", }; }); app.post<{ Body: { x?: number; y?: number; z?: number; yaw?: number; pitch?: number; dx?: number; dy?: number; dz?: number; d_yaw?: number; d_pitch?: number; }; }>("/cast/pose", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const data = req.body ?? {}; // Direct offset from headset (not world-space) — takes priority over deltas if ( "x" in data || "y" in data || "z" in data || "yaw" in data || "pitch" in data ) { session.setPoseOffset({ x: data.x, y: data.y, z: data.z, yaw: data.yaw, pitch: data.pitch, }); } else if ( // Incremental deltas — only when no offset fields present "dx" in data || "dy" in data || "dz" in data || "d_yaw" in data || "d_pitch" in data ) { session.applyPoseDelta({ dForward: data.dz ?? 0, dStrafe: data.dx ?? 0, dUp: data.dy ?? 0, dYaw: data.d_yaw ?? 0, dPitch: data.d_pitch ?? 0, }); } castManager.broadcastStatus(); return { ok: true }; }); app.post<{ Body: { action?: string; yaw?: number; pitch?: number; dwell_ms?: number; }; }>("/cast/gaze", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const data = req.body ?? {}; const action = data.action ?? "enable"; if (action === "enable") { session.ensureInputForwarding(); return { ok: true, action: "enable" }; } if (action === "click") { session.ensureInputForwarding(); const yaw = data.yaw ?? session.pose.yaw; const pitch = data.pitch ?? session.pose.pitch; const dwellMs = data.dwell_ms ?? 1200; session.setPoseOffset({ yaw, pitch }); const steps = Math.floor(dwellMs / 16); for (let i = 0; i < steps; i++) { session.sendPose(session.pose); await new Promise((r) => setTimeout(r, 16)); } return { ok: true, action: "click", yaw: Math.round(yaw * 10000) / 10000, pitch: Math.round(pitch * 10000) / 10000, dwell_ms: dwellMs, }; } return reply.code(400).send({ error: `unknown action: ${action}` }); }); app.post<{ Body: { active?: boolean } }>( "/cast/pose-loop", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const active = req.body?.active ?? !session.poseLoopActive; if (active) { session.startPoseLoop(); } else { session.stopPoseLoop(); } castManager.broadcastStatus(); return { ok: true, pose_loop: session.poseLoopActive }; }, ); app.post("/cast/reset-view", async (_req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); session.resetView(); castManager.broadcastToast("View reset"); castManager.broadcastStatus(); return { ok: true, mode: "normal" }; }); app.post("/cast/home", async (_req, reply) => { try { await execCommand("adb", adbArgs( "shell", "input", "keyevent", "KEYCODE_HOME", )); castManager.broadcastToast("Home"); return { ok: true }; } catch { return reply.code(500).send({ error: "failed to send home key" }); } }); app.post<{ Body: { type?: number; payload_hex?: string } }>( "/cast/mud", async (req, reply) => { const session = castManager.getSession(); if (!session?.connected) return castNotActive(reply); const typeId = req.body?.type ?? 0; const payloadHex = req.body?.payload_hex ?? ""; const payload = payloadHex ? Buffer.from(payloadHex, "hex") : undefined; session.sendMud(typeId, payload); return { ok: true, type: typeId, payload_len: payload?.length ?? 0 }; }, ); await app.listen({ port, host }); return app; }