@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
@@ -7,12 +7,12 @@
7
7
  */
8
8
 
9
9
  import { resolve, join } from 'path';
10
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, symlinkSync, statSync, readlinkSync, openSync } from 'fs';
10
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, symlinkSync, statSync, readlinkSync, openSync, closeSync } from 'fs';
11
11
  import { spawn } from 'child_process';
12
- import { checkADBPath, checkADBDevices } from '../utils/adb.js';
12
+ import { checkADBPath, checkADBDevices, adbArgs } from '../utils/adb.js';
13
13
  import { execCommand, execCommandFull } from '../utils/exec.js';
14
14
 
15
- const LOG_DIR = process.env.LOG_DIR || 'logs/logcat';
15
+ const LOG_DIR = resolve(process.env.LOG_DIR || 'logs/logcat');
16
16
  const PID_FILE = join(LOG_DIR, '.logcat_pid');
17
17
  const LOGFILE_LINK = join(LOG_DIR, 'latest.txt');
18
18
 
@@ -143,7 +143,7 @@ export async function startCommand(filter?: string): Promise<void> {
143
143
 
144
144
  // Clear the buffer first - critical for Quest
145
145
  try {
146
- await execCommand('adb', ['logcat', '-c']);
146
+ await execCommand('adb', adbArgs('logcat', '-c'));
147
147
  console.log('Ring buffer cleared.');
148
148
  } catch (error) {
149
149
  console.error('Failed to clear ring buffer:', (error as Error).message);
@@ -155,7 +155,7 @@ export async function startCommand(filter?: string): Promise<void> {
155
155
  }
156
156
 
157
157
  // Start background logcat process
158
- const args = ['logcat', '-v', 'threadtime'];
158
+ const args = adbArgs('logcat', '-v', 'threadtime');
159
159
  if (filter) {
160
160
  args.push(filter);
161
161
  }
@@ -167,6 +167,7 @@ export async function startCommand(filter?: string): Promise<void> {
167
167
  stdio: ['ignore', fd, fd],
168
168
  detached: true
169
169
  });
170
+ closeSync(fd); // child process owns the fd now
170
171
 
171
172
  // Unref so parent can exit immediately
172
173
  proc.unref();
@@ -5,9 +5,11 @@
5
5
 
6
6
  import { resolve, join } from 'path';
7
7
  import { existsSync, statSync } from 'fs';
8
- import { checkADBPath, checkADBDevices, checkUSBFileTransfer, checkQuestAwake } from '../utils/adb.js';
8
+ import { checkADBPath, checkADBDevices, checkUSBFileTransfer, checkQuestAwake, setAdbDevice, adbArgs } from '../utils/adb.js';
9
9
  import { execCommand, execCommandFull } from '../utils/exec.js';
10
+ import { loadConfig } from '../utils/config.js';
10
11
  import { generateScreenshotFilename } from '../utils/filename.js';
12
+ import { addJpegFileComment } from '../utils/jpeg-comment.js';
11
13
 
12
14
  /**
13
15
  * Validate directory exists and is writable
@@ -36,7 +38,7 @@ function validateDirectory(dirPath: string): void {
36
38
  */
37
39
  async function triggerScreenshot(): Promise<boolean> {
38
40
  try {
39
- await execCommand('adb', [
41
+ await execCommand('adb', adbArgs(
40
42
  'shell',
41
43
  'am',
42
44
  'startservice',
@@ -44,7 +46,7 @@ async function triggerScreenshot(): Promise<boolean> {
44
46
  'com.oculus.metacam/.capture.CaptureService',
45
47
  '-a',
46
48
  'TAKE_SCREENSHOT'
47
- ]);
49
+ ));
48
50
  console.log('Screenshot service triggered');
49
51
  return true;
50
52
  } catch (error) {
@@ -58,7 +60,7 @@ async function triggerScreenshot(): Promise<boolean> {
58
60
  */
59
61
  async function getMostRecentScreenshot(): Promise<string | null> {
60
62
  try {
61
- const output = await execCommand('adb', ['shell', 'ls', '-t', '/sdcard/Oculus/Screenshots/']);
63
+ const output = await execCommand('adb', adbArgs('shell', 'ls', '-t', '/sdcard/Oculus/Screenshots/'));
62
64
  const files = output.split('\n').filter(line => line.trim() && line.endsWith('.jpg'));
63
65
 
64
66
  if (files.length === 0) {
@@ -82,7 +84,7 @@ async function isJpegComplete(filename: string): Promise<boolean> {
82
84
  const { spawn } = await import('child_process');
83
85
 
84
86
  return new Promise((resolve) => {
85
- const proc = spawn('adb', ['exec-out', 'tail', '-c', '2', remotePath]);
87
+ const proc = spawn('adb', adbArgs('exec-out', 'tail', '-c', '2', remotePath));
86
88
  const chunks: Buffer[] = [];
87
89
 
88
90
  proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk));
@@ -108,7 +110,7 @@ async function isJpegComplete(filename: string): Promise<boolean> {
108
110
  async function pullScreenshot(filename: string, outputPath: string): Promise<boolean> {
109
111
  try {
110
112
  const remotePath = `/sdcard/Oculus/Screenshots/${filename}`;
111
- await execCommand('adb', ['pull', remotePath, outputPath]);
113
+ await execCommand('adb', adbArgs('pull', remotePath, outputPath));
112
114
  console.log(`Screenshot saved to: ${outputPath}`);
113
115
  return true;
114
116
  } catch (error) {
@@ -122,7 +124,7 @@ async function pullScreenshot(filename: string, outputPath: string): Promise<boo
122
124
  */
123
125
  async function deleteRemoteScreenshot(filename: string): Promise<void> {
124
126
  const remotePath = `/sdcard/Oculus/Screenshots/${filename}`;
125
- const result = await execCommandFull('adb', ['shell', 'rm', remotePath]);
127
+ const result = await execCommandFull('adb', adbArgs('shell', 'rm', remotePath));
126
128
  if (result.code !== 0) {
127
129
  console.warn(`Warning: Failed to delete screenshot from Quest: ${filename}`);
128
130
  } else {
@@ -133,11 +135,9 @@ async function deleteRemoteScreenshot(filename: string): Promise<void> {
133
135
  /**
134
136
  * Add caption to JPEG COM metadata
135
137
  */
136
- async function addJpegMetadata(filePath: string, caption: string): Promise<boolean> {
138
+ function addJpegMetadata(filePath: string, caption: string): boolean {
137
139
  try {
138
- const { exiftool } = await import('exiftool-vendored');
139
- await exiftool.write(filePath, { Comment: caption });
140
- await exiftool.end();
140
+ addJpegFileComment(filePath, caption);
141
141
  console.log(`Caption added: "${caption}"`);
142
142
  return true;
143
143
  } catch (error) {
@@ -158,9 +158,15 @@ export async function screenshotCommand(directoryPath: string, caption: string |
158
158
  validateDirectory(resolvedDir);
159
159
 
160
160
  // Generate filename
161
- const localFilename = generateScreenshotFilename();
161
+ const localFilename = generateScreenshotFilename(new Date(), caption);
162
162
  const outputPath = join(resolvedDir, localFilename);
163
163
 
164
+ // Load device config so -s <device> targets the right Quest
165
+ const config = loadConfig();
166
+ if (config.device) {
167
+ setAdbDevice(config.device);
168
+ }
169
+
164
170
  // Check prerequisites
165
171
  checkADBPath();
166
172
  await checkADBDevices();
@@ -215,7 +221,7 @@ export async function screenshotCommand(directoryPath: string, caption: string |
215
221
 
216
222
  // Add metadata (non-fatal, only if caption provided)
217
223
  if (caption) {
218
- await addJpegMetadata(outputPath, caption);
224
+ addJpegMetadata(outputPath, caption);
219
225
  }
220
226
 
221
227
  // Delete from Quest after successful pull
@@ -8,100 +8,29 @@
8
8
  * parent is killed (TaskStop, terminal close, claude code exit).
9
9
  */
10
10
 
11
- import { checkADBPath, getBatteryInfo, formatBatteryInfo } from '../utils/adb.js';
11
+ import { checkADBPath, getBatteryInfo, formatBatteryInfo, adbArgs } from '../utils/adb.js';
12
12
  import { loadPin, loadConfig } from '../utils/config.js';
13
- import { execCommand, execCommandFull } from '../utils/exec.js';
14
- import { execSync, spawn, ChildProcess } from 'child_process';
13
+ import { execCommand } from '../utils/exec.js';
14
+ import { execFileSync, spawn, ChildProcess } from 'child_process';
15
15
  import * as os from 'os';
16
16
  import * as fs from 'fs';
17
-
18
- export interface TestProperties {
19
- disable_guardian: boolean;
20
- disable_dialogs: boolean;
21
- disable_autosleep: boolean;
22
- set_proximity_close: boolean;
23
- }
24
-
25
- /**
26
- * Build ADB args for SET_PROPERTY call
27
- */
28
- export function buildSetPropertyArgs(pin: string, enabled: boolean): string[] {
29
- return [
30
- 'shell', 'content', 'call',
31
- '--uri', 'content://com.oculus.rc',
32
- '--method', 'SET_PROPERTY',
33
- '--extra', `disable_guardian:b:${enabled}`,
34
- '--extra', `disable_dialogs:b:${enabled}`,
35
- '--extra', `disable_autosleep:b:${enabled}`,
36
- '--extra', `set_proximity_close:b:${enabled}`,
37
- '--extra', `PIN:s:${pin}`,
38
- ];
39
- }
40
-
41
- /**
42
- * Parse GET_PROPERTY Bundle output into structured data
43
- * Input: "Bundle[{disable_guardian=true, set_proximity_close=true, disable_dialogs=true, disable_autosleep=true}]"
44
- */
45
- export function parseTestProperties(output: string): TestProperties {
46
- const defaults: TestProperties = {
47
- disable_guardian: false,
48
- disable_dialogs: false,
49
- disable_autosleep: false,
50
- set_proximity_close: false,
51
- };
52
-
53
- const match = output.match(/Bundle\[\{(.+)\}\]/);
54
- if (!match) return defaults;
55
-
56
- const pairs = match[1].split(',').map(s => s.trim());
57
- for (const pair of pairs) {
58
- const [key, value] = pair.split('=');
59
- if (key && value && key in defaults) {
60
- (defaults as any)[key] = value === 'true';
61
- }
62
- }
63
-
64
- return defaults;
65
- }
66
-
67
- /**
68
- * Call SET_PROPERTY to enable or disable test mode
69
- */
70
- async function setTestProperties(pin: string, enabled: boolean): Promise<void> {
71
- const args = buildSetPropertyArgs(pin, enabled);
72
- await execCommand('adb', args);
73
- }
74
-
75
- /**
76
- * Call GET_PROPERTY and return parsed test properties
77
- */
78
- async function getTestProperties(): Promise<TestProperties> {
79
- const result = await execCommandFull('adb', [
80
- 'shell', 'content', 'call',
81
- '--uri', 'content://com.oculus.rc',
82
- '--method', 'GET_PROPERTY',
83
- ]);
84
- return parseTestProperties(result.stdout);
85
- }
86
-
87
- /**
88
- * Format test properties for display
89
- */
90
- function formatTestProperties(props: TestProperties): string {
91
- const lines = [
92
- ` Guardian disabled: ${props.disable_guardian}`,
93
- ` Dialogs disabled: ${props.disable_dialogs}`,
94
- ` Autosleep disabled: ${props.disable_autosleep}`,
95
- ` Proximity close: ${props.set_proximity_close}`,
96
- ];
97
- return lines.join('\n');
98
- }
17
+ import {
18
+ type TestProperties,
19
+ buildSetPropertyArgs,
20
+ parseTestProperties,
21
+ setTestProperties,
22
+ getTestProperties,
23
+ formatTestProperties,
24
+ } from '../utils/test-properties.js';
25
+
26
+ // Re-export for backward compatibility with tests
27
+ export { type TestProperties, buildSetPropertyArgs, parseTestProperties };
99
28
 
100
29
  /**
101
30
  * Wake the Quest screen
102
31
  */
103
32
  async function wakeScreen(): Promise<void> {
104
- await execCommand('adb', ['shell', 'input', 'keyevent', 'KEYCODE_WAKEUP']);
33
+ await execCommand('adb', adbArgs('shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'));
105
34
  }
106
35
 
107
36
  /**
@@ -121,7 +50,9 @@ export async function stayAwakeDisable(cliPin?: string): Promise<void> {
121
50
  checkADBPath();
122
51
  const pin = loadPin(cliPin);
123
52
  await setTestProperties(pin, false);
124
- console.log('Test mode disabled guardian, dialogs, and autosleep restored');
53
+ const props = await getTestProperties();
54
+ console.log('Test mode disabled:');
55
+ console.log(formatTestProperties(props));
125
56
  }
126
57
 
127
58
  /**
@@ -138,8 +69,8 @@ export async function stayAwakeWatchdog(parentPid: number, pin: string): Promise
138
69
  clearInterval(checkParent);
139
70
 
140
71
  try {
141
- const args = buildSetPropertyArgs(pin, false);
142
- execSync(`adb ${args.join(' ')}`, { stdio: 'ignore' });
72
+ const args = adbArgs(...buildSetPropertyArgs(pin, false));
73
+ execFileSync('adb', args, { stdio: 'ignore' });
143
74
 
144
75
  const pidFile = `${os.homedir()}/.quest-dev-stay-awake.pid`;
145
76
  try { fs.unlinkSync(pidFile); } catch {}
@@ -288,8 +219,8 @@ export async function stayAwakeCommand(
288
219
  try {
289
220
  try { fs.unlinkSync(pidFilePath); } catch {}
290
221
 
291
- const args = buildSetPropertyArgs(pin, false);
292
- execSync(`adb ${args.join(' ')}`, { stdio: 'ignore' });
222
+ const args = adbArgs(...buildSetPropertyArgs(pin, false));
223
+ execFileSync('adb', args, { stdio: 'ignore' });
293
224
  console.log('Test mode disabled — guardian, dialogs, and autosleep restored');
294
225
  } catch (error) {
295
226
  console.error('Failed to restore settings:', (error as Error).message);
@@ -0,0 +1,14 @@
1
+ declare module "adbkit-apkreader" {
2
+ interface Manifest {
3
+ package: string;
4
+ versionCode: number;
5
+ versionName: string;
6
+ }
7
+
8
+ class ApkReader {
9
+ static open(path: string): Promise<ApkReader>;
10
+ readManifest(): Promise<Manifest>;
11
+ }
12
+
13
+ export default ApkReader;
14
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * CastManager: lazy-loaded manager for cast sessions within the daemon.
3
+ * Wraps CastSession with start/stop lifecycle and SSE broadcasting.
4
+ * Server state is the single source of truth — pushed to clients via SSE.
5
+ */
6
+
7
+ import { EventEmitter } from "node:events";
8
+ import type { ServerResponse } from "node:http";
9
+ import { checkADBPath, getAdbDevice } from "../utils/adb.js";
10
+ import { execCommand } from "../utils/exec.js";
11
+ import { verbose } from "../utils/verbose.js";
12
+ import { CastSession } from "../cast/session.js";
13
+ import { ensureCastingInstalled } from "../utils/casting-apk.js";
14
+ import { resolveResolution } from "@myerscarpenter/cast2-protocol";
15
+
16
+ export interface CastStartOptions {
17
+ listenPort?: number;
18
+ resolution?: string;
19
+ width?: number;
20
+ height?: number;
21
+ }
22
+
23
+ function round4(n: number): number {
24
+ return Math.round(n * 10000) / 10000;
25
+ }
26
+
27
+ function round1(n: number): number {
28
+ return Math.round(n * 10) / 10;
29
+ }
30
+
31
+ function deg(rad: number): number {
32
+ return (rad * 180) / Math.PI;
33
+ }
34
+
35
+ export class CastManager extends EventEmitter {
36
+ private session: CastSession | null = null;
37
+ private sseClients = new Set<ServerResponse>();
38
+ private questIp: string | null = null;
39
+ private configuredDevice: string | undefined;
40
+ private statsInterval: ReturnType<typeof setInterval> | null = null;
41
+ private starting = false;
42
+
43
+ constructor(device?: string) {
44
+ super();
45
+ this.configuredDevice = device;
46
+ }
47
+
48
+ get isActive(): boolean {
49
+ return this.session !== null && this.session.connected;
50
+ }
51
+
52
+ getSession(): CastSession | null {
53
+ return this.session;
54
+ }
55
+
56
+ /** Build full status snapshot from current session state. */
57
+ getStatus(): Record<string, unknown> {
58
+ const session = this.session;
59
+ if (!session) {
60
+ return { connected: false, running: false };
61
+ }
62
+ const p = session.pose;
63
+ return {
64
+ connected: session.connected,
65
+ running: session.running,
66
+ width: session.width,
67
+ height: session.height,
68
+ frame_count: session.frameCount,
69
+ bytes: session.byteCount,
70
+ fps: session.fps,
71
+ elapsed: session.elapsedSeconds,
72
+ has_frame: session.getScreenshot() !== null,
73
+ eye: session.eye === 0 ? "right" : session.eye === 2 ? "stereo" : "left",
74
+ pose_loop: session.poseLoopActive,
75
+ pose: {
76
+ x: round4(p.x),
77
+ y: round4(p.y),
78
+ z: round4(p.z),
79
+ yaw: round4(p.yaw),
80
+ pitch: round4(p.pitch),
81
+ yaw_deg: round1(deg(p.yaw)),
82
+ pitch_deg: round1(deg(p.pitch)),
83
+ },
84
+ };
85
+ }
86
+
87
+ async start(opts: CastStartOptions = {}): Promise<void> {
88
+ if (this.session?.connected) {
89
+ return; // Already active
90
+ }
91
+ if (this.starting) {
92
+ throw new Error("Cast start already in progress");
93
+ }
94
+ this.starting = true;
95
+
96
+ try {
97
+ // Stop any lingering session
98
+ if (this.session) {
99
+ await this.stopSession();
100
+ }
101
+
102
+ checkADBPath();
103
+
104
+ // Check for connected devices
105
+ const output = await execCommand("adb", ["devices"]);
106
+ const lines = output.trim().split("\n").slice(1);
107
+ const devices = lines.filter(
108
+ (line) => line.trim() && !line.includes("List of devices"),
109
+ );
110
+ if (devices.length === 0) {
111
+ throw new Error("No ADB devices connected");
112
+ }
113
+
114
+ // Get Quest IP
115
+ const questIp = await this.getQuestIp();
116
+ this.questIp = questIp;
117
+
118
+ // Ensure casting service APK is installed on Quest
119
+ // Use the ADB device serial (USB or configured), not the WiFi IP
120
+ const adbDevice = getAdbDevice() ?? await this.getAdbSerial() ?? `${questIp}:5555`;
121
+ await ensureCastingInstalled(adbDevice);
122
+
123
+ const listenPort = opts.listenPort ?? 4445;
124
+ const { width, height } = resolveResolution(opts.resolution, opts.width, opts.height);
125
+
126
+ // Create and start session
127
+ const session = new CastSession({ listenPort, width, height });
128
+ this.session = session;
129
+
130
+ // Wire session events → SSE broadcast
131
+ session.on("connected", () => this.broadcastStatus());
132
+ session.on("disconnected", () => this.broadcastStatus());
133
+ session.on("pose-loop", () => this.broadcastStatus());
134
+
135
+ // Complete all async setup before starting periodic work
136
+ await session.bind();
137
+ if (session.listenPort !== listenPort) {
138
+ verbose(
139
+ `Port ${listenPort} in use, listening on ${session.listenPort}`,
140
+ );
141
+ }
142
+ await session.adbSetup(questIp);
143
+ await session.start(questIp);
144
+
145
+ // Stats interval created AFTER all async ops succeed
146
+ this.statsInterval = setInterval(() => {
147
+ if (session.connected) {
148
+ this.broadcastStatus();
149
+ }
150
+ }, 500);
151
+
152
+ verbose("Quest connected, casting active");
153
+ } finally {
154
+ this.starting = false;
155
+ }
156
+ }
157
+
158
+ async stop(): Promise<void> {
159
+ await this.stopSession();
160
+ this.broadcastStatus();
161
+ }
162
+
163
+ async restart(): Promise<void> {
164
+ if (!this.session) {
165
+ await this.start();
166
+ return;
167
+ }
168
+ this.broadcastToast("Restarting cast\u2026");
169
+ await this.session.restart();
170
+ }
171
+
172
+ private async stopSession(): Promise<void> {
173
+ if (this.statsInterval) {
174
+ clearInterval(this.statsInterval);
175
+ this.statsInterval = null;
176
+ }
177
+ if (this.session) {
178
+ try {
179
+ await this.session.stop();
180
+ } catch {
181
+ // Best effort
182
+ }
183
+ this.session = null;
184
+ }
185
+ }
186
+
187
+ /** Get the first connected ADB device serial (e.g. USB serial or IP:port) */
188
+ private async getAdbSerial(): Promise<string | null> {
189
+ const output = await execCommand("adb", ["devices"]);
190
+ const lines = output.trim().split("\n").slice(1);
191
+ const first = lines.find((l) => l.includes("device"));
192
+ if (!first) return null;
193
+ return first.split("\t")[0].trim();
194
+ }
195
+
196
+ private async getQuestIp(): Promise<string> {
197
+ // If a device IP was configured, use it directly (strip :port if present)
198
+ if (this.configuredDevice) {
199
+ const ip = this.configuredDevice.split(":")[0];
200
+ verbose(`Using configured device: ${ip}`);
201
+ return ip;
202
+ }
203
+
204
+ const devOutput = await execCommand("adb", ["devices"]);
205
+ const devLines = devOutput.trim().split("\n").slice(1);
206
+ const firstDevice = devLines.find((l) => l.includes("device"));
207
+ if (!firstDevice) {
208
+ throw new Error("No authorized ADB device found");
209
+ }
210
+ const deviceId = firstDevice.split("\t")[0].trim();
211
+ try {
212
+ const ip = await execCommand("adb", [
213
+ "-s",
214
+ deviceId,
215
+ "shell",
216
+ "ip addr show wlan0 | grep 'inet ' | tr -s ' ' | cut -f3 -d' ' | cut -f1 -d/",
217
+ ]);
218
+ const trimmed = ip.trim();
219
+ if (trimmed && !trimmed.includes("error")) {
220
+ return trimmed;
221
+ }
222
+ } catch {
223
+ // Fallback
224
+ }
225
+ return deviceId.split(":")[0];
226
+ }
227
+
228
+ // --- SSE ---
229
+
230
+ broadcast(event: string, data: unknown): void {
231
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
232
+ for (const res of this.sseClients) {
233
+ try {
234
+ res.write(msg);
235
+ } catch {
236
+ this.sseClients.delete(res);
237
+ }
238
+ }
239
+ }
240
+
241
+ /** Broadcast full status snapshot as SSE "status" event. */
242
+ broadcastStatus(): void {
243
+ this.broadcast("status", this.getStatus());
244
+ }
245
+
246
+ broadcastToast(msg: string): void {
247
+ this.broadcast("toast", { message: msg });
248
+ }
249
+
250
+ addSSEClient(res: ServerResponse): void {
251
+ this.sseClients.add(res);
252
+ }
253
+
254
+ removeSSEClient(res: ServerResponse): void {
255
+ this.sseClients.delete(res);
256
+ }
257
+
258
+ // --- Cleanup ---
259
+
260
+ cleanup(): void {
261
+ if (this.statsInterval) {
262
+ clearInterval(this.statsInterval);
263
+ this.statsInterval = null;
264
+ }
265
+ if (this.session) {
266
+ try {
267
+ this.session.stop();
268
+ } catch {
269
+ // Best effort sync cleanup
270
+ }
271
+ this.session = null;
272
+ }
273
+ for (const res of this.sseClients) {
274
+ try {
275
+ res.end();
276
+ } catch {
277
+ // Ignore
278
+ }
279
+ }
280
+ this.sseClients.clear();
281
+ }
282
+ }