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.
Files changed (72) hide show
  1. package/LICENSE +186 -0
  2. package/README.md +285 -0
  3. package/client-dist/assets/index-DluwPdUK.js +49 -0
  4. package/client-dist/index.html +1 -1
  5. package/dist/cli.js +351 -83
  6. package/dist/cli.js.map +1 -1
  7. package/dist/control-client.d.ts +1 -0
  8. package/dist/control-client.d.ts.map +1 -1
  9. package/dist/control-client.js +10 -2
  10. package/dist/control-client.js.map +1 -1
  11. package/dist/engine/scrcpy-engine.d.ts +2 -2
  12. package/dist/engine/scrcpy-engine.d.ts.map +1 -1
  13. package/dist/engine/scrcpy-engine.js.map +1 -1
  14. package/dist/engine/types.d.ts +3 -8
  15. package/dist/engine/types.d.ts.map +1 -1
  16. package/dist/focus-navigation.d.ts +27 -0
  17. package/dist/focus-navigation.d.ts.map +1 -0
  18. package/dist/focus-navigation.js +118 -0
  19. package/dist/focus-navigation.js.map +1 -0
  20. package/dist/gesture.d.ts +14 -0
  21. package/dist/gesture.d.ts.map +1 -0
  22. package/dist/gesture.js +110 -0
  23. package/dist/gesture.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/input-validation.d.ts.map +1 -1
  29. package/dist/input-validation.js +4 -56
  30. package/dist/input-validation.js.map +1 -1
  31. package/dist/input.d.ts +1 -21
  32. package/dist/input.d.ts.map +1 -1
  33. package/dist/keycodes.d.ts +2 -1
  34. package/dist/keycodes.d.ts.map +1 -1
  35. package/dist/keycodes.js.map +1 -1
  36. package/dist/mcp/server.d.ts.map +1 -1
  37. package/dist/mcp/server.js +183 -1
  38. package/dist/mcp/server.js.map +1 -1
  39. package/dist/mp4-writer.d.ts +18 -0
  40. package/dist/mp4-writer.d.ts.map +1 -0
  41. package/dist/mp4-writer.js +195 -0
  42. package/dist/mp4-writer.js.map +1 -0
  43. package/dist/protocol.d.ts +74 -2
  44. package/dist/protocol.d.ts.map +1 -1
  45. package/dist/protocol.js +154 -7
  46. package/dist/protocol.js.map +1 -1
  47. package/dist/recording.d.ts +22 -0
  48. package/dist/recording.d.ts.map +1 -0
  49. package/dist/recording.js +132 -0
  50. package/dist/recording.js.map +1 -0
  51. package/dist/screen-diff.d.ts +21 -0
  52. package/dist/screen-diff.d.ts.map +1 -0
  53. package/dist/screen-diff.js +70 -0
  54. package/dist/screen-diff.js.map +1 -0
  55. package/dist/server/http.d.ts +5 -4
  56. package/dist/server/http.d.ts.map +1 -1
  57. package/dist/server/http.js +69 -31
  58. package/dist/server/http.js.map +1 -1
  59. package/dist/server/ws.d.ts +4 -3
  60. package/dist/server/ws.d.ts.map +1 -1
  61. package/dist/server/ws.js +32 -18
  62. package/dist/server/ws.js.map +1 -1
  63. package/dist/session.d.ts +25 -10
  64. package/dist/session.d.ts.map +1 -1
  65. package/dist/session.js +117 -67
  66. package/dist/session.js.map +1 -1
  67. package/dist/state.d.ts.map +1 -1
  68. package/dist/state.js +1 -3
  69. package/dist/state.js.map +1 -1
  70. package/package.json +18 -3
  71. package/skills/porthole/SKILL.md +130 -0
  72. 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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"}
@@ -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
- handleInput?: (event: InputEvent) => Promise<void>;
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;AAE9C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAmBvD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,CAAC,EAAE,MAAM,UAAU,CAAC;IAC7B,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,SAAS,GAAG,IAAI,GAAG,cAAc,GAAG,MAAM,CAAC;CAC9D;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB;;;EAgavD"}
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"}
@@ -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 mjpegPoller = new MjpegPoller(getEngine);
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
- mjpegPoller.addClient(res);
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
- engine: getEngine()?.metadata ?? null,
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}`);