portholejs 0.1.0 → 0.2.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/LICENSE +186 -0
- package/README.md +285 -0
- package/client-dist/assets/index-DluwPdUK.js +49 -0
- package/client-dist/index.html +1 -1
- package/dist/cli.js +351 -83
- package/dist/cli.js.map +1 -1
- package/dist/control-client.d.ts +1 -0
- package/dist/control-client.d.ts.map +1 -1
- package/dist/control-client.js +10 -2
- package/dist/control-client.js.map +1 -1
- package/dist/engine/scrcpy-engine.d.ts +2 -2
- package/dist/engine/scrcpy-engine.d.ts.map +1 -1
- package/dist/engine/scrcpy-engine.js.map +1 -1
- package/dist/engine/types.d.ts +3 -8
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/focus-navigation.d.ts +27 -0
- package/dist/focus-navigation.d.ts.map +1 -0
- package/dist/focus-navigation.js +118 -0
- package/dist/focus-navigation.js.map +1 -0
- package/dist/gesture.d.ts +14 -0
- package/dist/gesture.d.ts.map +1 -0
- package/dist/gesture.js +110 -0
- package/dist/gesture.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/input-validation.d.ts.map +1 -1
- package/dist/input-validation.js +4 -56
- package/dist/input-validation.js.map +1 -1
- package/dist/input.d.ts +1 -21
- package/dist/input.d.ts.map +1 -1
- package/dist/keycodes.d.ts +2 -1
- package/dist/keycodes.d.ts.map +1 -1
- package/dist/keycodes.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +183 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mp4-writer.d.ts +18 -0
- package/dist/mp4-writer.d.ts.map +1 -0
- package/dist/mp4-writer.js +195 -0
- package/dist/mp4-writer.js.map +1 -0
- package/dist/protocol.d.ts +74 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +154 -7
- package/dist/protocol.js.map +1 -1
- package/dist/recording.d.ts +22 -0
- package/dist/recording.d.ts.map +1 -0
- package/dist/recording.js +132 -0
- package/dist/recording.js.map +1 -0
- package/dist/screen-diff.d.ts +21 -0
- package/dist/screen-diff.d.ts.map +1 -0
- package/dist/screen-diff.js +70 -0
- package/dist/screen-diff.js.map +1 -0
- package/dist/server/http.d.ts +5 -4
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +69 -31
- package/dist/server/http.js.map +1 -1
- package/dist/server/ws.d.ts +4 -3
- package/dist/server/ws.d.ts.map +1 -1
- package/dist/server/ws.js +32 -18
- package/dist/server/ws.js.map +1 -1
- package/dist/session.d.ts +25 -10
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +117 -67
- package/dist/session.js.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +1 -3
- package/dist/state.js.map +1 -1
- package/package.json +18 -3
- package/skills/porthole/SKILL.md +130 -0
- package/client-dist/assets/index-C_qSS_Gy.js +0 -49
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
import { decodeVideoPacket } from "./protocol.js";
|
|
4
|
+
import { discoverSession } from "./control-client.js";
|
|
5
|
+
import { createMp4 } from "./mp4-writer.js";
|
|
6
|
+
export async function recordSession(opts) {
|
|
7
|
+
const session = await discoverSession(opts);
|
|
8
|
+
const health = await fetchHealth(session.url, session.serial);
|
|
9
|
+
const url = websocketUrl(session.url, session.serial);
|
|
10
|
+
const recorder = new H264Recorder(health.width, health.height);
|
|
11
|
+
const startedAt = Date.now();
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
const ws = new WebSocket(url);
|
|
14
|
+
let done = false;
|
|
15
|
+
const finish = () => {
|
|
16
|
+
if (done)
|
|
17
|
+
return;
|
|
18
|
+
done = true;
|
|
19
|
+
ws.close();
|
|
20
|
+
resolve();
|
|
21
|
+
};
|
|
22
|
+
const fail = (error) => {
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
done = true;
|
|
26
|
+
ws.close();
|
|
27
|
+
reject(error);
|
|
28
|
+
};
|
|
29
|
+
const timeout = opts.durationMs === undefined ? undefined : setTimeout(finish, opts.durationMs);
|
|
30
|
+
ws.on("message", (data) => {
|
|
31
|
+
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
|
32
|
+
recorder.addPacket(buffer);
|
|
33
|
+
});
|
|
34
|
+
ws.on("error", fail);
|
|
35
|
+
ws.on("close", () => {
|
|
36
|
+
if (timeout)
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
if (!done && opts.durationMs === undefined)
|
|
39
|
+
resolve();
|
|
40
|
+
});
|
|
41
|
+
process.once("SIGINT", finish);
|
|
42
|
+
});
|
|
43
|
+
const durationMs = Date.now() - startedAt;
|
|
44
|
+
const mp4 = recorder.finalize(durationMs);
|
|
45
|
+
await writeFile(opts.output, mp4);
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
path: opts.output,
|
|
49
|
+
samples: recorder.sampleCount,
|
|
50
|
+
durationMs,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export class H264Recorder {
|
|
54
|
+
width;
|
|
55
|
+
height;
|
|
56
|
+
#config = null;
|
|
57
|
+
#samples = [];
|
|
58
|
+
#started = false;
|
|
59
|
+
constructor(width, height) {
|
|
60
|
+
this.width = width;
|
|
61
|
+
this.height = height;
|
|
62
|
+
}
|
|
63
|
+
get sampleCount() {
|
|
64
|
+
return this.#samples.length;
|
|
65
|
+
}
|
|
66
|
+
addPacket(data) {
|
|
67
|
+
const packet = decodeVideoPacket(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
|
|
68
|
+
if (packet.type === "config") {
|
|
69
|
+
this.#config = packet.data;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const keyframe = packet.type === "key" || hasIdrNal(packet.data);
|
|
73
|
+
if (!this.#started) {
|
|
74
|
+
if (!keyframe)
|
|
75
|
+
return;
|
|
76
|
+
this.#started = true;
|
|
77
|
+
}
|
|
78
|
+
this.#samples.push({
|
|
79
|
+
data: packet.data,
|
|
80
|
+
timestamp: packet.timestamp || this.#samples.length * 33_333,
|
|
81
|
+
keyframe,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
finalize(durationMs) {
|
|
85
|
+
if (!this.#config)
|
|
86
|
+
throw new Error("Recording did not receive H.264 config data.");
|
|
87
|
+
if (this.#samples.length === 0)
|
|
88
|
+
throw new Error("Recording did not receive a keyframe.");
|
|
89
|
+
return createMp4({
|
|
90
|
+
width: this.width,
|
|
91
|
+
height: this.height,
|
|
92
|
+
durationMs,
|
|
93
|
+
config: this.#config,
|
|
94
|
+
samples: this.#samples,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function fetchHealth(sessionUrl, deviceId) {
|
|
99
|
+
const url = new URL(sessionUrl);
|
|
100
|
+
url.pathname = "/health";
|
|
101
|
+
url.searchParams.set("device", deviceId);
|
|
102
|
+
const response = await fetch(url);
|
|
103
|
+
if (!response.ok)
|
|
104
|
+
throw new Error(`Unable to read session health: ${response.status}`);
|
|
105
|
+
const body = (await response.json());
|
|
106
|
+
if (!body.width || !body.height)
|
|
107
|
+
throw new Error("Session health does not include video dimensions.");
|
|
108
|
+
return { width: body.width, height: body.height };
|
|
109
|
+
}
|
|
110
|
+
function websocketUrl(sessionUrl, deviceId) {
|
|
111
|
+
const url = new URL(sessionUrl);
|
|
112
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
113
|
+
url.pathname = "/ws";
|
|
114
|
+
url.searchParams.set("device", deviceId);
|
|
115
|
+
return url.toString();
|
|
116
|
+
}
|
|
117
|
+
function hasIdrNal(data) {
|
|
118
|
+
for (let i = 0; i < data.length - 4; i++) {
|
|
119
|
+
if (data[i] === 0 && data[i + 1] === 0) {
|
|
120
|
+
let offset = i + 2;
|
|
121
|
+
if (data[offset] === 0)
|
|
122
|
+
offset++;
|
|
123
|
+
if (data[offset] === 1) {
|
|
124
|
+
const nalByte = data[offset + 1];
|
|
125
|
+
if (nalByte !== undefined && (nalByte & 0x1f) === 5)
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=recording.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recording.js","sourceRoot":"","sources":["../src/recording.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface DiffRegion {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ScreenDiffOptions {
|
|
8
|
+
thresholdRatio?: number;
|
|
9
|
+
region?: DiffRegion;
|
|
10
|
+
pixelThreshold?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ScreenDiffResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
mismatchRatio: number;
|
|
15
|
+
mismatchPixels: number;
|
|
16
|
+
comparedPixels: number;
|
|
17
|
+
diffPng?: Uint8Array;
|
|
18
|
+
}
|
|
19
|
+
export declare function comparePngScreens(baselinePng: Uint8Array, currentPng: Uint8Array, opts?: ScreenDiffOptions): ScreenDiffResult;
|
|
20
|
+
export declare function parseDiffRegion(value: string): DiffRegion;
|
|
21
|
+
//# sourceMappingURL=screen-diff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"screen-diff.d.ts","sourceRoot":"","sources":["../src/screen-diff.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,OAAO,CAAC;IACZ,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,UAAU,CAAC;CACtB;AAED,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,UAAU,EACvB,UAAU,EAAE,UAAU,EACtB,IAAI,GAAE,iBAAsB,GAC3B,gBAAgB,CAiClB;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAqBzD"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
import pixelmatch from "pixelmatch";
|
|
3
|
+
export function comparePngScreens(baselinePng, currentPng, opts = {}) {
|
|
4
|
+
const baseline = PNG.sync.read(Buffer.from(baselinePng));
|
|
5
|
+
const current = PNG.sync.read(Buffer.from(currentPng));
|
|
6
|
+
if (baseline.width !== current.width || baseline.height !== current.height) {
|
|
7
|
+
throw new Error(`Screenshot dimensions differ: baseline ${baseline.width}x${baseline.height}, current ${current.width}x${current.height}.`);
|
|
8
|
+
}
|
|
9
|
+
const region = normalizeRegion(opts.region, baseline.width, baseline.height);
|
|
10
|
+
const baselineData = region
|
|
11
|
+
? cropRgba(baseline.data, baseline.width, region)
|
|
12
|
+
: baseline.data;
|
|
13
|
+
const currentData = region
|
|
14
|
+
? cropRgba(current.data, current.width, region)
|
|
15
|
+
: current.data;
|
|
16
|
+
const width = region?.width ?? baseline.width;
|
|
17
|
+
const height = region?.height ?? baseline.height;
|
|
18
|
+
const diff = new PNG({ width, height });
|
|
19
|
+
const mismatchPixels = pixelmatch(baselineData, currentData, diff.data, width, height, {
|
|
20
|
+
threshold: opts.pixelThreshold ?? 0.1,
|
|
21
|
+
});
|
|
22
|
+
const comparedPixels = width * height;
|
|
23
|
+
const mismatchRatio = comparedPixels === 0 ? 0 : mismatchPixels / comparedPixels;
|
|
24
|
+
const thresholdRatio = opts.thresholdRatio ?? 0.02;
|
|
25
|
+
return {
|
|
26
|
+
ok: mismatchRatio <= thresholdRatio,
|
|
27
|
+
mismatchRatio,
|
|
28
|
+
mismatchPixels,
|
|
29
|
+
comparedPixels,
|
|
30
|
+
diffPng: PNG.sync.write(diff),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function parseDiffRegion(value) {
|
|
34
|
+
const parts = value.split(",").map((part) => Number(part.trim()));
|
|
35
|
+
const [x, y, width, height] = parts;
|
|
36
|
+
if (parts.length !== 4 ||
|
|
37
|
+
x === undefined ||
|
|
38
|
+
y === undefined ||
|
|
39
|
+
width === undefined ||
|
|
40
|
+
height === undefined ||
|
|
41
|
+
parts.some((part) => !Number.isInteger(part) || part < 0) ||
|
|
42
|
+
width === 0 ||
|
|
43
|
+
height === 0) {
|
|
44
|
+
throw new Error("Region must be x,y,w,h using positive integer width/height.");
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
x,
|
|
48
|
+
y,
|
|
49
|
+
width,
|
|
50
|
+
height,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function normalizeRegion(region, imageWidth, imageHeight) {
|
|
54
|
+
if (!region)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (region.x + region.width > imageWidth || region.y + region.height > imageHeight) {
|
|
57
|
+
throw new Error(`Region ${region.x},${region.y},${region.width},${region.height} exceeds screenshot ${imageWidth}x${imageHeight}.`);
|
|
58
|
+
}
|
|
59
|
+
return region;
|
|
60
|
+
}
|
|
61
|
+
function cropRgba(data, sourceWidth, region) {
|
|
62
|
+
const result = new Uint8Array(region.width * region.height * 4);
|
|
63
|
+
for (let y = 0; y < region.height; y++) {
|
|
64
|
+
const sourceStart = ((region.y + y) * sourceWidth + region.x) * 4;
|
|
65
|
+
const sourceEnd = sourceStart + region.width * 4;
|
|
66
|
+
result.set(data.subarray(sourceStart, sourceEnd), y * region.width * 4);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=screen-diff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"screen-diff.js","sourceRoot":"","sources":["../src/screen-diff.ts"],"names":[],"mappings":"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"}
|
package/dist/server/http.d.ts
CHANGED
|
@@ -6,12 +6,13 @@ export interface HttpServerOptions {
|
|
|
6
6
|
port: number;
|
|
7
7
|
host: string;
|
|
8
8
|
clientDir: string;
|
|
9
|
-
getEngine: () => Engine | null;
|
|
10
|
-
getDevice?: () => DeviceInfo;
|
|
11
|
-
|
|
9
|
+
getEngine: (deviceId?: string) => Engine | null;
|
|
10
|
+
getDevice?: (deviceId?: string) => DeviceInfo | undefined;
|
|
11
|
+
getDevices?: () => DeviceInfo[];
|
|
12
|
+
handleInput?: (event: InputEvent, deviceId?: string) => Promise<void>;
|
|
12
13
|
token?: string;
|
|
13
14
|
forceMjpeg?: boolean;
|
|
14
|
-
getStatus?: () => "waiting" | "ok" | "reconnecting" | "dead";
|
|
15
|
+
getStatus?: (deviceId?: string) => "waiting" | "ok" | "reconnecting" | "dead";
|
|
15
16
|
}
|
|
16
17
|
export declare function createHttpServer(opts: HttpServerOptions): {
|
|
17
18
|
server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AAOpF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AAOpF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAoBvD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAChD,SAAS,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,KAAK,UAAU,GAAG,SAAS,CAAC;IAC1D,UAAU,CAAC,EAAE,MAAM,UAAU,EAAE,CAAC;IAChC,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,cAAc,GAAG,MAAM,CAAC;CAC/E;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB;;;EA0gBvD"}
|
package/dist/server/http.js
CHANGED
|
@@ -6,11 +6,13 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
import { adbBin, findAndroidSdk, listDevices } from "../device-manager.js";
|
|
8
8
|
import { assertInputAllowed, parseInputEvent } from "../input-validation.js";
|
|
9
|
+
import { sendGesture } from "../gesture.js";
|
|
9
10
|
import { readState } from "../state.js";
|
|
10
11
|
import { MjpegPoller } from "./mjpeg.js";
|
|
11
12
|
import { clearApp, openUrl, stopApp } from "../adb-actions.js";
|
|
12
13
|
import { parseCrashes } from "../crashes.js";
|
|
13
14
|
import { dumpUi, findElement, getFocusedNode, waitForUiText } from "../ui-tree.js";
|
|
15
|
+
import { focusOn } from "../focus-navigation.js";
|
|
14
16
|
const execFileAsync = promisify(execFile);
|
|
15
17
|
const MIME_TYPES = {
|
|
16
18
|
".html": "text/html",
|
|
@@ -22,11 +24,12 @@ const MIME_TYPES = {
|
|
|
22
24
|
".ico": "image/x-icon",
|
|
23
25
|
};
|
|
24
26
|
export function createHttpServer(opts) {
|
|
25
|
-
const { port, host, clientDir, getEngine, getDevice, handleInput, token, forceMjpeg, getStatus, } = opts;
|
|
26
|
-
const
|
|
27
|
+
const { port, host, clientDir, getEngine, getDevice, getDevices, handleInput, token, forceMjpeg, getStatus, } = opts;
|
|
28
|
+
const mjpegPollers = new Map();
|
|
27
29
|
const server = createServer(async (req, res) => {
|
|
28
30
|
const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
|
|
29
31
|
const url = parsedUrl.pathname;
|
|
32
|
+
const deviceId = parsedUrl.searchParams.get("device") ?? undefined;
|
|
30
33
|
const authorized = isAuthorized(req, parsedUrl, token);
|
|
31
34
|
if (!authorized) {
|
|
32
35
|
await sendJson(res, 401, { ok: false, error: "Porthole token required." });
|
|
@@ -37,12 +40,12 @@ export function createHttpServer(opts) {
|
|
|
37
40
|
res.setHeader("Set-Cookie", `porthole_token=${token}; Path=/; SameSite=Lax`);
|
|
38
41
|
}
|
|
39
42
|
if (url === "/health") {
|
|
40
|
-
const engine = getEngine();
|
|
43
|
+
const engine = getEngine(deviceId);
|
|
41
44
|
if (engine?.metadata) {
|
|
42
45
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
43
46
|
res.end(JSON.stringify({
|
|
44
|
-
status: getStatus?.() ?? "ok",
|
|
45
|
-
device: getDevice?.(),
|
|
47
|
+
status: getStatus?.(deviceId) ?? "ok",
|
|
48
|
+
device: getDevice?.(deviceId),
|
|
46
49
|
videoModes: ["webcodecs", "mjpeg"],
|
|
47
50
|
preferredVideoMode: forceMjpeg ? "mjpeg" : "webcodecs",
|
|
48
51
|
...engine.metadata,
|
|
@@ -50,22 +53,28 @@ export function createHttpServer(opts) {
|
|
|
50
53
|
}
|
|
51
54
|
else {
|
|
52
55
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
53
|
-
res.end(JSON.stringify({ status: getStatus?.() ?? "waiting" }));
|
|
56
|
+
res.end(JSON.stringify({ status: getStatus?.(deviceId) ?? "waiting" }));
|
|
54
57
|
}
|
|
55
58
|
return;
|
|
56
59
|
}
|
|
57
60
|
if (url === "/stream.mjpeg" && req.method === "GET") {
|
|
58
|
-
const engine = getEngine();
|
|
61
|
+
const engine = getEngine(deviceId);
|
|
59
62
|
if (!engine) {
|
|
60
63
|
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
61
64
|
res.end("No engine");
|
|
62
65
|
return;
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
const pollerKey = deviceId ?? getDevice?.()?.serial ?? "default";
|
|
68
|
+
let poller = mjpegPollers.get(pollerKey);
|
|
69
|
+
if (!poller) {
|
|
70
|
+
poller = new MjpegPoller(() => getEngine(deviceId));
|
|
71
|
+
mjpegPollers.set(pollerKey, poller);
|
|
72
|
+
}
|
|
73
|
+
poller.addClient(res);
|
|
65
74
|
return;
|
|
66
75
|
}
|
|
67
76
|
if (url === "/screenshot") {
|
|
68
|
-
const engine = getEngine();
|
|
77
|
+
const engine = getEngine(deviceId);
|
|
69
78
|
if (!engine) {
|
|
70
79
|
res.writeHead(503);
|
|
71
80
|
res.end("No engine");
|
|
@@ -86,19 +95,20 @@ export function createHttpServer(opts) {
|
|
|
86
95
|
return;
|
|
87
96
|
}
|
|
88
97
|
if (url === "/api/devices" && req.method === "GET") {
|
|
89
|
-
await sendJson(res, 200, await listDevices());
|
|
98
|
+
await sendJson(res, 200, getDevices?.() ?? (await listDevices()));
|
|
90
99
|
return;
|
|
91
100
|
}
|
|
92
101
|
if (url === "/api/state" && req.method === "GET") {
|
|
93
102
|
await sendJson(res, 200, {
|
|
94
|
-
device: getDevice?.() ?? null,
|
|
95
|
-
|
|
103
|
+
device: getDevice?.(deviceId) ?? null,
|
|
104
|
+
devices: getDevices?.() ?? [],
|
|
105
|
+
engine: getEngine(deviceId)?.metadata ?? null,
|
|
96
106
|
state: await readState(),
|
|
97
107
|
});
|
|
98
108
|
return;
|
|
99
109
|
}
|
|
100
110
|
if (url === "/api/input" && req.method === "POST") {
|
|
101
|
-
const device = getDevice?.();
|
|
111
|
+
const device = getDevice?.(deviceId);
|
|
102
112
|
if (!device) {
|
|
103
113
|
await sendJson(res, 503, { ok: false, error: "No active device." });
|
|
104
114
|
return;
|
|
@@ -106,12 +116,17 @@ export function createHttpServer(opts) {
|
|
|
106
116
|
try {
|
|
107
117
|
const event = parseInputEvent(await readJson(req));
|
|
108
118
|
assertInputAllowed(device.profile, event);
|
|
109
|
-
const engine = getEngine();
|
|
119
|
+
const engine = getEngine(deviceId);
|
|
110
120
|
if (!handleInput && !engine) {
|
|
111
121
|
throw new Error("No active engine.");
|
|
112
122
|
}
|
|
113
123
|
if (handleInput) {
|
|
114
|
-
await handleInput(event);
|
|
124
|
+
await handleInput(event, deviceId);
|
|
125
|
+
}
|
|
126
|
+
else if (event.kind === "gesture") {
|
|
127
|
+
if (!engine)
|
|
128
|
+
throw new Error("No active engine.");
|
|
129
|
+
await sendGesture(event, (touch) => engine.sendInput(touch));
|
|
115
130
|
}
|
|
116
131
|
else {
|
|
117
132
|
await engine?.sendInput(event);
|
|
@@ -124,7 +139,7 @@ export function createHttpServer(opts) {
|
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
126
141
|
if (url === "/api/logcat" && req.method === "GET") {
|
|
127
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
142
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
128
143
|
const lines = parsedUrl.searchParams.get("lines") ?? "100";
|
|
129
144
|
const filter = parsedUrl.searchParams.get("filter");
|
|
130
145
|
const args = ["-s", serial, "logcat", "-d", "-t", lines];
|
|
@@ -138,7 +153,7 @@ export function createHttpServer(opts) {
|
|
|
138
153
|
return;
|
|
139
154
|
}
|
|
140
155
|
if (url === "/api/crashes" && req.method === "GET") {
|
|
141
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
156
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
142
157
|
const { stdout } = await execFileAsync(adbBin(findAndroidSdk()), [
|
|
143
158
|
"-s",
|
|
144
159
|
serial,
|
|
@@ -152,7 +167,7 @@ export function createHttpServer(opts) {
|
|
|
152
167
|
return;
|
|
153
168
|
}
|
|
154
169
|
if (url === "/api/ui" && req.method === "GET") {
|
|
155
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
170
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
156
171
|
await sendJson(res, 200, {
|
|
157
172
|
ok: true,
|
|
158
173
|
tree: await dumpUi(serial, parsedUrl.searchParams.get("filter") ?? undefined),
|
|
@@ -161,13 +176,36 @@ export function createHttpServer(opts) {
|
|
|
161
176
|
return;
|
|
162
177
|
}
|
|
163
178
|
if (url === "/api/focused" && req.method === "GET") {
|
|
164
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
179
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
165
180
|
await sendJson(res, 200, { ok: true, node: await getFocusedNode(serial) });
|
|
166
181
|
});
|
|
167
182
|
return;
|
|
168
183
|
}
|
|
184
|
+
if (url === "/api/focus_on" && req.method === "POST") {
|
|
185
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
186
|
+
const body = (await readJson(req));
|
|
187
|
+
const text = typeof body.text === "string" ? body.text : undefined;
|
|
188
|
+
const resourceId = typeof body.resourceId === "string" ? body.resourceId : undefined;
|
|
189
|
+
const contentDesc = typeof body.contentDesc === "string" ? body.contentDesc : undefined;
|
|
190
|
+
if (!text && !resourceId && !contentDesc) {
|
|
191
|
+
throw new Error("text, resourceId, or contentDesc is required.");
|
|
192
|
+
}
|
|
193
|
+
const maxSteps = typeof body.maxSteps === "number" && Number.isInteger(body.maxSteps)
|
|
194
|
+
? body.maxSteps
|
|
195
|
+
: undefined;
|
|
196
|
+
const result = await focusOn(serial, { text, resourceId, contentDesc }, async (button) => {
|
|
197
|
+
const event = { kind: "remote", button };
|
|
198
|
+
if (handleInput)
|
|
199
|
+
await handleInput(event, deviceId);
|
|
200
|
+
else
|
|
201
|
+
await getEngine(deviceId)?.sendInput(event);
|
|
202
|
+
}, { select: body.select === true, maxSteps });
|
|
203
|
+
await sendJson(res, 200, { ok: true, result });
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
169
207
|
if (url === "/api/find" && req.method === "POST") {
|
|
170
|
-
await withSerial(res, getDevice, async (serial, device) => {
|
|
208
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial, device) => {
|
|
171
209
|
const body = (await readJson(req));
|
|
172
210
|
const text = typeof body.text === "string" ? body.text : undefined;
|
|
173
211
|
const resourceId = typeof body.resourceId === "string" ? body.resourceId : undefined;
|
|
@@ -179,7 +217,7 @@ export function createHttpServer(opts) {
|
|
|
179
217
|
if (!node.normalizedCenter) {
|
|
180
218
|
throw new Error("Could not determine display size from the UI dump.");
|
|
181
219
|
}
|
|
182
|
-
const engine = getEngine();
|
|
220
|
+
const engine = getEngine(deviceId);
|
|
183
221
|
await engine?.sendInput({
|
|
184
222
|
kind: "touch",
|
|
185
223
|
phase: "down",
|
|
@@ -196,7 +234,7 @@ export function createHttpServer(opts) {
|
|
|
196
234
|
return;
|
|
197
235
|
}
|
|
198
236
|
if (url === "/api/wait_for" && req.method === "POST") {
|
|
199
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
237
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
200
238
|
const body = (await readJson(req));
|
|
201
239
|
if (typeof body.text !== "string")
|
|
202
240
|
throw new Error("text is required.");
|
|
@@ -209,7 +247,7 @@ export function createHttpServer(opts) {
|
|
|
209
247
|
return;
|
|
210
248
|
}
|
|
211
249
|
if (url === "/api/open_url" && req.method === "POST") {
|
|
212
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
250
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
213
251
|
const body = (await readJson(req));
|
|
214
252
|
if (typeof body.url !== "string")
|
|
215
253
|
throw new Error("url is required.");
|
|
@@ -221,7 +259,7 @@ export function createHttpServer(opts) {
|
|
|
221
259
|
return;
|
|
222
260
|
}
|
|
223
261
|
if (url === "/api/stop_app" && req.method === "POST") {
|
|
224
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
262
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
225
263
|
const body = (await readJson(req));
|
|
226
264
|
if (typeof body.packageName !== "string") {
|
|
227
265
|
throw new Error("packageName is required.");
|
|
@@ -232,7 +270,7 @@ export function createHttpServer(opts) {
|
|
|
232
270
|
return;
|
|
233
271
|
}
|
|
234
272
|
if (url === "/api/clear_app" && req.method === "POST") {
|
|
235
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
273
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
236
274
|
const body = (await readJson(req));
|
|
237
275
|
if (typeof body.packageName !== "string") {
|
|
238
276
|
throw new Error("packageName is required.");
|
|
@@ -243,7 +281,7 @@ export function createHttpServer(opts) {
|
|
|
243
281
|
return;
|
|
244
282
|
}
|
|
245
283
|
if (url === "/api/apps" && req.method === "GET") {
|
|
246
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
284
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
247
285
|
const { stdout } = await execFileAsync(adbBin(findAndroidSdk()), [
|
|
248
286
|
"-s",
|
|
249
287
|
serial,
|
|
@@ -261,7 +299,7 @@ export function createHttpServer(opts) {
|
|
|
261
299
|
return;
|
|
262
300
|
}
|
|
263
301
|
if (url === "/api/launch" && req.method === "POST") {
|
|
264
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
302
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
265
303
|
const body = (await readJson(req));
|
|
266
304
|
if (typeof body.packageName !== "string") {
|
|
267
305
|
throw new Error("packageName is required.");
|
|
@@ -280,7 +318,7 @@ export function createHttpServer(opts) {
|
|
|
280
318
|
return;
|
|
281
319
|
}
|
|
282
320
|
if (url === "/api/rotate" && req.method === "POST") {
|
|
283
|
-
await withSerial(res, getDevice, async (serial, device) => {
|
|
321
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial, device) => {
|
|
284
322
|
if (device.profile === "tv") {
|
|
285
323
|
throw new Error("Rotation controls are only available for phone profiles.");
|
|
286
324
|
}
|
|
@@ -313,7 +351,7 @@ export function createHttpServer(opts) {
|
|
|
313
351
|
return;
|
|
314
352
|
}
|
|
315
353
|
if (url === "/api/emu" && req.method === "POST") {
|
|
316
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
354
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
317
355
|
const body = (await readJson(req));
|
|
318
356
|
if (!Array.isArray(body.args) ||
|
|
319
357
|
!body.args.every((arg) => typeof arg === "string")) {
|
|
@@ -330,7 +368,7 @@ export function createHttpServer(opts) {
|
|
|
330
368
|
return;
|
|
331
369
|
}
|
|
332
370
|
if (url === "/api/install" && req.method === "POST") {
|
|
333
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
371
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
334
372
|
const filename = sanitizeFilename(req.headers["x-porthole-filename"] ?? "drop.apk");
|
|
335
373
|
if (!filename.endsWith(".apk")) {
|
|
336
374
|
throw new Error("Only .apk files can be installed.");
|
|
@@ -355,7 +393,7 @@ export function createHttpServer(opts) {
|
|
|
355
393
|
return;
|
|
356
394
|
}
|
|
357
395
|
if (url === "/api/push" && req.method === "POST") {
|
|
358
|
-
await withSerial(res, getDevice, async (serial) => {
|
|
396
|
+
await withSerial(res, () => getDevice?.(deviceId), async (serial) => {
|
|
359
397
|
const filename = sanitizeFilename(req.headers["x-porthole-filename"] ?? "porthole-drop");
|
|
360
398
|
const file = await readBytes(req);
|
|
361
399
|
const tmpPath = join(tmpdir(), `porthole-${process.pid}-${Date.now()}-${filename}`);
|