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
@@ -0,0 +1,359 @@
1
+ import type { GeoFix } from "./location.ts";
2
+
3
+ export type RouteWaypoint = GeoFix;
4
+
5
+ export type RoutePlaybackRequest = {
6
+ waypoints: RouteWaypoint[];
7
+ speedKph?: number;
8
+ multiplier?: number;
9
+ intervalMs?: number;
10
+ loop?: boolean;
11
+ };
12
+
13
+ export type RoutePlaybackStatus =
14
+ | "idle"
15
+ | "running"
16
+ | "paused"
17
+ | "completed"
18
+ | "error";
19
+
20
+ export type RoutePlaybackSnapshot = {
21
+ status: RoutePlaybackStatus;
22
+ waypointCount: number;
23
+ totalMeters: number;
24
+ progressMeters: number;
25
+ speedKph: number;
26
+ multiplier: number;
27
+ intervalMs: number;
28
+ loop: boolean;
29
+ startedAt: string | null;
30
+ updatedAt: string | null;
31
+ pausedAt: string | null;
32
+ completedAt: string | null;
33
+ lastError: string | null;
34
+ currentLocation: (GeoFix & { appliedAt: string }) | null;
35
+ };
36
+
37
+ type RoutePlaybackOpts = {
38
+ applyLocation: (fix: GeoFix) => void | Promise<void>;
39
+ onLocation: (fix: GeoFix & { appliedAt: string }) => void;
40
+ };
41
+
42
+ type PreparedRoute = {
43
+ waypoints: RouteWaypoint[];
44
+ cumulativeMeters: number[];
45
+ totalMeters: number;
46
+ };
47
+
48
+ const EARTH_RADIUS_METERS = 6_371_000;
49
+ const DEFAULT_SPEED_KPH = 30;
50
+ const DEFAULT_INTERVAL_MS = 1000;
51
+ const MAX_WAYPOINTS = 10_000;
52
+ const MIN_INTERVAL_MS = 250;
53
+ const MAX_INTERVAL_MS = 60_000;
54
+
55
+ function finiteNumber(value: unknown, name: string): number {
56
+ if (typeof value !== "number" || !Number.isFinite(value)) {
57
+ throw new Error(`${name} must be a finite number`);
58
+ }
59
+ return value;
60
+ }
61
+
62
+ function optionalNumber(value: unknown, name: string): number | undefined {
63
+ if (value === undefined || value === null) return undefined;
64
+ return finiteNumber(value, name);
65
+ }
66
+
67
+ function clamp(n: number, min: number, max: number): number {
68
+ return Math.min(max, Math.max(min, n));
69
+ }
70
+
71
+ function radians(degrees: number): number {
72
+ return (degrees * Math.PI) / 180;
73
+ }
74
+
75
+ function degrees(radiansValue: number): number {
76
+ return (radiansValue * 180) / Math.PI;
77
+ }
78
+
79
+ function distanceMeters(a: RouteWaypoint, b: RouteWaypoint): number {
80
+ const lat1 = radians(a.latitude);
81
+ const lat2 = radians(b.latitude);
82
+ const dLat = lat2 - lat1;
83
+ const dLon = radians(b.longitude - a.longitude);
84
+ const h =
85
+ Math.sin(dLat / 2) ** 2 +
86
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
87
+ return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
88
+ }
89
+
90
+ function interpolate(a: RouteWaypoint, b: RouteWaypoint, t: number): GeoFix {
91
+ const lat1 = radians(a.latitude);
92
+ const lon1 = radians(a.longitude);
93
+ const lat2 = radians(b.latitude);
94
+ const lon2 = radians(b.longitude);
95
+ const d = 2 * Math.asin(Math.sqrt(
96
+ Math.sin((lat2 - lat1) / 2) ** 2 +
97
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin((lon2 - lon1) / 2) ** 2,
98
+ ));
99
+
100
+ if (d === 0) return { ...a };
101
+
102
+ const aa = Math.sin((1 - t) * d) / Math.sin(d);
103
+ const bb = Math.sin(t * d) / Math.sin(d);
104
+ const x = aa * Math.cos(lat1) * Math.cos(lon1) + bb * Math.cos(lat2) * Math.cos(lon2);
105
+ const y = aa * Math.cos(lat1) * Math.sin(lon1) + bb * Math.cos(lat2) * Math.sin(lon2);
106
+ const z = aa * Math.sin(lat1) + bb * Math.sin(lat2);
107
+ const lat = Math.atan2(z, Math.sqrt(x ** 2 + y ** 2));
108
+ const lon = Math.atan2(y, x);
109
+ const altitude =
110
+ a.altitude === undefined && b.altitude === undefined
111
+ ? undefined
112
+ : (a.altitude ?? 0) + ((b.altitude ?? a.altitude ?? 0) - (a.altitude ?? 0)) * t;
113
+
114
+ return {
115
+ latitude: degrees(lat),
116
+ longitude: degrees(lon),
117
+ ...(altitude === undefined ? {} : { altitude }),
118
+ velocity: a.velocity ?? b.velocity,
119
+ };
120
+ }
121
+
122
+ function isRecord(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value);
124
+ }
125
+
126
+ function parseWaypoint(value: unknown, index: number): RouteWaypoint {
127
+ if (!isRecord(value)) throw new Error(`waypoint ${index + 1} must be an object`);
128
+ const latitude = finiteNumber(value.latitude ?? value.lat, `waypoint ${index + 1} latitude`);
129
+ const longitude = finiteNumber(
130
+ value.longitude ?? value.lng ?? value.lon,
131
+ `waypoint ${index + 1} longitude`,
132
+ );
133
+ const altitude = optionalNumber(value.altitude ?? value.alt ?? value.ele, `waypoint ${index + 1} altitude`);
134
+
135
+ if (latitude < -90 || latitude > 90) {
136
+ throw new Error(`waypoint ${index + 1} latitude must be between -90 and 90`);
137
+ }
138
+ if (longitude < -180 || longitude > 180) {
139
+ throw new Error(`waypoint ${index + 1} longitude must be between -180 and 180`);
140
+ }
141
+ if (altitude !== undefined && (altitude < -1000 || altitude > 100000)) {
142
+ throw new Error(`waypoint ${index + 1} altitude must be between -1000 and 100000`);
143
+ }
144
+
145
+ return { latitude, longitude, ...(altitude === undefined ? {} : { altitude }) };
146
+ }
147
+
148
+ function prepareRoute(waypoints: RouteWaypoint[]): PreparedRoute {
149
+ const cumulativeMeters = [0];
150
+ let totalMeters = 0;
151
+ for (let i = 1; i < waypoints.length; i++) {
152
+ totalMeters += distanceMeters(waypoints[i - 1], waypoints[i]);
153
+ cumulativeMeters.push(totalMeters);
154
+ }
155
+ return { waypoints, cumulativeMeters, totalMeters };
156
+ }
157
+
158
+ function segmentForProgress(cumulativeMeters: number[], progress: number): number {
159
+ let low = 1;
160
+ let high = cumulativeMeters.length - 1;
161
+ while (low < high) {
162
+ const mid = Math.floor((low + high) / 2);
163
+ if (cumulativeMeters[mid] >= progress) high = mid;
164
+ else low = mid + 1;
165
+ }
166
+ return low;
167
+ }
168
+
169
+ function locationAt(route: PreparedRoute, progressMeters: number): GeoFix {
170
+ if (route.waypoints.length === 1 || route.totalMeters === 0) return route.waypoints[0];
171
+ const progress = clamp(progressMeters, 0, route.totalMeters);
172
+ const segment = segmentForProgress(route.cumulativeMeters, progress);
173
+ const startMeters = route.cumulativeMeters[segment - 1];
174
+ const endMeters = route.cumulativeMeters[segment];
175
+ const t = endMeters === startMeters ? 0 : (progress - startMeters) / (endMeters - startMeters);
176
+ return interpolate(route.waypoints[segment - 1], route.waypoints[segment], t);
177
+ }
178
+
179
+ export function parseRoutePlaybackRequest(value: unknown): RoutePlaybackRequest {
180
+ if (!isRecord(value)) throw new Error("route payload must be an object");
181
+ if (!Array.isArray(value.waypoints)) throw new Error("waypoints must be an array");
182
+ if (value.waypoints.length < 1) throw new Error("route must include at least one waypoint");
183
+ if (value.waypoints.length > MAX_WAYPOINTS) throw new Error(`route cannot exceed ${MAX_WAYPOINTS} waypoints`);
184
+
185
+ const speedKph = optionalNumber(value.speedKph, "speedKph") ?? DEFAULT_SPEED_KPH;
186
+ const multiplier = optionalNumber(value.multiplier, "multiplier") ?? 1;
187
+ const intervalMs = optionalNumber(value.intervalMs, "intervalMs") ?? DEFAULT_INTERVAL_MS;
188
+ if (speedKph <= 0 || speedKph > 500) throw new Error("speedKph must be between 0 and 500");
189
+ if (multiplier <= 0 || multiplier > 100) throw new Error("multiplier must be between 0 and 100");
190
+ if (intervalMs < MIN_INTERVAL_MS || intervalMs > MAX_INTERVAL_MS) {
191
+ throw new Error(`intervalMs must be between ${MIN_INTERVAL_MS} and ${MAX_INTERVAL_MS}`);
192
+ }
193
+
194
+ return {
195
+ waypoints: value.waypoints.map(parseWaypoint),
196
+ speedKph,
197
+ multiplier,
198
+ intervalMs: Math.round(intervalMs),
199
+ loop: value.loop === true,
200
+ };
201
+ }
202
+
203
+ export class RoutePlayback {
204
+ #applyLocation: RoutePlaybackOpts["applyLocation"];
205
+ #onLocation: RoutePlaybackOpts["onLocation"];
206
+ #route: PreparedRoute | null = null;
207
+ #timer: ReturnType<typeof setInterval> | null = null;
208
+ #status: RoutePlaybackStatus = "idle";
209
+ #speedKph = DEFAULT_SPEED_KPH;
210
+ #multiplier = 1;
211
+ #intervalMs = DEFAULT_INTERVAL_MS;
212
+ #loop = false;
213
+ #progressMeters = 0;
214
+ #lastTickMs = 0;
215
+ #startedAt: string | null = null;
216
+ #updatedAt: string | null = null;
217
+ #pausedAt: string | null = null;
218
+ #completedAt: string | null = null;
219
+ #lastError: string | null = null;
220
+ #currentLocation: (GeoFix & { appliedAt: string }) | null = null;
221
+ #applying = false;
222
+ #applyId = 0;
223
+
224
+ constructor(opts: RoutePlaybackOpts) {
225
+ this.#applyLocation = opts.applyLocation;
226
+ this.#onLocation = opts.onLocation;
227
+ }
228
+
229
+ async start(request: RoutePlaybackRequest): Promise<RoutePlaybackSnapshot> {
230
+ this.stop();
231
+ this.#route = prepareRoute(request.waypoints);
232
+ this.#speedKph = request.speedKph ?? DEFAULT_SPEED_KPH;
233
+ this.#multiplier = request.multiplier ?? 1;
234
+ this.#intervalMs = request.intervalMs ?? DEFAULT_INTERVAL_MS;
235
+ this.#loop = request.loop ?? false;
236
+ this.#progressMeters = 0;
237
+ this.#lastTickMs = Date.now();
238
+ this.#status = "running";
239
+ this.#startedAt = new Date(this.#lastTickMs).toISOString();
240
+ this.#updatedAt = this.#startedAt;
241
+ this.#pausedAt = null;
242
+ this.#completedAt = null;
243
+ this.#lastError = null;
244
+ await this.#applyCurrentLocation();
245
+ if (this.#status === "running") {
246
+ this.#timer = setInterval(() => this.#tick(), this.#intervalMs);
247
+ }
248
+ return this.snapshot();
249
+ }
250
+
251
+ pause(): RoutePlaybackSnapshot {
252
+ if (this.#status === "running") {
253
+ this.#status = "paused";
254
+ this.#pausedAt = new Date().toISOString();
255
+ this.#clearTimer();
256
+ }
257
+ return this.snapshot();
258
+ }
259
+
260
+ resume(): RoutePlaybackSnapshot {
261
+ if (this.#status === "paused" && this.#route) {
262
+ this.#status = "running";
263
+ this.#pausedAt = null;
264
+ this.#lastTickMs = Date.now();
265
+ this.#timer = setInterval(() => this.#tick(), this.#intervalMs);
266
+ }
267
+ return this.snapshot();
268
+ }
269
+
270
+ stop(): RoutePlaybackSnapshot {
271
+ this.#clearTimer();
272
+ this.#applyId++;
273
+ this.#applying = false;
274
+ this.#route = null;
275
+ this.#status = "idle";
276
+ this.#progressMeters = 0;
277
+ this.#startedAt = null;
278
+ this.#updatedAt = null;
279
+ this.#pausedAt = null;
280
+ this.#completedAt = null;
281
+ this.#lastError = null;
282
+ return this.snapshot();
283
+ }
284
+
285
+ snapshot(): RoutePlaybackSnapshot {
286
+ return {
287
+ status: this.#status,
288
+ waypointCount: this.#route?.waypoints.length ?? 0,
289
+ totalMeters: this.#route?.totalMeters ?? 0,
290
+ progressMeters: this.#progressMeters,
291
+ speedKph: this.#speedKph,
292
+ multiplier: this.#multiplier,
293
+ intervalMs: this.#intervalMs,
294
+ loop: this.#loop,
295
+ startedAt: this.#startedAt,
296
+ updatedAt: this.#updatedAt,
297
+ pausedAt: this.#pausedAt,
298
+ completedAt: this.#completedAt,
299
+ lastError: this.#lastError,
300
+ currentLocation: this.#currentLocation,
301
+ };
302
+ }
303
+
304
+ close(): void {
305
+ this.#clearTimer();
306
+ }
307
+
308
+ #tick(): void {
309
+ void this.#tickNow();
310
+ }
311
+
312
+ async #tickNow(): Promise<void> {
313
+ if (!this.#route || this.#status !== "running" || this.#applying) return;
314
+ const now = Date.now();
315
+ const elapsedSeconds = Math.max(0, (now - this.#lastTickMs) / 1000);
316
+ this.#lastTickMs = now;
317
+ this.#progressMeters += (this.#speedKph * 1000 * elapsedSeconds * this.#multiplier) / 3600;
318
+
319
+ if (this.#route.totalMeters === 0 || this.#progressMeters >= this.#route.totalMeters) {
320
+ if (this.#loop && this.#route.totalMeters > 0) {
321
+ this.#progressMeters %= this.#route.totalMeters;
322
+ } else {
323
+ this.#progressMeters = this.#route.totalMeters;
324
+ this.#status = "completed";
325
+ this.#completedAt = new Date(now).toISOString();
326
+ this.#clearTimer();
327
+ }
328
+ }
329
+ await this.#applyCurrentLocation();
330
+ }
331
+
332
+ async #applyCurrentLocation(): Promise<void> {
333
+ const route = this.#route;
334
+ if (!route) return;
335
+ const applyId = ++this.#applyId;
336
+ this.#applying = true;
337
+ try {
338
+ const fix = locationAt(route, this.#progressMeters);
339
+ await this.#applyLocation(fix);
340
+ if (this.#route !== route || this.#applyId !== applyId) return;
341
+ this.#currentLocation = { ...fix, appliedAt: new Date().toISOString() };
342
+ this.#updatedAt = this.#currentLocation.appliedAt;
343
+ this.#onLocation(this.#currentLocation);
344
+ } catch (err) {
345
+ if (this.#route === route && this.#applyId === applyId) {
346
+ this.#status = "error";
347
+ this.#lastError = err instanceof Error ? err.message : String(err);
348
+ this.#clearTimer();
349
+ }
350
+ } finally {
351
+ if (this.#applyId === applyId) this.#applying = false;
352
+ }
353
+ }
354
+
355
+ #clearTimer(): void {
356
+ if (this.#timer) clearInterval(this.#timer);
357
+ this.#timer = null;
358
+ }
359
+ }