@myerscarpenter/quest-dev 1.4.0 → 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.
- package/.claude/settings.local.json +7 -0
- package/.github/workflows/docs.yml +45 -0
- package/.github/workflows/publish.yml +11 -1
- package/README.md +27 -0
- package/build/cast/decoder.d.ts +48 -0
- package/build/cast/decoder.d.ts.map +1 -0
- package/build/cast/decoder.js +152 -0
- package/build/cast/decoder.js.map +1 -0
- package/build/cast/session.d.ts +87 -0
- package/build/cast/session.d.ts.map +1 -0
- package/build/cast/session.js +565 -0
- package/build/cast/session.js.map +1 -0
- package/build/commands/logcat.d.ts.map +1 -1
- package/build/commands/logcat.js +7 -6
- package/build/commands/logcat.js.map +1 -1
- package/build/commands/open.d.ts.map +1 -1
- package/build/commands/open.js +9 -4
- package/build/commands/open.js.map +1 -1
- package/build/commands/screenshot.d.ts.map +1 -1
- package/build/commands/screenshot.js +17 -20
- package/build/commands/screenshot.js.map +1 -1
- package/build/commands/stay-awake.d.ts +2 -15
- package/build/commands/stay-awake.d.ts.map +1 -1
- package/build/commands/stay-awake.js +14 -77
- package/build/commands/stay-awake.js.map +1 -1
- package/build/daemon/cast-manager.d.ts +42 -0
- package/build/daemon/cast-manager.d.ts.map +1 -0
- package/build/daemon/cast-manager.js +243 -0
- package/build/daemon/cast-manager.js.map +1 -0
- package/build/daemon/client.d.ts +40 -0
- package/build/daemon/client.d.ts.map +1 -0
- package/build/daemon/client.js +133 -0
- package/build/daemon/client.js.map +1 -0
- package/build/daemon/daemon.d.ts +20 -0
- package/build/daemon/daemon.d.ts.map +1 -0
- package/build/daemon/daemon.js +130 -0
- package/build/daemon/daemon.js.map +1 -0
- package/build/daemon/deploy.d.ts +44 -0
- package/build/daemon/deploy.d.ts.map +1 -0
- package/build/daemon/deploy.js +230 -0
- package/build/daemon/deploy.js.map +1 -0
- package/build/daemon/logcat-manager.d.ts +39 -0
- package/build/daemon/logcat-manager.d.ts.map +1 -0
- package/build/daemon/logcat-manager.js +194 -0
- package/build/daemon/logcat-manager.js.map +1 -0
- package/build/daemon/server.d.ts +19 -0
- package/build/daemon/server.d.ts.map +1 -0
- package/build/daemon/server.js +482 -0
- package/build/daemon/server.js.map +1 -0
- package/build/daemon/stay-awake-manager.d.ts +22 -0
- package/build/daemon/stay-awake-manager.d.ts.map +1 -0
- package/build/daemon/stay-awake-manager.js +74 -0
- package/build/daemon/stay-awake-manager.js.map +1 -0
- package/build/index.js +285 -45
- package/build/index.js.map +1 -1
- package/build/public/dashboard.js +749 -0
- package/build/public/index.html +12 -0
- package/build/public/style.css +106 -0
- package/build/utils/adb.d.ts +12 -0
- package/build/utils/adb.d.ts.map +1 -1
- package/build/utils/adb.js +116 -51
- package/build/utils/adb.js.map +1 -1
- package/build/utils/casting-apk.d.ts +40 -0
- package/build/utils/casting-apk.d.ts.map +1 -0
- package/build/utils/casting-apk.js +252 -0
- package/build/utils/casting-apk.js.map +1 -0
- package/build/utils/config.d.ts +5 -3
- package/build/utils/config.d.ts.map +1 -1
- package/build/utils/config.js +18 -38
- package/build/utils/config.js.map +1 -1
- package/build/utils/exec.d.ts +5 -0
- package/build/utils/exec.d.ts.map +1 -1
- package/build/utils/exec.js +17 -0
- package/build/utils/exec.js.map +1 -1
- package/build/utils/filename.d.ts +7 -1
- package/build/utils/filename.d.ts.map +1 -1
- package/build/utils/filename.js +17 -2
- package/build/utils/filename.js.map +1 -1
- package/build/utils/filename.test.js +33 -1
- package/build/utils/filename.test.js.map +1 -1
- package/build/utils/jpeg-comment.d.ts +14 -0
- package/build/utils/jpeg-comment.d.ts.map +1 -0
- package/build/utils/jpeg-comment.js +28 -0
- package/build/utils/jpeg-comment.js.map +1 -0
- package/build/utils/test-properties.d.ts +34 -0
- package/build/utils/test-properties.d.ts.map +1 -0
- package/build/utils/test-properties.js +73 -0
- package/build/utils/test-properties.js.map +1 -0
- package/build/utils/verbose.d.ts +3 -0
- package/build/utils/verbose.d.ts.map +1 -0
- package/build/utils/verbose.js +13 -0
- package/build/utils/verbose.js.map +1 -0
- package/package.json +11 -5
- package/packages/cast2-protocol/README.md +86 -0
- package/packages/cast2-protocol/docs/_config.yml +4 -0
- package/packages/cast2-protocol/docs/feature-flags.md +102 -0
- package/packages/cast2-protocol/docs/index.md +24 -0
- package/packages/cast2-protocol/docs/open-investigations.md +149 -0
- package/packages/cast2-protocol/docs/protocol.md +602 -0
- package/packages/cast2-protocol/package.json +46 -0
- package/packages/cast2-protocol/src/constants.ts +65 -0
- package/packages/cast2-protocol/src/index.ts +7 -0
- package/packages/cast2-protocol/src/mgik.ts +69 -0
- package/packages/cast2-protocol/src/mud.ts +294 -0
- package/packages/cast2-protocol/src/pose.ts +99 -0
- package/packages/cast2-protocol/src/resolutions.ts +34 -0
- package/packages/cast2-protocol/src/types.ts +64 -0
- package/packages/cast2-protocol/src/xrsp.ts +73 -0
- package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
- package/packages/cast2-protocol/tests/mud.test.ts +295 -0
- package/packages/cast2-protocol/tests/pose.test.ts +173 -0
- package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
- package/packages/cast2-protocol/tsconfig.json +20 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/cast/decoder.ts +178 -0
- package/src/cast/session.ts +708 -0
- package/src/commands/logcat.ts +6 -5
- package/src/commands/open.ts +10 -3
- package/src/commands/screenshot.ts +19 -13
- package/src/commands/stay-awake.ts +22 -91
- package/src/daemon/adbkit-apkreader.d.ts +14 -0
- package/src/daemon/cast-manager.ts +282 -0
- package/src/daemon/client.ts +166 -0
- package/src/daemon/daemon.ts +169 -0
- package/src/daemon/deploy.ts +307 -0
- package/src/daemon/logcat-manager.ts +229 -0
- package/src/daemon/server.ts +595 -0
- package/src/daemon/stay-awake-manager.ts +83 -0
- package/src/index.ts +340 -56
- package/src/public/dashboard.js +288 -0
- package/src/public/index.html +12 -0
- package/src/public/style.css +106 -0
- package/src/utils/adb.ts +129 -42
- package/src/utils/casting-apk.ts +276 -0
- package/src/utils/config.ts +18 -36
- package/src/utils/exec.ts +20 -0
- package/src/utils/filename.test.ts +41 -1
- package/src/utils/filename.ts +18 -2
- package/src/utils/jpeg-comment.ts +30 -0
- package/src/utils/test-properties.ts +94 -0
- package/src/utils/verbose.ts +14 -0
- package/tests/cast/auto-layer.test.ts +87 -0
- package/tests/cast/decoder.test.ts +82 -0
- package/tests/cast/session-restart.test.ts +107 -0
- package/tests/config.test.ts +17 -22
- package/tests/daemon/api-status.test.ts +82 -0
- package/tests/daemon/cast-manager.test.ts +69 -0
- package/tests/daemon/mjpeg-stream.test.ts +144 -0
- package/tests/daemon/pose-endpoint.test.ts +63 -0
- package/tests/daemon/start-guard.test.ts +77 -0
- 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
|
+
}
|