@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
package/src/utils/adb.ts
CHANGED
|
@@ -5,30 +5,52 @@
|
|
|
5
5
|
import which from 'which';
|
|
6
6
|
import net from 'net';
|
|
7
7
|
import { execCommand, execCommandFull } from './exec.js';
|
|
8
|
+
import { verbose } from './verbose.js';
|
|
8
9
|
|
|
9
10
|
const CDP_PORT = 9223; // Chrome DevTools Protocol port (Quest browser default)
|
|
10
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
|
+
|
|
11
38
|
/**
|
|
12
39
|
* Get browser process PID
|
|
13
40
|
*/
|
|
14
41
|
async function getBrowserPID(packageName: string): Promise<number | null> {
|
|
15
42
|
try {
|
|
16
|
-
const result = await execCommandFull('adb',
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return parseInt(parts[1], 10); // PID is second column
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
} catch {
|
|
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;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
verbose('getBrowserPID error:', e);
|
|
29
52
|
return null;
|
|
30
53
|
}
|
|
31
|
-
return null;
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
/**
|
|
@@ -39,30 +61,39 @@ async function detectCDPSocket(packageName: string): Promise<string> {
|
|
|
39
61
|
const pid = await getBrowserPID(packageName);
|
|
40
62
|
|
|
41
63
|
if (pid) {
|
|
42
|
-
// Try PID-based socket
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
64
|
+
// Try PID-based socket, with retries (socket may take a moment to appear)
|
|
65
|
+
const socketName = `chrome_devtools_remote_${pid}`;
|
|
66
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await execCommandFull('adb', adbArgs(
|
|
69
|
+
'shell', 'cat', '/proc/net/unix',
|
|
70
|
+
));
|
|
71
|
+
if (result.stdout.includes(socketName)) {
|
|
72
|
+
verbose('detectCDPSocket: found PID-specific socket:', socketName, `(attempt ${attempt + 1})`);
|
|
73
|
+
return socketName;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
if (attempt < 4) {
|
|
79
|
+
verbose('detectCDPSocket: PID-specific socket not found yet, retrying in 1s...', `(attempt ${attempt + 1})`);
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
50
81
|
}
|
|
51
|
-
} catch {
|
|
52
|
-
// Fall through to default
|
|
53
82
|
}
|
|
83
|
+
verbose('detectCDPSocket: PID-specific socket not found after retries:', socketName);
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
// Default: generic socket (Quest Browser)
|
|
87
|
+
verbose('detectCDPSocket: falling back to generic chrome_devtools_remote');
|
|
57
88
|
return 'chrome_devtools_remote';
|
|
58
89
|
}
|
|
59
90
|
|
|
60
91
|
/**
|
|
61
|
-
* Get CDP port for a socket
|
|
62
|
-
*
|
|
92
|
+
* Get CDP port for a socket.
|
|
93
|
+
* Always use 9223 regardless of socket type for consistency.
|
|
63
94
|
*/
|
|
64
|
-
function getCDPPortForSocket(
|
|
65
|
-
return
|
|
95
|
+
function getCDPPortForSocket(_socket: string): number {
|
|
96
|
+
return CDP_PORT;
|
|
66
97
|
}
|
|
67
98
|
|
|
68
99
|
/**
|
|
@@ -110,9 +141,24 @@ async function restartADBServer(): Promise<boolean> {
|
|
|
110
141
|
*/
|
|
111
142
|
export async function checkADBDevices(retryCount = 0): Promise<boolean> {
|
|
112
143
|
try {
|
|
144
|
+
const target = getAdbDevice();
|
|
113
145
|
const output = await execCommand('adb', ['devices']);
|
|
114
146
|
const lines = output.trim().split('\n').slice(1); // Skip header
|
|
115
|
-
|
|
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
|
+
}
|
|
116
162
|
|
|
117
163
|
if (devices.length === 0) {
|
|
118
164
|
console.error('Error: No ADB devices connected');
|
|
@@ -182,19 +228,19 @@ export async function ensurePortForwarding(
|
|
|
182
228
|
const cdpPort = getCDPPortForSocket(cdpSocket);
|
|
183
229
|
|
|
184
230
|
// Check reverse forwarding (Quest -> Host for dev server)
|
|
185
|
-
const reverseList = await execCommand('adb',
|
|
231
|
+
const reverseList = await execCommand('adb', adbArgs('reverse', '--list'));
|
|
186
232
|
const reverseExists = reverseList.includes(`tcp:${port}`);
|
|
187
233
|
|
|
188
234
|
if (reverseExists) {
|
|
189
235
|
console.log(`ADB reverse port forwarding already set up: Quest:${port} -> Host:${port}`);
|
|
190
236
|
} else {
|
|
191
|
-
await execCommand('adb',
|
|
237
|
+
await execCommand('adb', adbArgs('reverse', `tcp:${port}`, `tcp:${port}`));
|
|
192
238
|
console.log(`ADB reverse port forwarding set up: Quest:${port} -> Host:${port}`);
|
|
193
239
|
}
|
|
194
240
|
|
|
195
241
|
// Check forward forwarding (Host -> Quest for CDP)
|
|
196
242
|
// First check if ADB already has this forwarding set up
|
|
197
|
-
const forwardList = await execCommand('adb',
|
|
243
|
+
const forwardList = await execCommand('adb', adbArgs('forward', '--list'));
|
|
198
244
|
const forwardExists = forwardList.includes(`tcp:${cdpPort}`) && forwardList.includes(cdpSocket);
|
|
199
245
|
|
|
200
246
|
if (forwardExists) {
|
|
@@ -214,7 +260,7 @@ export async function ensurePortForwarding(
|
|
|
214
260
|
process.exit(1);
|
|
215
261
|
}
|
|
216
262
|
|
|
217
|
-
await execCommand('adb',
|
|
263
|
+
await execCommand('adb', adbArgs('forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`));
|
|
218
264
|
console.log(`ADB forward port forwarding set up: Host:${cdpPort} -> Quest:${cdpSocket} (CDP)`);
|
|
219
265
|
}
|
|
220
266
|
} catch (error) {
|
|
@@ -228,9 +274,12 @@ export async function ensurePortForwarding(
|
|
|
228
274
|
*/
|
|
229
275
|
export async function isBrowserRunning(browser: string = 'com.oculus.browser'): Promise<boolean> {
|
|
230
276
|
try {
|
|
231
|
-
const result = await execCommandFull('adb',
|
|
232
|
-
|
|
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;
|
|
233
281
|
} catch (error) {
|
|
282
|
+
verbose('isBrowserRunning error:', error);
|
|
234
283
|
return false;
|
|
235
284
|
}
|
|
236
285
|
}
|
|
@@ -241,7 +290,7 @@ export async function isBrowserRunning(browser: string = 'com.oculus.browser'):
|
|
|
241
290
|
export async function launchBrowser(url: string, browser: string = 'com.oculus.browser'): Promise<boolean> {
|
|
242
291
|
console.log('Launching browser...');
|
|
243
292
|
try {
|
|
244
|
-
await execCommand('adb',
|
|
293
|
+
await execCommand('adb', adbArgs(
|
|
245
294
|
'shell',
|
|
246
295
|
'am',
|
|
247
296
|
'start',
|
|
@@ -250,7 +299,7 @@ export async function launchBrowser(url: string, browser: string = 'com.oculus.b
|
|
|
250
299
|
'-d',
|
|
251
300
|
url,
|
|
252
301
|
browser
|
|
253
|
-
|
|
302
|
+
));
|
|
254
303
|
console.log(`Browser launched with URL: ${url}`);
|
|
255
304
|
return true;
|
|
256
305
|
} catch (error) {
|
|
@@ -279,7 +328,7 @@ export async function ensureCDPForwarding(
|
|
|
279
328
|
const cdpPort = getCDPPortForSocket(cdpSocket);
|
|
280
329
|
|
|
281
330
|
// Check forward forwarding (Host -> Quest for CDP)
|
|
282
|
-
const forwardList = await execCommand('adb',
|
|
331
|
+
const forwardList = await execCommand('adb', adbArgs('forward', '--list'));
|
|
283
332
|
const forwardExists = forwardList.includes(`tcp:${cdpPort}`) && forwardList.includes(cdpSocket);
|
|
284
333
|
|
|
285
334
|
if (forwardExists) {
|
|
@@ -299,7 +348,7 @@ export async function ensureCDPForwarding(
|
|
|
299
348
|
process.exit(1);
|
|
300
349
|
}
|
|
301
350
|
|
|
302
|
-
await execCommand('adb',
|
|
351
|
+
await execCommand('adb', adbArgs('forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`));
|
|
303
352
|
console.log(`ADB forward port forwarding set up: Host:${cdpPort} -> Quest:${cdpSocket} (CDP)`);
|
|
304
353
|
}
|
|
305
354
|
} catch (error) {
|
|
@@ -308,12 +357,50 @@ export async function ensureCDPForwarding(
|
|
|
308
357
|
}
|
|
309
358
|
}
|
|
310
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Re-detect CDP socket after browser launch and update forwarding if needed.
|
|
362
|
+
* This handles the case where the initial forwarding used the generic socket
|
|
363
|
+
* (before the browser had a PID), but the browser created a PID-specific socket.
|
|
364
|
+
*/
|
|
365
|
+
export async function refreshCDPForwarding(
|
|
366
|
+
browser: string = 'com.oculus.browser'
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
try {
|
|
369
|
+
const cdpSocket = await detectCDPSocket(browser);
|
|
370
|
+
const cdpPort = getCDPPortForSocket(cdpSocket);
|
|
371
|
+
|
|
372
|
+
// Check if forwarding already points to the correct socket
|
|
373
|
+
const forwardList = await execCommand('adb', adbArgs('forward', '--list'));
|
|
374
|
+
verbose('refreshCDPForwarding: detected socket:', cdpSocket, 'port:', cdpPort);
|
|
375
|
+
verbose('refreshCDPForwarding: current forwards:', forwardList.trim());
|
|
376
|
+
|
|
377
|
+
// Check exact match: the forward line must end with the exact socket name
|
|
378
|
+
const expectedForward = `tcp:${cdpPort} localabstract:${cdpSocket}`;
|
|
379
|
+
if (forwardList.includes(expectedForward)) {
|
|
380
|
+
verbose('refreshCDPForwarding: forwarding already correct');
|
|
381
|
+
return; // Already correct
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Remove existing forwarding on CDP port and re-create with correct socket
|
|
385
|
+
if (forwardList.includes(`tcp:${cdpPort}`)) {
|
|
386
|
+
verbose('refreshCDPForwarding: removing stale forwarding on port', cdpPort);
|
|
387
|
+
await execCommandFull('adb', adbArgs('forward', '--remove', `tcp:${cdpPort}`));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await execCommand('adb', adbArgs('forward', `tcp:${cdpPort}`, `localabstract:${cdpSocket}`));
|
|
391
|
+
console.log(`CDP forwarding updated: Host:${cdpPort} -> Quest:${cdpSocket}`);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// Non-fatal: CDP may still work with existing forwarding
|
|
394
|
+
console.log('Warning: Could not refresh CDP forwarding:', (error as Error).message);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
311
398
|
/**
|
|
312
399
|
* Check if USB file transfer is authorized on Quest
|
|
313
400
|
* After reboot, user must click notification to allow file access
|
|
314
401
|
*/
|
|
315
402
|
export async function checkUSBFileTransfer(): Promise<void> {
|
|
316
|
-
const result = await execCommandFull('adb',
|
|
403
|
+
const result = await execCommandFull('adb', adbArgs('shell', 'ls', '/sdcard/'));
|
|
317
404
|
|
|
318
405
|
if (result.code !== 0 ||
|
|
319
406
|
result.stdout.includes('Permission denied') ||
|
|
@@ -334,7 +421,7 @@ export async function checkUSBFileTransfer(): Promise<void> {
|
|
|
334
421
|
* Screenshots cannot be taken when the display is off
|
|
335
422
|
*/
|
|
336
423
|
export async function checkQuestAwake(): Promise<void> {
|
|
337
|
-
const result = await execCommandFull('adb',
|
|
424
|
+
const result = await execCommandFull('adb', adbArgs('shell', 'dumpsys', 'power'));
|
|
338
425
|
|
|
339
426
|
if (result.stdout.includes('mWakefulness=Asleep')) {
|
|
340
427
|
console.error('Error: Quest display is off');
|
|
@@ -354,7 +441,7 @@ export interface BatteryInfo {
|
|
|
354
441
|
* Get Quest battery info as structured data
|
|
355
442
|
*/
|
|
356
443
|
export async function getBatteryInfo(): Promise<BatteryInfo> {
|
|
357
|
-
const result = await execCommandFull('adb',
|
|
444
|
+
const result = await execCommandFull('adb', adbArgs('shell', 'dumpsys', 'battery'));
|
|
358
445
|
|
|
359
446
|
if (result.code !== 0) {
|
|
360
447
|
throw new Error('Failed to get battery status');
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Casting APK extraction and installation utilities.
|
|
3
|
+
*
|
|
4
|
+
* The casting service APK ships inside Meta Quest Developer Hub (MQDH).
|
|
5
|
+
*
|
|
6
|
+
* Supported MQDH sources:
|
|
7
|
+
* - macOS: /Applications/Meta Quest Developer Hub.app (or any .app bundle)
|
|
8
|
+
* - macOS: .dmg file (mounted, APKs extracted from the .app inside)
|
|
9
|
+
* - Windows: .exe.zip or .exe (NSIS installer, extracted via 7z)
|
|
10
|
+
* - Any platform: pre-extracted directory containing the APK files
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, statSync, readdirSync, copyFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir, platform } from "node:os";
|
|
17
|
+
import { execCommand } from "./exec.js";
|
|
18
|
+
import { verbose } from "./verbose.js";
|
|
19
|
+
|
|
20
|
+
const CASTING_PKG = "com.oculus.magicislandcastingservice";
|
|
21
|
+
const RELEASE_APK = "com.oculus.magicislandcastingservice.release.apk";
|
|
22
|
+
const APK_DIR = join(homedir(), ".local", "share", "quest-dev");
|
|
23
|
+
const RELEASE_APK_PATH = join(APK_DIR, RELEASE_APK);
|
|
24
|
+
|
|
25
|
+
/** APK location inside the macOS .app bundle */
|
|
26
|
+
const MACOS_APP_REL = "Contents/Resources/bin/Casting/Resources/";
|
|
27
|
+
|
|
28
|
+
/** APK location inside the Windows NSIS-extracted MQDH */
|
|
29
|
+
const WINDOWS_RES_REL = "resources/bin/Casting/Resources/";
|
|
30
|
+
|
|
31
|
+
/** Default macOS install location */
|
|
32
|
+
const MACOS_DEFAULT_APP = "/Applications/Meta Quest Developer Hub.app";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Try to find MQDH on this machine without user input.
|
|
36
|
+
* Returns the path if found, null otherwise.
|
|
37
|
+
*/
|
|
38
|
+
export function findInstalledMqdh(): string | null {
|
|
39
|
+
if (platform() === "darwin") {
|
|
40
|
+
if (existsSync(MACOS_DEFAULT_APP)) {
|
|
41
|
+
return MACOS_DEFAULT_APP;
|
|
42
|
+
}
|
|
43
|
+
const userApps = join(homedir(), "Applications", "Meta Quest Developer Hub.app");
|
|
44
|
+
if (existsSync(userApps)) {
|
|
45
|
+
return userApps;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract the casting APK from a MQDH source.
|
|
53
|
+
*
|
|
54
|
+
* Accepts:
|
|
55
|
+
* - macOS .app bundle (e.g. /Applications/Meta Quest Developer Hub.app)
|
|
56
|
+
* - macOS .dmg disk image
|
|
57
|
+
* - Windows .exe.zip or .exe (requires 7z)
|
|
58
|
+
* - Any directory containing the APK file
|
|
59
|
+
*/
|
|
60
|
+
export async function extractCastingApk(source: string): Promise<string> {
|
|
61
|
+
mkdirSync(APK_DIR, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const stat = statSync(source);
|
|
64
|
+
let searchDir: string;
|
|
65
|
+
|
|
66
|
+
if (stat.isDirectory() && source.endsWith(".app")) {
|
|
67
|
+
searchDir = source;
|
|
68
|
+
verbose(`Using macOS .app bundle: ${source}`);
|
|
69
|
+
} else if (stat.isDirectory()) {
|
|
70
|
+
searchDir = source;
|
|
71
|
+
} else if (source.endsWith(".dmg")) {
|
|
72
|
+
searchDir = await extractDmg(source);
|
|
73
|
+
} else if (source.endsWith(".zip")) {
|
|
74
|
+
require7z();
|
|
75
|
+
const tmpZip = join(APK_DIR, "mqdh-zip-tmp");
|
|
76
|
+
rmSync(tmpZip, { recursive: true, force: true });
|
|
77
|
+
execFileSync("7z", ["x", `-o${tmpZip}`, source, "-y"], { stdio: "pipe" });
|
|
78
|
+
const exe = readdirSync(tmpZip).find((f) => f.endsWith(".exe"));
|
|
79
|
+
if (!exe) {
|
|
80
|
+
throw new Error("No .exe found inside zip");
|
|
81
|
+
}
|
|
82
|
+
searchDir = extractNsis(join(tmpZip, exe));
|
|
83
|
+
rmSync(tmpZip, { recursive: true, force: true });
|
|
84
|
+
} else if (source.endsWith(".exe")) {
|
|
85
|
+
require7z();
|
|
86
|
+
searchDir = extractNsis(source);
|
|
87
|
+
} else {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Unsupported file type: ${source}\n` +
|
|
90
|
+
`Supported formats: .app (macOS), .dmg (macOS), .exe.zip (Windows), .exe (Windows), or a directory`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Try known paths first, then fall back to recursive search
|
|
95
|
+
if (!copyApk(searchDir, [MACOS_APP_REL, WINDOWS_RES_REL])) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"Could not find casting APK. Is this the right MQDH installation?",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Cleanup temp extraction dirs
|
|
102
|
+
for (const tmp of ["mqdh-app-tmp", "mqdh-nsis-tmp", "mqdh-zip-tmp", "mqdh-dmg-tmp"]) {
|
|
103
|
+
rmSync(join(APK_DIR, tmp), { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return APK_DIR;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Copy the release APK from a search directory, trying known relative paths
|
|
111
|
+
* first, then falling back to recursive search.
|
|
112
|
+
*/
|
|
113
|
+
function copyApk(searchDir: string, knownPaths: string[]): boolean {
|
|
114
|
+
// Try known paths first
|
|
115
|
+
for (const relPath of knownPaths) {
|
|
116
|
+
const candidate = join(searchDir, relPath, RELEASE_APK);
|
|
117
|
+
if (existsSync(candidate)) {
|
|
118
|
+
copyFileSync(candidate, RELEASE_APK_PATH);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Fall back to recursive search
|
|
123
|
+
const found = findFile(searchDir, RELEASE_APK);
|
|
124
|
+
if (found) {
|
|
125
|
+
copyFileSync(found, RELEASE_APK_PATH);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Mount a .dmg and extract APKs from the .app inside */
|
|
132
|
+
async function extractDmg(dmgPath: string): Promise<string> {
|
|
133
|
+
if (platform() !== "darwin") {
|
|
134
|
+
throw new Error(
|
|
135
|
+
".dmg files can only be opened on macOS. On other platforms, mount the DMG and point to the .app inside.",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const mountPoint = join(APK_DIR, "mqdh-dmg-tmp");
|
|
140
|
+
rmSync(mountPoint, { recursive: true, force: true });
|
|
141
|
+
mkdirSync(mountPoint, { recursive: true });
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
execFileSync("hdiutil", ["attach", dmgPath, "-mountpoint", mountPoint, "-nobrowse", "-quiet"], {
|
|
145
|
+
stdio: "pipe",
|
|
146
|
+
});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
throw new Error(`Failed to mount DMG: ${(e as Error).message}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const entries = readdirSync(mountPoint);
|
|
153
|
+
const app = entries.find((e) => e.endsWith(".app"));
|
|
154
|
+
if (!app) {
|
|
155
|
+
throw new Error("No .app found inside DMG");
|
|
156
|
+
}
|
|
157
|
+
return join(mountPoint, app);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
try { execFileSync("hdiutil", ["detach", mountPoint, "-quiet"], { stdio: "pipe" }); } catch {}
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function require7z(): void {
|
|
165
|
+
try {
|
|
166
|
+
execFileSync("7z", ["--help"], { stdio: "pipe" });
|
|
167
|
+
} catch {
|
|
168
|
+
throw new Error(
|
|
169
|
+
"7z is required to extract Windows MQDH installers.\n" +
|
|
170
|
+
"Install it with: sudo apt install p7zip-full (Linux) or brew install p7zip (macOS)",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractNsis(exePath: string): string {
|
|
176
|
+
const nsisDir = join(APK_DIR, "mqdh-nsis-tmp");
|
|
177
|
+
rmSync(nsisDir, { recursive: true, force: true });
|
|
178
|
+
execFileSync("7z", ["x", `-o${nsisDir}`, exePath, "-y"], { stdio: "pipe" });
|
|
179
|
+
|
|
180
|
+
const inner = join(nsisDir, "$PLUGINSDIR", "app-64.7z");
|
|
181
|
+
if (!existsSync(inner)) {
|
|
182
|
+
throw new Error("Could not find app-64.7z inside NSIS installer");
|
|
183
|
+
}
|
|
184
|
+
const appDir = join(APK_DIR, "mqdh-app-tmp");
|
|
185
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
186
|
+
execFileSync("7z", ["x", `-o${appDir}`, inner, "-y"], { stdio: "pipe" });
|
|
187
|
+
|
|
188
|
+
rmSync(nsisDir, { recursive: true, force: true });
|
|
189
|
+
return appDir;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Recursively find a file by name */
|
|
193
|
+
function findFile(dir: string, name: string): string | null {
|
|
194
|
+
for (const entry of readdirSync(dir)) {
|
|
195
|
+
const full = join(dir, entry);
|
|
196
|
+
try {
|
|
197
|
+
if (statSync(full).isDirectory()) {
|
|
198
|
+
const found = findFile(full, name);
|
|
199
|
+
if (found) return found;
|
|
200
|
+
} else if (entry === name) {
|
|
201
|
+
return full;
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Skip permission errors
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Check whether the casting APK has been extracted locally */
|
|
211
|
+
export function hasCastingApk(): boolean {
|
|
212
|
+
return existsSync(RELEASE_APK_PATH);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Check if casting service is installed on the connected Quest device */
|
|
216
|
+
export async function isCastingInstalled(device: string): Promise<boolean> {
|
|
217
|
+
try {
|
|
218
|
+
const output = await execCommand("adb", [
|
|
219
|
+
"-s", device, "shell", "pm", "list", "packages", CASTING_PKG,
|
|
220
|
+
]);
|
|
221
|
+
return output.includes(CASTING_PKG);
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Install the casting APK onto the Quest device.
|
|
229
|
+
*
|
|
230
|
+
* Uses the release APK which matches the system app's signing certificate.
|
|
231
|
+
* This installs as UPDATED_SYSTEM_APP, preserving privileged permissions.
|
|
232
|
+
*/
|
|
233
|
+
export async function installCastingApk(device: string): Promise<void> {
|
|
234
|
+
if (!hasCastingApk()) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Casting APK not found. Run: quest-dev setup-cast`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await execCommand("adb", ["-s", device, "install", "-r", "-g", RELEASE_APK_PATH]);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Failed to install casting APK: ${(error as Error).message}\n` +
|
|
245
|
+
`Try installing manually: adb -s ${device} install -r -g ${RELEASE_APK_PATH}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Ensure casting service is installed, installing if needed. */
|
|
251
|
+
export async function ensureCastingInstalled(device: string): Promise<boolean> {
|
|
252
|
+
if (await isCastingInstalled(device)) {
|
|
253
|
+
verbose("Casting service already installed");
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// If we don't have the APK locally, try to find MQDH on this machine
|
|
258
|
+
if (!hasCastingApk()) {
|
|
259
|
+
const mqdh = findInstalledMqdh();
|
|
260
|
+
if (mqdh) {
|
|
261
|
+
console.log(`Found MQDH at ${mqdh}, extracting casting APK...`);
|
|
262
|
+
await extractCastingApk(mqdh);
|
|
263
|
+
} else {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Casting service not installed on Quest and APK not found locally.\n\n` +
|
|
266
|
+
`To fix this, run:\n\n` +
|
|
267
|
+
` quest-dev setup-cast\n\n` +
|
|
268
|
+
`This will guide you through downloading and extracting the casting APK.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
console.log("Installing casting service on Quest...");
|
|
273
|
+
await installCastingApk(device);
|
|
274
|
+
console.log("Casting service installed");
|
|
275
|
+
return true;
|
|
276
|
+
}
|
package/src/utils/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Config file loading for quest-dev
|
|
3
|
-
* Resolves settings from CLI flags →
|
|
3
|
+
* Resolves settings from CLI flags → ~/.config/quest-dev/config.json
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
@@ -9,40 +9,25 @@ import { homedir } from 'os';
|
|
|
9
9
|
|
|
10
10
|
export interface QuestDevConfig {
|
|
11
11
|
pin?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
host?: string;
|
|
14
|
+
device?: string;
|
|
12
15
|
idleTimeout?: number;
|
|
13
16
|
lowBattery?: number;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
const
|
|
17
|
-
join(process.cwd(), '.quest-dev.json'),
|
|
18
|
-
join(homedir(), '.config', 'quest-dev', 'config.json'),
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
function tryReadConfig(path: string): QuestDevConfig | null {
|
|
22
|
-
try {
|
|
23
|
-
const content = readFileSync(path, 'utf-8');
|
|
24
|
-
return JSON.parse(content);
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
19
|
+
const CONFIG_PATH = join(homedir(), '.config', 'quest-dev', 'config.json');
|
|
29
20
|
|
|
30
21
|
/**
|
|
31
|
-
* Load
|
|
32
|
-
* First file found wins for each field.
|
|
22
|
+
* Load config from ~/.config/quest-dev/config.json
|
|
33
23
|
*/
|
|
34
24
|
export function loadConfig(): QuestDevConfig {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (merged.pin === undefined && config.pin) merged.pin = config.pin;
|
|
41
|
-
if (merged.idleTimeout === undefined && config.idleTimeout !== undefined) merged.idleTimeout = config.idleTimeout;
|
|
42
|
-
if (merged.lowBattery === undefined && config.lowBattery !== undefined) merged.lowBattery = config.lowBattery;
|
|
25
|
+
try {
|
|
26
|
+
const content = readFileSync(CONFIG_PATH, 'utf-8');
|
|
27
|
+
return JSON.parse(content);
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
43
30
|
}
|
|
44
|
-
|
|
45
|
-
return merged;
|
|
46
31
|
}
|
|
47
32
|
|
|
48
33
|
/**
|
|
@@ -50,22 +35,19 @@ export function loadConfig(): QuestDevConfig {
|
|
|
50
35
|
* Merges with existing config (doesn't overwrite unrelated fields).
|
|
51
36
|
*/
|
|
52
37
|
export function saveConfig(values: QuestDevConfig): string {
|
|
53
|
-
const
|
|
54
|
-
let existing: QuestDevConfig = {};
|
|
55
|
-
try {
|
|
56
|
-
existing = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
57
|
-
} catch {
|
|
58
|
-
// No existing config, start fresh
|
|
59
|
-
}
|
|
38
|
+
const existing = loadConfig();
|
|
60
39
|
|
|
61
40
|
const merged = { ...existing };
|
|
62
41
|
if (values.pin !== undefined) merged.pin = values.pin;
|
|
42
|
+
if (values.port !== undefined) merged.port = values.port;
|
|
43
|
+
if (values.host !== undefined) merged.host = values.host;
|
|
44
|
+
if (values.device !== undefined) merged.device = values.device;
|
|
63
45
|
if (values.idleTimeout !== undefined) merged.idleTimeout = values.idleTimeout;
|
|
64
46
|
if (values.lowBattery !== undefined) merged.lowBattery = values.lowBattery;
|
|
65
47
|
|
|
66
|
-
mkdirSync(dirname(
|
|
67
|
-
writeFileSync(
|
|
68
|
-
return
|
|
48
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
49
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n');
|
|
50
|
+
return CONFIG_PATH;
|
|
69
51
|
}
|
|
70
52
|
|
|
71
53
|
/**
|
package/src/utils/exec.ts
CHANGED
|
@@ -46,6 +46,26 @@ export function execCommand(command: string, args: string[] = []): Promise<strin
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Execute a shell command with stdout/stderr streamed to the console in real time.
|
|
51
|
+
* Returns the exit code.
|
|
52
|
+
*/
|
|
53
|
+
export function execCommandStreaming(command: string, args: string[] = []): Promise<number> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const proc = spawn(command, args, {
|
|
56
|
+
stdio: 'inherit',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
proc.on('close', (code) => {
|
|
60
|
+
resolve(code ?? 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
proc.on('error', () => {
|
|
64
|
+
resolve(1);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
/**
|
|
50
70
|
* Execute a shell command and return full result (doesn't throw on non-zero exit)
|
|
51
71
|
*/
|