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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +201 -0
- package/README.md +196 -0
- package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
- package/dist/ui/assets/index-CyUIa9dV.js +42 -0
- package/dist/ui/index.html +13 -0
- package/package.json +67 -0
- package/scripts/fetch-scrcpy.ts +28 -0
- package/scripts/release.ts +136 -0
- package/src/accessibility.ts +88 -0
- package/src/adb.ts +209 -0
- package/src/app-info.ts +114 -0
- package/src/app-management.ts +150 -0
- package/src/cli.ts +149 -0
- package/src/emulator.ts +229 -0
- package/src/input.ts +258 -0
- package/src/location.ts +135 -0
- package/src/route-playback.ts +359 -0
- package/src/scrcpy.ts +466 -0
- package/src/server.ts +1260 -0
- package/src/session-recorder.ts +149 -0
- package/src/ui/app.tsx +111 -0
- package/src/ui/components/accessibility-panel.tsx +113 -0
- package/src/ui/components/app-management-panel.tsx +256 -0
- package/src/ui/components/control-bar.tsx +24 -0
- package/src/ui/components/device-panel.tsx +532 -0
- package/src/ui/components/device-stream.tsx +142 -0
- package/src/ui/components/location-panel.tsx +584 -0
- package/src/ui/components/logcat-panel.tsx +100 -0
- package/src/ui/components/session-panel.tsx +127 -0
- package/src/ui/components/status-bar.tsx +19 -0
- package/src/ui/index.html +12 -0
- package/src/ui/lib/h264.ts +35 -0
- package/src/ui/lib/use-stream.ts +368 -0
- package/src/ui/main.tsx +7 -0
- package/src/ui/styles.css +708 -0
- package/src/ui/tsconfig.json +17 -0
- package/src/update-check.ts +93 -0
- package/vendor/scrcpy-server-v2.7 +0 -0
- package/vendor/scrcpy-server-v3.1 +0 -0
- package/vendor/scrcpy-server-v3.3.4 +0 -0
- package/vendor/scrcpy-server-v4.0 +0 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import type { ServerWebSocket } from "bun";
|
|
5
|
+
import {
|
|
6
|
+
getFontScale,
|
|
7
|
+
getNetworkStatus,
|
|
8
|
+
getNightMode,
|
|
9
|
+
getUserRotation,
|
|
10
|
+
listAllDevices,
|
|
11
|
+
screencapPng,
|
|
12
|
+
setFontScale,
|
|
13
|
+
setNetworkEnabled,
|
|
14
|
+
setNightMode,
|
|
15
|
+
setUserRotation,
|
|
16
|
+
type NightMode,
|
|
17
|
+
type OrientationMode,
|
|
18
|
+
} from "./adb.ts";
|
|
19
|
+
import { getAccessibilitySnapshot } from "./accessibility.ts";
|
|
20
|
+
import {
|
|
21
|
+
clearAppData,
|
|
22
|
+
forceStopApp,
|
|
23
|
+
grantPermission,
|
|
24
|
+
importMediaFile,
|
|
25
|
+
installApk,
|
|
26
|
+
launchApp,
|
|
27
|
+
} from "./app-management.ts";
|
|
28
|
+
import { getForegroundApp } from "./app-info.ts";
|
|
29
|
+
import { startScrcpy, type ScrcpySession } from "./scrcpy.ts";
|
|
30
|
+
import { listAvds, listRunningAvds, startEmulator, stopEmulator } from "./emulator.ts";
|
|
31
|
+
import { dispatch, parseGesture, resetVideoPacket, type Gesture, type Screen } from "./input.ts";
|
|
32
|
+
import { parseGeoFix, setEmulatorLocationAsync, type GeoFix } from "./location.ts";
|
|
33
|
+
import { parseRoutePlaybackRequest, RoutePlayback } from "./route-playback.ts";
|
|
34
|
+
import { SessionRecorder } from "./session-recorder.ts";
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const UI_DIR = join(__dirname, "..", "dist", "ui");
|
|
38
|
+
|
|
39
|
+
export type ServerOpts = {
|
|
40
|
+
serial: string;
|
|
41
|
+
port: number;
|
|
42
|
+
maxFps?: number;
|
|
43
|
+
bitRate?: number;
|
|
44
|
+
maxSize?: number;
|
|
45
|
+
keyFrameInterval?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type SessionStatus = "streaming" | "stopped" | "error";
|
|
49
|
+
type GridDeviceKind = "physical" | "emulator" | "avd";
|
|
50
|
+
|
|
51
|
+
type GridDevice = {
|
|
52
|
+
id: string;
|
|
53
|
+
kind: GridDeviceKind;
|
|
54
|
+
serial: string | null;
|
|
55
|
+
avd: string | null;
|
|
56
|
+
name: string;
|
|
57
|
+
state: string;
|
|
58
|
+
current: boolean;
|
|
59
|
+
canSelect: boolean;
|
|
60
|
+
canStart: boolean;
|
|
61
|
+
canStop: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type DeviceGridResponse = {
|
|
65
|
+
ok: true;
|
|
66
|
+
currentSerial: string;
|
|
67
|
+
sessionStatus: SessionStatus;
|
|
68
|
+
devices: GridDevice[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type WsData = { id: number; frameMeta: boolean; handle?: Client };
|
|
72
|
+
|
|
73
|
+
type Client = {
|
|
74
|
+
id: number;
|
|
75
|
+
ws: ServerWebSocket<WsData>;
|
|
76
|
+
frameMeta: boolean;
|
|
77
|
+
sentFrames: number;
|
|
78
|
+
droppedFrames: number;
|
|
79
|
+
backpressureEvents: number;
|
|
80
|
+
awaitingKeyFrame: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const MAX_WS_MESSAGE_BYTES = 16 * 1024;
|
|
84
|
+
const DROP_FRAME_BUFFERED_BYTES = 512 * 1024;
|
|
85
|
+
const CLOSE_CLIENT_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
86
|
+
const FRAME_META_MAGIC = 0x53454d55; // "SEMU"
|
|
87
|
+
const FRAME_META_VERSION = 1;
|
|
88
|
+
const FRAME_META_HEADER_BYTES = 16;
|
|
89
|
+
const FRAME_FLAG_KEY = 1 << 0;
|
|
90
|
+
const VIDEO_RESET_COOLDOWN_MS = 1500;
|
|
91
|
+
const STALE_VIDEO_RESET_MS = 2500;
|
|
92
|
+
const MAX_JSON_BODY_BYTES = 8 * 1024;
|
|
93
|
+
const MAX_ROUTE_BODY_BYTES = 2 * 1024 * 1024;
|
|
94
|
+
const MAX_LOGCAT_QUERY_BYTES = 200;
|
|
95
|
+
|
|
96
|
+
export async function startServer(opts: ServerOpts) {
|
|
97
|
+
const openScrcpy = (serial: string) => startScrcpy({
|
|
98
|
+
serial,
|
|
99
|
+
maxFps: opts.maxFps,
|
|
100
|
+
bitRate: opts.bitRate,
|
|
101
|
+
maxSize: opts.maxSize,
|
|
102
|
+
keyFrameInterval: opts.keyFrameInterval,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let currentSerial = opts.serial;
|
|
106
|
+
let session: ScrcpySession = await openScrcpy(currentSerial);
|
|
107
|
+
console.log(
|
|
108
|
+
`scrcpy ready: ${session.meta.deviceName} • ${session.meta.codecId} • ${session.meta.width}×${session.meta.height}`,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const clients = new Set<Client>();
|
|
112
|
+
const screen: Screen = { width: session.meta.width, height: session.meta.height };
|
|
113
|
+
let startedMs = Date.now();
|
|
114
|
+
let startedAt = new Date(startedMs).toISOString();
|
|
115
|
+
let status: SessionStatus = "streaming";
|
|
116
|
+
let lastError: string | null = null;
|
|
117
|
+
let stoppedAt: string | null = null;
|
|
118
|
+
let stopRequested = false;
|
|
119
|
+
let frameCount = 0;
|
|
120
|
+
let configPacketCount = 0;
|
|
121
|
+
let lastFrameMs = 0;
|
|
122
|
+
let totalDroppedFrames = 0;
|
|
123
|
+
let totalBackpressureEvents = 0;
|
|
124
|
+
let sourceFps = 0;
|
|
125
|
+
let lastFpsFrameCount = 0;
|
|
126
|
+
let videoResetRequests = 0;
|
|
127
|
+
let lastVideoResetAt: string | null = null;
|
|
128
|
+
let lastVideoResetReason: string | null = null;
|
|
129
|
+
let lastVideoResetMs = 0;
|
|
130
|
+
let watchdog: ReturnType<typeof setInterval> | null = null;
|
|
131
|
+
let lastLocation: (GeoFix & { appliedAt: string }) | null = null;
|
|
132
|
+
const createRoutePlayback = () => new RoutePlayback({
|
|
133
|
+
applyLocation: (fix) => setEmulatorLocationAsync(currentSerial, fix),
|
|
134
|
+
onLocation: (fix) => {
|
|
135
|
+
lastLocation = fix;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
let sessionRecorder = new SessionRecorder();
|
|
139
|
+
let routePlayback = createRoutePlayback();
|
|
140
|
+
let sessionGeneration = 0;
|
|
141
|
+
|
|
142
|
+
const health = () => ({
|
|
143
|
+
ok: status === "streaming",
|
|
144
|
+
status,
|
|
145
|
+
serial: currentSerial,
|
|
146
|
+
device: session.meta.deviceName,
|
|
147
|
+
codec: session.meta.codecId,
|
|
148
|
+
size: { width: screen.width, height: screen.height },
|
|
149
|
+
clients: clients.size,
|
|
150
|
+
frames: frameCount,
|
|
151
|
+
sourceFps,
|
|
152
|
+
configPackets: configPacketCount,
|
|
153
|
+
droppedFrames: totalDroppedFrames,
|
|
154
|
+
backpressureEvents: totalBackpressureEvents,
|
|
155
|
+
videoResetRequests,
|
|
156
|
+
lastVideoResetAt,
|
|
157
|
+
lastVideoResetReason,
|
|
158
|
+
location: lastLocation,
|
|
159
|
+
route: routePlayback.snapshot(),
|
|
160
|
+
session: sessionRecorder.snapshot(),
|
|
161
|
+
clientsDetail: Array.from(clients, (client) => ({
|
|
162
|
+
id: client.id,
|
|
163
|
+
frameMeta: client.frameMeta,
|
|
164
|
+
sentFrames: client.sentFrames,
|
|
165
|
+
droppedFrames: client.droppedFrames,
|
|
166
|
+
backpressureEvents: client.backpressureEvents,
|
|
167
|
+
bufferedBytes: client.ws.getBufferedAmount(),
|
|
168
|
+
awaitingKeyFrame: client.awaitingKeyFrame,
|
|
169
|
+
})),
|
|
170
|
+
startedAt,
|
|
171
|
+
stoppedAt,
|
|
172
|
+
lastFrameAt: lastFrameMs > 0 ? new Date(lastFrameMs).toISOString() : null,
|
|
173
|
+
lastError,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const closeClients = (code: number, reason: string) => {
|
|
177
|
+
for (const c of clients) {
|
|
178
|
+
try {
|
|
179
|
+
c.ws.close(code, reason);
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
clients.clear();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const deviceGrid = (): DeviceGridResponse => {
|
|
186
|
+
const adbDevices = listAllDevices();
|
|
187
|
+
const runningAvds = listRunningAvds();
|
|
188
|
+
const runningBySerial = new Map(runningAvds.map((running) => [running.serial, running]));
|
|
189
|
+
const runningByAvd = new Map(runningAvds.map((running) => [running.avd, running]));
|
|
190
|
+
const rows: GridDevice[] = adbDevices.map((device) => {
|
|
191
|
+
const running = runningBySerial.get(device.serial);
|
|
192
|
+
const isEmulator = /^emulator-\d+$/.test(device.serial);
|
|
193
|
+
return {
|
|
194
|
+
id: device.serial,
|
|
195
|
+
kind: isEmulator ? "emulator" : "physical",
|
|
196
|
+
serial: device.serial,
|
|
197
|
+
avd: running?.avd ?? null,
|
|
198
|
+
name: running?.avd ?? device.serial,
|
|
199
|
+
state: device.state,
|
|
200
|
+
current: device.serial === currentSerial,
|
|
201
|
+
canSelect: device.state === "device",
|
|
202
|
+
canStart: false,
|
|
203
|
+
canStop: isEmulator,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const knownAvdSerials = new Set(runningAvds.map((running) => running.serial));
|
|
208
|
+
for (const avd of listAvds()) {
|
|
209
|
+
const running = runningByAvd.get(avd);
|
|
210
|
+
if (running && knownAvdSerials.has(running.serial)) continue;
|
|
211
|
+
rows.push({
|
|
212
|
+
id: `avd:${avd}`,
|
|
213
|
+
kind: "avd",
|
|
214
|
+
serial: running?.serial ?? null,
|
|
215
|
+
avd,
|
|
216
|
+
name: avd,
|
|
217
|
+
state: running?.state ?? "stopped",
|
|
218
|
+
current: running?.serial === currentSerial,
|
|
219
|
+
canSelect: running?.state === "device",
|
|
220
|
+
canStart: !running,
|
|
221
|
+
canStop: Boolean(running),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { ok: true, currentSerial, sessionStatus: status, devices: rows };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const markTerminal = (
|
|
229
|
+
nextStatus: Exclude<SessionStatus, "streaming">,
|
|
230
|
+
reason: string,
|
|
231
|
+
generation = sessionGeneration,
|
|
232
|
+
) => {
|
|
233
|
+
if (generation !== sessionGeneration) return;
|
|
234
|
+
if (status !== "streaming") return;
|
|
235
|
+
status = nextStatus;
|
|
236
|
+
lastError = reason;
|
|
237
|
+
stoppedAt = new Date().toISOString();
|
|
238
|
+
if (watchdog) clearInterval(watchdog);
|
|
239
|
+
routePlayback.close();
|
|
240
|
+
session.close();
|
|
241
|
+
closeClients(nextStatus === "error" ? 1011 : 1000, reason);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const sendJson = (ws: ServerWebSocket<WsData>, value: unknown) => {
|
|
245
|
+
try {
|
|
246
|
+
ws.send(JSON.stringify(value));
|
|
247
|
+
} catch {}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const withFrameMeta = (
|
|
251
|
+
frameData: Buffer,
|
|
252
|
+
frame: { pts: bigint; isKey: boolean },
|
|
253
|
+
config: Buffer | null,
|
|
254
|
+
): Buffer => {
|
|
255
|
+
const configBytes = config?.length ?? 0;
|
|
256
|
+
const out = Buffer.allocUnsafe(FRAME_META_HEADER_BYTES + configBytes + frameData.length);
|
|
257
|
+
out.writeUInt32BE(FRAME_META_MAGIC, 0);
|
|
258
|
+
out.writeUInt8(FRAME_META_VERSION, 4);
|
|
259
|
+
out.writeUInt8(frame.isKey ? FRAME_FLAG_KEY : 0, 5);
|
|
260
|
+
out.writeUInt16BE(0, 6);
|
|
261
|
+
out.writeBigUInt64BE(frame.pts, 8);
|
|
262
|
+
if (config) config.copy(out, FRAME_META_HEADER_BYTES);
|
|
263
|
+
frameData.copy(out, FRAME_META_HEADER_BYTES + configBytes);
|
|
264
|
+
return out;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const withConfig = (frameData: Buffer, config: Buffer | null): Buffer => {
|
|
268
|
+
if (!config) return frameData;
|
|
269
|
+
const out = Buffer.allocUnsafe(config.length + frameData.length);
|
|
270
|
+
config.copy(out, 0);
|
|
271
|
+
frameData.copy(out, config.length);
|
|
272
|
+
return out;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const wantsAck = (value: unknown) => {
|
|
276
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
|
|
277
|
+
return (value as Record<string, unknown>).ack !== false;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const isResetVideoRequest = (value: unknown) =>
|
|
281
|
+
typeof value === "object" &&
|
|
282
|
+
value !== null &&
|
|
283
|
+
!Array.isArray(value) &&
|
|
284
|
+
(value as Record<string, unknown>).type === "reset-video";
|
|
285
|
+
|
|
286
|
+
const readJsonBody = async (req: Request, maxBytes = MAX_JSON_BODY_BYTES): Promise<unknown> => {
|
|
287
|
+
const contentLength = Number(req.headers.get("content-length") ?? "0");
|
|
288
|
+
if (contentLength > maxBytes) throw new Error("request body too large");
|
|
289
|
+
return req.json();
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const shouldRecord = (value: unknown) =>
|
|
293
|
+
typeof value !== "object" ||
|
|
294
|
+
value === null ||
|
|
295
|
+
Array.isArray(value) ||
|
|
296
|
+
(value as Record<string, unknown>).record !== false;
|
|
297
|
+
|
|
298
|
+
const dispatchGesture = async (gesture: Gesture, source: string, record = true) => {
|
|
299
|
+
if (status !== "streaming") throw new Error(`session is ${status}`);
|
|
300
|
+
await dispatch(session.controlSocket, gesture, screen);
|
|
301
|
+
if (record) sessionRecorder.recordGesture(gesture, source);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const applyLocation = async (fix: GeoFix, source: string, record = true) => {
|
|
305
|
+
routePlayback.stop();
|
|
306
|
+
await setEmulatorLocationAsync(currentSerial, fix);
|
|
307
|
+
lastLocation = { ...fix, appliedAt: new Date().toISOString() };
|
|
308
|
+
if (record) sessionRecorder.recordLocation(fix, source);
|
|
309
|
+
return lastLocation;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const resolvePackagePids = (packageName: string): Set<string> => {
|
|
313
|
+
if (!/^[A-Za-z0-9_.:-]+$/.test(packageName)) return new Set();
|
|
314
|
+
const r = spawnSync("adb", ["-s", currentSerial, "shell", "pidof", packageName], {
|
|
315
|
+
encoding: "utf8",
|
|
316
|
+
timeout: 2_000,
|
|
317
|
+
});
|
|
318
|
+
if (r.status !== 0) return new Set();
|
|
319
|
+
return new Set(r.stdout.trim().split(/\s+/).filter(Boolean));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const logcatStream = (url: URL) => {
|
|
323
|
+
const packageName = (url.searchParams.get("package") ?? "").trim().slice(0, MAX_LOGCAT_QUERY_BYTES);
|
|
324
|
+
const search = (url.searchParams.get("search") ?? "").trim().slice(0, MAX_LOGCAT_QUERY_BYTES).toLowerCase();
|
|
325
|
+
const proc = spawn("adb", ["-s", currentSerial, "logcat", "-v", "threadtime"]);
|
|
326
|
+
const encoder = new TextEncoder();
|
|
327
|
+
let pidSet = packageName ? resolvePackagePids(packageName) : new Set<string>();
|
|
328
|
+
let pidTimer: ReturnType<typeof setInterval> | null = null;
|
|
329
|
+
let buffer = "";
|
|
330
|
+
|
|
331
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
332
|
+
start(controller) {
|
|
333
|
+
const send = (event: string, value: unknown) => {
|
|
334
|
+
try {
|
|
335
|
+
controller.enqueue(
|
|
336
|
+
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`),
|
|
337
|
+
);
|
|
338
|
+
} catch {}
|
|
339
|
+
};
|
|
340
|
+
const matches = (line: string) => {
|
|
341
|
+
if (search && !line.toLowerCase().includes(search)) return false;
|
|
342
|
+
if (!packageName) return true;
|
|
343
|
+
const parts = line.trim().split(/\s+/, 5);
|
|
344
|
+
const pid = parts[2];
|
|
345
|
+
return (pid && pidSet.has(pid)) || line.includes(packageName);
|
|
346
|
+
};
|
|
347
|
+
const consume = (chunk: Buffer) => {
|
|
348
|
+
buffer += chunk.toString("utf8");
|
|
349
|
+
const lines = buffer.split(/\r?\n/);
|
|
350
|
+
buffer = lines.pop() ?? "";
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
if (line && matches(line)) send("log", { line, at: new Date().toISOString() });
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
send("ready", {
|
|
357
|
+
serial: currentSerial,
|
|
358
|
+
package: packageName || null,
|
|
359
|
+
pids: Array.from(pidSet),
|
|
360
|
+
search: search || null,
|
|
361
|
+
});
|
|
362
|
+
if (packageName) {
|
|
363
|
+
pidTimer = setInterval(() => {
|
|
364
|
+
pidSet = resolvePackagePids(packageName);
|
|
365
|
+
}, 5_000);
|
|
366
|
+
}
|
|
367
|
+
proc.stdout.on("data", consume);
|
|
368
|
+
proc.stderr.on("data", (chunk) => {
|
|
369
|
+
const text = chunk.toString("utf8").trim();
|
|
370
|
+
if (text) send("error", { line: text, at: new Date().toISOString() });
|
|
371
|
+
});
|
|
372
|
+
proc.once("exit", (code, signal) => {
|
|
373
|
+
send("close", { code, signal });
|
|
374
|
+
try {
|
|
375
|
+
controller.close();
|
|
376
|
+
} catch {}
|
|
377
|
+
if (pidTimer) clearInterval(pidTimer);
|
|
378
|
+
});
|
|
379
|
+
proc.once("error", (err) => {
|
|
380
|
+
send("error", { line: err.message, at: new Date().toISOString() });
|
|
381
|
+
try {
|
|
382
|
+
controller.close();
|
|
383
|
+
} catch {}
|
|
384
|
+
if (pidTimer) clearInterval(pidTimer);
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
cancel() {
|
|
388
|
+
if (pidTimer) clearInterval(pidTimer);
|
|
389
|
+
try {
|
|
390
|
+
proc.kill("SIGTERM");
|
|
391
|
+
} catch {}
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return new Response(stream, {
|
|
396
|
+
headers: {
|
|
397
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
398
|
+
"Cache-Control": "no-cache",
|
|
399
|
+
"Connection": "keep-alive",
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const gestureEndpoint = async (req: Request, type: Gesture["type"], source: string) => {
|
|
405
|
+
try {
|
|
406
|
+
const payload = await readJsonBody(req);
|
|
407
|
+
const gesture = parseGesture(
|
|
408
|
+
typeof payload === "object" && payload !== null && !Array.isArray(payload)
|
|
409
|
+
? { ...payload, type }
|
|
410
|
+
: payload,
|
|
411
|
+
);
|
|
412
|
+
await dispatchGesture(gesture, source, shouldRecord(payload));
|
|
413
|
+
return Response.json({ ok: true });
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return Response.json(
|
|
416
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
417
|
+
{ status: 400 },
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const keyEndpoint = async (req: Request) => {
|
|
423
|
+
try {
|
|
424
|
+
const payload = await readJsonBody(req);
|
|
425
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
426
|
+
throw new Error("key payload must be an object");
|
|
427
|
+
}
|
|
428
|
+
const key = (payload as Record<string, unknown>).key;
|
|
429
|
+
const gesture =
|
|
430
|
+
key === "back" || key === "home" || key === "recents" || key === "power"
|
|
431
|
+
? parseGesture({ type: key })
|
|
432
|
+
: parseGesture({ ...payload, type: "key" });
|
|
433
|
+
await dispatchGesture(gesture, "rest:key", shouldRecord(payload));
|
|
434
|
+
return Response.json({ ok: true });
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return Response.json(
|
|
437
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
438
|
+
{ status: 400 },
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const appJsonEndpoint = async (
|
|
444
|
+
req: Request,
|
|
445
|
+
action: (payload: Record<string, unknown>) => unknown,
|
|
446
|
+
) => {
|
|
447
|
+
try {
|
|
448
|
+
const payload = await readJsonBody(req);
|
|
449
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
450
|
+
throw new Error("payload must be an object");
|
|
451
|
+
}
|
|
452
|
+
const result = action(payload as Record<string, unknown>);
|
|
453
|
+
return Response.json(result);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
return Response.json(
|
|
456
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
457
|
+
{ status: 400 },
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const installEndpoint = async (req: Request) => {
|
|
463
|
+
try {
|
|
464
|
+
const form = await req.formData();
|
|
465
|
+
const file = form.get("apk");
|
|
466
|
+
if (!(file instanceof File)) throw new Error("multipart field apk must be a file");
|
|
467
|
+
return Response.json(await installApk(currentSerial, file));
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return Response.json(
|
|
470
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
471
|
+
{ status: 400 },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const fileImportEndpoint = async (req: Request) => {
|
|
477
|
+
try {
|
|
478
|
+
const form = await req.formData();
|
|
479
|
+
const file = form.get("file");
|
|
480
|
+
if (!(file instanceof File)) throw new Error("multipart field file must be a file");
|
|
481
|
+
return Response.json(await importMediaFile(currentSerial, file));
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return Response.json(
|
|
484
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
485
|
+
{ status: 400 },
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const requestVideoReset = (reason: string) => {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
if (now - lastVideoResetMs < VIDEO_RESET_COOLDOWN_MS) return;
|
|
493
|
+
lastVideoResetMs = now;
|
|
494
|
+
videoResetRequests++;
|
|
495
|
+
lastVideoResetAt = new Date(now).toISOString();
|
|
496
|
+
lastVideoResetReason = reason;
|
|
497
|
+
try {
|
|
498
|
+
session.controlSocket.write(resetVideoPacket());
|
|
499
|
+
} catch {}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const dropUntilKeyFrame = (client: Client) => {
|
|
503
|
+
client.droppedFrames++;
|
|
504
|
+
totalDroppedFrames++;
|
|
505
|
+
client.awaitingKeyFrame = true;
|
|
506
|
+
requestVideoReset("client backpressure");
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const sendFrame = (client: Client, data: Buffer, isKeyFrame: boolean) => {
|
|
510
|
+
if (client.awaitingKeyFrame) {
|
|
511
|
+
if (!isKeyFrame) {
|
|
512
|
+
client.droppedFrames++;
|
|
513
|
+
totalDroppedFrames++;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
client.awaitingKeyFrame = false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const buffered = client.ws.getBufferedAmount();
|
|
520
|
+
if (buffered > CLOSE_CLIENT_BUFFERED_BYTES) {
|
|
521
|
+
clients.delete(client);
|
|
522
|
+
try {
|
|
523
|
+
client.ws.close(1013, "client too slow");
|
|
524
|
+
} catch {}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (buffered > DROP_FRAME_BUFFERED_BYTES) {
|
|
528
|
+
dropUntilKeyFrame(client);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const sent = client.ws.send(data);
|
|
532
|
+
if (sent === -1) {
|
|
533
|
+
client.backpressureEvents++;
|
|
534
|
+
totalBackpressureEvents++;
|
|
535
|
+
dropUntilKeyFrame(client);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (sent === 0) {
|
|
539
|
+
clients.delete(client);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
client.sentFrames++;
|
|
543
|
+
};
|
|
544
|
+
// Cache the SPS+PPS bytes that scrcpy emits as a standalone "config" packet
|
|
545
|
+
// and inline them in front of every keyframe so each WS message is a
|
|
546
|
+
// self-contained Access Unit the browser can hand straight to WebCodecs.
|
|
547
|
+
let cachedConfig: Buffer | null = null;
|
|
548
|
+
|
|
549
|
+
const startFramePump = (activeSession: ScrcpySession, generation: number) => {
|
|
550
|
+
cachedConfig = null;
|
|
551
|
+
void (async () => {
|
|
552
|
+
try {
|
|
553
|
+
while (!stopRequested && generation === sessionGeneration) {
|
|
554
|
+
const f = await activeSession.readFrame();
|
|
555
|
+
if (generation !== sessionGeneration) break;
|
|
556
|
+
if (!f) {
|
|
557
|
+
if (!stopRequested) markTerminal("error", "scrcpy video stream ended", generation);
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
if (f.type === "session") {
|
|
561
|
+
if (f.width > 0 && f.height > 0) {
|
|
562
|
+
screen.width = f.width;
|
|
563
|
+
screen.height = f.height;
|
|
564
|
+
cachedConfig = null;
|
|
565
|
+
for (const c of clients) {
|
|
566
|
+
c.awaitingKeyFrame = true;
|
|
567
|
+
sendJson(c.ws, { type: "video-session", size: { width: f.width, height: f.height } });
|
|
568
|
+
}
|
|
569
|
+
requestVideoReset(`video session resized to ${f.width}×${f.height}`);
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (f.isConfig) {
|
|
574
|
+
cachedConfig = f.data;
|
|
575
|
+
configPacketCount++;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
frameCount++;
|
|
579
|
+
lastFrameMs = Date.now();
|
|
580
|
+
const config = f.isKey ? cachedConfig : null;
|
|
581
|
+
let rawOut: Buffer | null = null;
|
|
582
|
+
let framedOut: Buffer | null = null;
|
|
583
|
+
for (const c of clients) {
|
|
584
|
+
if (c.awaitingKeyFrame && !f.isKey) {
|
|
585
|
+
c.droppedFrames++;
|
|
586
|
+
totalDroppedFrames++;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const out = c.frameMeta
|
|
590
|
+
? (framedOut ??= withFrameMeta(f.data, f, config))
|
|
591
|
+
: (rawOut ??= withConfig(f.data, config));
|
|
592
|
+
sendFrame(c, out, f.isKey);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (!stopRequested) markTerminal("error", String(err), generation);
|
|
597
|
+
}
|
|
598
|
+
})();
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const attachSessionHandlers = (activeSession: ScrcpySession, generation: number) => {
|
|
602
|
+
activeSession.proc.once("exit", (code, signal) => {
|
|
603
|
+
if (!stopRequested && status === "streaming") {
|
|
604
|
+
markTerminal(
|
|
605
|
+
"error",
|
|
606
|
+
`scrcpy exited with code ${code ?? "null"} signal ${signal ?? "null"}`,
|
|
607
|
+
generation,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
activeSession.controlSocket.once("error", (err) => {
|
|
612
|
+
if (!stopRequested && status === "streaming") {
|
|
613
|
+
markTerminal("error", `scrcpy control socket error: ${err.message}`, generation);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const resetSessionStats = (nextSession: ScrcpySession) => {
|
|
619
|
+
screen.width = nextSession.meta.width;
|
|
620
|
+
screen.height = nextSession.meta.height;
|
|
621
|
+
startedMs = Date.now();
|
|
622
|
+
startedAt = new Date(startedMs).toISOString();
|
|
623
|
+
status = "streaming";
|
|
624
|
+
lastError = null;
|
|
625
|
+
stoppedAt = null;
|
|
626
|
+
frameCount = 0;
|
|
627
|
+
configPacketCount = 0;
|
|
628
|
+
lastFrameMs = 0;
|
|
629
|
+
totalDroppedFrames = 0;
|
|
630
|
+
totalBackpressureEvents = 0;
|
|
631
|
+
sourceFps = 0;
|
|
632
|
+
lastFpsFrameCount = 0;
|
|
633
|
+
videoResetRequests = 0;
|
|
634
|
+
lastVideoResetAt = null;
|
|
635
|
+
lastVideoResetReason = null;
|
|
636
|
+
lastVideoResetMs = 0;
|
|
637
|
+
lastLocation = null;
|
|
638
|
+
sessionRecorder = new SessionRecorder();
|
|
639
|
+
routePlayback.close();
|
|
640
|
+
routePlayback = createRoutePlayback();
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const switchSession = async (serial: string) => {
|
|
644
|
+
if (serial === currentSerial && status === "streaming") {
|
|
645
|
+
return { ok: true, serial: currentSerial, device: session.meta.deviceName };
|
|
646
|
+
}
|
|
647
|
+
const device = listAllDevices().find((candidate) => candidate.serial === serial);
|
|
648
|
+
if (!device) throw new Error(`Unknown adb device "${serial}".`);
|
|
649
|
+
if (device.state !== "device") throw new Error(`${serial} is ${device.state}, not ready.`);
|
|
650
|
+
|
|
651
|
+
const nextSession = await openScrcpy(serial);
|
|
652
|
+
const previousSession = session;
|
|
653
|
+
sessionGeneration++;
|
|
654
|
+
closeClients(1012, "device switched");
|
|
655
|
+
try {
|
|
656
|
+
previousSession.close();
|
|
657
|
+
} catch {}
|
|
658
|
+
currentSerial = serial;
|
|
659
|
+
session = nextSession;
|
|
660
|
+
resetSessionStats(nextSession);
|
|
661
|
+
startFramePump(nextSession, sessionGeneration);
|
|
662
|
+
attachSessionHandlers(nextSession, sessionGeneration);
|
|
663
|
+
console.log(
|
|
664
|
+
`scrcpy ready: ${nextSession.meta.deviceName} • ${nextSession.meta.codecId} • ${nextSession.meta.width}×${nextSession.meta.height}`,
|
|
665
|
+
);
|
|
666
|
+
return { ok: true, serial: currentSerial, device: nextSession.meta.deviceName };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const stopCurrentSession = (reason: string) => {
|
|
670
|
+
sessionGeneration++;
|
|
671
|
+
status = "stopped";
|
|
672
|
+
lastError = reason;
|
|
673
|
+
stoppedAt = new Date().toISOString();
|
|
674
|
+
routePlayback.close();
|
|
675
|
+
closeClients(1000, reason);
|
|
676
|
+
try {
|
|
677
|
+
session.close();
|
|
678
|
+
} catch {}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
startFramePump(session, sessionGeneration);
|
|
682
|
+
|
|
683
|
+
watchdog = setInterval(() => {
|
|
684
|
+
sourceFps = frameCount - lastFpsFrameCount;
|
|
685
|
+
lastFpsFrameCount = frameCount;
|
|
686
|
+
if (status !== "streaming" || clients.size === 0) return;
|
|
687
|
+
const lastFrameSeenMs = lastFrameMs || startedMs;
|
|
688
|
+
if (Date.now() - lastFrameSeenMs > STALE_VIDEO_RESET_MS) {
|
|
689
|
+
requestVideoReset("source stream stalled");
|
|
690
|
+
}
|
|
691
|
+
}, 1000);
|
|
692
|
+
|
|
693
|
+
attachSessionHandlers(session, sessionGeneration);
|
|
694
|
+
|
|
695
|
+
let nextId = 1;
|
|
696
|
+
const server = Bun.serve<WsData>({
|
|
697
|
+
port: opts.port,
|
|
698
|
+
async fetch(req, srv) {
|
|
699
|
+
const url = new URL(req.url);
|
|
700
|
+
|
|
701
|
+
if (url.pathname === "/api") {
|
|
702
|
+
return Response.json({
|
|
703
|
+
serial: currentSerial,
|
|
704
|
+
device: session.meta.deviceName,
|
|
705
|
+
codec: session.meta.codecId,
|
|
706
|
+
size: { width: screen.width, height: screen.height },
|
|
707
|
+
status,
|
|
708
|
+
clients: clients.size,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (url.pathname === "/api/devices") {
|
|
713
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
714
|
+
try {
|
|
715
|
+
return Response.json({
|
|
716
|
+
ok: true,
|
|
717
|
+
currentSerial,
|
|
718
|
+
devices: listAllDevices().map((device) => ({
|
|
719
|
+
...device,
|
|
720
|
+
current: device.serial === currentSerial,
|
|
721
|
+
})),
|
|
722
|
+
});
|
|
723
|
+
} catch (err) {
|
|
724
|
+
return Response.json(
|
|
725
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
726
|
+
{ status: 400 },
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (url.pathname === "/api/device-grid") {
|
|
732
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
733
|
+
try {
|
|
734
|
+
return Response.json(deviceGrid());
|
|
735
|
+
} catch (err) {
|
|
736
|
+
return Response.json(
|
|
737
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
738
|
+
{ status: 400 },
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (url.pathname === "/api/devices/select") {
|
|
744
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
745
|
+
try {
|
|
746
|
+
const payload = await readJsonBody(req);
|
|
747
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
748
|
+
throw new Error("select payload must be an object");
|
|
749
|
+
}
|
|
750
|
+
const serial = (payload as Record<string, unknown>).serial;
|
|
751
|
+
if (typeof serial !== "string" || !serial.trim()) {
|
|
752
|
+
throw new Error("serial is required");
|
|
753
|
+
}
|
|
754
|
+
return Response.json(await switchSession(serial.trim()));
|
|
755
|
+
} catch (err) {
|
|
756
|
+
return Response.json(
|
|
757
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
758
|
+
{ status: 400 },
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (url.pathname === "/api/avds/start") {
|
|
764
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
765
|
+
try {
|
|
766
|
+
const payload = await readJsonBody(req);
|
|
767
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
768
|
+
throw new Error("start payload must be an object");
|
|
769
|
+
}
|
|
770
|
+
const avd = (payload as Record<string, unknown>).avd;
|
|
771
|
+
if (typeof avd !== "string" || !avd.trim()) throw new Error("avd is required");
|
|
772
|
+
const launch = await startEmulator({ avd: avd.trim() });
|
|
773
|
+
const select = (payload as Record<string, unknown>).select !== false;
|
|
774
|
+
if (select) {
|
|
775
|
+
const switched = await switchSession(launch.serial);
|
|
776
|
+
return Response.json({ ...switched, avd: avd.trim() });
|
|
777
|
+
}
|
|
778
|
+
return Response.json({ ok: true, serial: launch.serial, avd: avd.trim() });
|
|
779
|
+
} catch (err) {
|
|
780
|
+
return Response.json(
|
|
781
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
782
|
+
{ status: 400 },
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (url.pathname === "/api/avds/stop") {
|
|
788
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
789
|
+
try {
|
|
790
|
+
const payload = await readJsonBody(req);
|
|
791
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
792
|
+
throw new Error("stop payload must be an object");
|
|
793
|
+
}
|
|
794
|
+
const body = payload as Record<string, unknown>;
|
|
795
|
+
let serial = typeof body.serial === "string" ? body.serial.trim() : "";
|
|
796
|
+
if (!serial && typeof body.avd === "string" && body.avd.trim()) {
|
|
797
|
+
serial = listRunningAvds().find((running) => running.avd === body.avd)?.serial ?? "";
|
|
798
|
+
}
|
|
799
|
+
if (!serial) throw new Error("serial or running avd is required");
|
|
800
|
+
if (!/^emulator-\d+$/.test(serial)) throw new Error(`${serial} is not an emulator`);
|
|
801
|
+
if (serial === currentSerial) stopCurrentSession("current emulator stopped");
|
|
802
|
+
stopEmulator(serial);
|
|
803
|
+
return Response.json({ ok: true, serial });
|
|
804
|
+
} catch (err) {
|
|
805
|
+
return Response.json(
|
|
806
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
807
|
+
{ status: 400 },
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (url.pathname === "/api/orientation") {
|
|
813
|
+
if (req.method === "GET") {
|
|
814
|
+
try {
|
|
815
|
+
return Response.json({ ok: true, orientation: getUserRotation(currentSerial) });
|
|
816
|
+
} catch (err) {
|
|
817
|
+
return Response.json(
|
|
818
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
819
|
+
{ status: 400 },
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (req.method === "POST") {
|
|
824
|
+
try {
|
|
825
|
+
const payload = await readJsonBody(req);
|
|
826
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
827
|
+
throw new Error("orientation payload must be an object");
|
|
828
|
+
}
|
|
829
|
+
const orientation = (payload as Record<string, unknown>).orientation;
|
|
830
|
+
if (orientation !== "auto" && orientation !== "portrait" && orientation !== "landscape") {
|
|
831
|
+
throw new Error("orientation must be auto, portrait, or landscape");
|
|
832
|
+
}
|
|
833
|
+
return Response.json({
|
|
834
|
+
ok: true,
|
|
835
|
+
orientation: setUserRotation(currentSerial, orientation as OrientationMode),
|
|
836
|
+
});
|
|
837
|
+
} catch (err) {
|
|
838
|
+
return Response.json(
|
|
839
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
840
|
+
{ status: 400 },
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return new Response("method not allowed", { status: 405 });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (url.pathname === "/api/night-mode") {
|
|
848
|
+
if (req.method === "GET") {
|
|
849
|
+
try {
|
|
850
|
+
return Response.json({ ok: true, nightMode: getNightMode(currentSerial) });
|
|
851
|
+
} catch (err) {
|
|
852
|
+
return Response.json(
|
|
853
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
854
|
+
{ status: 400 },
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (req.method === "POST") {
|
|
859
|
+
try {
|
|
860
|
+
const payload = await readJsonBody(req);
|
|
861
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
862
|
+
throw new Error("night mode payload must be an object");
|
|
863
|
+
}
|
|
864
|
+
const mode = (payload as Record<string, unknown>).mode;
|
|
865
|
+
if (mode !== "dark" && mode !== "light" && mode !== "auto") {
|
|
866
|
+
throw new Error("mode must be dark, light, or auto");
|
|
867
|
+
}
|
|
868
|
+
return Response.json({
|
|
869
|
+
ok: true,
|
|
870
|
+
nightMode: setNightMode(currentSerial, mode as NightMode),
|
|
871
|
+
});
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return Response.json(
|
|
874
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
875
|
+
{ status: 400 },
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return new Response("method not allowed", { status: 405 });
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (url.pathname === "/api/font-scale") {
|
|
883
|
+
if (req.method === "GET") {
|
|
884
|
+
try {
|
|
885
|
+
return Response.json({ ok: true, fontScale: getFontScale(currentSerial) });
|
|
886
|
+
} catch (err) {
|
|
887
|
+
return Response.json(
|
|
888
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
889
|
+
{ status: 400 },
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (req.method === "POST") {
|
|
894
|
+
try {
|
|
895
|
+
const payload = await readJsonBody(req);
|
|
896
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
897
|
+
throw new Error("font scale payload must be an object");
|
|
898
|
+
}
|
|
899
|
+
const scale = Number((payload as Record<string, unknown>).scale);
|
|
900
|
+
if (!Number.isFinite(scale) || scale < 0.7 || scale > 2) {
|
|
901
|
+
throw new Error("scale must be a number between 0.7 and 2.0");
|
|
902
|
+
}
|
|
903
|
+
return Response.json({
|
|
904
|
+
ok: true,
|
|
905
|
+
fontScale: setFontScale(currentSerial, scale),
|
|
906
|
+
});
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return Response.json(
|
|
909
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
910
|
+
{ status: 400 },
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return new Response("method not allowed", { status: 405 });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (url.pathname === "/api/network") {
|
|
918
|
+
if (req.method === "GET") {
|
|
919
|
+
try {
|
|
920
|
+
return Response.json({ ok: true, network: getNetworkStatus(currentSerial) });
|
|
921
|
+
} catch (err) {
|
|
922
|
+
return Response.json(
|
|
923
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
924
|
+
{ status: 400 },
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (req.method === "POST") {
|
|
929
|
+
try {
|
|
930
|
+
const payload = await readJsonBody(req);
|
|
931
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
932
|
+
throw new Error("network payload must be an object");
|
|
933
|
+
}
|
|
934
|
+
const enabled = (payload as Record<string, unknown>).enabled;
|
|
935
|
+
if (typeof enabled !== "boolean") {
|
|
936
|
+
throw new Error("enabled must be a boolean");
|
|
937
|
+
}
|
|
938
|
+
return Response.json({
|
|
939
|
+
ok: true,
|
|
940
|
+
network: setNetworkEnabled(currentSerial, enabled),
|
|
941
|
+
});
|
|
942
|
+
} catch (err) {
|
|
943
|
+
return Response.json(
|
|
944
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
945
|
+
{ status: 400 },
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return new Response("method not allowed", { status: 405 });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (url.pathname === "/health") {
|
|
953
|
+
return Response.json(health(), { status: status === "streaming" ? 200 : 503 });
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (url.pathname === "/api/logcat") {
|
|
957
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
958
|
+
return logcatStream(url);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (url.pathname === "/api/screenshot") {
|
|
962
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
963
|
+
return new Response("method not allowed", { status: 405 });
|
|
964
|
+
}
|
|
965
|
+
try {
|
|
966
|
+
const png = screencapPng(currentSerial);
|
|
967
|
+
if (url.searchParams.get("format") === "base64") {
|
|
968
|
+
return Response.json({
|
|
969
|
+
ok: true,
|
|
970
|
+
mimeType: "image/png",
|
|
971
|
+
data: png.toString("base64"),
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
return new Response(new Uint8Array(png), { headers: { "Content-Type": "image/png" } });
|
|
975
|
+
} catch (err) {
|
|
976
|
+
return Response.json(
|
|
977
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
978
|
+
{ status: 400 },
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (url.pathname === "/api/foreground") {
|
|
984
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
985
|
+
try {
|
|
986
|
+
return Response.json({ ok: true, app: getForegroundApp(currentSerial) });
|
|
987
|
+
} catch (err) {
|
|
988
|
+
return Response.json(
|
|
989
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
990
|
+
{ status: 400 },
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (url.pathname === "/api/accessibility") {
|
|
996
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
997
|
+
try {
|
|
998
|
+
return Response.json(getAccessibilitySnapshot(currentSerial));
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
return Response.json(
|
|
1001
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1002
|
+
{ status: 400 },
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (url.pathname === "/api/tap") {
|
|
1008
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1009
|
+
return gestureEndpoint(req, "tap", "rest:tap");
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (url.pathname === "/api/swipe") {
|
|
1013
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1014
|
+
return gestureEndpoint(req, "swipe", "rest:swipe");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (url.pathname === "/api/text") {
|
|
1018
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1019
|
+
return gestureEndpoint(req, "text", "rest:text");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (url.pathname === "/api/key") {
|
|
1023
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1024
|
+
return keyEndpoint(req);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (url.pathname === "/api/session") {
|
|
1028
|
+
if (req.method === "GET") return Response.json(sessionRecorder.snapshot());
|
|
1029
|
+
if (req.method === "DELETE") return Response.json({ ok: true, session: sessionRecorder.clear() });
|
|
1030
|
+
return new Response("method not allowed", { status: 405 });
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (url.pathname === "/api/session/replay") {
|
|
1034
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1035
|
+
try {
|
|
1036
|
+
const payload = await readJsonBody(req);
|
|
1037
|
+
const multiplier =
|
|
1038
|
+
typeof payload === "object" && payload !== null && !Array.isArray(payload)
|
|
1039
|
+
? Number((payload as Record<string, unknown>).multiplier ?? 1)
|
|
1040
|
+
: 1;
|
|
1041
|
+
const replay = sessionRecorder.replay(
|
|
1042
|
+
{
|
|
1043
|
+
dispatchGesture: (gesture) => dispatchGesture(gesture, "session:replay", false),
|
|
1044
|
+
setLocation: async (fix) => {
|
|
1045
|
+
await applyLocation(fix, "session:replay", false);
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
multiplier,
|
|
1049
|
+
);
|
|
1050
|
+
void replay.catch(() => {});
|
|
1051
|
+
return Response.json({ ok: true, session: sessionRecorder.snapshot() });
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
return Response.json(
|
|
1054
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1055
|
+
{ status: 400 },
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (url.pathname === "/api/session/replay/stop") {
|
|
1061
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1062
|
+
return Response.json({ ok: true, session: sessionRecorder.stopReplay() });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (url.pathname === "/api/apps/install") {
|
|
1066
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1067
|
+
return installEndpoint(req);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (url.pathname === "/api/files/import") {
|
|
1071
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1072
|
+
return fileImportEndpoint(req);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (url.pathname === "/api/apps/launch") {
|
|
1076
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1077
|
+
return appJsonEndpoint(req, (payload) =>
|
|
1078
|
+
launchApp(
|
|
1079
|
+
currentSerial,
|
|
1080
|
+
String(payload.packageName ?? ""),
|
|
1081
|
+
typeof payload.activity === "string" && payload.activity.trim()
|
|
1082
|
+
? payload.activity
|
|
1083
|
+
: undefined,
|
|
1084
|
+
),
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (url.pathname === "/api/apps/clear") {
|
|
1089
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1090
|
+
return appJsonEndpoint(req, (payload) =>
|
|
1091
|
+
clearAppData(currentSerial, String(payload.packageName ?? "")),
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (url.pathname === "/api/apps/force-stop") {
|
|
1096
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1097
|
+
return appJsonEndpoint(req, (payload) =>
|
|
1098
|
+
forceStopApp(currentSerial, String(payload.packageName ?? "")),
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (url.pathname === "/api/apps/grant") {
|
|
1103
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1104
|
+
return appJsonEndpoint(req, (payload) =>
|
|
1105
|
+
grantPermission(
|
|
1106
|
+
currentSerial,
|
|
1107
|
+
String(payload.packageName ?? ""),
|
|
1108
|
+
String(payload.permission ?? ""),
|
|
1109
|
+
),
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (url.pathname === "/api/location") {
|
|
1114
|
+
if (req.method === "GET") {
|
|
1115
|
+
return Response.json({
|
|
1116
|
+
serial: currentSerial,
|
|
1117
|
+
emulator: /^emulator-\d+$/.test(currentSerial),
|
|
1118
|
+
location: lastLocation,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
if (req.method === "POST") {
|
|
1122
|
+
try {
|
|
1123
|
+
const fix = parseGeoFix(await readJsonBody(req));
|
|
1124
|
+
lastLocation = await applyLocation(fix, "rest:location");
|
|
1125
|
+
return Response.json({ ok: true, location: lastLocation });
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
return Response.json(
|
|
1128
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1129
|
+
{ status: 400 },
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return new Response("method not allowed", { status: 405 });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (url.pathname === "/api/route") {
|
|
1137
|
+
if (req.method === "GET") {
|
|
1138
|
+
return Response.json(routePlayback.snapshot());
|
|
1139
|
+
}
|
|
1140
|
+
if (req.method === "POST") {
|
|
1141
|
+
try {
|
|
1142
|
+
const route = parseRoutePlaybackRequest(await readJsonBody(req, MAX_ROUTE_BODY_BYTES));
|
|
1143
|
+
return Response.json({ ok: true, route: await routePlayback.start(route) });
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
return Response.json(
|
|
1146
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1147
|
+
{ status: 400 },
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (req.method === "DELETE") {
|
|
1152
|
+
return Response.json({ ok: true, route: routePlayback.stop() });
|
|
1153
|
+
}
|
|
1154
|
+
return new Response("method not allowed", { status: 405 });
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (url.pathname === "/api/route/control") {
|
|
1158
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1159
|
+
try {
|
|
1160
|
+
const payload = await readJsonBody(req);
|
|
1161
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
1162
|
+
throw new Error("control payload must be an object");
|
|
1163
|
+
}
|
|
1164
|
+
const action = (payload as Record<string, unknown>).action;
|
|
1165
|
+
if (action === "pause") return Response.json({ ok: true, route: routePlayback.pause() });
|
|
1166
|
+
if (action === "resume") return Response.json({ ok: true, route: routePlayback.resume() });
|
|
1167
|
+
if (action === "stop") return Response.json({ ok: true, route: routePlayback.stop() });
|
|
1168
|
+
throw new Error("action must be pause, resume, or stop");
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
return Response.json(
|
|
1171
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1172
|
+
{ status: 400 },
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (url.pathname === "/ws") {
|
|
1178
|
+
if (status !== "streaming") {
|
|
1179
|
+
return new Response(JSON.stringify(health()), {
|
|
1180
|
+
status: 503,
|
|
1181
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
const frameMeta = url.searchParams.get("frame-meta") === "1";
|
|
1185
|
+
const ok = srv.upgrade(req, { data: { id: nextId++, frameMeta } });
|
|
1186
|
+
if (ok) return undefined as unknown as Response;
|
|
1187
|
+
return new Response("upgrade failed", { status: 400 });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const reqPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
1191
|
+
if (reqPath.includes("..")) return new Response("not found", { status: 404 });
|
|
1192
|
+
const file = Bun.file(join(UI_DIR, reqPath));
|
|
1193
|
+
if (await file.exists()) return new Response(file);
|
|
1194
|
+
return new Response("not found", { status: 404 });
|
|
1195
|
+
},
|
|
1196
|
+
websocket: {
|
|
1197
|
+
open(ws) {
|
|
1198
|
+
const handle: Client = {
|
|
1199
|
+
id: ws.data.id,
|
|
1200
|
+
ws,
|
|
1201
|
+
frameMeta: ws.data.frameMeta,
|
|
1202
|
+
sentFrames: 0,
|
|
1203
|
+
droppedFrames: 0,
|
|
1204
|
+
backpressureEvents: 0,
|
|
1205
|
+
awaitingKeyFrame: true,
|
|
1206
|
+
};
|
|
1207
|
+
clients.add(handle);
|
|
1208
|
+
ws.data.handle = handle;
|
|
1209
|
+
requestVideoReset("client opened");
|
|
1210
|
+
},
|
|
1211
|
+
message(ws, raw) {
|
|
1212
|
+
if (typeof raw !== "string") return;
|
|
1213
|
+
if (raw.length > MAX_WS_MESSAGE_BYTES) {
|
|
1214
|
+
ws.close(1009, "message too large");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
if (status !== "streaming") throw new Error(`session is ${status}`);
|
|
1219
|
+
const payload = JSON.parse(raw);
|
|
1220
|
+
const acknowledge = wantsAck(payload);
|
|
1221
|
+
if (isResetVideoRequest(payload)) {
|
|
1222
|
+
requestVideoReset("client requested keyframe");
|
|
1223
|
+
if (acknowledge) sendJson(ws, { ok: true });
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const msg = parseGesture(payload);
|
|
1227
|
+
void dispatchGesture(msg, "ws", shouldRecord(payload))
|
|
1228
|
+
.then(() => {
|
|
1229
|
+
if (acknowledge) sendJson(ws, { ok: true });
|
|
1230
|
+
})
|
|
1231
|
+
.catch((err) => sendJson(ws, { ok: false, error: String(err) }));
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
sendJson(ws, { ok: false, error: String(err) });
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
close(ws) {
|
|
1237
|
+
if (ws.data.handle) clients.delete(ws.data.handle);
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
const stop = () => {
|
|
1243
|
+
if (stopRequested) return;
|
|
1244
|
+
stopRequested = true;
|
|
1245
|
+
if (status === "streaming") {
|
|
1246
|
+
status = "stopped";
|
|
1247
|
+
stoppedAt = new Date().toISOString();
|
|
1248
|
+
}
|
|
1249
|
+
closeClients(1001, "server stopping");
|
|
1250
|
+
if (watchdog) clearInterval(watchdog);
|
|
1251
|
+
routePlayback.close();
|
|
1252
|
+
server.stop(true);
|
|
1253
|
+
session.close();
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
return { server, session, stop };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export type StartedServer = Awaited<ReturnType<typeof startServer>>;
|
|
1260
|
+
export type { ScrcpySession };
|