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
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type GridDeviceKind = "physical" | "emulator" | "avd";
|
|
4
|
+
|
|
5
|
+
type GridDevice = {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: GridDeviceKind;
|
|
8
|
+
serial: string | null;
|
|
9
|
+
avd: string | null;
|
|
10
|
+
name: string;
|
|
11
|
+
state: string;
|
|
12
|
+
current: boolean;
|
|
13
|
+
canSelect: boolean;
|
|
14
|
+
canStart: boolean;
|
|
15
|
+
canStop: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type DeviceGridResponse = {
|
|
19
|
+
ok?: boolean;
|
|
20
|
+
currentSerial?: string;
|
|
21
|
+
sessionStatus?: "streaming" | "stopped" | "error";
|
|
22
|
+
devices?: GridDevice[];
|
|
23
|
+
error?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Orientation = "auto" | "portrait" | "landscape";
|
|
27
|
+
type OrientationResponse = {
|
|
28
|
+
ok?: boolean;
|
|
29
|
+
orientation?: { orientation?: Orientation | "unknown"; raw?: string };
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type NightMode = "auto" | "dark" | "light";
|
|
34
|
+
type NightModeResponse = {
|
|
35
|
+
ok?: boolean;
|
|
36
|
+
nightMode?: { mode?: NightMode | "unknown"; raw?: string };
|
|
37
|
+
error?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type FontScaleResponse = {
|
|
41
|
+
ok?: boolean;
|
|
42
|
+
fontScale?: { scale?: number; raw?: string };
|
|
43
|
+
error?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type NetworkResponse = {
|
|
47
|
+
ok?: boolean;
|
|
48
|
+
network?: {
|
|
49
|
+
enabled?: boolean | null;
|
|
50
|
+
wifi?: "enabled" | "disabled" | "unknown";
|
|
51
|
+
mobileData?: "enabled" | "disabled" | "unknown";
|
|
52
|
+
};
|
|
53
|
+
error?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type BusyAction = "select" | "start" | "stop";
|
|
57
|
+
const FONT_SCALE_PRESETS = [0.85, 1, 1.15, 1.3, 1.5] as const;
|
|
58
|
+
|
|
59
|
+
export function DevicePanel() {
|
|
60
|
+
const [devices, setDevices] = useState<GridDevice[]>([]);
|
|
61
|
+
const [status, setStatus] = useState("Loading...");
|
|
62
|
+
const [sessionStatus, setSessionStatus] = useState<DeviceGridResponse["sessionStatus"]>("streaming");
|
|
63
|
+
const [query, setQuery] = useState("");
|
|
64
|
+
const [busy, setBusy] = useState<Record<string, BusyAction | undefined>>({});
|
|
65
|
+
|
|
66
|
+
const refresh = useCallback(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch("/api/device-grid", { cache: "no-store" });
|
|
69
|
+
const json = await res.json() as DeviceGridResponse;
|
|
70
|
+
if (!json.ok || !json.devices) {
|
|
71
|
+
setDevices([]);
|
|
72
|
+
setStatus(json.error || "Unavailable");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setDevices(json.devices);
|
|
76
|
+
setSessionStatus(json.sessionStatus ?? "streaming");
|
|
77
|
+
const running = json.devices.filter((device) => device.serial && device.state === "device").length;
|
|
78
|
+
setStatus(`${running}/${json.devices.length} ready`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
setDevices([]);
|
|
81
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
82
|
+
}
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const runDeviceAction = useCallback(
|
|
86
|
+
async (device: GridDevice, action: BusyAction) => {
|
|
87
|
+
setBusy((current) => ({ ...current, [device.id]: action }));
|
|
88
|
+
setStatus(action === "select" ? "Switching..." : action === "start" ? "Starting..." : "Stopping...");
|
|
89
|
+
try {
|
|
90
|
+
const endpoint =
|
|
91
|
+
action === "select"
|
|
92
|
+
? "/api/devices/select"
|
|
93
|
+
: action === "start"
|
|
94
|
+
? "/api/avds/start"
|
|
95
|
+
: "/api/avds/stop";
|
|
96
|
+
const body =
|
|
97
|
+
action === "select"
|
|
98
|
+
? { serial: device.serial }
|
|
99
|
+
: action === "start"
|
|
100
|
+
? { avd: device.avd ?? device.name }
|
|
101
|
+
: { serial: device.serial, avd: device.avd ?? undefined };
|
|
102
|
+
const res = await fetch(endpoint, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
});
|
|
107
|
+
const json = await res.json() as { ok?: boolean; error?: string };
|
|
108
|
+
if (!json.ok) throw new Error(json.error || "Action failed");
|
|
109
|
+
await refresh();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
112
|
+
} finally {
|
|
113
|
+
setBusy((current) => {
|
|
114
|
+
const next = { ...current };
|
|
115
|
+
delete next[device.id];
|
|
116
|
+
return next;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[refresh],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const filtered = useMemo(() => {
|
|
124
|
+
const needle = query.trim().replace(/^\/+/, "").toLowerCase();
|
|
125
|
+
if (!needle) return devices;
|
|
126
|
+
return devices.filter((device) =>
|
|
127
|
+
[device.name, device.serial ?? "", device.avd ?? "", device.kind, device.state]
|
|
128
|
+
.join(" ")
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.includes(needle),
|
|
131
|
+
);
|
|
132
|
+
}, [devices, query]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
void refresh();
|
|
136
|
+
const timer = setInterval(() => {
|
|
137
|
+
void refresh();
|
|
138
|
+
}, 3000);
|
|
139
|
+
return () => clearInterval(timer);
|
|
140
|
+
}, [refresh]);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<section className="device-panel">
|
|
144
|
+
<div className="panel-heading">
|
|
145
|
+
<h2>Devices</h2>
|
|
146
|
+
<div className="location-status">{status}</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div className="device-search">
|
|
150
|
+
<input
|
|
151
|
+
value={query}
|
|
152
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
153
|
+
placeholder="Search devices and AVDs"
|
|
154
|
+
/>
|
|
155
|
+
{query ? <button onClick={() => setQuery("")}>Clear</button> : null}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="device-list android-grid-list">
|
|
159
|
+
{filtered.length === 0 ? (
|
|
160
|
+
<div className="device-empty">{query ? "No matching Android targets." : "No Android targets found."}</div>
|
|
161
|
+
) : (
|
|
162
|
+
filtered.map((device) => (
|
|
163
|
+
<DeviceRow
|
|
164
|
+
key={device.id}
|
|
165
|
+
device={device}
|
|
166
|
+
sessionStatus={sessionStatus}
|
|
167
|
+
busy={busy[device.id]}
|
|
168
|
+
onSelect={() => void runDeviceAction(device, "select")}
|
|
169
|
+
onStart={() => void runDeviceAction(device, "start")}
|
|
170
|
+
onStop={() => void runDeviceAction(device, "stop")}
|
|
171
|
+
/>
|
|
172
|
+
))
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<button onClick={() => void refresh()}>Refresh Devices</button>
|
|
177
|
+
</section>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function OrientationPanel() {
|
|
182
|
+
const [orientation, setOrientation] = useState<Orientation | "unknown">("unknown");
|
|
183
|
+
const [orientationStatus, setOrientationStatus] = useState("Loading...");
|
|
184
|
+
|
|
185
|
+
const refreshOrientation = useCallback(async () => {
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetch("/api/orientation", { cache: "no-store" });
|
|
188
|
+
const json = await res.json() as OrientationResponse;
|
|
189
|
+
if (!json.ok || !json.orientation) {
|
|
190
|
+
setOrientation("unknown");
|
|
191
|
+
setOrientationStatus(json.error || "Unavailable");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const next = json.orientation.orientation ?? "unknown";
|
|
195
|
+
setOrientation(next);
|
|
196
|
+
setOrientationStatus(next === "unknown" ? json.orientation.raw || "Unknown" : next);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
setOrientation("unknown");
|
|
199
|
+
setOrientationStatus(err instanceof Error ? err.message : String(err));
|
|
200
|
+
}
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
const setDeviceOrientation = useCallback(async (next: Orientation) => {
|
|
204
|
+
setOrientationStatus("Applying...");
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch("/api/orientation", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ orientation: next }),
|
|
210
|
+
});
|
|
211
|
+
const json = await res.json() as OrientationResponse;
|
|
212
|
+
if (!json.ok || !json.orientation) {
|
|
213
|
+
setOrientationStatus(json.error || "Failed");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const applied = json.orientation.orientation ?? "unknown";
|
|
217
|
+
setOrientation(applied);
|
|
218
|
+
setOrientationStatus(applied === "unknown" ? json.orientation.raw || "Unknown" : applied);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
setOrientationStatus(err instanceof Error ? err.message : String(err));
|
|
221
|
+
}
|
|
222
|
+
}, []);
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
void refreshOrientation();
|
|
226
|
+
const timer = setInterval(() => void refreshOrientation(), 3000);
|
|
227
|
+
return () => clearInterval(timer);
|
|
228
|
+
}, [refreshOrientation]);
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<section className="tool-panel orientation-panel">
|
|
232
|
+
<div className="panel-heading">
|
|
233
|
+
<h2>Orientation</h2>
|
|
234
|
+
<div className="location-status">{orientationStatus}</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="segmented-row">
|
|
237
|
+
<button
|
|
238
|
+
className={orientation === "portrait" ? "selected" : ""}
|
|
239
|
+
onClick={() => void setDeviceOrientation("portrait")}
|
|
240
|
+
>
|
|
241
|
+
Portrait
|
|
242
|
+
</button>
|
|
243
|
+
<button
|
|
244
|
+
className={orientation === "landscape" ? "selected" : ""}
|
|
245
|
+
onClick={() => void setDeviceOrientation("landscape")}
|
|
246
|
+
>
|
|
247
|
+
Landscape
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
250
|
+
className={orientation === "auto" ? "selected" : ""}
|
|
251
|
+
onClick={() => void setDeviceOrientation("auto")}
|
|
252
|
+
>
|
|
253
|
+
Auto
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
</section>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function NightModePanel() {
|
|
261
|
+
const [nightMode, setNightMode] = useState<NightMode | "unknown">("unknown");
|
|
262
|
+
const [nightModeStatus, setNightModeStatus] = useState("Loading...");
|
|
263
|
+
|
|
264
|
+
const refreshNightMode = useCallback(async () => {
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch("/api/night-mode", { cache: "no-store" });
|
|
267
|
+
const json = await res.json() as NightModeResponse;
|
|
268
|
+
if (!json.ok || !json.nightMode) {
|
|
269
|
+
setNightMode("unknown");
|
|
270
|
+
setNightModeStatus(json.error || "Unavailable");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const next = json.nightMode.mode ?? "unknown";
|
|
274
|
+
setNightMode(next);
|
|
275
|
+
setNightModeStatus(next === "unknown" ? json.nightMode.raw || "Unknown" : next);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
setNightMode("unknown");
|
|
278
|
+
setNightModeStatus(err instanceof Error ? err.message : String(err));
|
|
279
|
+
}
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
const setDeviceNightMode = useCallback(async (next: NightMode) => {
|
|
283
|
+
setNightModeStatus("Applying...");
|
|
284
|
+
try {
|
|
285
|
+
const res = await fetch("/api/night-mode", {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: { "Content-Type": "application/json" },
|
|
288
|
+
body: JSON.stringify({ mode: next }),
|
|
289
|
+
});
|
|
290
|
+
const json = await res.json() as NightModeResponse;
|
|
291
|
+
if (!json.ok || !json.nightMode) {
|
|
292
|
+
setNightModeStatus(json.error || "Failed");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const applied = json.nightMode.mode ?? "unknown";
|
|
296
|
+
setNightMode(applied);
|
|
297
|
+
setNightModeStatus(applied === "unknown" ? json.nightMode.raw || "Unknown" : applied);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
setNightModeStatus(err instanceof Error ? err.message : String(err));
|
|
300
|
+
}
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
void refreshNightMode();
|
|
305
|
+
const timer = setInterval(() => void refreshNightMode(), 3000);
|
|
306
|
+
return () => clearInterval(timer);
|
|
307
|
+
}, [refreshNightMode]);
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<section className="tool-panel night-mode-panel">
|
|
311
|
+
<div className="panel-heading">
|
|
312
|
+
<h2>Theme</h2>
|
|
313
|
+
<div className="location-status">{nightModeStatus}</div>
|
|
314
|
+
</div>
|
|
315
|
+
<div className="segmented-row">
|
|
316
|
+
<button
|
|
317
|
+
className={nightMode === "dark" ? "selected" : ""}
|
|
318
|
+
onClick={() => void setDeviceNightMode("dark")}
|
|
319
|
+
>
|
|
320
|
+
Dark
|
|
321
|
+
</button>
|
|
322
|
+
<button
|
|
323
|
+
className={nightMode === "light" ? "selected" : ""}
|
|
324
|
+
onClick={() => void setDeviceNightMode("light")}
|
|
325
|
+
>
|
|
326
|
+
Light
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
className={nightMode === "auto" ? "selected" : ""}
|
|
330
|
+
onClick={() => void setDeviceNightMode("auto")}
|
|
331
|
+
>
|
|
332
|
+
Auto
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
</section>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function FontScalePanel() {
|
|
340
|
+
const [fontScale, setFontScale] = useState<number | null>(null);
|
|
341
|
+
const [fontScaleStatus, setFontScaleStatus] = useState("Loading...");
|
|
342
|
+
|
|
343
|
+
const refreshFontScale = useCallback(async () => {
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch("/api/font-scale", { cache: "no-store" });
|
|
346
|
+
const json = await res.json() as FontScaleResponse;
|
|
347
|
+
if (!json.ok || !json.fontScale || typeof json.fontScale.scale !== "number") {
|
|
348
|
+
setFontScale(null);
|
|
349
|
+
setFontScaleStatus(json.error || "Unavailable");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
setFontScale(json.fontScale.scale);
|
|
353
|
+
setFontScaleStatus(`${Math.round(json.fontScale.scale * 100)}%`);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
setFontScale(null);
|
|
356
|
+
setFontScaleStatus(err instanceof Error ? err.message : String(err));
|
|
357
|
+
}
|
|
358
|
+
}, []);
|
|
359
|
+
|
|
360
|
+
const setDeviceFontScale = useCallback(async (next: number) => {
|
|
361
|
+
setFontScaleStatus("Applying...");
|
|
362
|
+
try {
|
|
363
|
+
const res = await fetch("/api/font-scale", {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: { "Content-Type": "application/json" },
|
|
366
|
+
body: JSON.stringify({ scale: next }),
|
|
367
|
+
});
|
|
368
|
+
const json = await res.json() as FontScaleResponse;
|
|
369
|
+
if (!json.ok || !json.fontScale || typeof json.fontScale.scale !== "number") {
|
|
370
|
+
setFontScaleStatus(json.error || "Failed");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
setFontScale(json.fontScale.scale);
|
|
374
|
+
setFontScaleStatus(`${Math.round(json.fontScale.scale * 100)}%`);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
setFontScaleStatus(err instanceof Error ? err.message : String(err));
|
|
377
|
+
}
|
|
378
|
+
}, []);
|
|
379
|
+
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
void refreshFontScale();
|
|
382
|
+
const timer = setInterval(() => void refreshFontScale(), 3000);
|
|
383
|
+
return () => clearInterval(timer);
|
|
384
|
+
}, [refreshFontScale]);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<section className="tool-panel font-scale-panel">
|
|
388
|
+
<div className="panel-heading">
|
|
389
|
+
<h2>Font Size</h2>
|
|
390
|
+
<div className="location-status">{fontScaleStatus}</div>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="font-scale-row">
|
|
393
|
+
{FONT_SCALE_PRESETS.map((scale) => (
|
|
394
|
+
<button
|
|
395
|
+
key={scale}
|
|
396
|
+
className={fontScale !== null && Math.abs(fontScale - scale) < 0.01 ? "selected" : ""}
|
|
397
|
+
onClick={() => void setDeviceFontScale(scale)}
|
|
398
|
+
>
|
|
399
|
+
{Math.round(scale * 100)}%
|
|
400
|
+
</button>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
</section>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function networkLabel(network: NonNullable<NetworkResponse["network"]>): string {
|
|
408
|
+
const state = network.enabled === true ? "on" : network.enabled === false ? "off" : "unknown";
|
|
409
|
+
const wifi = network.wifi && network.wifi !== "unknown" ? `wifi ${network.wifi}` : "wifi ?";
|
|
410
|
+
const mobileData =
|
|
411
|
+
network.mobileData && network.mobileData !== "unknown" ? `data ${network.mobileData}` : "data ?";
|
|
412
|
+
return `${state} (${wifi}, ${mobileData})`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function NetworkPanel() {
|
|
416
|
+
const [enabled, setEnabled] = useState<boolean | null>(null);
|
|
417
|
+
const [networkStatus, setNetworkStatus] = useState("Loading...");
|
|
418
|
+
|
|
419
|
+
const refreshNetwork = useCallback(async () => {
|
|
420
|
+
try {
|
|
421
|
+
const res = await fetch("/api/network", { cache: "no-store" });
|
|
422
|
+
const json = await res.json() as NetworkResponse;
|
|
423
|
+
if (!json.ok || !json.network) {
|
|
424
|
+
setEnabled(null);
|
|
425
|
+
setNetworkStatus(json.error || "Unavailable");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
setEnabled(json.network.enabled ?? null);
|
|
429
|
+
setNetworkStatus(networkLabel(json.network));
|
|
430
|
+
} catch (err) {
|
|
431
|
+
setEnabled(null);
|
|
432
|
+
setNetworkStatus(err instanceof Error ? err.message : String(err));
|
|
433
|
+
}
|
|
434
|
+
}, []);
|
|
435
|
+
|
|
436
|
+
const setDeviceNetwork = useCallback(async (next: boolean) => {
|
|
437
|
+
setNetworkStatus("Applying...");
|
|
438
|
+
try {
|
|
439
|
+
const res = await fetch("/api/network", {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers: { "Content-Type": "application/json" },
|
|
442
|
+
body: JSON.stringify({ enabled: next }),
|
|
443
|
+
});
|
|
444
|
+
const json = await res.json() as NetworkResponse;
|
|
445
|
+
if (!json.ok || !json.network) {
|
|
446
|
+
setNetworkStatus(json.error || "Failed");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
setEnabled(json.network.enabled ?? null);
|
|
450
|
+
setNetworkStatus(networkLabel(json.network));
|
|
451
|
+
} catch (err) {
|
|
452
|
+
setNetworkStatus(err instanceof Error ? err.message : String(err));
|
|
453
|
+
}
|
|
454
|
+
}, []);
|
|
455
|
+
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
void refreshNetwork();
|
|
458
|
+
const timer = setInterval(() => void refreshNetwork(), 5000);
|
|
459
|
+
return () => clearInterval(timer);
|
|
460
|
+
}, [refreshNetwork]);
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<section className="tool-panel network-panel">
|
|
464
|
+
<div className="panel-heading">
|
|
465
|
+
<h2>Network</h2>
|
|
466
|
+
<div className="location-status">{networkStatus}</div>
|
|
467
|
+
</div>
|
|
468
|
+
<div className="segmented-row network-row">
|
|
469
|
+
<button
|
|
470
|
+
className={enabled === true ? "selected" : ""}
|
|
471
|
+
onClick={() => void setDeviceNetwork(true)}
|
|
472
|
+
>
|
|
473
|
+
On
|
|
474
|
+
</button>
|
|
475
|
+
<button
|
|
476
|
+
className={enabled === false ? "selected" : ""}
|
|
477
|
+
onClick={() => void setDeviceNetwork(false)}
|
|
478
|
+
>
|
|
479
|
+
Off
|
|
480
|
+
</button>
|
|
481
|
+
</div>
|
|
482
|
+
</section>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function DeviceRow({
|
|
487
|
+
device,
|
|
488
|
+
sessionStatus,
|
|
489
|
+
busy,
|
|
490
|
+
onSelect,
|
|
491
|
+
onStart,
|
|
492
|
+
onStop,
|
|
493
|
+
}: {
|
|
494
|
+
device: GridDevice;
|
|
495
|
+
sessionStatus: DeviceGridResponse["sessionStatus"];
|
|
496
|
+
busy: BusyAction | undefined;
|
|
497
|
+
onSelect: () => void;
|
|
498
|
+
onStart: () => void;
|
|
499
|
+
onStop: () => void;
|
|
500
|
+
}) {
|
|
501
|
+
const isLiveCurrent = device.current && sessionStatus === "streaming";
|
|
502
|
+
const status = device.current ? (sessionStatus ?? "streaming") : device.state;
|
|
503
|
+
const title = device.kind === "avd" ? "AVD" : device.kind === "emulator" ? "EMU" : "USB";
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<div className={device.current ? "device-row grid-device-row current" : "device-row grid-device-row"}>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
className="device-row-main"
|
|
510
|
+
disabled={!device.canSelect || Boolean(busy) || isLiveCurrent}
|
|
511
|
+
onClick={onSelect}
|
|
512
|
+
>
|
|
513
|
+
<span className="device-kind" title={device.kind}>{title}</span>
|
|
514
|
+
<span className="device-name">{device.name}</span>
|
|
515
|
+
<span className="device-subtitle">{device.serial ?? device.avd ?? "not running"}</span>
|
|
516
|
+
</button>
|
|
517
|
+
<div className="device-row-actions">
|
|
518
|
+
<code>{busy ?? status}</code>
|
|
519
|
+
{device.canStart ? (
|
|
520
|
+
<button disabled={Boolean(busy)} onClick={onStart}>
|
|
521
|
+
Start
|
|
522
|
+
</button>
|
|
523
|
+
) : null}
|
|
524
|
+
{device.canStop ? (
|
|
525
|
+
<button disabled={Boolean(busy)} onClick={onStop}>
|
|
526
|
+
Stop
|
|
527
|
+
</button>
|
|
528
|
+
) : null}
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import type { PointerEvent, RefObject } from "react";
|
|
3
|
+
import type { Sender } from "../lib/use-stream";
|
|
4
|
+
import type { AccessibilityNode } from "./accessibility-panel";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
canvasRef: RefObject<HTMLCanvasElement>;
|
|
8
|
+
send: Sender;
|
|
9
|
+
accessibilityNodes?: AccessibilityNode[];
|
|
10
|
+
accessibilityEnabled?: boolean;
|
|
11
|
+
highlightedAccessibilityId?: string | null;
|
|
12
|
+
deviceSize?: { width: number; height: number } | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Point = { x: number; y: number };
|
|
16
|
+
|
|
17
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
18
|
+
|
|
19
|
+
export function DeviceStream({
|
|
20
|
+
canvasRef,
|
|
21
|
+
send,
|
|
22
|
+
accessibilityNodes = [],
|
|
23
|
+
accessibilityEnabled = false,
|
|
24
|
+
highlightedAccessibilityId = null,
|
|
25
|
+
deviceSize = null,
|
|
26
|
+
}: Props) {
|
|
27
|
+
const activeRef = useRef<{ id: number; x: number; y: number } | null>(null);
|
|
28
|
+
const pendingMoveRef = useRef<Point | null>(null);
|
|
29
|
+
const moveRafRef = useRef(0);
|
|
30
|
+
|
|
31
|
+
const pointFromClient = (clientX: number, clientY: number): Point | null => {
|
|
32
|
+
const canvas = canvasRef.current;
|
|
33
|
+
if (!canvas) return null;
|
|
34
|
+
const r = canvas.getBoundingClientRect();
|
|
35
|
+
return {
|
|
36
|
+
x: clamp01((clientX - r.left) / r.width),
|
|
37
|
+
y: clamp01((clientY - r.top) / r.height),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const norm = (e: PointerEvent<HTMLCanvasElement>): Point | null =>
|
|
42
|
+
pointFromClient(e.clientX, e.clientY);
|
|
43
|
+
|
|
44
|
+
const sendTouch = (action: "down" | "move" | "up", p: Point, pointerId: number) => {
|
|
45
|
+
send({ type: "touch", action, x: p.x, y: p.y, pointerId }, false);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const flushMove = () => {
|
|
49
|
+
moveRafRef.current = 0;
|
|
50
|
+
const active = activeRef.current;
|
|
51
|
+
const next = pendingMoveRef.current;
|
|
52
|
+
if (!active || !next) return;
|
|
53
|
+
pendingMoveRef.current = null;
|
|
54
|
+
active.x = next.x;
|
|
55
|
+
active.y = next.y;
|
|
56
|
+
sendTouch("move", next, active.id);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const queueMove = (p: Point) => {
|
|
60
|
+
pendingMoveRef.current = p;
|
|
61
|
+
if (!moveRafRef.current) {
|
|
62
|
+
moveRafRef.current = requestAnimationFrame(flushMove);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onPointerDown = (e: PointerEvent<HTMLCanvasElement>) => {
|
|
67
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
68
|
+
if (activeRef.current) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
canvasRef.current?.setPointerCapture(e.pointerId);
|
|
71
|
+
const p = norm(e);
|
|
72
|
+
if (!p) return;
|
|
73
|
+
activeRef.current = { id: e.pointerId, ...p };
|
|
74
|
+
pendingMoveRef.current = null;
|
|
75
|
+
sendTouch("down", p, e.pointerId);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const onPointerMove = (e: PointerEvent<HTMLCanvasElement>) => {
|
|
79
|
+
const active = activeRef.current;
|
|
80
|
+
if (!active || e.pointerId !== active.id) return;
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
const native = e.nativeEvent;
|
|
83
|
+
const coalesced =
|
|
84
|
+
typeof native.getCoalescedEvents === "function" ? native.getCoalescedEvents() : null;
|
|
85
|
+
if (coalesced && coalesced.length > 0) {
|
|
86
|
+
const last = coalesced[coalesced.length - 1];
|
|
87
|
+
const p = pointFromClient(last.clientX, last.clientY);
|
|
88
|
+
if (p) queueMove(p);
|
|
89
|
+
} else {
|
|
90
|
+
const p = norm(e);
|
|
91
|
+
if (p) queueMove(p);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const stopPointer = (e: PointerEvent<HTMLCanvasElement>) => {
|
|
96
|
+
const active = activeRef.current;
|
|
97
|
+
if (!active || e.pointerId !== active.id) return;
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
if (moveRafRef.current) {
|
|
100
|
+
cancelAnimationFrame(moveRafRef.current);
|
|
101
|
+
flushMove();
|
|
102
|
+
}
|
|
103
|
+
const up = norm(e);
|
|
104
|
+
if (up) sendTouch("up", up, active.id);
|
|
105
|
+
try {
|
|
106
|
+
canvasRef.current?.releasePointerCapture(active.id);
|
|
107
|
+
} catch {}
|
|
108
|
+
activeRef.current = null;
|
|
109
|
+
pendingMoveRef.current = null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="stream-surface">
|
|
114
|
+
<canvas
|
|
115
|
+
ref={canvasRef}
|
|
116
|
+
onPointerDown={onPointerDown}
|
|
117
|
+
onPointerMove={onPointerMove}
|
|
118
|
+
onPointerUp={stopPointer}
|
|
119
|
+
onPointerCancel={stopPointer}
|
|
120
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
121
|
+
/>
|
|
122
|
+
{accessibilityEnabled && deviceSize && (
|
|
123
|
+
<div className="ax-overlay" aria-hidden="true">
|
|
124
|
+
{accessibilityNodes.map((node) => {
|
|
125
|
+
const left = (node.bounds.left / deviceSize.width) * 100;
|
|
126
|
+
const top = (node.bounds.top / deviceSize.height) * 100;
|
|
127
|
+
const width = ((node.bounds.right - node.bounds.left) / deviceSize.width) * 100;
|
|
128
|
+
const height = ((node.bounds.bottom - node.bounds.top) / deviceSize.height) * 100;
|
|
129
|
+
const active = node.id === highlightedAccessibilityId;
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
key={node.id}
|
|
133
|
+
className={active ? "ax-box active" : "ax-box"}
|
|
134
|
+
style={{ left: `${left}%`, top: `${top}%`, width: `${width}%`, height: `${height}%` }}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|