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,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
|
+
}
|