serve-emul 0.0.4

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 (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +201 -0
  3. package/README.md +196 -0
  4. package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
  5. package/dist/ui/assets/index-CyUIa9dV.js +42 -0
  6. package/dist/ui/index.html +13 -0
  7. package/package.json +67 -0
  8. package/scripts/fetch-scrcpy.ts +28 -0
  9. package/scripts/release.ts +136 -0
  10. package/src/accessibility.ts +88 -0
  11. package/src/adb.ts +209 -0
  12. package/src/app-info.ts +114 -0
  13. package/src/app-management.ts +150 -0
  14. package/src/cli.ts +149 -0
  15. package/src/emulator.ts +229 -0
  16. package/src/input.ts +258 -0
  17. package/src/location.ts +135 -0
  18. package/src/route-playback.ts +359 -0
  19. package/src/scrcpy.ts +466 -0
  20. package/src/server.ts +1260 -0
  21. package/src/session-recorder.ts +149 -0
  22. package/src/ui/app.tsx +111 -0
  23. package/src/ui/components/accessibility-panel.tsx +113 -0
  24. package/src/ui/components/app-management-panel.tsx +256 -0
  25. package/src/ui/components/control-bar.tsx +24 -0
  26. package/src/ui/components/device-panel.tsx +532 -0
  27. package/src/ui/components/device-stream.tsx +142 -0
  28. package/src/ui/components/location-panel.tsx +584 -0
  29. package/src/ui/components/logcat-panel.tsx +100 -0
  30. package/src/ui/components/session-panel.tsx +127 -0
  31. package/src/ui/components/status-bar.tsx +19 -0
  32. package/src/ui/index.html +12 -0
  33. package/src/ui/lib/h264.ts +35 -0
  34. package/src/ui/lib/use-stream.ts +368 -0
  35. package/src/ui/main.tsx +7 -0
  36. package/src/ui/styles.css +708 -0
  37. package/src/ui/tsconfig.json +17 -0
  38. package/src/update-check.ts +93 -0
  39. package/vendor/scrcpy-server-v2.7 +0 -0
  40. package/vendor/scrcpy-server-v3.1 +0 -0
  41. package/vendor/scrcpy-server-v3.3.4 +0 -0
  42. package/vendor/scrcpy-server-v4.0 +0 -0
package/src/input.ts ADDED
@@ -0,0 +1,258 @@
1
+ import type { Socket } from "node:net";
2
+
3
+ // scrcpy v3 ControlMessage type codes
4
+ const TYPE_INJECT_KEYCODE = 0;
5
+ const TYPE_INJECT_TEXT = 1;
6
+ const TYPE_INJECT_TOUCH = 2;
7
+ const TYPE_BACK_OR_SCREEN_ON = 4;
8
+ const TYPE_RESET_VIDEO = 17;
9
+
10
+ export function resetVideoPacket(): Buffer {
11
+ return RESET_VIDEO_PACKET;
12
+ }
13
+
14
+ // Android KeyEvent action
15
+ const ACTION_DOWN = 0;
16
+ const ACTION_UP = 1;
17
+ const ACTION_MOVE = 2;
18
+
19
+ // Common Android keycodes
20
+ const KEY = {
21
+ back: 4,
22
+ home: 3,
23
+ recents: 187,
24
+ power: 26,
25
+ enter: 66,
26
+ } as const;
27
+
28
+ const PRIMARY_POINTER_ID = 0n;
29
+ const PRESSURE_FULL = 0xffff;
30
+ const BUTTON_PRIMARY = 1;
31
+ const RESET_VIDEO_PACKET = Buffer.from([TYPE_RESET_VIDEO]);
32
+
33
+ export type Gesture =
34
+ | { type: "tap"; x: number; y: number }
35
+ | { type: "swipe"; x1: number; y1: number; x2: number; y2: number; durationMs?: number }
36
+ | { type: "touch"; action: "down" | "move" | "up"; x: number; y: number; pointerId?: number }
37
+ | { type: "key"; keycode: number }
38
+ | { type: "text"; text: string }
39
+ | { type: "back" }
40
+ | { type: "home" }
41
+ | { type: "recents" }
42
+ | { type: "power" };
43
+
44
+ export type Screen = { width: number; height: number };
45
+
46
+ const MAX_TEXT_BYTES = 300;
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ function finiteNumber(value: unknown, name: string): number {
53
+ if (typeof value !== "number" || !Number.isFinite(value)) {
54
+ throw new Error(`${name} must be a finite number`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function unitNumber(value: unknown, name: string): number {
60
+ const n = finiteNumber(value, name);
61
+ if (n < 0 || n > 1) throw new Error(`${name} must be between 0 and 1`);
62
+ return n;
63
+ }
64
+
65
+ function optionalDurationMs(value: unknown): number | undefined {
66
+ if (value === undefined) return undefined;
67
+ const n = finiteNumber(value, "durationMs");
68
+ if (n < 0 || n > 10_000) throw new Error("durationMs must be between 0 and 10000");
69
+ return n;
70
+ }
71
+
72
+ function optionalPointerId(value: unknown): number | undefined {
73
+ if (value === undefined) return undefined;
74
+ const n = finiteNumber(value, "pointerId");
75
+ if (!Number.isInteger(n) || n < 0 || n > Number.MAX_SAFE_INTEGER) {
76
+ throw new Error("pointerId must be a non-negative safe integer");
77
+ }
78
+ return n;
79
+ }
80
+
81
+ function keycode(value: unknown): number {
82
+ const n = finiteNumber(value, "keycode");
83
+ if (!Number.isInteger(n) || n < 0 || n > 10_000) {
84
+ throw new Error("keycode must be an integer between 0 and 10000");
85
+ }
86
+ return n;
87
+ }
88
+
89
+ function textBytes(text: string): Buffer {
90
+ const out: string[] = [];
91
+ let total = 0;
92
+ for (const char of text) {
93
+ const bytes = Buffer.byteLength(char, "utf8");
94
+ if (total + bytes > MAX_TEXT_BYTES) break;
95
+ out.push(char);
96
+ total += bytes;
97
+ }
98
+ return Buffer.from(out.join(""), "utf8");
99
+ }
100
+
101
+ export function parseGesture(value: unknown): Gesture {
102
+ if (!isRecord(value) || typeof value.type !== "string") {
103
+ throw new Error("message must be a gesture object");
104
+ }
105
+
106
+ switch (value.type) {
107
+ case "tap":
108
+ return { type: "tap", x: unitNumber(value.x, "x"), y: unitNumber(value.y, "y") };
109
+ case "swipe":
110
+ return {
111
+ type: "swipe",
112
+ x1: unitNumber(value.x1, "x1"),
113
+ y1: unitNumber(value.y1, "y1"),
114
+ x2: unitNumber(value.x2, "x2"),
115
+ y2: unitNumber(value.y2, "y2"),
116
+ durationMs: optionalDurationMs(value.durationMs),
117
+ };
118
+ case "touch": {
119
+ if (value.action !== "down" && value.action !== "move" && value.action !== "up") {
120
+ throw new Error("touch action must be down, move, or up");
121
+ }
122
+ return {
123
+ type: "touch",
124
+ action: value.action,
125
+ x: unitNumber(value.x, "x"),
126
+ y: unitNumber(value.y, "y"),
127
+ pointerId: optionalPointerId(value.pointerId),
128
+ };
129
+ }
130
+ case "key":
131
+ return { type: "key", keycode: keycode(value.keycode) };
132
+ case "text":
133
+ if (typeof value.text !== "string") throw new Error("text must be a string");
134
+ return { type: "text", text: value.text };
135
+ case "back":
136
+ case "home":
137
+ case "recents":
138
+ case "power":
139
+ return { type: value.type };
140
+ default:
141
+ throw new Error(`unsupported gesture type: ${value.type}`);
142
+ }
143
+ }
144
+
145
+ function touchPacket(
146
+ action: number,
147
+ x: number,
148
+ y: number,
149
+ screen: Screen,
150
+ pointerId = PRIMARY_POINTER_ID,
151
+ ): Buffer {
152
+ const buf = Buffer.allocUnsafe(32);
153
+ let o = 0;
154
+ buf.writeUInt8(TYPE_INJECT_TOUCH, o); o += 1;
155
+ buf.writeUInt8(action, o); o += 1;
156
+ buf.writeBigUInt64BE(pointerId, o); o += 8;
157
+ buf.writeInt32BE(Math.round(x), o); o += 4;
158
+ buf.writeInt32BE(Math.round(y), o); o += 4;
159
+ buf.writeUInt16BE(screen.width, o); o += 2;
160
+ buf.writeUInt16BE(screen.height, o); o += 2;
161
+ buf.writeUInt16BE(action === ACTION_UP ? 0 : PRESSURE_FULL, o); o += 2;
162
+ buf.writeUInt32BE(BUTTON_PRIMARY, o); o += 4;
163
+ buf.writeUInt32BE(action === ACTION_UP ? 0 : BUTTON_PRIMARY, o); o += 4;
164
+ return buf;
165
+ }
166
+
167
+ function keyPacket(action: number, keycode: number): Buffer {
168
+ const buf = Buffer.allocUnsafe(14);
169
+ let o = 0;
170
+ buf.writeUInt8(TYPE_INJECT_KEYCODE, o); o += 1;
171
+ buf.writeUInt8(action, o); o += 1;
172
+ buf.writeInt32BE(keycode, o); o += 4;
173
+ buf.writeInt32BE(0, o); o += 4; // repeat
174
+ buf.writeInt32BE(0, o); o += 4; // meta state
175
+ return buf;
176
+ }
177
+
178
+ function textPacket(text: string): Buffer {
179
+ const bytes = textBytes(text);
180
+ const len = bytes.length;
181
+ const buf = Buffer.allocUnsafe(5 + len);
182
+ buf.writeUInt8(TYPE_INJECT_TEXT, 0);
183
+ buf.writeUInt32BE(len, 1);
184
+ bytes.copy(buf, 5);
185
+ return buf;
186
+ }
187
+
188
+ function backOrScreenOnPacket(action: number): Buffer {
189
+ const buf = Buffer.allocUnsafe(2);
190
+ buf.writeUInt8(TYPE_BACK_OR_SCREEN_ON, 0);
191
+ buf.writeUInt8(action, 1);
192
+ return buf;
193
+ }
194
+
195
+ function sleep(ms: number) {
196
+ return new Promise((r) => setTimeout(r, ms));
197
+ }
198
+
199
+ function actionCode(a: "down" | "move" | "up"): number {
200
+ return a === "down" ? ACTION_DOWN : a === "up" ? ACTION_UP : ACTION_MOVE;
201
+ }
202
+
203
+ export async function dispatch(control: Socket, g: Gesture, screen: Screen): Promise<void> {
204
+ const px = (n: number) => n * screen.width;
205
+ const py = (n: number) => n * screen.height;
206
+
207
+ switch (g.type) {
208
+ case "tap": {
209
+ control.write(touchPacket(ACTION_DOWN, px(g.x), py(g.y), screen));
210
+ await sleep(20);
211
+ control.write(touchPacket(ACTION_UP, px(g.x), py(g.y), screen));
212
+ return;
213
+ }
214
+ case "swipe": {
215
+ const dur = Math.max(80, g.durationMs ?? 250);
216
+ const steps = Math.max(8, Math.round(dur / 16));
217
+ control.write(touchPacket(ACTION_DOWN, px(g.x1), py(g.y1), screen));
218
+ for (let i = 1; i < steps; i++) {
219
+ const t = i / steps;
220
+ const x = px(g.x1 + (g.x2 - g.x1) * t);
221
+ const y = py(g.y1 + (g.y2 - g.y1) * t);
222
+ await sleep(dur / steps);
223
+ control.write(touchPacket(ACTION_MOVE, x, y, screen));
224
+ }
225
+ await sleep(dur / steps);
226
+ control.write(touchPacket(ACTION_UP, px(g.x2), py(g.y2), screen));
227
+ return;
228
+ }
229
+ case "touch": {
230
+ control.write(touchPacket(actionCode(g.action), px(g.x), py(g.y), screen, BigInt(g.pointerId ?? 0)));
231
+ return;
232
+ }
233
+ case "key": {
234
+ control.write(keyPacket(ACTION_DOWN, g.keycode));
235
+ control.write(keyPacket(ACTION_UP, g.keycode));
236
+ return;
237
+ }
238
+ case "text":
239
+ control.write(textPacket(g.text));
240
+ return;
241
+ case "back":
242
+ control.write(backOrScreenOnPacket(ACTION_DOWN));
243
+ control.write(backOrScreenOnPacket(ACTION_UP));
244
+ return;
245
+ case "home":
246
+ control.write(keyPacket(ACTION_DOWN, KEY.home));
247
+ control.write(keyPacket(ACTION_UP, KEY.home));
248
+ return;
249
+ case "recents":
250
+ control.write(keyPacket(ACTION_DOWN, KEY.recents));
251
+ control.write(keyPacket(ACTION_UP, KEY.recents));
252
+ return;
253
+ case "power":
254
+ control.write(keyPacket(ACTION_DOWN, KEY.power));
255
+ control.write(keyPacket(ACTION_UP, KEY.power));
256
+ return;
257
+ }
258
+ }
@@ -0,0 +1,135 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+
3
+ export type GeoFix = {
4
+ latitude: number;
5
+ longitude: number;
6
+ altitude?: number;
7
+ satellites?: number;
8
+ velocity?: number;
9
+ };
10
+
11
+ function finiteNumber(value: unknown, name: string): number {
12
+ if (typeof value !== "number" || !Number.isFinite(value)) {
13
+ throw new Error(`${name} must be a finite number`);
14
+ }
15
+ return value;
16
+ }
17
+
18
+ function optionalNumber(value: unknown, name: string): number | undefined {
19
+ if (value === undefined || value === null) return undefined;
20
+ return finiteNumber(value, name);
21
+ }
22
+
23
+ function optionalInteger(value: unknown, name: string): number | undefined {
24
+ const n = optionalNumber(value, name);
25
+ if (n === undefined) return undefined;
26
+ if (!Number.isInteger(n)) throw new Error(`${name} must be an integer`);
27
+ return n;
28
+ }
29
+
30
+ function decimal(value: number): string {
31
+ return String(Number(value.toFixed(7)));
32
+ }
33
+
34
+ function geoFixArgs(serial: string, fix: GeoFix): string[] {
35
+ if (!/^emulator-\d+$/.test(serial)) {
36
+ throw new Error("location control is currently supported for Android Emulator serials only");
37
+ }
38
+
39
+ const args = [
40
+ "-s",
41
+ serial,
42
+ "emu",
43
+ "geo",
44
+ "fix",
45
+ decimal(fix.longitude),
46
+ decimal(fix.latitude),
47
+ ];
48
+ if (fix.altitude !== undefined) args.push(decimal(fix.altitude));
49
+ if (fix.satellites !== undefined) args.push(String(fix.satellites));
50
+ if (fix.velocity !== undefined) args.push(decimal(fix.velocity));
51
+ return args;
52
+ }
53
+
54
+ function assertGeoFixOutput(status: number | null, output: string): void {
55
+ if (status !== 0 || /^KO\b/.test(output)) {
56
+ throw new Error(`adb emu geo fix failed: ${output || "unknown error"}`);
57
+ }
58
+ }
59
+
60
+ export function parseGeoFix(value: unknown): GeoFix {
61
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
62
+ throw new Error("location payload must be an object");
63
+ }
64
+ const record = value as Record<string, unknown>;
65
+ const latitude = finiteNumber(record.latitude, "latitude");
66
+ const longitude = finiteNumber(record.longitude, "longitude");
67
+ const altitude = optionalNumber(record.altitude, "altitude");
68
+ const satellites = optionalInteger(record.satellites, "satellites");
69
+ const velocity = optionalNumber(record.velocity, "velocity");
70
+
71
+ if (latitude < -90 || latitude > 90) throw new Error("latitude must be between -90 and 90");
72
+ if (longitude < -180 || longitude > 180) throw new Error("longitude must be between -180 and 180");
73
+ if (altitude !== undefined && (altitude < -1000 || altitude > 100000)) {
74
+ throw new Error("altitude must be between -1000 and 100000");
75
+ }
76
+ if (satellites !== undefined && (satellites < 1 || satellites > 64)) {
77
+ throw new Error("satellites must be between 1 and 64");
78
+ }
79
+ if (velocity !== undefined && (velocity < 0 || velocity > 1000)) {
80
+ throw new Error("velocity must be between 0 and 1000");
81
+ }
82
+
83
+ return { latitude, longitude, altitude, satellites, velocity };
84
+ }
85
+
86
+ export function setEmulatorLocation(serial: string, fix: GeoFix): void {
87
+ const r = spawnSync("adb", geoFixArgs(serial, fix), { encoding: "utf8", timeout: 5_000 });
88
+ const output = `${r.stdout}${r.stderr}`.trim();
89
+ assertGeoFixOutput(r.status, output);
90
+ }
91
+
92
+ export function setEmulatorLocationAsync(serial: string, fix: GeoFix): Promise<void> {
93
+ return new Promise((resolve, reject) => {
94
+ const child = spawn("adb", geoFixArgs(serial, fix), {
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ });
97
+ let output = "";
98
+ let settled = false;
99
+ const timeout = setTimeout(() => {
100
+ if (settled) return;
101
+ settled = true;
102
+ try {
103
+ child.kill("SIGKILL");
104
+ } catch {}
105
+ reject(new Error("adb emu geo fix timed out"));
106
+ }, 5_000);
107
+ const finish = (fn: () => void) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ clearTimeout(timeout);
111
+ fn();
112
+ };
113
+
114
+ child.stdout.setEncoding("utf8");
115
+ child.stderr.setEncoding("utf8");
116
+ child.stdout.on("data", (chunk: string) => {
117
+ output += chunk;
118
+ });
119
+ child.stderr.on("data", (chunk: string) => {
120
+ output += chunk;
121
+ });
122
+ child.once("error", (err) => finish(() => reject(err)));
123
+ child.once("exit", (status) =>
124
+ finish(() => {
125
+ const text = output.trim();
126
+ try {
127
+ assertGeoFixOutput(status, text);
128
+ resolve();
129
+ } catch (err) {
130
+ reject(err);
131
+ }
132
+ }),
133
+ );
134
+ });
135
+ }