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,149 @@
1
+ import type { Gesture } from "./input.ts";
2
+ import type { GeoFix } from "./location.ts";
3
+
4
+ export type RecordedEvent =
5
+ | {
6
+ id: number;
7
+ at: string;
8
+ delayMs: number;
9
+ source: string;
10
+ kind: "gesture";
11
+ gesture: Gesture;
12
+ }
13
+ | {
14
+ id: number;
15
+ at: string;
16
+ delayMs: number;
17
+ source: string;
18
+ kind: "location";
19
+ location: GeoFix;
20
+ };
21
+
22
+ export type SessionSnapshot = {
23
+ events: RecordedEvent[];
24
+ recording: boolean;
25
+ replaying: boolean;
26
+ replayStartedAt: string | null;
27
+ replayCompletedAt: string | null;
28
+ lastError: string | null;
29
+ };
30
+
31
+ type ReplayHandlers = {
32
+ dispatchGesture: (gesture: Gesture) => Promise<void>;
33
+ setLocation: (fix: GeoFix) => Promise<void> | void;
34
+ };
35
+
36
+ const MAX_EVENTS = 2_000;
37
+
38
+ function sleep(ms: number) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+
42
+ export class SessionRecorder {
43
+ #events: RecordedEvent[] = [];
44
+ #nextId = 1;
45
+ #lastEventMs = 0;
46
+ #recording = true;
47
+ #replaying = false;
48
+ #stopReplay = false;
49
+ #replayStartedAt: string | null = null;
50
+ #replayCompletedAt: string | null = null;
51
+ #lastError: string | null = null;
52
+
53
+ get isReplaying(): boolean {
54
+ return this.#replaying;
55
+ }
56
+
57
+ recordGesture(gesture: Gesture, source: string): void {
58
+ this.#record({ kind: "gesture", gesture, source });
59
+ }
60
+
61
+ recordLocation(location: GeoFix, source: string): void {
62
+ this.#record({ kind: "location", location, source });
63
+ }
64
+
65
+ clear(): SessionSnapshot {
66
+ this.#events = [];
67
+ this.#lastEventMs = 0;
68
+ this.#lastError = null;
69
+ this.#replayCompletedAt = null;
70
+ return this.snapshot();
71
+ }
72
+
73
+ stopReplay(): SessionSnapshot {
74
+ this.#stopReplay = true;
75
+ return this.snapshot();
76
+ }
77
+
78
+ snapshot(): SessionSnapshot {
79
+ return {
80
+ events: this.#events,
81
+ recording: this.#recording,
82
+ replaying: this.#replaying,
83
+ replayStartedAt: this.#replayStartedAt,
84
+ replayCompletedAt: this.#replayCompletedAt,
85
+ lastError: this.#lastError,
86
+ };
87
+ }
88
+
89
+ async replay(handlers: ReplayHandlers, multiplier = 1): Promise<SessionSnapshot> {
90
+ if (this.#replaying) throw new Error("session replay is already running");
91
+ if (this.#events.length === 0) throw new Error("session has no recorded events");
92
+ if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) {
93
+ throw new Error("multiplier must be between 0 and 100");
94
+ }
95
+
96
+ const events = [...this.#events];
97
+ this.#replaying = true;
98
+ this.#stopReplay = false;
99
+ this.#replayStartedAt = new Date().toISOString();
100
+ this.#replayCompletedAt = null;
101
+ this.#lastError = null;
102
+
103
+ try {
104
+ for (const event of events) {
105
+ if (this.#stopReplay) break;
106
+ await sleep(Math.max(0, event.delayMs / multiplier));
107
+ if (event.kind === "gesture") {
108
+ await handlers.dispatchGesture(event.gesture);
109
+ } else {
110
+ await handlers.setLocation(event.location);
111
+ }
112
+ }
113
+ this.#replayCompletedAt = new Date().toISOString();
114
+ } catch (err) {
115
+ this.#lastError = err instanceof Error ? err.message : String(err);
116
+ throw err;
117
+ } finally {
118
+ this.#replaying = false;
119
+ this.#stopReplay = false;
120
+ }
121
+
122
+ return this.snapshot();
123
+ }
124
+
125
+ #record(
126
+ event:
127
+ | { kind: "gesture"; gesture: Gesture; source: string }
128
+ | { kind: "location"; location: GeoFix; source: string },
129
+ ): void {
130
+ if (!this.#recording || this.#replaying) return;
131
+ const now = Date.now();
132
+ const delayMs = this.#lastEventMs ? Math.max(0, now - this.#lastEventMs) : 0;
133
+ this.#lastEventMs = now;
134
+ const base = {
135
+ id: this.#nextId++,
136
+ at: new Date(now).toISOString(),
137
+ delayMs,
138
+ source: event.source,
139
+ };
140
+ this.#events.push(
141
+ event.kind === "gesture"
142
+ ? { ...base, kind: "gesture", gesture: event.gesture }
143
+ : { ...base, kind: "location", location: event.location },
144
+ );
145
+ if (this.#events.length > MAX_EVENTS) {
146
+ this.#events.splice(0, this.#events.length - MAX_EVENTS);
147
+ }
148
+ }
149
+ }
package/src/ui/app.tsx ADDED
@@ -0,0 +1,111 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { StatusBar } from "./components/status-bar";
3
+ import { AppManagementPanel } from "./components/app-management-panel";
4
+ import { AccessibilityPanel, type AccessibilityNode } from "./components/accessibility-panel";
5
+ import { DevicePanel, FontScalePanel, NetworkPanel, NightModePanel, OrientationPanel } from "./components/device-panel";
6
+ import { DeviceStream } from "./components/device-stream";
7
+ import { ControlBar, type HardwareKey } from "./components/control-bar";
8
+ import { LogcatPanel } from "./components/logcat-panel";
9
+ import { LocationPanel } from "./components/location-panel";
10
+ import { SessionPanel } from "./components/session-panel";
11
+ import { useStream } from "./lib/use-stream";
12
+
13
+ export function App() {
14
+ const canvasRef = useRef<HTMLCanvasElement>(null);
15
+ const { state, send } = useStream(canvasRef);
16
+ const [accessibilityEnabled, setAccessibilityEnabled] = useState(false);
17
+ const [accessibilityNodes, setAccessibilityNodes] = useState<AccessibilityNode[]>([]);
18
+ const [highlightedAccessibilityId, setHighlightedAccessibilityId] = useState<string | null>(null);
19
+ const [devicesOpen, setDevicesOpen] = useState(true);
20
+
21
+ useEffect(() => {
22
+ const onKey = (e: KeyboardEvent) => {
23
+ if (e.target !== document.body) return;
24
+ if (e.key === "Escape") {
25
+ send({ type: "back" });
26
+ return;
27
+ }
28
+ if (e.key === "Enter") {
29
+ send({ type: "key", keycode: 66 });
30
+ return;
31
+ }
32
+ if (e.key.length === 1) {
33
+ send({ type: "text", text: e.key });
34
+ }
35
+ };
36
+ document.addEventListener("keydown", onKey);
37
+ return () => document.removeEventListener("keydown", onKey);
38
+ }, [send]);
39
+
40
+ const onPress = useCallback(
41
+ (key: HardwareKey) => send({ type: key }),
42
+ [send],
43
+ );
44
+
45
+ return (
46
+ <>
47
+ <StatusBar status={state.status} deviceSize={state.deviceSize} fps={state.fps} />
48
+ <main className={devicesOpen ? "app-layout devices-open" : "app-layout devices-collapsed"}>
49
+ <aside className="device-sidebar" aria-label="Devices sidebar">
50
+ <div className="device-sidebar-header">
51
+ <button
52
+ type="button"
53
+ className="sidebar-toggle"
54
+ onClick={() => setDevicesOpen((open) => !open)}
55
+ aria-label={devicesOpen ? "Collapse devices sidebar" : "Expand devices sidebar"}
56
+ title={devicesOpen ? "Collapse devices" : "Expand devices"}
57
+ >
58
+ <SidebarIcon collapsed={!devicesOpen} />
59
+ </button>
60
+ {devicesOpen ? <span>Devices</span> : null}
61
+ </div>
62
+ {devicesOpen ? <DevicePanel /> : null}
63
+ </aside>
64
+ <div className="device">
65
+ <DeviceStream
66
+ canvasRef={canvasRef}
67
+ send={send}
68
+ accessibilityEnabled={accessibilityEnabled}
69
+ accessibilityNodes={accessibilityNodes}
70
+ highlightedAccessibilityId={highlightedAccessibilityId}
71
+ deviceSize={state.deviceSize}
72
+ />
73
+ </div>
74
+ <aside className="side-panel">
75
+ <NetworkPanel />
76
+ <NightModePanel />
77
+ <FontScalePanel />
78
+ <OrientationPanel />
79
+ <AccessibilityPanel
80
+ enabled={accessibilityEnabled}
81
+ nodes={accessibilityNodes}
82
+ highlightedId={highlightedAccessibilityId}
83
+ onEnabledChange={setAccessibilityEnabled}
84
+ onNodesChange={setAccessibilityNodes}
85
+ onHighlight={setHighlightedAccessibilityId}
86
+ />
87
+ <LocationPanel />
88
+ <AppManagementPanel />
89
+ <LogcatPanel />
90
+ <SessionPanel />
91
+ </aside>
92
+ </main>
93
+ <ControlBar onPress={onPress} />
94
+ </>
95
+ );
96
+ }
97
+
98
+ function SidebarIcon({ collapsed }: { collapsed: boolean }) {
99
+ return (
100
+ <svg
101
+ aria-hidden="true"
102
+ className={collapsed ? "sidebar-icon collapsed" : "sidebar-icon"}
103
+ viewBox="0 0 20 20"
104
+ fill="none"
105
+ >
106
+ <rect x="3" y="3" width="14" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
107
+ <path d="M8 3.75V16.25" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
108
+ <path d="M12.5 7.5L10 10L12.5 12.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
109
+ </svg>
110
+ );
111
+ }
@@ -0,0 +1,113 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export type AccessibilityNode = {
4
+ id: string;
5
+ text: string;
6
+ contentDescription: string;
7
+ resourceId: string;
8
+ className: string;
9
+ packageName: string;
10
+ clickable: boolean;
11
+ enabled: boolean;
12
+ bounds: { left: number; top: number; right: number; bottom: number };
13
+ };
14
+
15
+ type Props = {
16
+ enabled: boolean;
17
+ nodes: AccessibilityNode[];
18
+ highlightedId: string | null;
19
+ onEnabledChange: (enabled: boolean) => void;
20
+ onNodesChange: (nodes: AccessibilityNode[]) => void;
21
+ onHighlight: (id: string | null) => void;
22
+ };
23
+
24
+ function nodeLabel(node: AccessibilityNode): string {
25
+ return node.text || node.contentDescription || node.resourceId || node.className || "Unlabeled";
26
+ }
27
+
28
+ function nodeMeta(node: AccessibilityNode): string {
29
+ const role = node.className.split(".").pop() || "node";
30
+ const width = node.bounds.right - node.bounds.left;
31
+ const height = node.bounds.bottom - node.bounds.top;
32
+ return `${role} · ${width}x${height}`;
33
+ }
34
+
35
+ export function AccessibilityPanel({
36
+ enabled,
37
+ nodes,
38
+ highlightedId,
39
+ onEnabledChange,
40
+ onNodesChange,
41
+ onHighlight,
42
+ }: Props) {
43
+ const [status, setStatus] = useState("AX off");
44
+
45
+ const refresh = useCallback(async () => {
46
+ if (!enabled) return;
47
+ setStatus("Reading...");
48
+ try {
49
+ const res = await fetch("/api/accessibility", { cache: "no-store" });
50
+ const json = await res.json() as { ok?: boolean; nodes?: AccessibilityNode[]; error?: string };
51
+ if (!json.ok || !json.nodes) {
52
+ setStatus(json.error || "AX unavailable");
53
+ onNodesChange([]);
54
+ return;
55
+ }
56
+ onNodesChange(json.nodes);
57
+ setStatus(`${json.nodes.length} nodes`);
58
+ } catch (err) {
59
+ onNodesChange([]);
60
+ setStatus(err instanceof Error ? err.message : String(err));
61
+ }
62
+ }, [enabled, onNodesChange]);
63
+
64
+ useEffect(() => {
65
+ if (!enabled) {
66
+ onNodesChange([]);
67
+ onHighlight(null);
68
+ setStatus("AX off");
69
+ return;
70
+ }
71
+ void refresh();
72
+ const timer = setInterval(refresh, 3000);
73
+ return () => clearInterval(timer);
74
+ }, [enabled, refresh, onHighlight, onNodesChange]);
75
+
76
+ return (
77
+ <section className="tool-panel accessibility-panel">
78
+ <div className="panel-heading">
79
+ <h2>Accessibility</h2>
80
+ <div className="location-status">{status}</div>
81
+ </div>
82
+ <div className="panel-actions ax-actions">
83
+ <button onClick={() => onEnabledChange(!enabled)}>{enabled ? "Hide" : "Show"}</button>
84
+ <button onClick={() => void refresh()} disabled={!enabled}>
85
+ Refresh
86
+ </button>
87
+ </div>
88
+ {enabled && (
89
+ <div className="ax-list" role="list">
90
+ {nodes.length === 0 ? (
91
+ <div className="ax-empty">No accessibility nodes yet.</div>
92
+ ) : (
93
+ nodes.map((node) => (
94
+ <button
95
+ key={node.id}
96
+ type="button"
97
+ className={node.id === highlightedId ? "ax-node active" : "ax-node"}
98
+ onMouseEnter={() => onHighlight(node.id)}
99
+ onMouseLeave={() => onHighlight(null)}
100
+ onFocus={() => onHighlight(node.id)}
101
+ onBlur={() => onHighlight(null)}
102
+ title={node.resourceId || node.packageName}
103
+ >
104
+ <span>{nodeLabel(node)}</span>
105
+ <code>{nodeMeta(node)}</code>
106
+ </button>
107
+ ))
108
+ )}
109
+ </div>
110
+ )}
111
+ </section>
112
+ );
113
+ }
@@ -0,0 +1,256 @@
1
+ import { useEffect, useRef, useState, type DragEvent } from "react";
2
+
3
+ type AppApiResult = {
4
+ ok?: boolean;
5
+ output?: string;
6
+ error?: string;
7
+ path?: string;
8
+ kind?: string;
9
+ };
10
+
11
+ type ForegroundApp = {
12
+ packageName: string | null;
13
+ activity: string | null;
14
+ pid: number | null;
15
+ label: string | null;
16
+ versionName: string | null;
17
+ versionCode: string | null;
18
+ debuggable: boolean | null;
19
+ };
20
+
21
+ async function postJson(path: string, body: Record<string, unknown>): Promise<AppApiResult> {
22
+ const res = await fetch(path, {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify(body),
26
+ });
27
+ return await res.json() as AppApiResult;
28
+ }
29
+
30
+ function outputFor(result: AppApiResult): string {
31
+ return result.ok ? result.output || "OK" : result.error || "Failed";
32
+ }
33
+
34
+ function isApk(file: File): boolean {
35
+ return file.name.toLowerCase().endsWith(".apk") || file.type === "application/vnd.android.package-archive";
36
+ }
37
+
38
+ export function AppManagementPanel() {
39
+ const apkRef = useRef<HTMLInputElement>(null);
40
+ const [packageName, setPackageName] = useState("");
41
+ const [activity, setActivity] = useState("");
42
+ const [permission, setPermission] = useState("android.permission.POST_NOTIFICATIONS");
43
+ const [status, setStatus] = useState("Ready");
44
+ const [dragOver, setDragOver] = useState(false);
45
+ const [foreground, setForeground] = useState<ForegroundApp | null>(null);
46
+ const [foregroundError, setForegroundError] = useState<string | null>(null);
47
+
48
+ useEffect(() => {
49
+ let cancelled = false;
50
+ const refresh = async () => {
51
+ try {
52
+ const res = await fetch("/api/foreground", { cache: "no-store" });
53
+ const json = await res.json() as { ok?: boolean; app?: ForegroundApp; error?: string };
54
+ if (cancelled) return;
55
+ if (json.ok && json.app) {
56
+ setForeground(json.app);
57
+ setForegroundError(null);
58
+ } else {
59
+ setForeground(null);
60
+ setForegroundError(json.error || "Foreground app unavailable");
61
+ }
62
+ } catch (err) {
63
+ if (!cancelled) {
64
+ setForeground(null);
65
+ setForegroundError(err instanceof Error ? err.message : String(err));
66
+ }
67
+ }
68
+ };
69
+ void refresh();
70
+ const timer = setInterval(refresh, 2500);
71
+ return () => {
72
+ cancelled = true;
73
+ clearInterval(timer);
74
+ };
75
+ }, []);
76
+
77
+ const run = async (label: string, request: () => Promise<AppApiResult>) => {
78
+ setStatus(`${label}...`);
79
+ try {
80
+ const result = await request();
81
+ setStatus(outputFor(result));
82
+ } catch (err) {
83
+ setStatus(err instanceof Error ? err.message : String(err));
84
+ }
85
+ };
86
+
87
+ const uploadFile = async (file: File) => {
88
+ const apk = isApk(file);
89
+ await run(apk ? "Installing" : "Importing", async () => {
90
+ const form = new FormData();
91
+ form.set(apk ? "apk" : "file", file);
92
+ const res = await fetch(apk ? "/api/apps/install" : "/api/files/import", {
93
+ method: "POST",
94
+ body: form,
95
+ });
96
+ return await res.json() as AppApiResult;
97
+ });
98
+ };
99
+
100
+ const install = async () => {
101
+ const file = apkRef.current?.files?.[0];
102
+ if (!file) {
103
+ setStatus("Choose an APK, image, or video first");
104
+ return;
105
+ }
106
+ await uploadFile(file);
107
+ if (apkRef.current) apkRef.current.value = "";
108
+ };
109
+
110
+ const onDrop = (event: DragEvent) => {
111
+ event.preventDefault();
112
+ setDragOver(false);
113
+ const files = Array.from(event.dataTransfer.files);
114
+ if (files.length === 0) return;
115
+ void (async () => {
116
+ for (const file of files) {
117
+ await uploadFile(file);
118
+ }
119
+ })();
120
+ };
121
+
122
+ const packageBody = () => ({ packageName: packageName.trim() });
123
+
124
+ return (
125
+ <section className="tool-panel app-management-panel">
126
+ <div className="panel-heading">
127
+ <h2>Apps</h2>
128
+ <div className="location-status">{status}</div>
129
+ </div>
130
+ <div className="foreground-card">
131
+ <div className="foreground-title">
132
+ <span>{foreground?.label || foreground?.packageName || "No foreground app"}</span>
133
+ {foreground?.packageName && (
134
+ <button
135
+ type="button"
136
+ onClick={() => {
137
+ setPackageName(foreground.packageName || "");
138
+ setActivity(foreground.activity || "");
139
+ }}
140
+ >
141
+ Use
142
+ </button>
143
+ )}
144
+ </div>
145
+ {foreground?.packageName ? (
146
+ <dl>
147
+ <div>
148
+ <dt>Package</dt>
149
+ <dd>{foreground.packageName}</dd>
150
+ </div>
151
+ <div>
152
+ <dt>Activity</dt>
153
+ <dd>{foreground.activity || "—"}</dd>
154
+ </div>
155
+ <div>
156
+ <dt>Version</dt>
157
+ <dd>
158
+ {foreground.versionName || "—"}
159
+ {foreground.versionCode ? ` (${foreground.versionCode})` : ""}
160
+ </dd>
161
+ </div>
162
+ <div>
163
+ <dt>PID</dt>
164
+ <dd>{foreground.pid ?? "—"}</dd>
165
+ </div>
166
+ <div>
167
+ <dt>Debuggable</dt>
168
+ <dd>{foreground.debuggable == null ? "—" : foreground.debuggable ? "yes" : "no"}</dd>
169
+ </div>
170
+ </dl>
171
+ ) : (
172
+ <div className="foreground-empty">{foregroundError || "Waiting for app focus..."}</div>
173
+ )}
174
+ </div>
175
+ <div
176
+ className={dragOver ? "file-drop active" : "file-drop"}
177
+ onDragEnter={(event) => {
178
+ event.preventDefault();
179
+ setDragOver(true);
180
+ }}
181
+ onDragOver={(event) => {
182
+ event.preventDefault();
183
+ event.dataTransfer.dropEffect = "copy";
184
+ }}
185
+ onDragLeave={(event) => {
186
+ event.preventDefault();
187
+ setDragOver(false);
188
+ }}
189
+ onDrop={onDrop}
190
+ >
191
+ <span>Drop APK, image, or video</span>
192
+ <small>APK installs; media is pushed to device storage.</small>
193
+ </div>
194
+ <input
195
+ ref={apkRef}
196
+ type="file"
197
+ accept=".apk,application/vnd.android.package-archive,image/*,video/*"
198
+ />
199
+ <button className="primary-action" onClick={() => void install()}>
200
+ Upload Selected File
201
+ </button>
202
+ <label className="stacked-field">
203
+ Package
204
+ <input
205
+ onChange={(e) => setPackageName(e.currentTarget.value)}
206
+ placeholder="com.example.app"
207
+ value={packageName}
208
+ />
209
+ </label>
210
+ <label className="stacked-field">
211
+ Activity
212
+ <input
213
+ onChange={(e) => setActivity(e.currentTarget.value)}
214
+ placeholder=".MainActivity"
215
+ value={activity}
216
+ />
217
+ </label>
218
+ <div className="panel-actions app-actions">
219
+ <button
220
+ onClick={() =>
221
+ void run("Launching", () =>
222
+ postJson("/api/apps/launch", { ...packageBody(), activity: activity.trim() || undefined }),
223
+ )
224
+ }
225
+ >
226
+ Launch
227
+ </button>
228
+ <button onClick={() => void run("Clearing", () => postJson("/api/apps/clear", packageBody()))}>
229
+ Clear
230
+ </button>
231
+ <button
232
+ onClick={() => void run("Stopping", () => postJson("/api/apps/force-stop", packageBody()))}
233
+ >
234
+ Stop
235
+ </button>
236
+ </div>
237
+ <label className="stacked-field">
238
+ Permission
239
+ <input
240
+ onChange={(e) => setPermission(e.currentTarget.value)}
241
+ placeholder="android.permission.POST_NOTIFICATIONS"
242
+ value={permission}
243
+ />
244
+ </label>
245
+ <button
246
+ onClick={() =>
247
+ void run("Granting", () =>
248
+ postJson("/api/apps/grant", { ...packageBody(), permission: permission.trim() }),
249
+ )
250
+ }
251
+ >
252
+ Grant Permission
253
+ </button>
254
+ </section>
255
+ );
256
+ }
@@ -0,0 +1,24 @@
1
+ export type HardwareKey = "back" | "home" | "recents" | "power";
2
+
3
+ type Props = {
4
+ onPress: (key: HardwareKey) => void;
5
+ };
6
+
7
+ const BUTTONS: { key: HardwareKey; label: string }[] = [
8
+ { key: "back", label: "Back" },
9
+ { key: "home", label: "Home" },
10
+ { key: "recents", label: "Recents" },
11
+ { key: "power", label: "Power" },
12
+ ];
13
+
14
+ export function ControlBar({ onPress }: Props) {
15
+ return (
16
+ <footer>
17
+ {BUTTONS.map((b) => (
18
+ <button key={b.key} onClick={() => onPress(b.key)}>
19
+ {b.label}
20
+ </button>
21
+ ))}
22
+ </footer>
23
+ );
24
+ }