@myerscarpenter/quest-dev 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.github/workflows/docs.yml +45 -0
  3. package/.github/workflows/publish.yml +11 -1
  4. package/README.md +27 -0
  5. package/build/cast/decoder.d.ts +48 -0
  6. package/build/cast/decoder.d.ts.map +1 -0
  7. package/build/cast/decoder.js +152 -0
  8. package/build/cast/decoder.js.map +1 -0
  9. package/build/cast/session.d.ts +87 -0
  10. package/build/cast/session.d.ts.map +1 -0
  11. package/build/cast/session.js +565 -0
  12. package/build/cast/session.js.map +1 -0
  13. package/build/commands/logcat.d.ts.map +1 -1
  14. package/build/commands/logcat.js +7 -6
  15. package/build/commands/logcat.js.map +1 -1
  16. package/build/commands/screenshot.d.ts.map +1 -1
  17. package/build/commands/screenshot.js +17 -20
  18. package/build/commands/screenshot.js.map +1 -1
  19. package/build/commands/stay-awake.d.ts +2 -15
  20. package/build/commands/stay-awake.d.ts.map +1 -1
  21. package/build/commands/stay-awake.js +14 -77
  22. package/build/commands/stay-awake.js.map +1 -1
  23. package/build/daemon/cast-manager.d.ts +42 -0
  24. package/build/daemon/cast-manager.d.ts.map +1 -0
  25. package/build/daemon/cast-manager.js +243 -0
  26. package/build/daemon/cast-manager.js.map +1 -0
  27. package/build/daemon/client.d.ts +40 -0
  28. package/build/daemon/client.d.ts.map +1 -0
  29. package/build/daemon/client.js +133 -0
  30. package/build/daemon/client.js.map +1 -0
  31. package/build/daemon/daemon.d.ts +20 -0
  32. package/build/daemon/daemon.d.ts.map +1 -0
  33. package/build/daemon/daemon.js +130 -0
  34. package/build/daemon/daemon.js.map +1 -0
  35. package/build/daemon/deploy.d.ts +44 -0
  36. package/build/daemon/deploy.d.ts.map +1 -0
  37. package/build/daemon/deploy.js +230 -0
  38. package/build/daemon/deploy.js.map +1 -0
  39. package/build/daemon/logcat-manager.d.ts +39 -0
  40. package/build/daemon/logcat-manager.d.ts.map +1 -0
  41. package/build/daemon/logcat-manager.js +194 -0
  42. package/build/daemon/logcat-manager.js.map +1 -0
  43. package/build/daemon/server.d.ts +19 -0
  44. package/build/daemon/server.d.ts.map +1 -0
  45. package/build/daemon/server.js +482 -0
  46. package/build/daemon/server.js.map +1 -0
  47. package/build/daemon/stay-awake-manager.d.ts +22 -0
  48. package/build/daemon/stay-awake-manager.d.ts.map +1 -0
  49. package/build/daemon/stay-awake-manager.js +74 -0
  50. package/build/daemon/stay-awake-manager.js.map +1 -0
  51. package/build/index.js +272 -45
  52. package/build/index.js.map +1 -1
  53. package/build/public/dashboard.js +749 -0
  54. package/build/public/index.html +12 -0
  55. package/build/public/style.css +106 -0
  56. package/build/utils/adb.d.ts +6 -0
  57. package/build/utils/adb.d.ts.map +1 -1
  58. package/build/utils/adb.js +62 -66
  59. package/build/utils/adb.js.map +1 -1
  60. package/build/utils/casting-apk.d.ts +40 -0
  61. package/build/utils/casting-apk.d.ts.map +1 -0
  62. package/build/utils/casting-apk.js +252 -0
  63. package/build/utils/casting-apk.js.map +1 -0
  64. package/build/utils/config.d.ts +5 -3
  65. package/build/utils/config.d.ts.map +1 -1
  66. package/build/utils/config.js +18 -38
  67. package/build/utils/config.js.map +1 -1
  68. package/build/utils/exec.d.ts +5 -0
  69. package/build/utils/exec.d.ts.map +1 -1
  70. package/build/utils/exec.js +17 -0
  71. package/build/utils/exec.js.map +1 -1
  72. package/build/utils/filename.d.ts +7 -1
  73. package/build/utils/filename.d.ts.map +1 -1
  74. package/build/utils/filename.js +17 -2
  75. package/build/utils/filename.js.map +1 -1
  76. package/build/utils/filename.test.js +33 -1
  77. package/build/utils/filename.test.js.map +1 -1
  78. package/build/utils/jpeg-comment.d.ts +14 -0
  79. package/build/utils/jpeg-comment.d.ts.map +1 -0
  80. package/build/utils/jpeg-comment.js +28 -0
  81. package/build/utils/jpeg-comment.js.map +1 -0
  82. package/build/utils/test-properties.d.ts +34 -0
  83. package/build/utils/test-properties.d.ts.map +1 -0
  84. package/build/utils/test-properties.js +73 -0
  85. package/build/utils/test-properties.js.map +1 -0
  86. package/package.json +11 -5
  87. package/packages/cast2-protocol/README.md +86 -0
  88. package/packages/cast2-protocol/docs/_config.yml +4 -0
  89. package/packages/cast2-protocol/docs/feature-flags.md +102 -0
  90. package/packages/cast2-protocol/docs/index.md +24 -0
  91. package/packages/cast2-protocol/docs/open-investigations.md +149 -0
  92. package/packages/cast2-protocol/docs/protocol.md +602 -0
  93. package/packages/cast2-protocol/package.json +46 -0
  94. package/packages/cast2-protocol/src/constants.ts +65 -0
  95. package/packages/cast2-protocol/src/index.ts +7 -0
  96. package/packages/cast2-protocol/src/mgik.ts +69 -0
  97. package/packages/cast2-protocol/src/mud.ts +294 -0
  98. package/packages/cast2-protocol/src/pose.ts +99 -0
  99. package/packages/cast2-protocol/src/resolutions.ts +34 -0
  100. package/packages/cast2-protocol/src/types.ts +64 -0
  101. package/packages/cast2-protocol/src/xrsp.ts +73 -0
  102. package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
  103. package/packages/cast2-protocol/tests/mud.test.ts +295 -0
  104. package/packages/cast2-protocol/tests/pose.test.ts +173 -0
  105. package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
  106. package/packages/cast2-protocol/tsconfig.json +20 -0
  107. package/pnpm-workspace.yaml +2 -0
  108. package/src/cast/decoder.ts +178 -0
  109. package/src/cast/session.ts +708 -0
  110. package/src/commands/logcat.ts +6 -5
  111. package/src/commands/screenshot.ts +19 -13
  112. package/src/commands/stay-awake.ts +22 -91
  113. package/src/daemon/adbkit-apkreader.d.ts +14 -0
  114. package/src/daemon/cast-manager.ts +282 -0
  115. package/src/daemon/client.ts +166 -0
  116. package/src/daemon/daemon.ts +169 -0
  117. package/src/daemon/deploy.ts +307 -0
  118. package/src/daemon/logcat-manager.ts +229 -0
  119. package/src/daemon/server.ts +595 -0
  120. package/src/daemon/stay-awake-manager.ts +83 -0
  121. package/src/index.ts +326 -56
  122. package/src/public/dashboard.js +288 -0
  123. package/src/public/index.html +12 -0
  124. package/src/public/style.css +106 -0
  125. package/src/utils/adb.ts +70 -57
  126. package/src/utils/casting-apk.ts +276 -0
  127. package/src/utils/config.ts +18 -36
  128. package/src/utils/exec.ts +20 -0
  129. package/src/utils/filename.test.ts +41 -1
  130. package/src/utils/filename.ts +18 -2
  131. package/src/utils/jpeg-comment.ts +30 -0
  132. package/src/utils/test-properties.ts +94 -0
  133. package/tests/cast/auto-layer.test.ts +87 -0
  134. package/tests/cast/decoder.test.ts +82 -0
  135. package/tests/cast/session-restart.test.ts +107 -0
  136. package/tests/config.test.ts +17 -22
  137. package/tests/daemon/api-status.test.ts +82 -0
  138. package/tests/daemon/cast-manager.test.ts +69 -0
  139. package/tests/daemon/mjpeg-stream.test.ts +144 -0
  140. package/tests/daemon/pose-endpoint.test.ts +63 -0
  141. package/tests/daemon/start-guard.test.ts +77 -0
  142. package/vitest.config.ts +10 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Daemon discovery + HTTP client for CLI commands.
3
+ * Auto-starts daemon if needed, then calls HTTP endpoints.
4
+ */
5
+
6
+ import { readFileSync, existsSync, unlinkSync } from "node:fs";
7
+ import { spawn } from "node:child_process";
8
+ import { DAEMON_JSON, type DaemonInfo } from "./daemon.js";
9
+ import { loadConfig } from "../utils/config.js";
10
+ import { verbose } from "../utils/verbose.js";
11
+
12
+ const DEFAULT_PORT = 19872;
13
+
14
+ /** Check if a PID is alive */
15
+ function isPidAlive(pid: number): boolean {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /** Read daemon.json and verify PID is alive */
25
+ export function discoverDaemon(): DaemonInfo | null {
26
+ if (!existsSync(DAEMON_JSON)) {
27
+ return null;
28
+ }
29
+ try {
30
+ const info: DaemonInfo = JSON.parse(readFileSync(DAEMON_JSON, "utf-8"));
31
+ if (isPidAlive(info.pid)) {
32
+ return info;
33
+ }
34
+ verbose(`Stale daemon.json (PID ${info.pid} is dead), cleaning up`);
35
+ try {
36
+ unlinkSync(DAEMON_JSON);
37
+ } catch {
38
+ // Best effort
39
+ }
40
+ } catch {
41
+ // Corrupt file
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export interface SpawnDaemonOptions {
47
+ port: number;
48
+ device?: string;
49
+ host?: string;
50
+ idleTimeout?: number;
51
+ lowBattery?: number;
52
+ }
53
+
54
+ /** Spawn daemon as a detached background process */
55
+ async function spawnDaemon(opts: SpawnDaemonOptions): Promise<DaemonInfo> {
56
+ const args = [process.argv[1], "daemon", "--port", String(opts.port)];
57
+ if (opts.device) {
58
+ args.push("--device", opts.device);
59
+ }
60
+ if (opts.host) {
61
+ args.push("--host", opts.host);
62
+ }
63
+ if (opts.idleTimeout !== undefined) {
64
+ args.push("--idle-timeout", String(opts.idleTimeout));
65
+ }
66
+ if (opts.lowBattery !== undefined) {
67
+ args.push("--low-battery", String(opts.lowBattery));
68
+ }
69
+ const child = spawn(process.execPath, args, {
70
+ detached: true,
71
+ stdio: "ignore",
72
+ });
73
+ child.unref();
74
+
75
+ // Wait for daemon.json to appear (up to 5s)
76
+ for (let i = 0; i < 50; i++) {
77
+ await new Promise((r) => setTimeout(r, 100));
78
+ const info = discoverDaemon();
79
+ if (info) {
80
+ return info;
81
+ }
82
+ }
83
+
84
+ throw new Error("Daemon failed to start (timed out waiting for daemon.json)");
85
+ }
86
+
87
+ /** Resolve daemon port from CLI flag → config → default */
88
+ export function resolvePort(cliPort?: number): number {
89
+ if (cliPort) return cliPort;
90
+ const config = loadConfig();
91
+ return config.port ?? DEFAULT_PORT;
92
+ }
93
+
94
+ function printDaemonUrl(port: number, host: string = "127.0.0.1"): void {
95
+ console.log(`Daemon: http://${host}:${port} (API: /help)`);
96
+ }
97
+
98
+ /** Resolve device from CLI flag → config */
99
+ export function resolveDevice(cliDevice?: string): string | undefined {
100
+ if (cliDevice) return cliDevice;
101
+ const config = loadConfig();
102
+ return config.device;
103
+ }
104
+
105
+ /** Resolve host from CLI flag → config → default */
106
+ export function resolveHost(cliHost?: string): string {
107
+ if (cliHost) return cliHost;
108
+ const config = loadConfig();
109
+ return config.host ?? "127.0.0.1";
110
+ }
111
+
112
+ export interface EnsureDaemonOptions {
113
+ port?: number;
114
+ device?: string;
115
+ host?: string;
116
+ idleTimeout?: number;
117
+ lowBattery?: number;
118
+ }
119
+
120
+ /** Ensure daemon is running, starting it if needed. Returns connection info. */
121
+ export async function ensureDaemon(opts: EnsureDaemonOptions = {}): Promise<DaemonInfo> {
122
+ const existing = discoverDaemon();
123
+ if (existing) {
124
+ verbose(`Daemon already running (PID: ${existing.pid}, port: ${existing.port})`);
125
+ printDaemonUrl(existing.port);
126
+ return existing;
127
+ }
128
+
129
+ const port = resolvePort(opts.port);
130
+ const device = resolveDevice(opts.device);
131
+ const host = resolveHost(opts.host);
132
+ console.log("Starting quest-dev daemon...");
133
+ const info = await spawnDaemon({ port, device, host, idleTimeout: opts.idleTimeout, lowBattery: opts.lowBattery });
134
+ printDaemonUrl(info.port, host);
135
+ return info;
136
+ }
137
+
138
+ /** Make an HTTP request to the daemon */
139
+ export async function daemonFetch(
140
+ info: DaemonInfo,
141
+ path: string,
142
+ options?: { method?: string; body?: unknown },
143
+ ): Promise<unknown> {
144
+ const url = `http://127.0.0.1:${info.port}${path}`;
145
+ const fetchOptions: RequestInit = {
146
+ method: options?.method ?? "GET",
147
+ };
148
+
149
+ if (options?.body !== undefined) {
150
+ fetchOptions.method = "POST";
151
+ fetchOptions.headers = { "Content-Type": "application/json" };
152
+ fetchOptions.body = JSON.stringify(options.body);
153
+ }
154
+
155
+ const response = await fetch(url, fetchOptions);
156
+ return response.json();
157
+ }
158
+
159
+ /** Convenience: ensure daemon + fetch */
160
+ export async function daemonRequest(
161
+ path: string,
162
+ options?: { method?: string; body?: unknown },
163
+ ): Promise<unknown> {
164
+ const info = await ensureDaemon();
165
+ return daemonFetch(info, path, options);
166
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Daemon process entry point.
3
+ * Starts the unified HTTP server, manages lifecycle, writes daemon.json.
4
+ */
5
+
6
+ import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { getBatteryInfo, setAdbDevice } from "../utils/adb.js";
10
+ import { loadConfig } from "../utils/config.js";
11
+ import { verbose } from "../utils/verbose.js";
12
+ import { StayAwakeManager } from "./stay-awake-manager.js";
13
+ import { LogcatManager } from "./logcat-manager.js";
14
+ import { CastManager } from "./cast-manager.js";
15
+ import { createDaemonServer } from "./server.js";
16
+
17
+ export const DAEMON_DIR = join(homedir(), ".local", "share", "quest-dev");
18
+ export const DAEMON_JSON = join(DAEMON_DIR, "daemon.json");
19
+
20
+ export interface DaemonInfo {
21
+ pid: number;
22
+ port: number;
23
+ startedAt: string;
24
+ }
25
+
26
+ export interface StartDaemonOptions {
27
+ port: number;
28
+ device?: string;
29
+ host?: string;
30
+ idleTimeout?: number;
31
+ lowBattery?: number;
32
+ }
33
+
34
+ export async function startDaemon(opts: StartDaemonOptions): Promise<void> {
35
+ const { port } = opts;
36
+ const config = loadConfig();
37
+ const idleTimeout = opts.idleTimeout ?? config.idleTimeout ?? 300000;
38
+ const lowBattery = opts.lowBattery ?? config.lowBattery ?? 10;
39
+ const device = opts.device ?? config.device;
40
+ const host = opts.host ?? config.host ?? "127.0.0.1";
41
+ if (device) {
42
+ setAdbDevice(device);
43
+ }
44
+
45
+ const stayAwake = new StayAwakeManager();
46
+ const logcat = new LogcatManager();
47
+ const castManager = new CastManager(device);
48
+
49
+ // Idle timer
50
+ let idleHandle: NodeJS.Timeout | null = null;
51
+
52
+ const resetIdleTimer = () => {
53
+ if (idleHandle) clearTimeout(idleHandle);
54
+ idleHandle = setTimeout(() => {
55
+ console.log("Idle timeout reached, shutting down daemon...");
56
+ shutdown();
57
+ }, idleTimeout);
58
+ };
59
+
60
+ // Battery monitor
61
+ let batteryInterval: NodeJS.Timer | null = null;
62
+ let lastReportedBucket = -1;
63
+
64
+ const startBatteryMonitor = () => {
65
+ batteryInterval = setInterval(async () => {
66
+ try {
67
+ const battery = await getBatteryInfo();
68
+ const currentBucket = Math.floor(battery.level / 5) * 5;
69
+ if (currentBucket !== lastReportedBucket) {
70
+ verbose(`Battery: ${battery.level}% ${battery.state}`);
71
+ lastReportedBucket = currentBucket;
72
+ }
73
+ if (battery.level <= lowBattery && battery.state === "not charging") {
74
+ console.log(
75
+ `Battery critically low (${battery.level}%), shutting down daemon...`,
76
+ );
77
+ shutdown();
78
+ }
79
+ } catch {
80
+ // Device might be unavailable
81
+ }
82
+ }, 60000);
83
+ };
84
+
85
+ // Cleanup + shutdown
86
+ let shutdownInProgress = false;
87
+
88
+ const shutdown = async () => {
89
+ if (shutdownInProgress) return;
90
+ shutdownInProgress = true;
91
+
92
+ console.log("Daemon shutting down...");
93
+
94
+ if (idleHandle) clearTimeout(idleHandle);
95
+ if (batteryInterval) clearInterval(batteryInterval as NodeJS.Timeout);
96
+
97
+ // Stop cast
98
+ castManager.cleanup();
99
+
100
+ // Restore stay-awake
101
+ stayAwake.cleanupSync();
102
+
103
+ // Stop logcat
104
+ logcat.cleanup();
105
+
106
+ // Remove daemon.json
107
+ try {
108
+ unlinkSync(DAEMON_JSON);
109
+ } catch {
110
+ // Might already be gone
111
+ }
112
+
113
+ console.log("Daemon stopped.");
114
+ process.exit(0);
115
+ };
116
+
117
+ // Signal handlers
118
+ process.on("SIGINT", shutdown);
119
+ process.on("SIGTERM", shutdown);
120
+ process.on("SIGHUP", shutdown);
121
+
122
+ // SIGUSR1 resets idle timer (used by Claude Code hooks)
123
+ process.on("SIGUSR1", () => {
124
+ const now = new Date().toLocaleTimeString();
125
+ console.log(`[${now}] Activity detected, resetting idle timer`);
126
+ resetIdleTimer();
127
+ });
128
+
129
+ // Start server
130
+ const server = await createDaemonServer({
131
+ port,
132
+ host,
133
+ stayAwake,
134
+ logcat,
135
+ castManager,
136
+ onActivity: resetIdleTimer,
137
+ onShutdown: () => shutdown(),
138
+ });
139
+
140
+ // Get the actual port (Fastify resolves it)
141
+ const address = server.server.address();
142
+ const actualPort =
143
+ typeof address === "object" && address ? address.port : port;
144
+
145
+ // Write daemon.json
146
+ mkdirSync(DAEMON_DIR, { recursive: true });
147
+ const info: DaemonInfo = {
148
+ pid: process.pid,
149
+ port: actualPort,
150
+ startedAt: new Date().toISOString(),
151
+ };
152
+ writeFileSync(DAEMON_JSON, JSON.stringify(info, null, 2) + "\n");
153
+
154
+ console.log(
155
+ `quest-dev daemon started (PID: ${process.pid}, port: ${actualPort})`,
156
+ );
157
+ console.log(
158
+ `Idle timeout: ${Math.round(idleTimeout / 1000)}s, low battery: ${lowBattery}%`,
159
+ );
160
+
161
+ // Start idle timer + battery monitor
162
+ resetIdleTimer();
163
+ startBatteryMonitor();
164
+
165
+ // Keep process alive
166
+ await new Promise<void>((resolve) => {
167
+ process.on("exit", () => resolve());
168
+ });
169
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Deploy handler for the daemon.
3
+ * Extracts package name from APK, installs, launches, checks for crashes.
4
+ */
5
+
6
+ import { resolve } from "node:path";
7
+ import { existsSync, statSync } from "node:fs";
8
+ import { spawn } from "node:child_process";
9
+ import { execCommand, execCommandFull, execCommandStreaming } from "../utils/exec.js";
10
+ import type { ExecResult } from "../utils/exec.js";
11
+ import { verbose } from "../utils/verbose.js";
12
+ import { adbArgs } from "../utils/adb.js";
13
+ import type { StayAwakeManager } from "./stay-awake-manager.js";
14
+ import type { LogcatManager } from "./logcat-manager.js";
15
+
16
+ export interface DeployOptions {
17
+ apkPath: string;
18
+ crashWaitMs?: number;
19
+ pin?: string;
20
+ }
21
+
22
+ export interface InstallInfo {
23
+ incremental: boolean;
24
+ blocksTransferred?: number;
25
+ totalBlocks?: number;
26
+ bytesTransferred?: number;
27
+ installSecs: number;
28
+ apkSizeMB: number;
29
+ }
30
+
31
+ export type DeployResult =
32
+ | { ok: true; package: string; crashed: false; logcatFile: string; install?: InstallInfo }
33
+ | { ok: false; package: string; crashed: true; logcatFile: string; logcatLines?: string[]; error?: string }
34
+ | { ok: false; package: string; crashed: false; error: string; logcatFile?: string };
35
+
36
+ /**
37
+ * Extract package name from APK using aapt2 or aapt
38
+ */
39
+ async function extractPackageName(apkPath: string): Promise<string> {
40
+ // Try aapt2 first, then aapt
41
+ for (const tool of ["aapt2", "aapt"]) {
42
+ try {
43
+ const output = await execCommand(tool, ["dump", "badging", apkPath]);
44
+ const match = output.match(/package:\s*name='([^']+)'/);
45
+ if (match) {
46
+ return match[1];
47
+ }
48
+ } catch {
49
+ verbose(`${tool} not found or failed, trying next`);
50
+ }
51
+ }
52
+
53
+ // Fallback: use adb shell to parse via pm on device (after install)
54
+ // But we need it before install, so try apkreader
55
+ try {
56
+ const ApkReader = (await import("adbkit-apkreader")).default;
57
+ const reader = await ApkReader.open(apkPath);
58
+ const manifest = await reader.readManifest();
59
+ return manifest.package;
60
+ } catch {
61
+ throw new Error(
62
+ "Cannot extract package name from APK. Install aapt2 (Android build-tools) or adbkit-apkreader.",
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Install APK with progress reporting for incremental installs.
69
+ * When .idsig exists, uses ADB_TRACE=incremental to parse block transfer progress.
70
+ */
71
+ interface InstallResult extends ExecResult {
72
+ blocksTransferred: number;
73
+ totalBlocks: number;
74
+ }
75
+
76
+ async function installWithProgress(
77
+ absPath: string,
78
+ adbArgsList: string[],
79
+ hasIdsig: boolean,
80
+ ): Promise<InstallResult> {
81
+ if (!hasIdsig) {
82
+ const result = await execCommandFull("adb", adbArgsList);
83
+ return { ...result, blocksTransferred: 0, totalBlocks: 0 };
84
+ }
85
+
86
+ return new Promise((resolve) => {
87
+ const env = { ...process.env, ADB_TRACE: "incremental" };
88
+ const proc = spawn("adb", adbArgsList, { stdio: "pipe", env });
89
+
90
+ let stdout = "";
91
+ let stderr = "";
92
+ let totalBlocks = 0;
93
+ let lastReported = 0;
94
+ let blocksTransferred = 0;
95
+
96
+ if (proc.stdout) {
97
+ proc.stdout.on("data", (data) => {
98
+ stdout += data.toString();
99
+ });
100
+ }
101
+
102
+ if (proc.stderr) {
103
+ proc.stderr.on("data", (data) => {
104
+ const chunk = data.toString();
105
+ stderr += chunk;
106
+
107
+ // Parse incremental progress: "in priority: 37904 of 52096"
108
+ const matches = chunk.matchAll(/in priority: (\d+) of (\d+)/g);
109
+ for (const match of matches) {
110
+ const current = parseInt(match[1], 10);
111
+ totalBlocks = parseInt(match[2], 10);
112
+ blocksTransferred++;
113
+
114
+ // Report every 10% or every 5000 blocks
115
+ if (totalBlocks > 0 && current - lastReported >= totalBlocks * 0.1) {
116
+ const pct = Math.round((current / totalBlocks) * 100);
117
+ process.stdout.write(`\r Streaming: ${current}/${totalBlocks} blocks (${pct}%)`);
118
+ lastReported = current;
119
+ }
120
+ }
121
+ });
122
+ }
123
+
124
+ proc.on("close", (code) => {
125
+ if (totalBlocks > 0) {
126
+ const kbTransferred = Math.round((blocksTransferred * 4096) / 1024);
127
+ console.log(`\r Transferred: ${blocksTransferred} blocks (~${kbTransferred}KB)`);
128
+ }
129
+ resolve({ stdout, stderr, code: code ?? 1, blocksTransferred, totalBlocks });
130
+ });
131
+
132
+ proc.on("error", (err) => {
133
+ resolve({ stdout, stderr: err.message, code: 1, blocksTransferred: 0, totalBlocks: 0 });
134
+ });
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Run the full deploy sequence.
140
+ */
141
+ export async function deploy(
142
+ options: DeployOptions,
143
+ stayAwake: StayAwakeManager,
144
+ logcat: LogcatManager,
145
+ ): Promise<DeployResult> {
146
+ const { apkPath, crashWaitMs = 5000, pin } = options;
147
+ const absPath = resolve(apkPath);
148
+
149
+ // Keep Quest awake FIRST — before anything else touches ADB.
150
+ // Large APK uploads over WiFi ADB fail if the Quest sleeps mid-transfer.
151
+ if (!stayAwake.isEnabled && pin) {
152
+ try {
153
+ await stayAwake.enable(pin);
154
+ } catch (error) {
155
+ console.warn("Failed to enable stay-awake:", (error as Error).message);
156
+ }
157
+ }
158
+
159
+ // Validate APK exists
160
+ if (!existsSync(absPath)) {
161
+ return { ok: false, package: "", crashed: false, error: `APK not found: ${absPath}` };
162
+ }
163
+
164
+ // Warn if APK is stale (older than 1 minute — probably deploying old code)
165
+ const apkAge = Date.now() - statSync(absPath).mtimeMs;
166
+ if (apkAge > 60_000) {
167
+ const mins = Math.floor(apkAge / 60_000);
168
+ const secs = Math.floor((apkAge % 60_000) / 1000);
169
+ console.warn(`\n⚠️ APK is ${mins}m${secs}s old — you may be deploying stale code!\n`);
170
+ }
171
+
172
+ // Extract package name
173
+ let packageName: string;
174
+ try {
175
+ packageName = await extractPackageName(absPath);
176
+ console.log(`Package: ${packageName}`);
177
+ } catch (error) {
178
+ return {
179
+ ok: false,
180
+ package: "",
181
+ crashed: false,
182
+ error: (error as Error).message,
183
+ };
184
+ }
185
+
186
+ // Force-stop existing app
187
+ try {
188
+ await execCommand("adb", adbArgs("shell", "am", "force-stop", packageName));
189
+ verbose(`Force-stopped ${packageName}`);
190
+ } catch {
191
+ // App might not be running
192
+ }
193
+
194
+ // Install APK
195
+ const apkSizeMB = (statSync(absPath).size / 1_048_576).toFixed(1);
196
+ const hasIdsig = existsSync(`${absPath}.idsig`);
197
+ console.log(`Installing APK (${apkSizeMB} MB)${hasIdsig ? " [incremental]" : ""}...`);
198
+ const installStart = Date.now();
199
+ const installResult = await installWithProgress(
200
+ absPath,
201
+ adbArgs("install", "-r", absPath),
202
+ hasIdsig,
203
+ );
204
+ const installSecs = ((Date.now() - installStart) / 1000).toFixed(1);
205
+ verbose("Install stdout:", installResult.stdout.trim());
206
+ verbose("Install stderr:", installResult.stderr.trim());
207
+ if (installResult.code !== 0) {
208
+ const detail = [installResult.stdout.trim(), installResult.stderr.trim()]
209
+ .filter(Boolean)
210
+ .join("\n");
211
+ return {
212
+ ok: false,
213
+ package: packageName,
214
+ crashed: false,
215
+ error: `Install failed (exit ${installResult.code}):\n${detail}`,
216
+ };
217
+ }
218
+ const apkSizeNum = parseFloat(apkSizeMB);
219
+ const installInfo: InstallInfo = {
220
+ incremental: hasIdsig,
221
+ installSecs: parseFloat(installSecs),
222
+ apkSizeMB: apkSizeNum,
223
+ ...(installResult.totalBlocks > 0 ? {
224
+ blocksTransferred: installResult.blocksTransferred,
225
+ totalBlocks: installResult.totalBlocks,
226
+ bytesTransferred: installResult.blocksTransferred * 4096,
227
+ } : {}),
228
+ };
229
+ console.log(`APK installed (${installSecs}s)`);
230
+
231
+ // Start logcat capture (clears buffer first)
232
+ await logcat.start();
233
+ const logcatFile = logcat.status().file;
234
+ if (!logcatFile) throw new Error("logcat started but no file created");
235
+
236
+ // Launch app
237
+ console.log("Launching app...");
238
+ try {
239
+ // Try to launch via monkey (works for any app with a launcher activity)
240
+ await execCommand("adb", adbArgs(
241
+ "shell",
242
+ "monkey",
243
+ "-p",
244
+ packageName,
245
+ "-c",
246
+ "android.intent.category.LAUNCHER",
247
+ "1",
248
+ ));
249
+ verbose(`Launched ${packageName}`);
250
+ } catch (error) {
251
+ return {
252
+ ok: false,
253
+ package: packageName,
254
+ crashed: false,
255
+ logcatFile,
256
+ error: `Launch failed: ${(error as Error).message}`,
257
+ };
258
+ }
259
+
260
+ // Wait for potential crash
261
+ console.log(`Waiting ${crashWaitMs}ms for crash check...`);
262
+ await new Promise((r) => setTimeout(r, crashWaitMs));
263
+
264
+ // Check for crash in logcat
265
+ const { crashed, lines, reason, matchedLine, matchedPattern } = logcat.scanForCrash(200, packageName);
266
+
267
+ if (crashed) {
268
+ const detail = [
269
+ `Crash reason: ${reason ?? "unknown"}`,
270
+ `Matched pattern: /${matchedPattern}/`,
271
+ `Triggered by line: ${matchedLine}`,
272
+ ].join("\n");
273
+ return {
274
+ ok: false,
275
+ package: packageName,
276
+ crashed: true,
277
+ logcatLines: lines,
278
+ logcatFile,
279
+ error: detail,
280
+ };
281
+ }
282
+
283
+ // Verify process is still running
284
+ const psResult = await execCommandFull("adb", adbArgs(
285
+ "shell",
286
+ "pidof",
287
+ packageName,
288
+ ));
289
+ const pid = psResult.stdout.trim();
290
+ const processAlive = psResult.code === 0 && pid.length > 0;
291
+
292
+ if (!processAlive) {
293
+ // Process died without obvious crash pattern
294
+ const tail = logcat.readTail(100);
295
+ return {
296
+ ok: false,
297
+ package: packageName,
298
+ crashed: true,
299
+ logcatLines: tail,
300
+ logcatFile,
301
+ error: `Process not running (pidof exit=${psResult.code}, stdout="${pid}", stderr="${psResult.stderr.trim()}")`,
302
+ };
303
+ }
304
+
305
+ console.log(`Deploy successful: ${packageName} is running`);
306
+ return { ok: true, package: packageName, crashed: false, logcatFile, install: installInfo };
307
+ }