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