dsh-apk-vm 0.0.0-stage → 0.3.13
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 +345 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +177 -0
- package/DEMO.md +85 -0
- package/DESIGN.md +335 -0
- package/LICENSE +204 -0
- package/NOTICE +74 -0
- package/README.md +920 -2
- package/SECURITY.md +108 -0
- package/client.js +3780 -0
- package/cordis.patch.yml +56 -0
- package/docs/PRIVACY.md +36 -0
- package/docs/README.md +16 -0
- package/docs/RELEASING.md +104 -0
- package/docs/SUBMISSION.md +75 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-sidebar.png +0 -0
- package/icon.svg +8 -0
- package/index.js +2532 -0
- package/lib/android.js +2502 -0
- package/lib/instances.js +559 -0
- package/lib/scrcpy.js +2370 -0
- package/lib/setup.js +1624 -0
- package/lib/vendor/scrcpy-server-v5.0.1.jar +0 -0
- package/lib/video-stream.js +329 -0
- package/lib/ws.js +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/scripts/privacy-scan.mjs +94 -0
package/client.js
ADDED
|
@@ -0,0 +1,3780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-apk-vm — Web client half (static DSH web plugin, no bundler).
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - a `main` panel (key `apk-vm`) that renders the APK-VM console: header with
|
|
6
|
+
* state pill + Start/Stop/Refresh, a left column (scanned APK list, manual
|
|
7
|
+
* path install, VM log/diagnostics, boot hint), and a live phone frame with
|
|
8
|
+
* tap/swipe/wheel/key/text input;
|
|
9
|
+
* - a `sidebar.panellist` entry (id `apk-vm`, order 30) with the phone glyph.
|
|
10
|
+
*
|
|
11
|
+
* Everything talks to the Host half over same-origin HTTP under /apk-vm/:
|
|
12
|
+
* GET /apk-vm/state GET /apk-vm/apks POST /apk-vm/vm
|
|
13
|
+
* POST /apk-vm/install GET /apk-vm/frame?t=.. POST /apk-vm/input
|
|
14
|
+
*
|
|
15
|
+
* Only `require('react')` is used; styling goes through host theme tokens
|
|
16
|
+
* (var(--dsw-alias-*)) so the panel follows the active Harness theme. Every
|
|
17
|
+
* network/JSON access is guarded so a failing Host route degrades to an inline
|
|
18
|
+
* banner instead of blanking the slot.
|
|
19
|
+
*/
|
|
20
|
+
window.__ModuleLoader__.load({
|
|
21
|
+
id: 'dsh-apk-vm',
|
|
22
|
+
factory: function (require) {
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
var React = require('react');
|
|
26
|
+
var h = React.createElement;
|
|
27
|
+
|
|
28
|
+
var NS = 'apk-vm';
|
|
29
|
+
var VM_STATES = ['stopped', 'starting', 'booting', 'ready', 'error'];
|
|
30
|
+
var SWIPE_THRESHOLD_PX = 24;
|
|
31
|
+
var FRAME_INTERVAL_MS = 500;
|
|
32
|
+
var STATE_INTERVAL_MS = 2000;
|
|
33
|
+
var CLIENT_VERSION = '0.2.1';
|
|
34
|
+
|
|
35
|
+
/* ------------------------------------------------------------------ *
|
|
36
|
+
* Optional dsh-better-sidebar integration.
|
|
37
|
+
*
|
|
38
|
+
* The ecosystem plugin publishes `ctx.betterSidebar` (registerTab /
|
|
39
|
+
* registerFileViewer / openTab / getSnapshot). We do NOT hard-inject it,
|
|
40
|
+
* because this plugin must keep working on a standalone DSH; instead we
|
|
41
|
+
* wait for the service with ctx.inject at apply time. When it appears we
|
|
42
|
+
* register one real tab type (`apk-vm:preview`) exactly like a first-party
|
|
43
|
+
* tab, so it shows up in the native right Sidebar's + menu / guide and in
|
|
44
|
+
* the bottom workbench, and the main panel gets an "Open in side bar"
|
|
45
|
+
* action. Everything degrades to the plain `main` panel without it.
|
|
46
|
+
* ------------------------------------------------------------------ */
|
|
47
|
+
|
|
48
|
+
var BETTER_SIDEBAR_TAB_ID = 'apk-vm:preview';
|
|
49
|
+
var betterSidebar = null;
|
|
50
|
+
var betterSidebarListeners = [];
|
|
51
|
+
|
|
52
|
+
function getBetterSidebar() {
|
|
53
|
+
return betterSidebar;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function publishBetterSidebar(next) {
|
|
57
|
+
if (betterSidebar === next) return;
|
|
58
|
+
betterSidebar = next;
|
|
59
|
+
var listeners = betterSidebarListeners.slice();
|
|
60
|
+
for (var i = 0; i < listeners.length; i++) {
|
|
61
|
+
try {
|
|
62
|
+
listeners[i](next);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
/* a listener must never break the registry */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function subscribeBetterSidebar(listener) {
|
|
70
|
+
if (typeof listener !== 'function') return function () {};
|
|
71
|
+
betterSidebarListeners.push(listener);
|
|
72
|
+
return function () {
|
|
73
|
+
var at = betterSidebarListeners.indexOf(listener);
|
|
74
|
+
if (at >= 0) betterSidebarListeners.splice(at, 1);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ *
|
|
79
|
+
* Locale dictionaries (flat dotted keys; placeholders use {name}).
|
|
80
|
+
* ------------------------------------------------------------------ */
|
|
81
|
+
|
|
82
|
+
var EN = {
|
|
83
|
+
'nav.label': 'APK VM',
|
|
84
|
+
'title': 'APK Virtual Machine',
|
|
85
|
+
'subtitle': 'Boot a local open-source Android VM, install an APK, and preview its screen.',
|
|
86
|
+
'apk.current': 'Current APK: {name}',
|
|
87
|
+
'state.loading': 'Connecting…',
|
|
88
|
+
'state.stopped': 'Stopped',
|
|
89
|
+
'state.starting': 'Starting',
|
|
90
|
+
'state.booting': 'Booting',
|
|
91
|
+
'state.ready': 'Ready',
|
|
92
|
+
'state.error': 'Error',
|
|
93
|
+
'pill.boot': 'boot {s}s',
|
|
94
|
+
'pill.snapshot': 'quick {s}s',
|
|
95
|
+
'pill.cold': 'cold {s}s',
|
|
96
|
+
'action.start': 'Start VM',
|
|
97
|
+
'action.starting': 'Starting…',
|
|
98
|
+
'action.stop': 'Stop',
|
|
99
|
+
'action.stopping': 'Stopping…',
|
|
100
|
+
'action.restart': 'Restart',
|
|
101
|
+
'action.refresh': 'Refresh',
|
|
102
|
+
'action.rescan': 'Rescan',
|
|
103
|
+
'action.installLaunch': 'Install & launch',
|
|
104
|
+
'action.dismiss': 'Dismiss',
|
|
105
|
+
'action.openSidebar': 'Open in side bar',
|
|
106
|
+
'a11y.refresh': 'Refresh VM state',
|
|
107
|
+
'a11y.retry': 'Retry the request',
|
|
108
|
+
'a11y.openSidebar': 'Open the APK VM in dsh-better-sidebar',
|
|
109
|
+
'sidebar.tab': 'APK VM',
|
|
110
|
+
'sidebar.desc': 'Boot the open-source Android VM and preview an APK in the side panel.',
|
|
111
|
+
'sidebar.opened': 'Opened the APK VM in the side bar',
|
|
112
|
+
'sidebar.noSession': 'Open a conversation first: the side bar is session-scoped.',
|
|
113
|
+
'sidebar.unavailable': 'dsh-better-sidebar is not installed or not ready',
|
|
114
|
+
'apks.title': 'APK files',
|
|
115
|
+
'apks.count': '{n} found',
|
|
116
|
+
'apks.scanning': 'Scanning…',
|
|
117
|
+
'upload.button': 'Upload APK',
|
|
118
|
+
'upload.drop': 'Drop APK to install',
|
|
119
|
+
'upload.detail': 'Installs into the local emulator and launches it.',
|
|
120
|
+
'upload.uploading': 'Uploading {name}…',
|
|
121
|
+
'upload.invalid': 'Please choose an .apk file',
|
|
122
|
+
'upload.failed': 'Upload/install failed',
|
|
123
|
+
'upload.done': 'Installed {name}',
|
|
124
|
+
'apks.empty': 'No APK files found in the scan roots.',
|
|
125
|
+
'apks.roots': 'Scan roots: {roots}',
|
|
126
|
+
'apks.installing': 'Installing…',
|
|
127
|
+
'manual.title': 'Install from path',
|
|
128
|
+
'manual.placeholder': '/absolute/path/to/app.apk',
|
|
129
|
+
'manual.hint': 'Absolute APK file path on this machine.',
|
|
130
|
+
'log.title': 'VM log',
|
|
131
|
+
'log.waiting': 'Waiting for VM output…',
|
|
132
|
+
'hint.boot': 'An x86_64 Android image needs CPU hardware acceleration (AEHD/WHPX). If booting does not finish, enable it and restart Windows once; the plugin then saves a quick-boot snapshot so later starts stay under 30 s.',
|
|
133
|
+
'hint.short': 'Android 14 · x86_64 · AEHD · H.264 60 fps',
|
|
134
|
+
'overlay.connecting': 'Connecting to the VM service…',
|
|
135
|
+
'overlay.stopped': 'The virtual machine is not running',
|
|
136
|
+
'overlay.startedHint': 'Start the VM, then install an APK from the list on the left.',
|
|
137
|
+
'overlay.booting': 'Booting the Android VM…',
|
|
138
|
+
'overlay.bootingHint': 'If this stays here for minutes, CPU hardware acceleration is missing: enable AEHD/WHPX (or attach an adb device) and restart Windows once. Quick-boot snapshots then make later starts fast.',
|
|
139
|
+
'overlay.error': 'The virtual machine hit an error',
|
|
140
|
+
'overlay.waitingFrame': 'Waiting for the first screen frame…',
|
|
141
|
+
'overlay.retry': 'Try again',
|
|
142
|
+
'screen.alt': 'Android device screen',
|
|
143
|
+
'screen.hint': 'Click to tap · drag to swipe · wheel to scroll · two fingers pinch (H.264) · click then type for the device keyboard',
|
|
144
|
+
'keys.back': 'Back',
|
|
145
|
+
'keys.home': 'Home',
|
|
146
|
+
'keys.recents': 'Recents',
|
|
147
|
+
'keys.power': 'Power',
|
|
148
|
+
'input.placeholder': 'Text to type on the device…',
|
|
149
|
+
'input.send': 'Send',
|
|
150
|
+
'debug.title': 'Rooted debug',
|
|
151
|
+
'debug.state.root': 'root',
|
|
152
|
+
'debug.state.shell': 'shell',
|
|
153
|
+
'debug.placeholder': 'adb shell command (runs as root)…',
|
|
154
|
+
'debug.run': 'Run',
|
|
155
|
+
'debug.logcat': 'Logcat',
|
|
156
|
+
'debug.live': 'Live logcat',
|
|
157
|
+
'debug.liveStop': 'Stop live',
|
|
158
|
+
'debug.crash': 'Crash log',
|
|
159
|
+
'debug.clipboardSet': 'Copy field to device',
|
|
160
|
+
'debug.clipboardGet': 'Get device clipboard',
|
|
161
|
+
'debug.clipboardEmpty': 'Type text in the input field first',
|
|
162
|
+
'debug.clipboardSetDone': 'Device clipboard updated',
|
|
163
|
+
'debug.clipboardGot': 'Device clipboard copied to host',
|
|
164
|
+
'debug.props': 'Props',
|
|
165
|
+
'debug.rootAction': 'Root',
|
|
166
|
+
'debug.ui': 'UI tree',
|
|
167
|
+
'debug.perf': 'Perf',
|
|
168
|
+
'debug.record': 'Record 5s',
|
|
169
|
+
'debug.rotate': 'Rotate',
|
|
170
|
+
'debug.screenshot': 'Screenshot',
|
|
171
|
+
'debug.recording': 'Recording 5s…',
|
|
172
|
+
'setup.title': 'Environment',
|
|
173
|
+
'setup.ok': 'Ready',
|
|
174
|
+
'setup.missing': 'Setup needed',
|
|
175
|
+
'setup.sdk': 'SDK',
|
|
176
|
+
'setup.accel': 'Acceleration',
|
|
177
|
+
'setup.avd': 'AVD',
|
|
178
|
+
'setup.image': 'System image',
|
|
179
|
+
'setup.createAvd': 'Create AVD',
|
|
180
|
+
'setup.installImage': 'Download image',
|
|
181
|
+
'setup.installTools': 'Install SDK tools',
|
|
182
|
+
'setup.bootstrapSdk': 'Bootstrap SDK',
|
|
183
|
+
'setup.refresh': 'Re-check',
|
|
184
|
+
'setup.switch': 'Switch AVD',
|
|
185
|
+
'setup.loading': 'Checking environment…',
|
|
186
|
+
'setup.downloadsDisabled': 'Automatic system-image download is disabled (allowSetupDownloads).',
|
|
187
|
+
'instances.label': 'VM instance',
|
|
188
|
+
'instances.default': 'default',
|
|
189
|
+
'instances.new': 'New VM',
|
|
190
|
+
'instances.delete': 'Destroy VM',
|
|
191
|
+
'instances.created': 'Created instance {id}',
|
|
192
|
+
'instances.destroyed': 'Destroyed instance {id}',
|
|
193
|
+
'instances.manage': 'VM instances',
|
|
194
|
+
'instances.stop': 'Stop',
|
|
195
|
+
'instances.stopped': 'Stopped instance {id}',
|
|
196
|
+
'instances.idle': 'idle {seconds}s',
|
|
197
|
+
'devices.title': 'Physical/remote adb device',
|
|
198
|
+
'devices.select': 'Select device…',
|
|
199
|
+
'devices.attach': 'Attach',
|
|
200
|
+
'devices.detach': 'Detach',
|
|
201
|
+
'devices.attached': 'Attached {serial}',
|
|
202
|
+
'devices.detached': 'Detached external device',
|
|
203
|
+
'devices.current': 'External device: {serial}',
|
|
204
|
+
'apps.title': 'Installed apps',
|
|
205
|
+
'apps.refresh': 'Refresh',
|
|
206
|
+
'apps.loading': 'Loading…',
|
|
207
|
+
'apps.empty': 'No third-party apps installed.',
|
|
208
|
+
'apps.thirdParty': 'third-party only',
|
|
209
|
+
'apps.stop': 'Stop',
|
|
210
|
+
'apps.clear': 'Clear data',
|
|
211
|
+
'apps.uninstall': 'Uninstall',
|
|
212
|
+
'apps.done': '{action}: {packageName}',
|
|
213
|
+
'debug.empty': 'No debug output yet.',
|
|
214
|
+
'debug.identity': '{release} · API {sdk} · {abi}',
|
|
215
|
+
'debug.busy': 'Running…',
|
|
216
|
+
'fps.label': 'FPS',
|
|
217
|
+
'fps.actual': '{fps} fps',
|
|
218
|
+
'fps.target': 'Target FPS',
|
|
219
|
+
'fps.mode.h264': 'H.264',
|
|
220
|
+
'fps.mode.png': 'PNG',
|
|
221
|
+
'fps.fallbackHint': 'H.264 is unavailable in this WebView; using the PNG fallback (~1–2 fps).',
|
|
222
|
+
'fps.hint': 'Preview frame rate. With WebCodecs H.264 streaming this reaches up to 60 fps; the PNG fallback is capped by adb screencap (~2 fps). ',
|
|
223
|
+
'stream.error': 'H.264 stream error',
|
|
224
|
+
'stream.h264': 'H.264 · max {fps} fps',
|
|
225
|
+
'tag.installed': 'installed',
|
|
226
|
+
'tag.launched': 'launched',
|
|
227
|
+
'toast.started': 'Start command sent.',
|
|
228
|
+
'toast.stopped': 'Stop command sent.',
|
|
229
|
+
'toast.restarted': 'Restart command sent.',
|
|
230
|
+
'toast.installed': 'Installed {name}.',
|
|
231
|
+
'toast.installedLaunched': 'Installed and launched {name}.',
|
|
232
|
+
'err.state': 'Cannot read VM state',
|
|
233
|
+
'err.apks': 'Cannot list APK files',
|
|
234
|
+
'err.install': 'Install failed',
|
|
235
|
+
'err.input': 'Device input failed',
|
|
236
|
+
'err.action': 'VM action failed',
|
|
237
|
+
'err.frame': 'Cannot read the device screen',
|
|
238
|
+
'err.frameEmpty': 'The device returned an empty screen frame',
|
|
239
|
+
'err.pathEmpty': 'Enter an APK path first.',
|
|
240
|
+
'err.render': 'The APK VM panel failed to render.',
|
|
241
|
+
'err.http': 'HTTP {status}',
|
|
242
|
+
'err.badJson': 'The server returned invalid JSON',
|
|
243
|
+
'err.request': 'The request failed',
|
|
244
|
+
'err.noFetch': 'Network requests are unavailable',
|
|
245
|
+
'err.noObjectUrl': 'Image object URLs are unavailable',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
var ZH = {
|
|
249
|
+
'nav.label': 'APK 虚拟机',
|
|
250
|
+
'title': 'APK 虚拟机',
|
|
251
|
+
'subtitle': '启动本地开源 Android 虚拟机,安装 APK 并在此预览屏幕。',
|
|
252
|
+
'apk.current': '当前 APK:{name}',
|
|
253
|
+
'state.loading': '连接中…',
|
|
254
|
+
'state.stopped': '已停止',
|
|
255
|
+
'state.starting': '启动中',
|
|
256
|
+
'state.booting': '开机中',
|
|
257
|
+
'state.ready': '已就绪',
|
|
258
|
+
'state.error': '出错',
|
|
259
|
+
'pill.boot': '启动用时 {s} 秒',
|
|
260
|
+
'pill.snapshot': '快照启动 {s} 秒',
|
|
261
|
+
'pill.cold': '冷启动 {s} 秒',
|
|
262
|
+
'action.start': '启动虚拟机',
|
|
263
|
+
'action.starting': '正在启动…',
|
|
264
|
+
'action.stop': '停止',
|
|
265
|
+
'action.stopping': '正在停止…',
|
|
266
|
+
'action.restart': '重启',
|
|
267
|
+
'action.refresh': '刷新',
|
|
268
|
+
'action.rescan': '重新扫描',
|
|
269
|
+
'action.installLaunch': '安装并启动',
|
|
270
|
+
'action.dismiss': '关闭',
|
|
271
|
+
'action.openSidebar': '在侧栏打开',
|
|
272
|
+
'a11y.refresh': '刷新虚拟机状态',
|
|
273
|
+
'a11y.retry': '重试请求',
|
|
274
|
+
'a11y.openSidebar': '在 dsh-better-sidebar 中打开 APK 虚拟机',
|
|
275
|
+
'sidebar.tab': 'APK 虚拟机',
|
|
276
|
+
'sidebar.desc': '在侧边栏中启动开源 Android 虚拟机并预览 APK。',
|
|
277
|
+
'sidebar.opened': '已在侧栏打开 APK 虚拟机',
|
|
278
|
+
'sidebar.noSession': '请先打开一个会话:侧栏是按会话隔离的。',
|
|
279
|
+
'sidebar.unavailable': '未检测到 dsh-better-sidebar 或服务未就绪',
|
|
280
|
+
'apks.title': 'APK 文件',
|
|
281
|
+
'apks.count': '共 {n} 个',
|
|
282
|
+
'apks.scanning': '扫描中…',
|
|
283
|
+
'upload.button': '上传 APK',
|
|
284
|
+
'upload.drop': '松开以安装 APK',
|
|
285
|
+
'upload.detail': '上传后自动安装到本地模拟器并启动。',
|
|
286
|
+
'upload.uploading': '正在上传 {name}…',
|
|
287
|
+
'upload.invalid': '请选择 .apk 文件',
|
|
288
|
+
'upload.failed': '上传/安装失败',
|
|
289
|
+
'upload.done': '已安装 {name}',
|
|
290
|
+
'apks.empty': '扫描目录中没有找到 APK 文件。',
|
|
291
|
+
'apks.roots': '扫描目录:{roots}',
|
|
292
|
+
'apks.installing': '安装中…',
|
|
293
|
+
'manual.title': '按路径安装',
|
|
294
|
+
'manual.placeholder': '/绝对路径/app.apk',
|
|
295
|
+
'manual.hint': '本机 APK 文件的绝对路径。',
|
|
296
|
+
'log.title': '虚拟机日志',
|
|
297
|
+
'log.waiting': '等待虚拟机输出…',
|
|
298
|
+
'hint.boot': 'x86_64 Android 镜像需要 CPU 硬件加速(AEHD/WHPX)。若长时间停在“正在开机”,请启用硬件加速并重启一次 Windows;之后插件会把冷启动保存为快照,后续启动保持在 30 秒内。',
|
|
299
|
+
'hint.short': 'Android 14 · x86_64 · AEHD · H.264 60fps',
|
|
300
|
+
'overlay.connecting': '正在连接虚拟机服务…',
|
|
301
|
+
'overlay.stopped': '虚拟机尚未运行',
|
|
302
|
+
'overlay.startedHint': '启动虚拟机后,即可从左侧列表安装 APK。',
|
|
303
|
+
'overlay.booting': 'Android 虚拟机正在开机…',
|
|
304
|
+
'overlay.bootingHint': '若长时间停留在此,说明缺少 CPU 硬件加速:请启用 AEHD/WHPX(或接入 adb 设备)并重启一次 Windows;之后快照启动会很快。',
|
|
305
|
+
'overlay.error': '虚拟机出现错误',
|
|
306
|
+
'overlay.waitingFrame': '正在等待第一帧画面…',
|
|
307
|
+
'overlay.retry': '重试',
|
|
308
|
+
'screen.alt': 'Android 设备屏幕',
|
|
309
|
+
'screen.hint': '单击 = 点按 · 拖动 = 滑动 · 滚轮 = 滚动 · 双指缩放(H.264)· 点按后可直接键盘输入',
|
|
310
|
+
'keys.back': '返回',
|
|
311
|
+
'keys.home': '主页',
|
|
312
|
+
'keys.recents': '多任务',
|
|
313
|
+
'keys.power': '电源',
|
|
314
|
+
'input.placeholder': '输入要发送到设备的文本…',
|
|
315
|
+
'input.send': '发送',
|
|
316
|
+
'debug.title': 'Root 调试',
|
|
317
|
+
'debug.state.root': 'root',
|
|
318
|
+
'debug.state.shell': 'shell',
|
|
319
|
+
'debug.placeholder': 'adb shell 命令(以 root 执行)…',
|
|
320
|
+
'debug.run': '执行',
|
|
321
|
+
'debug.logcat': '日志',
|
|
322
|
+
'debug.live': '实时日志',
|
|
323
|
+
'debug.liveStop': '停止实时',
|
|
324
|
+
'debug.crash': '崩溃日志',
|
|
325
|
+
'debug.clipboardSet': '文本写入设备剪贴板',
|
|
326
|
+
'debug.clipboardGet': '读取设备剪贴板',
|
|
327
|
+
'debug.clipboardEmpty': '请先在上方输入框输入文本',
|
|
328
|
+
'debug.clipboardSetDone': '已写入设备剪贴板',
|
|
329
|
+
'debug.clipboardGot': '已复制设备剪贴板到主机',
|
|
330
|
+
'debug.props': '属性',
|
|
331
|
+
'debug.rootAction': '提权',
|
|
332
|
+
'debug.ui': 'UI 树',
|
|
333
|
+
'debug.perf': '性能',
|
|
334
|
+
'debug.record': '录屏 5s',
|
|
335
|
+
'debug.rotate': '旋转',
|
|
336
|
+
'debug.screenshot': '截图',
|
|
337
|
+
'debug.recording': '正在录屏 5s…',
|
|
338
|
+
'setup.title': '环境',
|
|
339
|
+
'setup.ok': '就绪',
|
|
340
|
+
'setup.missing': '需要配置',
|
|
341
|
+
'setup.sdk': 'SDK',
|
|
342
|
+
'setup.accel': '硬件加速',
|
|
343
|
+
'setup.avd': 'AVD',
|
|
344
|
+
'setup.image': '系统镜像',
|
|
345
|
+
'setup.createAvd': '创建 AVD',
|
|
346
|
+
'setup.installImage': '下载镜像',
|
|
347
|
+
'setup.installTools': '安装 SDK 工具',
|
|
348
|
+
'setup.bootstrapSdk': '从零安装 SDK',
|
|
349
|
+
'setup.refresh': '重新检测',
|
|
350
|
+
'setup.switch': '切换 AVD',
|
|
351
|
+
'setup.loading': '正在检测环境…',
|
|
352
|
+
'setup.downloadsDisabled': '自动下载系统镜像已禁用(allowSetupDownloads)。',
|
|
353
|
+
'instances.label': '虚拟机实例',
|
|
354
|
+
'instances.default': '默认',
|
|
355
|
+
'instances.new': '新建虚拟机',
|
|
356
|
+
'instances.delete': '销毁虚拟机',
|
|
357
|
+
'instances.created': '已创建实例 {id}',
|
|
358
|
+
'instances.destroyed': '已销毁实例 {id}',
|
|
359
|
+
'instances.manage': '虚拟机实例',
|
|
360
|
+
'instances.stop': '停止',
|
|
361
|
+
'instances.stopped': '已停止实例 {id}',
|
|
362
|
+
'instances.idle': '空闲 {seconds}s',
|
|
363
|
+
'devices.title': '物理/远程 adb 设备',
|
|
364
|
+
'devices.select': '选择设备…',
|
|
365
|
+
'devices.attach': '连接',
|
|
366
|
+
'devices.detach': '断开',
|
|
367
|
+
'devices.attached': '已连接 {serial}',
|
|
368
|
+
'devices.detached': '已断开外部设备',
|
|
369
|
+
'devices.current': '外部设备:{serial}',
|
|
370
|
+
'apps.title': '已安装应用',
|
|
371
|
+
'apps.refresh': '刷新',
|
|
372
|
+
'apps.loading': '加载中…',
|
|
373
|
+
'apps.empty': '没有已安装的第三方应用。',
|
|
374
|
+
'apps.thirdParty': '仅第三方',
|
|
375
|
+
'apps.stop': '停止',
|
|
376
|
+
'apps.clear': '清数据',
|
|
377
|
+
'apps.uninstall': '卸载',
|
|
378
|
+
'apps.done': '{action}:{packageName}',
|
|
379
|
+
'debug.empty': '暂无调试输出。',
|
|
380
|
+
'debug.identity': '{release} · API {sdk} · {abi}',
|
|
381
|
+
'debug.busy': '执行中…',
|
|
382
|
+
'fps.label': '帧率',
|
|
383
|
+
'fps.actual': '{fps} fps',
|
|
384
|
+
'fps.target': '目标帧率',
|
|
385
|
+
'fps.mode.h264': 'H.264',
|
|
386
|
+
'fps.mode.png': 'PNG',
|
|
387
|
+
'fps.fallbackHint': '当前 WebView 不支持 H.264,使用 PNG 回退(约 1–2 fps)。',
|
|
388
|
+
'fps.hint': '预览帧率。WebCodecs H.264 串流最高可达 60 fps;PNG 回退受 adb screencap 限制(约 2 fps)。',
|
|
389
|
+
'stream.error': 'H.264 串流错误',
|
|
390
|
+
'stream.h264': 'H.264 · 最高 {fps} fps',
|
|
391
|
+
'tag.installed': '已安装',
|
|
392
|
+
'tag.launched': '已启动',
|
|
393
|
+
'toast.started': '已发送启动指令。',
|
|
394
|
+
'toast.stopped': '已发送停止指令。',
|
|
395
|
+
'toast.restarted': '已发送重启指令。',
|
|
396
|
+
'toast.installed': '已安装 {name}。',
|
|
397
|
+
'toast.installedLaunched': '已安装并启动 {name}。',
|
|
398
|
+
'err.state': '无法读取虚拟机状态',
|
|
399
|
+
'err.apks': '无法列出 APK 文件',
|
|
400
|
+
'err.install': '安装失败',
|
|
401
|
+
'err.input': '设备输入失败',
|
|
402
|
+
'err.action': '虚拟机操作失败',
|
|
403
|
+
'err.frame': '无法读取设备画面',
|
|
404
|
+
'err.frameEmpty': '设备画面数据为空',
|
|
405
|
+
'err.pathEmpty': '请先输入 APK 路径。',
|
|
406
|
+
'err.render': 'APK 虚拟机面板渲染失败。',
|
|
407
|
+
'err.http': 'HTTP {status}',
|
|
408
|
+
'err.badJson': '服务器返回的 JSON 无效',
|
|
409
|
+
'err.request': '请求失败',
|
|
410
|
+
'err.noFetch': '当前环境无法发起网络请求',
|
|
411
|
+
'err.noObjectUrl': '当前环境无法创建图片对象地址',
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/* ------------------------------------------------------------------ *
|
|
415
|
+
* Small utilities (defensive: none of these may throw to the caller).
|
|
416
|
+
* ------------------------------------------------------------------ */
|
|
417
|
+
|
|
418
|
+
function hasOwn(object, key) {
|
|
419
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isObject(value) {
|
|
423
|
+
return value !== null && typeof value === 'object';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function interpolate(template, vars) {
|
|
427
|
+
var text = String(template);
|
|
428
|
+
if (!vars) return text;
|
|
429
|
+
return text.replace(/\{(\w+)\}/g, function (match, name) {
|
|
430
|
+
return hasOwn(vars, name) ? String(vars[name]) : match;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Wrap a locale `t` so a missing/throwing translation falls back to English. */
|
|
435
|
+
function createTranslator(translate) {
|
|
436
|
+
return function T(key, vars) {
|
|
437
|
+
var out = null;
|
|
438
|
+
if (typeof translate === 'function') {
|
|
439
|
+
try {
|
|
440
|
+
out = translate(key, vars);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
out = null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (typeof out !== 'string' || out === '') {
|
|
446
|
+
out = hasOwn(EN, key) && typeof EN[key] === 'string' ? EN[key] : String(key);
|
|
447
|
+
}
|
|
448
|
+
return vars ? interpolate(out, vars) : out;
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function clamp(value, minValue, maxValue) {
|
|
453
|
+
var n = Number(value);
|
|
454
|
+
if (!isFinite(n)) return minValue;
|
|
455
|
+
if (n < minValue) return minValue;
|
|
456
|
+
if (n > maxValue) return maxValue;
|
|
457
|
+
return n;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function formatBytes(value) {
|
|
461
|
+
var n = Number(value);
|
|
462
|
+
if (!isFinite(n) || n <= 0) return '—';
|
|
463
|
+
if (n < 1024) return Math.round(n) + ' B';
|
|
464
|
+
var units = ['KB', 'MB', 'GB', 'TB'];
|
|
465
|
+
var size = n / 1024;
|
|
466
|
+
var unit = 0;
|
|
467
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
468
|
+
size /= 1024;
|
|
469
|
+
unit += 1;
|
|
470
|
+
}
|
|
471
|
+
var rounded = size >= 10 ? Math.round(size) : Math.round(size * 10) / 10;
|
|
472
|
+
return rounded + ' ' + units[unit];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function baseName(filePath) {
|
|
476
|
+
var parts = String(filePath == null ? '' : filePath).split(/[\\/]/);
|
|
477
|
+
for (var i = parts.length - 1; i >= 0; i -= 1) {
|
|
478
|
+
if (parts[i]) return parts[i];
|
|
479
|
+
}
|
|
480
|
+
return String(filePath == null ? '' : filePath);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function revokeObjectUrl(url) {
|
|
484
|
+
if (!url) return;
|
|
485
|
+
try {
|
|
486
|
+
if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
|
|
487
|
+
URL.revokeObjectURL(url);
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
/* ignore — a leaked object URL must never break the panel */
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* ------------------------------------------------------------------ *
|
|
495
|
+
* HTTP helpers. Every call resolves to {ok, status, error, data}; none
|
|
496
|
+
* reject, so a dead Host route only produces an inline error banner.
|
|
497
|
+
* ------------------------------------------------------------------ */
|
|
498
|
+
|
|
499
|
+
function requestJson(url, options, T) {
|
|
500
|
+
var tr = typeof T === 'function' ? T : createTranslator(null);
|
|
501
|
+
var opts = isObject(options) ? options : {};
|
|
502
|
+
if (typeof fetch !== 'function') {
|
|
503
|
+
return Promise.resolve({ ok: false, status: 0, error: tr('err.noFetch'), data: null });
|
|
504
|
+
}
|
|
505
|
+
var init = { method: opts.method ? String(opts.method).toUpperCase() : 'GET', cache: 'no-store' };
|
|
506
|
+
if (opts.body !== undefined) {
|
|
507
|
+
init.body = opts.body;
|
|
508
|
+
init.headers = { 'content-type': 'application/json' };
|
|
509
|
+
}
|
|
510
|
+
return fetch(url, init)
|
|
511
|
+
.then(function (response) {
|
|
512
|
+
return response.text().then(function (text) {
|
|
513
|
+
var data = null;
|
|
514
|
+
var parseFailed = false;
|
|
515
|
+
if (text) {
|
|
516
|
+
try {
|
|
517
|
+
data = JSON.parse(text);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
parseFailed = true;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (!response.ok) {
|
|
523
|
+
var message = '';
|
|
524
|
+
if (isObject(data)) {
|
|
525
|
+
if (typeof data.error === 'string' && data.error) message = data.error;
|
|
526
|
+
else if (typeof data.message === 'string' && data.message) message = data.message;
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
status: response.status,
|
|
531
|
+
error: message || tr('err.http', { status: response.status }),
|
|
532
|
+
data: data,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
if (parseFailed) {
|
|
536
|
+
return { ok: false, status: response.status, error: tr('err.badJson'), data: null };
|
|
537
|
+
}
|
|
538
|
+
if (isObject(data) && data.ok === false) {
|
|
539
|
+
var reason = typeof data.error === 'string' && data.error ? data.error : tr('err.request');
|
|
540
|
+
return { ok: false, status: response.status, error: reason, data: data };
|
|
541
|
+
}
|
|
542
|
+
return { ok: true, status: response.status, error: '', data: data || {} };
|
|
543
|
+
});
|
|
544
|
+
})
|
|
545
|
+
.catch(function (error) {
|
|
546
|
+
var message = error && error.message ? error.message : String(error);
|
|
547
|
+
return { ok: false, status: 0, error: message, data: null };
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Fetch one device frame as an object URL; resolves to {url, error}. */
|
|
552
|
+
function requestFrame(T, framePath) {
|
|
553
|
+
var tr = typeof T === 'function' ? T : createTranslator(null);
|
|
554
|
+
if (typeof fetch !== 'function') {
|
|
555
|
+
return Promise.resolve({ url: '', error: tr('err.noFetch') });
|
|
556
|
+
}
|
|
557
|
+
return fetch(framePath || ('/apk-vm/frame?t=' + Date.now()), { cache: 'no-store' })
|
|
558
|
+
.then(function (response) {
|
|
559
|
+
if (!response.ok) {
|
|
560
|
+
return response.text().then(function (text) {
|
|
561
|
+
var message = '';
|
|
562
|
+
if (text) {
|
|
563
|
+
try {
|
|
564
|
+
var data = JSON.parse(text);
|
|
565
|
+
if (isObject(data)) {
|
|
566
|
+
if (typeof data.error === 'string') message = data.error;
|
|
567
|
+
else if (typeof data.message === 'string') message = data.message;
|
|
568
|
+
}
|
|
569
|
+
} catch (error) {
|
|
570
|
+
message = '';
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { url: '', error: message || tr('err.http', { status: response.status }) };
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return response.blob().then(function (blob) {
|
|
577
|
+
if (!blob || !blob.size) return { url: '', error: tr('err.frameEmpty') };
|
|
578
|
+
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
|
|
579
|
+
return { url: '', error: tr('err.noObjectUrl') };
|
|
580
|
+
}
|
|
581
|
+
return { url: URL.createObjectURL(blob), error: '' };
|
|
582
|
+
});
|
|
583
|
+
})
|
|
584
|
+
.catch(function (error) {
|
|
585
|
+
return { url: '', error: error && error.message ? error.message : String(error) };
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/* ------------------------------------------------------------------ *
|
|
590
|
+
* /apk-vm/state and /apk-vm/apks payload normalization.
|
|
591
|
+
* ------------------------------------------------------------------ */
|
|
592
|
+
|
|
593
|
+
function normalizeState(raw) {
|
|
594
|
+
var data = isObject(raw) ? raw : {};
|
|
595
|
+
var vmRaw = isObject(data.vm) ? data.vm : {};
|
|
596
|
+
var state = typeof vmRaw.state === 'string' ? vmRaw.state : 'stopped';
|
|
597
|
+
if (VM_STATES.indexOf(state) < 0) state = 'stopped';
|
|
598
|
+
|
|
599
|
+
var logRaw = Array.isArray(vmRaw.logTail) ? vmRaw.logTail : [];
|
|
600
|
+
var logTail = [];
|
|
601
|
+
for (var i = 0; i < logRaw.length; i += 1) {
|
|
602
|
+
var line = logRaw[i];
|
|
603
|
+
if (typeof line === 'string') logTail.push(line);
|
|
604
|
+
else if (line !== null && line !== undefined) logTail.push(String(line));
|
|
605
|
+
}
|
|
606
|
+
if (logTail.length > 160) logTail = logTail.slice(logTail.length - 160);
|
|
607
|
+
|
|
608
|
+
var screen = null;
|
|
609
|
+
if (isObject(data.screen)) {
|
|
610
|
+
var width = Number(data.screen.width);
|
|
611
|
+
var height = Number(data.screen.height);
|
|
612
|
+
if (isFinite(width) && isFinite(height) && width > 0 && height > 0) {
|
|
613
|
+
screen = { width: Math.round(width), height: Math.round(height) };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
var apk = null;
|
|
618
|
+
if (isObject(data.apk)) {
|
|
619
|
+
apk = {
|
|
620
|
+
path: typeof data.apk.path === 'string' ? data.apk.path : '',
|
|
621
|
+
packageName: typeof data.apk.packageName === 'string' ? data.apk.packageName : '',
|
|
622
|
+
label: typeof data.apk.label === 'string' ? data.apk.label : '',
|
|
623
|
+
launched: data.apk.launched === true,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
var bootMs = Number(vmRaw.bootMs);
|
|
628
|
+
return {
|
|
629
|
+
vm: {
|
|
630
|
+
state: state,
|
|
631
|
+
serial: typeof vmRaw.serial === 'string' ? vmRaw.serial : '',
|
|
632
|
+
avd: typeof vmRaw.avd === 'string' ? vmRaw.avd : '',
|
|
633
|
+
bootMs: isFinite(bootMs) && bootMs > 0 ? Math.round(bootMs) : 0,
|
|
634
|
+
error: typeof vmRaw.error === 'string' ? vmRaw.error : '',
|
|
635
|
+
external: vmRaw.external === true,
|
|
636
|
+
externalSerial: typeof vmRaw.externalSerial === 'string' ? vmRaw.externalSerial : '',
|
|
637
|
+
managedAvd: typeof vmRaw.managedAvd === 'string' ? vmRaw.managedAvd : '',
|
|
638
|
+
logTail: logTail,
|
|
639
|
+
},
|
|
640
|
+
screen: screen,
|
|
641
|
+
apk: apk,
|
|
642
|
+
client: isObject(data.client) ? data.client : null,
|
|
643
|
+
origin: typeof data.origin === 'string' && data.origin ? data.origin : '',
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function normalizeApkList(raw) {
|
|
648
|
+
var data = isObject(raw) ? raw : {};
|
|
649
|
+
var roots = [];
|
|
650
|
+
var rootsRaw = Array.isArray(data.roots) ? data.roots : [];
|
|
651
|
+
for (var i = 0; i < rootsRaw.length; i += 1) {
|
|
652
|
+
if (typeof rootsRaw[i] === 'string' && rootsRaw[i]) roots.push(rootsRaw[i]);
|
|
653
|
+
}
|
|
654
|
+
var apks = [];
|
|
655
|
+
var apksRaw = Array.isArray(data.apks) ? data.apks : [];
|
|
656
|
+
for (var j = 0; j < apksRaw.length; j += 1) {
|
|
657
|
+
var item = apksRaw[j];
|
|
658
|
+
if (!isObject(item) || typeof item.path !== 'string' || !item.path) continue;
|
|
659
|
+
apks.push({
|
|
660
|
+
path: item.path,
|
|
661
|
+
name: typeof item.name === 'string' && item.name ? item.name : baseName(item.path),
|
|
662
|
+
size: Number(item.size),
|
|
663
|
+
mtime: Number(item.mtime),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return { roots: roots, apks: apks };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* ------------------------------------------------------------------ *
|
|
670
|
+
* Inline SVG glyphs (stroke = currentColor, so the host theme colors
|
|
671
|
+
* every icon and the icon artwork stays token-free).
|
|
672
|
+
* ------------------------------------------------------------------ */
|
|
673
|
+
|
|
674
|
+
function svgIcon(size, children) {
|
|
675
|
+
return h(
|
|
676
|
+
'svg',
|
|
677
|
+
{
|
|
678
|
+
width: size,
|
|
679
|
+
height: size,
|
|
680
|
+
viewBox: '0 0 24 24',
|
|
681
|
+
fill: 'none',
|
|
682
|
+
stroke: 'currentColor',
|
|
683
|
+
strokeWidth: 1.8,
|
|
684
|
+
strokeLinecap: 'round',
|
|
685
|
+
strokeLinejoin: 'round',
|
|
686
|
+
'aria-hidden': 'true',
|
|
687
|
+
focusable: 'false',
|
|
688
|
+
style: { display: 'block', flex: 'none' },
|
|
689
|
+
},
|
|
690
|
+
children
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function PhoneIcon(props) {
|
|
695
|
+
var size = props && typeof props.size === 'number' && props.size > 0 ? props.size : 20;
|
|
696
|
+
return h(
|
|
697
|
+
'svg',
|
|
698
|
+
{
|
|
699
|
+
width: size,
|
|
700
|
+
height: size,
|
|
701
|
+
viewBox: '0 0 24 24',
|
|
702
|
+
fill: 'none',
|
|
703
|
+
stroke: 'currentColor',
|
|
704
|
+
strokeWidth: 1.7,
|
|
705
|
+
strokeLinecap: 'round',
|
|
706
|
+
strokeLinejoin: 'round',
|
|
707
|
+
'aria-hidden': 'true',
|
|
708
|
+
focusable: 'false',
|
|
709
|
+
style: { display: 'block', flex: 'none' },
|
|
710
|
+
},
|
|
711
|
+
h('rect', { key: 'body', x: 6.25, y: 2.25, width: 11.5, height: 19.5, rx: 2.75 }),
|
|
712
|
+
h('path', { key: 'speaker', d: 'M10.25 4.9h3.5' }),
|
|
713
|
+
h('circle', { key: 'eye-l', cx: 9.9, cy: 9.8, r: 0.85, fill: 'currentColor', stroke: 'none' }),
|
|
714
|
+
h('circle', { key: 'eye-r', cx: 14.1, cy: 9.8, r: 0.85, fill: 'currentColor', stroke: 'none' }),
|
|
715
|
+
h('path', { key: 'mouth', d: 'M9.6 13.1c.55.9 1.4 1.35 2.4 1.35s1.85-.45 2.4-1.35' }),
|
|
716
|
+
h('path', { key: 'base', d: 'M9.4 19.2h5.2' })
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function IconBack(props) {
|
|
721
|
+
return svgIcon(props && props.size ? props.size : 14, h('path', { d: 'M15 5l-7 7 7 7' }));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function IconHome(props) {
|
|
725
|
+
return svgIcon(
|
|
726
|
+
props && props.size ? props.size : 14,
|
|
727
|
+
[
|
|
728
|
+
h('path', { key: 'roof', d: 'M4 11.5 12 4l8 7.5' }),
|
|
729
|
+
h('path', { key: 'house', d: 'M6.5 10v9.5h11V10' }),
|
|
730
|
+
]
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function IconRecents(props) {
|
|
735
|
+
return svgIcon(props && props.size ? props.size : 14, h('rect', { x: 4.5, y: 4.5, width: 15, height: 15, rx: 2.5 }));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function IconPower(props) {
|
|
739
|
+
return svgIcon(
|
|
740
|
+
props && props.size ? props.size : 14,
|
|
741
|
+
[
|
|
742
|
+
h('path', { key: 'stem', d: 'M12 3.5v8' }),
|
|
743
|
+
h('path', { key: 'ring', d: 'M7.4 6.5a7 7 0 1 0 9.2 0' }),
|
|
744
|
+
]
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function IconRefresh(props) {
|
|
749
|
+
return svgIcon(
|
|
750
|
+
props && props.size ? props.size : 14,
|
|
751
|
+
[
|
|
752
|
+
h('path', { key: 'arc', d: 'M20 12a8 8 0 1 1-2.35-5.65' }),
|
|
753
|
+
h('path', { key: 'arrow', d: 'M20 4v5h-5' }),
|
|
754
|
+
]
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/* ------------------------------------------------------------------ *
|
|
759
|
+
* Component styles. Colors come exclusively from the host theme tokens
|
|
760
|
+
* (bg-base / bg-layer-1/2, border-l1/l2, label-primary/secondary,
|
|
761
|
+
* brand-primary, state-*-primary) — no literal palette colors.
|
|
762
|
+
* ------------------------------------------------------------------ */
|
|
763
|
+
|
|
764
|
+
var CSS_TEXT =
|
|
765
|
+
'.apkvm-page{box-sizing:border-box;position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:0;overflow:hidden;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.45}' +
|
|
766
|
+
'.apkvm-page *,.apkvm-page *::before,.apkvm-page *::after{box-sizing:border-box}' +
|
|
767
|
+
'.apkvm-header{flex:0 0 auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base)}' +
|
|
768
|
+
'.apkvm-headicon{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;flex:none;border-radius:9px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary)}' +
|
|
769
|
+
'.apkvm-title{font-size:16px;font-weight:600;line-height:21px;margin:0;white-space:nowrap}' +
|
|
770
|
+
'.apkvm-subtitle{font-size:11.5px;line-height:16px;margin:1px 0 0;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:min(46vw,560px)}' +
|
|
771
|
+
'.apkvm-spacer{flex:1 1 auto;min-width:8px}' +
|
|
772
|
+
'.apkvm-pill{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap;flex:none}' +
|
|
773
|
+
'.apkvm-pillmeta{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;max-width:220px;overflow:hidden;text-overflow:ellipsis}' +
|
|
774
|
+
'.apkvm-dot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-idle-primary);flex:none}' +
|
|
775
|
+
'.apkvm-dot.ready{background:var(--dsw-alias-state-success-primary)}' +
|
|
776
|
+
'.apkvm-dot.starting,.apkvm-dot.booting{background:var(--dsw-alias-state-warn-primary)}' +
|
|
777
|
+
'.apkvm-dot.error{background:var(--dsw-alias-state-error-primary)}' +
|
|
778
|
+
'.apkvm-dot.loading{background:var(--dsw-alias-state-idle-primary)}' +
|
|
779
|
+
'.apkvm-btn{flex:none;appearance:none;font:inherit;font-size:12px;line-height:18px;display:inline-flex;align-items:center;justify-content:center;gap:5px;height:28px;padding:0 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;user-select:none;transition:border-color .12s ease,background .12s ease,color .12s ease}' +
|
|
780
|
+
'.apkvm-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l1)}' +
|
|
781
|
+
'.apkvm-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}' +
|
|
782
|
+
'.apkvm-btn:disabled{opacity:.5;cursor:default}' +
|
|
783
|
+
'.apkvm-btn-primary{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-base)}' +
|
|
784
|
+
'.apkvm-btn-primary:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-brand-primary)}' +
|
|
785
|
+
'.apkvm-btn-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-base)}' +
|
|
786
|
+
'.apkvm-btn-danger:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
787
|
+
'.apkvm-btn-sm{height:24px;padding:0 8px;font-size:11px;border-radius:7px}' +
|
|
788
|
+
'.apkvm-banner{flex:0 0 auto;display:flex;align-items:center;gap:8px;margin:10px 20px 0;padding:7px 10px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);font-size:12px;color:var(--dsw-alias-label-primary)}' +
|
|
789
|
+
'.apkvm-banner.error{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}' +
|
|
790
|
+
'.apkvm-banner.success{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}' +
|
|
791
|
+
'.apkvm-banner.warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}' +
|
|
792
|
+
'.apkvm-banner-text{flex:1 1 auto;min-width:0;word-break:break-word}' +
|
|
793
|
+
'.apkvm-banner-close{appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-size:15px;line-height:1;padding:2px 4px;cursor:pointer;border-radius:5px;opacity:.75}' +
|
|
794
|
+
'.apkvm-banner-close:hover{opacity:1}' +
|
|
795
|
+
'.apkvm-body{flex:1 1 auto;min-height:0;display:flex;gap:16px;padding:14px 20px 18px;overflow:hidden;align-items:stretch}' +
|
|
796
|
+
'.apkvm-aside{flex:0 0 280px;width:280px;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px;overflow-y:auto;overflow-x:hidden;padding-right:2px}' +
|
|
797
|
+
'.apkvm-card{flex:none;display:flex;flex-direction:column;gap:8px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);min-width:0}' +
|
|
798
|
+
'.apkvm-cardhead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}' +
|
|
799
|
+
'.apkvm-card-title{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-label-secondary);margin:0}' +
|
|
800
|
+
'.apkvm-list{display:flex;flex-direction:column;gap:6px;overflow-y:auto;max-height:min(38vh,360px);min-height:60px;padding:1px 2px 1px 1px}' +
|
|
801
|
+
'.apkvm-empty{display:flex;align-items:center;gap:8px;padding:10px 4px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
|
|
802
|
+
'.apkvm-apk{display:flex;flex-direction:column;gap:5px;padding:7px 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-base);min-width:0}' +
|
|
803
|
+
'.apkvm-apk:hover{border-color:var(--dsw-alias-border-l2)}' +
|
|
804
|
+
'.apkvm-apk-name{font-size:12.5px;font-weight:500;line-height:17px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
805
|
+
'.apkvm-apk-icon{width:18px;height:18px;flex:none;border-radius:4px;align-self:center;object-fit:contain}' +
|
|
806
|
+
'.apkvm-dim{font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
807
|
+
'.apkvm-tag{flex:none;font-size:10px;line-height:14px;padding:0 6px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}' +
|
|
808
|
+
'.apkvm-tag.ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}' +
|
|
809
|
+
'.apkvm-input{font:inherit;font-size:12px;line-height:18px;height:28px;padding:0 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);min-width:0;width:100%;flex:1 1 auto}' +
|
|
810
|
+
'.apkvm-input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.85}' +
|
|
811
|
+
'.apkvm-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}' +
|
|
812
|
+
'.apkvm-input:disabled{opacity:.55}' +
|
|
813
|
+
'.apkvm-inline-error{font-size:11px;line-height:15px;color:var(--dsw-alias-state-error-primary);word-break:break-word;max-height:96px;overflow-y:auto}' +
|
|
814
|
+
'.apkvm-log{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:10.5px;line-height:15px;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:7px 8px;max-height:150px;min-height:42px;overflow-y:auto}' +
|
|
815
|
+
'.apkvm-hint{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);padding:0 2px;flex:none}' +
|
|
816
|
+
'.apkvm-stage{flex:1 1 auto;min-width:0;min-height:0;display:flex;flex-direction:column;align-items:center;gap:8px}' +
|
|
817
|
+
'.apkvm-phonewrap{flex:1 1 auto;min-height:160px;min-width:0;width:100%;display:flex;align-items:stretch;justify-content:center;padding:2px 0}' +
|
|
818
|
+
'.apkvm-phone{display:flex;height:100%;padding:8px;border-radius:26px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);max-width:100%;max-height:100%;min-height:0;flex:none}' +
|
|
819
|
+
'.apkvm-screen{position:relative;flex:none;height:100%;max-width:100%;aspect-ratio:9/19.5;border-radius:19px;overflow:hidden;background:var(--dsw-alias-bg-layer-1);display:flex;align-items:center;justify-content:center;touch-action:none;user-select:none;-webkit-user-select:none}' +
|
|
820
|
+
'.apkvm-screen.ready{cursor:crosshair}' +
|
|
821
|
+
'.apkvm-frame{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}' +
|
|
822
|
+
'.apkvm-overlay{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:22px 18px;text-align:center;background:var(--dsw-alias-bg-layer-1)}' +
|
|
823
|
+
'.apkvm-overlay-title{font-size:12.5px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary);word-break:break-word}' +
|
|
824
|
+
'.apkvm-overlay-detail{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);word-break:break-word;max-width:100%;max-height:110px;overflow-y:auto}' +
|
|
825
|
+
'.apkvm-overlay-icon{color:var(--dsw-alias-label-secondary);opacity:.85;display:inline-flex}' +
|
|
826
|
+
'.apkvm-spinner{width:20px;height:20px;flex:none;border-radius:50%;border:2px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-brand-primary);animation:apkvm-spin 1s linear infinite}' +
|
|
827
|
+
'.apkvm-spinner-sm{width:13px;height:13px;border-width:2px}' +
|
|
828
|
+
'@keyframes apkvm-spin{to{transform:rotate(360deg)}}' +
|
|
829
|
+
'.apkvm-toolbar{flex:none;display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;width:100%}' +
|
|
830
|
+
'.apkvm-textrow{flex:none;display:flex;align-items:center;gap:6px;width:100%;max-width:340px}' +
|
|
831
|
+
'.apkvm-stagehint{flex:none;font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary);text-align:center;max-width:520px}' +
|
|
832
|
+
'.apkvm-stagehint.error{color:var(--dsw-alias-state-error-primary);word-break:break-word}' +
|
|
833
|
+
'.apkvm-scroll{scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2) transparent}' +
|
|
834
|
+
'.apkvm-scroll::-webkit-scrollbar{width:8px;height:8px}' +
|
|
835
|
+
'.apkvm-scroll::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2);border-radius:4px}' +
|
|
836
|
+
'.apkvm-scroll::-webkit-scrollbar-track{background:transparent}' +
|
|
837
|
+
'@media (max-width:900px){.apkvm-body{flex-direction:column;overflow-y:auto}.apkvm-aside{flex:0 0 auto;width:100%;overflow:visible}.apkvm-list{max-height:200px}.apkvm-stage{min-height:440px}.apkvm-phonewrap{min-height:340px}.apkvm-subtitle{max-width:60vw}}' +
|
|
838
|
+
'@media (max-width:560px){.apkvm-header{padding:10px 12px}.apkvm-body{padding:10px 12px 14px}.apkvm-toolbar{gap:4px}}' +
|
|
839
|
+
/* dsh-better-sidebar tab layout: a native side pane is narrow, so stack
|
|
840
|
+
the APK list above the phone frame and keep the header compact. */
|
|
841
|
+
'.apkvm-page.apkvm-embedded{height:100%;min-height:0;background:var(--dsw-alias-bg-base)}' +
|
|
842
|
+
'.apkvm-embedded .apkvm-header{padding:9px 12px;gap:6px;flex-wrap:wrap}' +
|
|
843
|
+
'.apkvm-embedded .apkvm-subtitle{display:none}' +
|
|
844
|
+
'.apkvm-embedded .apkvm-body{flex-direction:column;overflow-y:auto;gap:8px;padding:10px 12px 14px}' +
|
|
845
|
+
'.apkvm-embedded .apkvm-aside{flex:0 0 auto;width:100%;overflow:visible;padding-right:0;gap:8px}' +
|
|
846
|
+
'.apkvm-embedded .apkvm-list{max-height:170px}' +
|
|
847
|
+
'.apkvm-embedded .apkvm-log{max-height:110px}' +
|
|
848
|
+
'.apkvm-embedded .apkvm-stage{min-height:400px;flex:0 0 auto}' +
|
|
849
|
+
'.apkvm-embedded .apkvm-phonewrap{min-height:320px;padding:0}' +
|
|
850
|
+
'.apkvm-embedded .apkvm-phone{padding:5px;border-radius:20px}' +
|
|
851
|
+
'.apkvm-embedded .apkvm-screen{border-radius:15px}' +
|
|
852
|
+
'.apkvm-embedded .apkvm-overlay{padding:16px 12px;gap:7px}' +
|
|
853
|
+
/* Compact layout refresh: tighter header, APK-first aside, collapsible
|
|
854
|
+
debug, phone fills the stage height, controls in one row. */
|
|
855
|
+
'.apkvm-header{padding:8px 16px;gap:8px;flex-wrap:wrap}' +
|
|
856
|
+
'.apkvm-headicon{width:26px;height:26px;border-radius:8px}' +
|
|
857
|
+
'.apkvm-title{font-size:15px;line-height:20px}' +
|
|
858
|
+
'.apkvm-subtitle{font-size:11px;line-height:15px;max-width:min(40vw,480px)}' +
|
|
859
|
+
'.apkvm-pill{height:22px;padding:0 9px;font-size:11px}' +
|
|
860
|
+
'.apkvm-pillmeta{max-width:300px}' +
|
|
861
|
+
'.apkvm-instance-select{font:inherit;font-size:11px;height:24px;max-width:190px;padding:0 5px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);flex:none}' +
|
|
862
|
+
'.apkvm-instlist{display:flex;flex-direction:column;gap:4px;padding-top:4px}' +
|
|
863
|
+
'.apkvm-instrow{display:flex;align-items:center;gap:5px;font-size:11px;min-width:0}' +
|
|
864
|
+
'.apkvm-instname{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
865
|
+
'.apkvm-instmeta{flex:none;font-size:10px;color:var(--dsw-alias-label-secondary)}' +
|
|
866
|
+
'.apkvm-instactions{display:flex;gap:3px;flex:none}' +
|
|
867
|
+
'.apkvm-mini{font:inherit;font-size:10px;height:19px;padding:0 5px;border-radius:5px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);cursor:pointer}' +
|
|
868
|
+
'.apkvm-mini:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}' +
|
|
869
|
+
'.apkvm-mini.danger:hover{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
870
|
+
'.apkvm-appsbody{display:flex;flex-direction:column;gap:5px;padding-top:4px}' +
|
|
871
|
+
'.apkvm-appslist{display:flex;flex-direction:column;gap:3px;max-height:180px}' +
|
|
872
|
+
'.apkvm-approw{display:flex;align-items:center;gap:4px;min-width:0;font-size:11px}' +
|
|
873
|
+
'.apkvm-appname{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
874
|
+
'.apkvm-appactions{display:flex;gap:2px;flex:none}' +
|
|
875
|
+
'.apkvm-body{gap:12px;padding:10px 14px 12px}' +
|
|
876
|
+
'.apkvm-aside{flex:0 0 296px;width:296px;gap:8px;padding-right:0}' +
|
|
877
|
+
'.apkvm-card{padding:8px 9px;gap:6px;border-radius:10px}' +
|
|
878
|
+
'.apkvm-apkcard{flex:1 1 auto;min-height:180px}' +
|
|
879
|
+
'.apkvm-apkcard .apkvm-list{flex:1 1 auto;max-height:none;min-height:80px}' +
|
|
880
|
+
'.apkvm-list{max-height:none;gap:4px}' +
|
|
881
|
+
'.apkvm-apk{padding:5px 7px;gap:2px}' +
|
|
882
|
+
'.apkvm-apk-name{font-size:12px;line-height:16px}' +
|
|
883
|
+
'.apkvm-toolsline{display:flex;align-items:center;gap:5px;min-width:0}' +
|
|
884
|
+
'.apkvm-stage{flex:1 1 0;min-height:0;gap:6px;justify-content:flex-start}' +
|
|
885
|
+
'.apkvm-phonewrap{flex:1 1 0;min-height:0;padding:0;align-items:center}' +
|
|
886
|
+
'.apkvm-phone{height:100%;padding:6px;border-radius:22px}' +
|
|
887
|
+
'.apkvm-screen{border-radius:16px}' +
|
|
888
|
+
'.apkvm-controls{flex:none;display:flex;align-items:center;justify-content:center;gap:6px;width:100%;max-width:640px;flex-wrap:wrap}' +
|
|
889
|
+
'.apkvm-keys{display:flex;align-items:center;gap:4px;flex:none}' +
|
|
890
|
+
'.apkvm-controls .apkvm-textrow{flex:1 1 220px;width:auto;max-width:none;min-width:160px}' +
|
|
891
|
+
'.apkvm-stagehint{font-size:10.5px;line-height:14px}' +
|
|
892
|
+
'.apkvm-debug{padding:0;overflow:hidden}' +
|
|
893
|
+
'.apkvm-debug summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:7px;padding:8px 9px;min-height:34px}' +
|
|
894
|
+
'.apkvm-debug summary::-webkit-details-marker{display:none}' +
|
|
895
|
+
'.apkvm-debug summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}' +
|
|
896
|
+
'.apkvm-debug[open] summary{border-bottom:1px solid var(--dsw-alias-border-l1)}' +
|
|
897
|
+
'.apkvm-debug-body{display:flex;flex-direction:column;gap:6px;padding:8px 9px 9px}' +
|
|
898
|
+
'.apkvm-chevron{color:var(--dsw-alias-label-secondary);font-size:10px;flex:none;transition:transform .15s ease}' +
|
|
899
|
+
'.apkvm-debug[open] .apkvm-chevron{transform:rotate(90deg)}' +
|
|
900
|
+
'.apkvm-footnote{flex:none;font-size:10.5px;line-height:14px;color:var(--dsw-alias-label-secondary);padding:0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
|
|
901
|
+
'.apkvm-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}' +
|
|
902
|
+
'.apkvm-fileinput{display:none}' +
|
|
903
|
+
'.apkvm-uploadbar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 6px;font-size:10.5px;line-height:14px;color:var(--dsw-alias-label-secondary)}' +
|
|
904
|
+
'.apkvm-uploadbar-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
905
|
+
'.apkvm-uploadbar-pct{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}' +
|
|
906
|
+
'.apkvm-progress{grid-column:1/-1;height:4px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}' +
|
|
907
|
+
'.apkvm-progress-fill{height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary);transition:width .15s ease}' +
|
|
908
|
+
'.apkvm-drop-overlay{position:absolute;inset:8px;z-index:30;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border:2px dashed var(--dsw-alias-brand-primary);border-radius:14px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary);pointer-events:none;font-size:13px;font-weight:600;opacity:.97}' +
|
|
909
|
+
'.apkvm-drop-overlay .apkvm-dim{color:var(--dsw-alias-label-secondary);font-weight:400}' +
|
|
910
|
+
'.apkvm-setup-row{display:grid;grid-template-columns:13px 70px minmax(0,1fr);gap:5px;align-items:center;font-size:10.5px;line-height:14px}' +
|
|
911
|
+
'.apkvm-setup-dot{font-weight:700;text-align:center}' +
|
|
912
|
+
'.apkvm-setup-dot.ok{color:var(--dsw-alias-state-success-primary)}' +
|
|
913
|
+
'.apkvm-setup-dot.bad{color:var(--dsw-alias-state-warn-primary)}' +
|
|
914
|
+
'.apkvm-setup-label{color:var(--dsw-alias-label-primary)}' +
|
|
915
|
+
'.apkvm-setup-detail{text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
|
|
916
|
+
'.apkvm-embedded .apkvm-header{padding:8px 10px;gap:6px}' +
|
|
917
|
+
'.apkvm-embedded .apkvm-body{padding:8px 10px 10px;gap:6px}' +
|
|
918
|
+
'.apkvm-embedded .apkvm-aside{gap:6px}' +
|
|
919
|
+
'.apkvm-embedded .apkvm-apkcard{min-height:0}' +
|
|
920
|
+
'.apkvm-embedded .apkvm-apkcard .apkvm-list{max-height:150px;min-height:50px}' +
|
|
921
|
+
'.apkvm-embedded .apkvm-phonewrap{flex:0 0 auto;min-height:320px}' +
|
|
922
|
+
'.apkvm-embedded .apkvm-controls{max-width:none}' +
|
|
923
|
+
'@media (max-width:900px){.apkvm-header{flex-wrap:wrap}}' +
|
|
924
|
+
'.apkvm-embedded .apkvm-header{flex-wrap:wrap}';
|
|
925
|
+
|
|
926
|
+
/* ------------------------------------------------------------------ *
|
|
927
|
+
* Render-error boundary: an unexpected exception inside the panel keeps
|
|
928
|
+
* the slot alive with a recoverable message instead of a blank panel.
|
|
929
|
+
* ------------------------------------------------------------------ */
|
|
930
|
+
|
|
931
|
+
function createBoundary() {
|
|
932
|
+
var Boundary = class ApkVmBoundary extends React.Component {
|
|
933
|
+
constructor(props) {
|
|
934
|
+
super(props);
|
|
935
|
+
this.state = { error: null };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
static getDerivedStateFromError(error) {
|
|
939
|
+
return { error: error };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
componentDidCatch() {
|
|
943
|
+
/* deliberately swallowed: the fallback UI reports the failure */
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
render() {
|
|
947
|
+
if (!this.state || !this.state.error) return this.props ? this.props.children : null;
|
|
948
|
+
var T = createTranslator(this.props && typeof this.props.translate === 'function' ? this.props.translate : null);
|
|
949
|
+
var detail = this.state.error && this.state.error.message ? this.state.error.message : String(this.state.error);
|
|
950
|
+
var self = this;
|
|
951
|
+
return h(
|
|
952
|
+
'div',
|
|
953
|
+
{
|
|
954
|
+
className: 'apkvm-page',
|
|
955
|
+
style: { alignItems: 'center', justifyContent: 'center', gap: '10px', padding: '24px', textAlign: 'center' },
|
|
956
|
+
},
|
|
957
|
+
h('div', { className: 'apkvm-title' }, T('title')),
|
|
958
|
+
h('div', { className: 'apkvm-dim' }, T('err.render')),
|
|
959
|
+
h('div', { className: 'apkvm-dim', style: { whiteSpace: 'normal', maxWidth: '480px', overflow: 'visible' } }, detail),
|
|
960
|
+
h(
|
|
961
|
+
'button',
|
|
962
|
+
{
|
|
963
|
+
type: 'button',
|
|
964
|
+
className: 'apkvm-btn apkvm-btn-primary',
|
|
965
|
+
onClick: function () {
|
|
966
|
+
self.setState({ error: null });
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
T('overlay.retry')
|
|
970
|
+
)
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
return Boundary;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/* ------------------------------------------------------------------ *
|
|
978
|
+
* The main panel component. All hooks are unconditional and in a stable
|
|
979
|
+
* order so React can re-render it safely across Host state transitions.
|
|
980
|
+
* ------------------------------------------------------------------ */
|
|
981
|
+
|
|
982
|
+
function createPage(getTranslate) {
|
|
983
|
+
function ApkVmPage(props) {
|
|
984
|
+
var propsSafe = isObject(props) ? props : {};
|
|
985
|
+
var translate = typeof propsSafe.t === 'function' ? propsSafe.t : getTranslate();
|
|
986
|
+
var T = createTranslator(translate);
|
|
987
|
+
/* dsh-better-sidebar's TabComponentProps include `visible`; the native
|
|
988
|
+
`main` panel has no such prop and is always treated as visible. */
|
|
989
|
+
var embedded = propsSafe.embedded === true;
|
|
990
|
+
var tabVisible = propsSafe.visible === undefined ? true : propsSafe.visible !== false;
|
|
991
|
+
|
|
992
|
+
/* ---- state ---------------------------------------------------- */
|
|
993
|
+
var snapshotState = React.useState(null);
|
|
994
|
+
var snapshot = snapshotState[0];
|
|
995
|
+
var setSnapshot = snapshotState[1];
|
|
996
|
+
|
|
997
|
+
var stateErrorState = React.useState('');
|
|
998
|
+
var stateError = stateErrorState[0];
|
|
999
|
+
var setStateError = stateErrorState[1];
|
|
1000
|
+
|
|
1001
|
+
var apksState = React.useState(null);
|
|
1002
|
+
var apks = apksState[0];
|
|
1003
|
+
var setApks = apksState[1];
|
|
1004
|
+
|
|
1005
|
+
var rootsState = React.useState([]);
|
|
1006
|
+
var roots = rootsState[0];
|
|
1007
|
+
var setRoots = rootsState[1];
|
|
1008
|
+
|
|
1009
|
+
var apksErrorState = React.useState('');
|
|
1010
|
+
var apksError = apksErrorState[0];
|
|
1011
|
+
var setApksError = apksErrorState[1];
|
|
1012
|
+
|
|
1013
|
+
var apksBusyState = React.useState(false);
|
|
1014
|
+
var apksBusy = apksBusyState[0];
|
|
1015
|
+
var setApksBusy = apksBusyState[1];
|
|
1016
|
+
|
|
1017
|
+
var installedAppsState = React.useState([]);
|
|
1018
|
+
var installedApps = installedAppsState[0];
|
|
1019
|
+
var setInstalledApps = installedAppsState[1];
|
|
1020
|
+
var appsOpenState = React.useState(false);
|
|
1021
|
+
var appsOpen = appsOpenState[0];
|
|
1022
|
+
var setAppsOpen = appsOpenState[1];
|
|
1023
|
+
var appsBusyState = React.useState(false);
|
|
1024
|
+
var appsBusy = appsBusyState[0];
|
|
1025
|
+
var setAppsBusy = appsBusyState[1];
|
|
1026
|
+
var appsErrorState = React.useState('');
|
|
1027
|
+
var appsError = appsErrorState[0];
|
|
1028
|
+
var setAppsError = appsErrorState[1];
|
|
1029
|
+
var appActionBusyState = React.useState('');
|
|
1030
|
+
var appActionBusy = appActionBusyState[0];
|
|
1031
|
+
var setAppActionBusy = appActionBusyState[1];
|
|
1032
|
+
|
|
1033
|
+
var vmBusyState = React.useState(false);
|
|
1034
|
+
var vmBusy = vmBusyState[0];
|
|
1035
|
+
var setVmBusy = vmBusyState[1];
|
|
1036
|
+
|
|
1037
|
+
var installingState = React.useState('');
|
|
1038
|
+
var installing = installingState[0];
|
|
1039
|
+
var setInstalling = installingState[1];
|
|
1040
|
+
|
|
1041
|
+
var pathInputState = React.useState('');
|
|
1042
|
+
var pathInput = pathInputState[0];
|
|
1043
|
+
var setPathInput = pathInputState[1];
|
|
1044
|
+
|
|
1045
|
+
var uploadState = React.useState(null);
|
|
1046
|
+
var upload = uploadState[0];
|
|
1047
|
+
var setUpload = uploadState[1];
|
|
1048
|
+
var draggingState = React.useState(false);
|
|
1049
|
+
var dragging = draggingState[0];
|
|
1050
|
+
var setDragging = draggingState[1];
|
|
1051
|
+
var fileInputRef = React.useRef(null);
|
|
1052
|
+
var dragDepthRef = React.useRef(0);
|
|
1053
|
+
|
|
1054
|
+
var setupState = React.useState(null);
|
|
1055
|
+
var setup = setupState[0];
|
|
1056
|
+
var setSetup = setupState[1];
|
|
1057
|
+
var setupBusyState = React.useState(false);
|
|
1058
|
+
var setupBusy = setupBusyState[0];
|
|
1059
|
+
var setSetupBusy = setupBusyState[1];
|
|
1060
|
+
var avdsState = React.useState([]);
|
|
1061
|
+
var avds = avdsState[0];
|
|
1062
|
+
var setAvds = avdsState[1];
|
|
1063
|
+
var avdPickState = React.useState('');
|
|
1064
|
+
var avdPick = avdPickState[0];
|
|
1065
|
+
var setAvdPick = avdPickState[1];
|
|
1066
|
+
var devicesState = React.useState([]);
|
|
1067
|
+
var devices = devicesState[0];
|
|
1068
|
+
var setDevices = devicesState[1];
|
|
1069
|
+
var devicePickState = React.useState('');
|
|
1070
|
+
var devicePick = devicePickState[0];
|
|
1071
|
+
var setDevicePick = devicePickState[1];
|
|
1072
|
+
var deviceBusyState = React.useState(false);
|
|
1073
|
+
var deviceBusy = deviceBusyState[0];
|
|
1074
|
+
var setDeviceBusy = deviceBusyState[1];
|
|
1075
|
+
var deviceErrorState = React.useState('');
|
|
1076
|
+
var deviceError = deviceErrorState[0];
|
|
1077
|
+
var setDeviceError = deviceErrorState[1];
|
|
1078
|
+
|
|
1079
|
+
var activeInstanceState = React.useState('default');
|
|
1080
|
+
var activeInstance = activeInstanceState[0];
|
|
1081
|
+
var setActiveInstance = activeInstanceState[1];
|
|
1082
|
+
var activeInstanceRef = React.useRef('default');
|
|
1083
|
+
var instancesListState = React.useState([]);
|
|
1084
|
+
var instancesList = instancesListState[0];
|
|
1085
|
+
var setInstancesList = instancesListState[1];
|
|
1086
|
+
var instanceBusyState = React.useState(false);
|
|
1087
|
+
var instanceBusy = instanceBusyState[0];
|
|
1088
|
+
var setInstanceBusy = instanceBusyState[1];
|
|
1089
|
+
|
|
1090
|
+
var textInputState = React.useState('');
|
|
1091
|
+
var textInput = textInputState[0];
|
|
1092
|
+
var setTextInput = textInputState[1];
|
|
1093
|
+
|
|
1094
|
+
var debugCommandState = React.useState('');
|
|
1095
|
+
var debugCommand = debugCommandState[0];
|
|
1096
|
+
var setDebugCommand = debugCommandState[1];
|
|
1097
|
+
var debugOutputState = React.useState('');
|
|
1098
|
+
var debugOutput = debugOutputState[0];
|
|
1099
|
+
var setDebugOutput = debugOutputState[1];
|
|
1100
|
+
var debugBusyState = React.useState(false);
|
|
1101
|
+
var debugBusy = debugBusyState[0];
|
|
1102
|
+
var setDebugBusy = debugBusyState[1];
|
|
1103
|
+
var logLiveState = React.useState(false);
|
|
1104
|
+
var logLive = logLiveState[0];
|
|
1105
|
+
var setLogLive = logLiveState[1];
|
|
1106
|
+
var logLiveTimerRef = React.useRef(null);
|
|
1107
|
+
|
|
1108
|
+
var actualFpsState = React.useState(0);
|
|
1109
|
+
var actualFps = actualFpsState[0];
|
|
1110
|
+
var setActualFps = actualFpsState[1];
|
|
1111
|
+
|
|
1112
|
+
var videoModeState = React.useState('png');
|
|
1113
|
+
var videoMode = videoModeState[0];
|
|
1114
|
+
var setVideoMode = videoModeState[1];
|
|
1115
|
+
var h264MetaState = React.useState(null);
|
|
1116
|
+
var h264Meta = h264MetaState[0];
|
|
1117
|
+
var setH264Meta = h264MetaState[1];
|
|
1118
|
+
|
|
1119
|
+
var frameUrlState = React.useState('');
|
|
1120
|
+
var frameUrl = frameUrlState[0];
|
|
1121
|
+
var setFrameUrl = frameUrlState[1];
|
|
1122
|
+
|
|
1123
|
+
var frameErrorState = React.useState('');
|
|
1124
|
+
var frameError = frameErrorState[0];
|
|
1125
|
+
var setFrameError = frameErrorState[1];
|
|
1126
|
+
|
|
1127
|
+
var hiddenState = React.useState(function () {
|
|
1128
|
+
return typeof document !== 'undefined' && document.hidden === true;
|
|
1129
|
+
});
|
|
1130
|
+
var hidden = hiddenState[0];
|
|
1131
|
+
var setHidden = hiddenState[1];
|
|
1132
|
+
|
|
1133
|
+
var toastState = React.useState(null);
|
|
1134
|
+
var toast = toastState[0];
|
|
1135
|
+
var setToast = toastState[1];
|
|
1136
|
+
|
|
1137
|
+
var sbState = React.useState(getBetterSidebar());
|
|
1138
|
+
var sidebarService = sbState[0];
|
|
1139
|
+
var setSidebarService = sbState[1];
|
|
1140
|
+
|
|
1141
|
+
/* ---- refs ----------------------------------------------------- */
|
|
1142
|
+
var mountedRef = React.useRef(true);
|
|
1143
|
+
var snapshotRef = React.useRef(null);
|
|
1144
|
+
var screenRef = React.useRef(null);
|
|
1145
|
+
var logRef = React.useRef(null);
|
|
1146
|
+
var frameUrlRef = React.useRef('');
|
|
1147
|
+
var stateBusyRef = React.useRef(false);
|
|
1148
|
+
var vmBusyRef = React.useRef(false);
|
|
1149
|
+
var installingRef = React.useRef('');
|
|
1150
|
+
var dragRef = React.useRef(null);
|
|
1151
|
+
var multiTouchRef = React.useRef(null);
|
|
1152
|
+
var latestRef = React.useRef({});
|
|
1153
|
+
var frameStatsRef = React.useRef([]);
|
|
1154
|
+
var canvasRef = React.useRef(null);
|
|
1155
|
+
var decoderRef = React.useRef(null);
|
|
1156
|
+
var wsRef = React.useRef(null);
|
|
1157
|
+
|
|
1158
|
+
/* ---- derived snapshot values --------------------------------- */
|
|
1159
|
+
var vmInfo = snapshot ? snapshot.vm : null;
|
|
1160
|
+
var clientReport = snapshot && isObject(snapshot.client) ? snapshot.client : null;
|
|
1161
|
+
var wsOrigin = snapshot && snapshot.origin ? snapshot.origin : '';
|
|
1162
|
+
var apkInfo = snapshot ? snapshot.apk : null;
|
|
1163
|
+
var vmIdentity = vmInfo && isObject(vmInfo.identity) ? vmInfo.identity : null;
|
|
1164
|
+
var rootActive = vmInfo && vmInfo.root === true;
|
|
1165
|
+
var identityText = vmIdentity
|
|
1166
|
+
? T('debug.identity', {
|
|
1167
|
+
release: vmIdentity.release ? 'Android ' + vmIdentity.release : 'Android',
|
|
1168
|
+
sdk: vmIdentity.sdk ? String(vmIdentity.sdk) : '?',
|
|
1169
|
+
abi: vmIdentity.abi || '?',
|
|
1170
|
+
})
|
|
1171
|
+
: '';
|
|
1172
|
+
var displayState = snapshot ? snapshot.vm.state : stateError ? 'error' : 'loading';
|
|
1173
|
+
var stateLabel = T('state.' + displayState);
|
|
1174
|
+
var ready = displayState === 'ready';
|
|
1175
|
+
var targetFps =
|
|
1176
|
+
snapshot && isObject(snapshot.stream) && Number(snapshot.stream.fps) > 0
|
|
1177
|
+
? Math.min(10, Math.max(1, Math.round(Number(snapshot.stream.fps))))
|
|
1178
|
+
: 2;
|
|
1179
|
+
var frameIntervalMs = Math.max(200, Math.round(1000 / targetFps));
|
|
1180
|
+
var busyState = displayState === 'starting' || displayState === 'booting';
|
|
1181
|
+
var screen = snapshot ? snapshot.screen : null;
|
|
1182
|
+
var displayScreen =
|
|
1183
|
+
videoMode === 'h264' && h264Meta && Number(h264Meta.width) > 0 && Number(h264Meta.height) > 0
|
|
1184
|
+
? { width: Number(h264Meta.width), height: Number(h264Meta.height) }
|
|
1185
|
+
: screen;
|
|
1186
|
+
// Input injection expects the guest display coordinate space
|
|
1187
|
+
// (`wm size`, swapped when rotated), not the downscaled H.264 frame.
|
|
1188
|
+
var inputScreen = screen;
|
|
1189
|
+
if (screen && videoMode === 'h264' && h264Meta) {
|
|
1190
|
+
var videoLandscape = Number(h264Meta.width) > Number(h264Meta.height);
|
|
1191
|
+
var screenLandscape = Number(screen.width) > Number(screen.height);
|
|
1192
|
+
if (videoLandscape !== screenLandscape) {
|
|
1193
|
+
inputScreen = { width: Number(screen.height), height: Number(screen.width) };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (!inputScreen) inputScreen = displayScreen;
|
|
1197
|
+
var screenAspect = displayScreen ? displayScreen.width + ' / ' + displayScreen.height : '9 / 16';
|
|
1198
|
+
var currentApkPath = apkInfo && apkInfo.path ? apkInfo.path : '';
|
|
1199
|
+
var logLines = vmInfo && vmInfo.logTail ? vmInfo.logTail : [];
|
|
1200
|
+
var logText = logLines.join('\n');
|
|
1201
|
+
|
|
1202
|
+
var pillMeta = [];
|
|
1203
|
+
if (vmInfo) {
|
|
1204
|
+
if (vmInfo.serial) pillMeta.push(vmInfo.serial);
|
|
1205
|
+
else if (vmInfo.avd) pillMeta.push(vmInfo.avd);
|
|
1206
|
+
if (displayState === 'ready' && vmInfo.bootMs > 0) {
|
|
1207
|
+
var bootSeconds = Math.round(vmInfo.bootMs / 1000);
|
|
1208
|
+
var bootKey =
|
|
1209
|
+
vmInfo.bootKind === 'snapshot'
|
|
1210
|
+
? 'pill.snapshot'
|
|
1211
|
+
: vmInfo.bootKind === 'cold'
|
|
1212
|
+
? 'pill.cold'
|
|
1213
|
+
: 'pill.boot';
|
|
1214
|
+
pillMeta.push(T(bootKey, { s: bootSeconds }));
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
var subtitleText = apkInfo
|
|
1218
|
+
? T('apk.current', { name: apkInfo.label || apkInfo.packageName || apkInfo.path })
|
|
1219
|
+
: T('subtitle');
|
|
1220
|
+
var rootsLabel =
|
|
1221
|
+
roots.length > 2 ? roots.slice(0, 2).join(' · ') + ' +' + (roots.length - 2) : roots.join(' · ');
|
|
1222
|
+
|
|
1223
|
+
snapshotRef.current = snapshot;
|
|
1224
|
+
latestRef.current = { ready: ready, screen: screen, send: sendInput, T: T };
|
|
1225
|
+
|
|
1226
|
+
function sendClientReport(extra) {
|
|
1227
|
+
if (typeof fetch !== 'function') return;
|
|
1228
|
+
var payload = {
|
|
1229
|
+
version: CLIENT_VERSION,
|
|
1230
|
+
userAgent: typeof navigator !== 'undefined' ? String(navigator.userAgent || '') : '',
|
|
1231
|
+
location: typeof location !== 'undefined' ? String(location.href || '') : '',
|
|
1232
|
+
webCodecs: typeof VideoDecoder === 'function',
|
|
1233
|
+
mode: extra && extra.mode ? String(extra.mode) : videoMode || 'png',
|
|
1234
|
+
codec: extra && extra.codec ? String(extra.codec) : '',
|
|
1235
|
+
h264Supported: extra && extra.h264Supported === true,
|
|
1236
|
+
h264Error: extra && extra.h264Error ? String(extra.h264Error).slice(0, 400) : '',
|
|
1237
|
+
decoderError: extra && extra.decoderError ? String(extra.decoderError).slice(0, 400) : '',
|
|
1238
|
+
};
|
|
1239
|
+
try {
|
|
1240
|
+
fetch('/apk-vm/client-report', {
|
|
1241
|
+
method: 'POST',
|
|
1242
|
+
headers: { 'content-type': 'application/json' },
|
|
1243
|
+
body: JSON.stringify(payload),
|
|
1244
|
+
cache: 'no-store',
|
|
1245
|
+
}).catch(function () {});
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
/* diagnostics are best effort */
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
React.useEffect(function () {
|
|
1252
|
+
if (typeof VideoDecoder !== 'function' || typeof EncodedVideoChunk !== 'function') {
|
|
1253
|
+
sendClientReport({ h264Supported: false, h264Error: 'WebCodecs VideoDecoder/EncodedVideoChunk unavailable', decoderError: '' });
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (typeof VideoDecoder.isConfigSupported !== 'function') {
|
|
1257
|
+
sendClientReport({ h264Supported: false, h264Error: 'VideoDecoder.isConfigSupported unavailable' });
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
VideoDecoder.isConfigSupported({ codec: 'avc1.42C029' }).then(function (support) {
|
|
1262
|
+
sendClientReport({
|
|
1263
|
+
h264Supported: Boolean(support && support.supported),
|
|
1264
|
+
h264Error: support && support.supported ? '' : 'avc1.42C029 is not supported by this WebView',
|
|
1265
|
+
});
|
|
1266
|
+
}).catch(function (error) {
|
|
1267
|
+
sendClientReport({ h264Supported: false, h264Error: String(error && error.message ? error.message : error) });
|
|
1268
|
+
});
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
sendClientReport({ h264Supported: false, h264Error: String(error && error.message ? error.message : error) });
|
|
1271
|
+
}
|
|
1272
|
+
}, []);
|
|
1273
|
+
|
|
1274
|
+
/* ---- effects -------------------------------------------------- */
|
|
1275
|
+
// Lifecycle flag + initial load + 2s state polling.
|
|
1276
|
+
React.useEffect(function () {
|
|
1277
|
+
mountedRef.current = true;
|
|
1278
|
+
loadState();
|
|
1279
|
+
loadApks();
|
|
1280
|
+
loadInstances();
|
|
1281
|
+
loadDevices();
|
|
1282
|
+
var pollId = setInterval(function () {
|
|
1283
|
+
if (stateBusyRef.current) return;
|
|
1284
|
+
stateBusyRef.current = true;
|
|
1285
|
+
loadState().then(function () {
|
|
1286
|
+
stateBusyRef.current = false;
|
|
1287
|
+
});
|
|
1288
|
+
}, STATE_INTERVAL_MS);
|
|
1289
|
+
var instancePollId = setInterval(function () {
|
|
1290
|
+
if (mountedRef.current) loadInstances();
|
|
1291
|
+
}, 5000);
|
|
1292
|
+
return function () {
|
|
1293
|
+
mountedRef.current = false;
|
|
1294
|
+
clearInterval(pollId);
|
|
1295
|
+
clearInterval(instancePollId);
|
|
1296
|
+
if (logLiveTimerRef.current) {
|
|
1297
|
+
clearInterval(logLiveTimerRef.current);
|
|
1298
|
+
logLiveTimerRef.current = null;
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
}, []);
|
|
1302
|
+
|
|
1303
|
+
// Track document visibility so the frame stream pauses when hidden.
|
|
1304
|
+
React.useEffect(function () {
|
|
1305
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
|
|
1306
|
+
return undefined;
|
|
1307
|
+
}
|
|
1308
|
+
function onVisibilityChange() {
|
|
1309
|
+
if (mountedRef.current) setHidden(document.hidden === true);
|
|
1310
|
+
}
|
|
1311
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
1312
|
+
return function () {
|
|
1313
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
1314
|
+
};
|
|
1315
|
+
}, []);
|
|
1316
|
+
|
|
1317
|
+
// Follow the optional dsh-better-sidebar service: it can activate
|
|
1318
|
+
// before or after this panel, so both the initial read and the
|
|
1319
|
+
// subscription are needed. Without it the action stays hidden.
|
|
1320
|
+
React.useEffect(function () {
|
|
1321
|
+
var current = getBetterSidebar();
|
|
1322
|
+
if (current !== sidebarService) setSidebarService(current);
|
|
1323
|
+
return subscribeBetterSidebar(function (next) {
|
|
1324
|
+
if (mountedRef.current) setSidebarService(next);
|
|
1325
|
+
});
|
|
1326
|
+
}, []);
|
|
1327
|
+
|
|
1328
|
+
// Auto-dismiss toast banners (errors linger longer than confirmations).
|
|
1329
|
+
React.useEffect(function () {
|
|
1330
|
+
if (!toast) return undefined;
|
|
1331
|
+
var hideId = setTimeout(
|
|
1332
|
+
function () {
|
|
1333
|
+
if (mountedRef.current) setToast(null);
|
|
1334
|
+
},
|
|
1335
|
+
toast.kind === 'error' ? 9000 : 4500
|
|
1336
|
+
);
|
|
1337
|
+
return function () {
|
|
1338
|
+
clearTimeout(hideId);
|
|
1339
|
+
};
|
|
1340
|
+
}, [toast && toast.id]);
|
|
1341
|
+
|
|
1342
|
+
// Frame streaming: poll PNG frames only while ready, visible and the
|
|
1343
|
+
// H.264 WebCodecs path is not active.
|
|
1344
|
+
React.useEffect(function () {
|
|
1345
|
+
if (!ready) {
|
|
1346
|
+
var stale = frameUrlRef.current;
|
|
1347
|
+
frameUrlRef.current = '';
|
|
1348
|
+
if (stale) {
|
|
1349
|
+
revokeObjectUrl(stale);
|
|
1350
|
+
setFrameUrl('');
|
|
1351
|
+
}
|
|
1352
|
+
setFrameError('');
|
|
1353
|
+
setActualFps(0);
|
|
1354
|
+
frameStatsRef.current = [];
|
|
1355
|
+
return undefined;
|
|
1356
|
+
}
|
|
1357
|
+
if (hidden || !tabVisible || videoMode === 'h264') return undefined;
|
|
1358
|
+
var cancelled = false;
|
|
1359
|
+
var timer = 0;
|
|
1360
|
+
function schedule() {
|
|
1361
|
+
timer = setTimeout(tick, frameIntervalMs);
|
|
1362
|
+
}
|
|
1363
|
+
function tick() {
|
|
1364
|
+
if (cancelled) return;
|
|
1365
|
+
requestFrame(T, instancePath('/frame?t=' + Date.now())).then(function (result) {
|
|
1366
|
+
if (cancelled) {
|
|
1367
|
+
if (result.url) revokeObjectUrl(result.url);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (result.url) {
|
|
1371
|
+
var previous = frameUrlRef.current;
|
|
1372
|
+
frameUrlRef.current = result.url;
|
|
1373
|
+
setFrameUrl(result.url);
|
|
1374
|
+
setFrameError('');
|
|
1375
|
+
if (previous) revokeObjectUrl(previous);
|
|
1376
|
+
var now = Date.now();
|
|
1377
|
+
var stats = frameStatsRef.current;
|
|
1378
|
+
stats.push(now);
|
|
1379
|
+
var cutoff = now - 3000;
|
|
1380
|
+
while (stats.length && stats[0] < cutoff) stats.shift();
|
|
1381
|
+
if (stats.length >= 2) {
|
|
1382
|
+
var span = now - stats[0];
|
|
1383
|
+
if (span > 0) {
|
|
1384
|
+
var measured = Math.round((stats.length / (span / 1000)) * 10) / 10;
|
|
1385
|
+
setActualFps(Math.min(30, measured));
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
} else {
|
|
1389
|
+
setFrameError(result.error || T('err.frame'));
|
|
1390
|
+
}
|
|
1391
|
+
schedule();
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
tick();
|
|
1395
|
+
return function () {
|
|
1396
|
+
cancelled = true;
|
|
1397
|
+
clearTimeout(timer);
|
|
1398
|
+
};
|
|
1399
|
+
}, [ready, hidden, tabVisible, frameIntervalMs, videoMode, activeInstance]);
|
|
1400
|
+
|
|
1401
|
+
/* ---- H.264 low-latency stream (scrcpy → WebSocket → WebCodecs) ---- */
|
|
1402
|
+
function base64ToBytes(text) {
|
|
1403
|
+
var binary = typeof atob === 'function' ? atob(String(text || '')) : '';
|
|
1404
|
+
var bytes = new Uint8Array(binary.length);
|
|
1405
|
+
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
1406
|
+
return bytes;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
React.useEffect(function () {
|
|
1410
|
+
if (!ready || hidden || !tabVisible) {
|
|
1411
|
+
setVideoMode('png');
|
|
1412
|
+
setH264Meta(null);
|
|
1413
|
+
return undefined;
|
|
1414
|
+
}
|
|
1415
|
+
if (typeof WebSocket !== 'function' || typeof VideoDecoder !== 'function' || typeof EncodedVideoChunk !== 'function') {
|
|
1416
|
+
return undefined; // fall back to the PNG screencap path
|
|
1417
|
+
}
|
|
1418
|
+
var cancelled = false;
|
|
1419
|
+
var reconnectTimer = 0;
|
|
1420
|
+
var ws = null;
|
|
1421
|
+
var decoder = null;
|
|
1422
|
+
var attempts = 0;
|
|
1423
|
+
var waitingForKey = true;
|
|
1424
|
+
|
|
1425
|
+
function stopDecoder() {
|
|
1426
|
+
if (decoder) {
|
|
1427
|
+
try {
|
|
1428
|
+
decoder.close();
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
/* ignore */
|
|
1431
|
+
}
|
|
1432
|
+
decoder = null;
|
|
1433
|
+
}
|
|
1434
|
+
decoderRef.current = null;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function closeSocket() {
|
|
1438
|
+
if (ws) {
|
|
1439
|
+
try {
|
|
1440
|
+
ws.close();
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
/* ignore */
|
|
1443
|
+
}
|
|
1444
|
+
ws = null;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function scheduleReconnect(delayMs) {
|
|
1449
|
+
if (cancelled) return;
|
|
1450
|
+
attempts += 1;
|
|
1451
|
+
if (attempts > 3) return; // stay on the PNG fallback
|
|
1452
|
+
clearTimeout(reconnectTimer);
|
|
1453
|
+
reconnectTimer = setTimeout(function () {
|
|
1454
|
+
if (!cancelled) connect();
|
|
1455
|
+
}, Math.min(10000, delayMs || 2000));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function drawFrame(frame) {
|
|
1459
|
+
var canvas = canvasRef.current;
|
|
1460
|
+
if (canvas) {
|
|
1461
|
+
if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
|
|
1462
|
+
canvas.width = frame.displayWidth;
|
|
1463
|
+
canvas.height = frame.displayHeight;
|
|
1464
|
+
}
|
|
1465
|
+
var ctx = canvas.getContext('2d');
|
|
1466
|
+
if (ctx) ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
|
1467
|
+
}
|
|
1468
|
+
frame.close();
|
|
1469
|
+
var now = Date.now();
|
|
1470
|
+
var stats = frameStatsRef.current;
|
|
1471
|
+
stats.push(now);
|
|
1472
|
+
var cutoff = now - 3000;
|
|
1473
|
+
while (stats.length && stats[0] < cutoff) stats.shift();
|
|
1474
|
+
if (stats.length >= 2) {
|
|
1475
|
+
var span = now - stats[0];
|
|
1476
|
+
if (span > 0) setActualFps(Math.min(60, Math.round((stats.length / (span / 1000)) * 10) / 10));
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function onPacket(data) {
|
|
1481
|
+
if (!decoder || decoder.state !== 'configured') return;
|
|
1482
|
+
var bytes = new Uint8Array(data);
|
|
1483
|
+
if (bytes.byteLength < 10 || bytes[0] !== 1) return;
|
|
1484
|
+
var view = new DataView(data);
|
|
1485
|
+
var pts = Number(view.getBigInt64(2, true));
|
|
1486
|
+
var key = (bytes[1] & 1) === 1;
|
|
1487
|
+
if (waitingForKey && !key) return;
|
|
1488
|
+
if (key) waitingForKey = false;
|
|
1489
|
+
try {
|
|
1490
|
+
decoder.decode(
|
|
1491
|
+
new EncodedVideoChunk({
|
|
1492
|
+
type: key ? 'key' : 'delta',
|
|
1493
|
+
timestamp: pts,
|
|
1494
|
+
data: bytes.subarray(10),
|
|
1495
|
+
})
|
|
1496
|
+
);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
/* decoder errors surface through VideoDecoder's error callback */
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function connect() {
|
|
1503
|
+
if (cancelled) return;
|
|
1504
|
+
var protocol = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss://' : 'ws://';
|
|
1505
|
+
var host = typeof location !== 'undefined' && location.host ? location.host : '';
|
|
1506
|
+
var wsBase = '';
|
|
1507
|
+
if (wsOrigin) {
|
|
1508
|
+
// The desktop shell can load the panel from a custom scheme where
|
|
1509
|
+
// location.host is empty/meaningless; the Host-derived origin is
|
|
1510
|
+
// the only reliable ws target.
|
|
1511
|
+
wsBase = String(wsOrigin).replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:').replace(/\/+$/, '');
|
|
1512
|
+
} else {
|
|
1513
|
+
wsBase = protocol + host;
|
|
1514
|
+
}
|
|
1515
|
+
if (!wsBase) {
|
|
1516
|
+
setVideoMode('png');
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
var wsPath = activeInstance === 'default'
|
|
1520
|
+
? '/apk-vm/ws'
|
|
1521
|
+
: '/apk-vm/instances/' + encodeURIComponent(activeInstance) + '/ws';
|
|
1522
|
+
try {
|
|
1523
|
+
ws = new WebSocket(wsBase + wsPath);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
scheduleReconnect(3000);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
ws.binaryType = 'arraybuffer';
|
|
1529
|
+
wsRef.current = ws;
|
|
1530
|
+
ws.onmessage = function (event) {
|
|
1531
|
+
if (cancelled) return;
|
|
1532
|
+
if (typeof event.data === 'string') {
|
|
1533
|
+
var message = null;
|
|
1534
|
+
try {
|
|
1535
|
+
message = JSON.parse(event.data);
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (message && message.type === 'init') {
|
|
1540
|
+
attempts = 0;
|
|
1541
|
+
setH264Meta({
|
|
1542
|
+
width: message.width,
|
|
1543
|
+
height: message.height,
|
|
1544
|
+
codec: message.codecString,
|
|
1545
|
+
maxFps: message.maxFps,
|
|
1546
|
+
});
|
|
1547
|
+
try {
|
|
1548
|
+
stopDecoder();
|
|
1549
|
+
var description = base64ToBytes(message.description);
|
|
1550
|
+
decoder = new VideoDecoder({
|
|
1551
|
+
output: drawFrame,
|
|
1552
|
+
error: function (error) {
|
|
1553
|
+
if (cancelled) return;
|
|
1554
|
+
sendClientReport({ h264Supported: true, mode: 'png', decoderError: error && error.message ? error.message : String(error) });
|
|
1555
|
+
setFrameError(T('stream.error') + (error && error.message ? ': ' + error.message : ''));
|
|
1556
|
+
stopDecoder();
|
|
1557
|
+
setVideoMode('png');
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
decoder.configure({
|
|
1561
|
+
codec: message.codecString,
|
|
1562
|
+
description: description,
|
|
1563
|
+
optimizeForLatency: true,
|
|
1564
|
+
});
|
|
1565
|
+
decoderRef.current = decoder;
|
|
1566
|
+
waitingForKey = true;
|
|
1567
|
+
frameStatsRef.current = [];
|
|
1568
|
+
setActualFps(0);
|
|
1569
|
+
setVideoMode('h264');
|
|
1570
|
+
setFrameError('');
|
|
1571
|
+
sendClientReport({ h264Supported: true, mode: 'h264', codec: message.codecString });
|
|
1572
|
+
} catch (error) {
|
|
1573
|
+
sendClientReport({ h264Supported: true, mode: 'png', decoderError: error && error.message ? error.message : String(error) });
|
|
1574
|
+
setFrameError(T('stream.error'));
|
|
1575
|
+
stopDecoder();
|
|
1576
|
+
setVideoMode('png');
|
|
1577
|
+
}
|
|
1578
|
+
} else if (message && message.type === 'error') {
|
|
1579
|
+
setFrameError(T('stream.error') + (message.error ? ': ' + message.error : ''));
|
|
1580
|
+
}
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (event.data && typeof event.data.byteLength === 'number') onPacket(event.data);
|
|
1584
|
+
};
|
|
1585
|
+
ws.onclose = function (event) {
|
|
1586
|
+
if (cancelled) return;
|
|
1587
|
+
sendClientReport({
|
|
1588
|
+
h264Supported: true,
|
|
1589
|
+
mode: 'png',
|
|
1590
|
+
decoderError: 'ws closed ' + (event ? event.code : '?') + ' ' + (event && event.reason ? event.reason : ''),
|
|
1591
|
+
});
|
|
1592
|
+
stopDecoder();
|
|
1593
|
+
setVideoMode('png');
|
|
1594
|
+
scheduleReconnect(2000);
|
|
1595
|
+
};
|
|
1596
|
+
ws.onerror = function () {
|
|
1597
|
+
/* onclose follows; reconnect is scheduled there */
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
connect();
|
|
1602
|
+
return function () {
|
|
1603
|
+
cancelled = true;
|
|
1604
|
+
clearTimeout(reconnectTimer);
|
|
1605
|
+
closeSocket();
|
|
1606
|
+
stopDecoder();
|
|
1607
|
+
setVideoMode('png');
|
|
1608
|
+
setH264Meta(null);
|
|
1609
|
+
};
|
|
1610
|
+
}, [ready, hidden, tabVisible, activeInstance, wsOrigin]);
|
|
1611
|
+
|
|
1612
|
+
// Revoke the last object URL if the panel unmounts mid-stream.
|
|
1613
|
+
React.useEffect(function () {
|
|
1614
|
+
return function () {
|
|
1615
|
+
var leftover = frameUrlRef.current;
|
|
1616
|
+
frameUrlRef.current = '';
|
|
1617
|
+
if (leftover) revokeObjectUrl(leftover);
|
|
1618
|
+
};
|
|
1619
|
+
}, []);
|
|
1620
|
+
|
|
1621
|
+
// Keep the log pinned to the bottom unless the user scrolled up.
|
|
1622
|
+
React.useEffect(function () {
|
|
1623
|
+
var node = logRef.current;
|
|
1624
|
+
if (!node) return;
|
|
1625
|
+
var nearBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 28;
|
|
1626
|
+
if (nearBottom) node.scrollTop = node.scrollHeight;
|
|
1627
|
+
}, [logText]);
|
|
1628
|
+
|
|
1629
|
+
// Wheel → vertical swipe on the device (native non-passive listener so
|
|
1630
|
+
// the page itself never scrolls while the cursor is over the screen).
|
|
1631
|
+
React.useEffect(function () {
|
|
1632
|
+
var node = screenRef.current;
|
|
1633
|
+
if (!node || typeof node.addEventListener !== 'function') return undefined;
|
|
1634
|
+
var lastSentAt = 0;
|
|
1635
|
+
function onWheel(event) {
|
|
1636
|
+
var info = latestRef.current || {};
|
|
1637
|
+
if (!info.ready || !info.screen || typeof info.send !== 'function') return;
|
|
1638
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
1639
|
+
var now = Date.now();
|
|
1640
|
+
if (now - lastSentAt < 130) return;
|
|
1641
|
+
lastSentAt = now;
|
|
1642
|
+
var device = info.screen;
|
|
1643
|
+
var step = clamp(Number(event.deltaY), -3, 3) * device.height * 0.16;
|
|
1644
|
+
var centerX = Math.round(device.width / 2);
|
|
1645
|
+
var fromY = Math.round(device.height * 0.62);
|
|
1646
|
+
var toY = clamp(Math.round(fromY - step), 0, device.height - 1);
|
|
1647
|
+
if (Math.abs(toY - fromY) < 8) return;
|
|
1648
|
+
info.send({ type: 'swipe', x1: centerX, y1: fromY, x2: centerX, y2: toY, duration: 160 });
|
|
1649
|
+
}
|
|
1650
|
+
node.addEventListener('wheel', onWheel, { passive: false });
|
|
1651
|
+
return function () {
|
|
1652
|
+
node.removeEventListener('wheel', onWheel);
|
|
1653
|
+
};
|
|
1654
|
+
}, []);
|
|
1655
|
+
|
|
1656
|
+
/* ---- data loading -------------------------------------------- */
|
|
1657
|
+
function instancePath(pathname) {
|
|
1658
|
+
var id = activeInstanceRef.current || 'default';
|
|
1659
|
+
return id === 'default' ? '/apk-vm' + pathname : '/apk-vm/instances/' + encodeURIComponent(id) + pathname;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function setActiveInstanceId(id) {
|
|
1663
|
+
var next = id || 'default';
|
|
1664
|
+
activeInstanceRef.current = next;
|
|
1665
|
+
setActiveInstance(next);
|
|
1666
|
+
var stale = frameUrlRef.current;
|
|
1667
|
+
frameUrlRef.current = '';
|
|
1668
|
+
if (stale) revokeObjectUrl(stale);
|
|
1669
|
+
setFrameUrl('');
|
|
1670
|
+
setFrameError('');
|
|
1671
|
+
setSnapshot(null);
|
|
1672
|
+
setActualFps(0);
|
|
1673
|
+
setInstalledApps([]);
|
|
1674
|
+
setAppsError('');
|
|
1675
|
+
stopLiveLogcat();
|
|
1676
|
+
frameStatsRef.current = [];
|
|
1677
|
+
loadState();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function loadInstances() {
|
|
1681
|
+
return requestJson('/apk-vm/instances', null, T).then(function (result) {
|
|
1682
|
+
if (!mountedRef.current) return result;
|
|
1683
|
+
if (result.ok && isObject(result.data) && Array.isArray(result.data.instances)) {
|
|
1684
|
+
setInstancesList(result.data.instances);
|
|
1685
|
+
}
|
|
1686
|
+
return result;
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function createInstance() {
|
|
1691
|
+
if (instanceBusy) return;
|
|
1692
|
+
setInstanceBusy(true);
|
|
1693
|
+
requestJson('/apk-vm/instances', { method: 'POST', body: JSON.stringify({}) }, T).then(function (result) {
|
|
1694
|
+
if (!mountedRef.current) return;
|
|
1695
|
+
setInstanceBusy(false);
|
|
1696
|
+
if (!result.ok) {
|
|
1697
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 400));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
var data = isObject(result.data) ? result.data : {};
|
|
1701
|
+
var created = data.instance && data.instance.id;
|
|
1702
|
+
pushToast('success', T('instances.created', { id: created || '' }));
|
|
1703
|
+
loadInstances();
|
|
1704
|
+
if (created) setActiveInstanceId(created);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function destroyActiveInstance() {
|
|
1709
|
+
var id = activeInstanceRef.current;
|
|
1710
|
+
if (!id || id === 'default' || instanceBusy) return;
|
|
1711
|
+
setInstanceBusy(true);
|
|
1712
|
+
requestJson('/apk-vm/instances/' + encodeURIComponent(id), { method: 'DELETE' }, T).then(function (result) {
|
|
1713
|
+
if (!mountedRef.current) return;
|
|
1714
|
+
setInstanceBusy(false);
|
|
1715
|
+
if (!result.ok) {
|
|
1716
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 400));
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
pushToast('success', T('instances.destroyed', { id: id }));
|
|
1720
|
+
setActiveInstanceId('default');
|
|
1721
|
+
loadInstances();
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function stopInstanceById(id) {
|
|
1726
|
+
if (!id || instanceBusy) return;
|
|
1727
|
+
var vmPath = id === 'default' ? '/apk-vm/vm' : '/apk-vm/instances/' + encodeURIComponent(id) + '/vm';
|
|
1728
|
+
setInstanceBusy(true);
|
|
1729
|
+
requestJson(vmPath, { method: 'POST', body: JSON.stringify({ action: 'stop' }) }, T).then(function (result) {
|
|
1730
|
+
if (!mountedRef.current) return;
|
|
1731
|
+
setInstanceBusy(false);
|
|
1732
|
+
if (!result.ok) {
|
|
1733
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 400));
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
pushToast('success', T('instances.stopped', { id: id }));
|
|
1737
|
+
loadInstances();
|
|
1738
|
+
loadState();
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function destroyInstanceById(id) {
|
|
1743
|
+
if (!id || id === 'default' || instanceBusy) return;
|
|
1744
|
+
setInstanceBusy(true);
|
|
1745
|
+
requestJson('/apk-vm/instances/' + encodeURIComponent(id), { method: 'DELETE' }, T).then(function (result) {
|
|
1746
|
+
if (!mountedRef.current) return;
|
|
1747
|
+
setInstanceBusy(false);
|
|
1748
|
+
if (!result.ok) {
|
|
1749
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 400));
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
pushToast('success', T('instances.destroyed', { id: id }));
|
|
1753
|
+
if (activeInstanceRef.current === id) setActiveInstanceId('default');
|
|
1754
|
+
loadInstances();
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function loadState() {
|
|
1759
|
+
var statePath = activeInstanceRef.current === 'default' ? '/apk-vm/state' : '/apk-vm/instances/' + encodeURIComponent(activeInstanceRef.current) + '/state';
|
|
1760
|
+
return requestJson(statePath, null, T).then(function (result) {
|
|
1761
|
+
if (!mountedRef.current) return result;
|
|
1762
|
+
if (result.ok) {
|
|
1763
|
+
setSnapshot(normalizeState(result.data));
|
|
1764
|
+
setStateError('');
|
|
1765
|
+
if (isObject(result.data) && Array.isArray(result.data.instances)) {
|
|
1766
|
+
setInstancesList(result.data.instances);
|
|
1767
|
+
}
|
|
1768
|
+
} else {
|
|
1769
|
+
setStateError(result.error || T('err.state'));
|
|
1770
|
+
}
|
|
1771
|
+
return result;
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function loadApks() {
|
|
1776
|
+
if (mountedRef.current) {
|
|
1777
|
+
setApksBusy(true);
|
|
1778
|
+
setApksError('');
|
|
1779
|
+
}
|
|
1780
|
+
return requestJson('/apk-vm/apks', null, T).then(function (result) {
|
|
1781
|
+
if (!mountedRef.current) return result;
|
|
1782
|
+
if (result.ok) {
|
|
1783
|
+
var normalized = normalizeApkList(result.data);
|
|
1784
|
+
setApks(normalized.apks);
|
|
1785
|
+
setRoots(normalized.roots);
|
|
1786
|
+
} else {
|
|
1787
|
+
setApksError(result.error || T('err.apks'));
|
|
1788
|
+
}
|
|
1789
|
+
setApksBusy(false);
|
|
1790
|
+
return result;
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function refreshAll() {
|
|
1795
|
+
loadState();
|
|
1796
|
+
loadApks();
|
|
1797
|
+
loadDevices();
|
|
1798
|
+
if (appsOpen) loadInstalledApps();
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function loadInstalledApps() {
|
|
1802
|
+
if (!ready) return Promise.resolve(null);
|
|
1803
|
+
setAppsBusy(true);
|
|
1804
|
+
setAppsError('');
|
|
1805
|
+
return requestJson(instancePath('/debug/packages'), null, T).then(function (result) {
|
|
1806
|
+
if (!mountedRef.current) return result;
|
|
1807
|
+
setAppsBusy(false);
|
|
1808
|
+
if (!result.ok) {
|
|
1809
|
+
setAppsError(result.error || T('err.action'));
|
|
1810
|
+
return result;
|
|
1811
|
+
}
|
|
1812
|
+
var data = isObject(result.data) ? result.data : {};
|
|
1813
|
+
var list = Array.isArray(data.packages) ? data.packages : [];
|
|
1814
|
+
var apps = [];
|
|
1815
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
1816
|
+
var item = list[i];
|
|
1817
|
+
if (!isObject(item) || typeof item.packageName !== 'string' || !item.packageName) continue;
|
|
1818
|
+
apps.push({ packageName: item.packageName, apkPath: typeof item.apkPath === 'string' ? item.apkPath : '' });
|
|
1819
|
+
}
|
|
1820
|
+
apps.sort(function (a, b) {
|
|
1821
|
+
return a.packageName < b.packageName ? -1 : a.packageName > b.packageName ? 1 : 0;
|
|
1822
|
+
});
|
|
1823
|
+
setInstalledApps(apps);
|
|
1824
|
+
return result;
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function runAppAction(action, packageName) {
|
|
1829
|
+
if (!ready || !packageName || appActionBusy) return;
|
|
1830
|
+
var key = action + ':' + packageName;
|
|
1831
|
+
setAppActionBusy(key);
|
|
1832
|
+
requestJson(instancePath('/debug/action'), {
|
|
1833
|
+
method: 'POST',
|
|
1834
|
+
body: JSON.stringify({ action: action, packageName: packageName }),
|
|
1835
|
+
}, T).then(function (result) {
|
|
1836
|
+
if (!mountedRef.current) return;
|
|
1837
|
+
setAppActionBusy('');
|
|
1838
|
+
if (!result.ok) {
|
|
1839
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 400));
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
pushToast('success', T('apps.done', { action: action, packageName: packageName }));
|
|
1843
|
+
loadInstalledApps();
|
|
1844
|
+
loadState();
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function toggleApps() {
|
|
1849
|
+
var next = !appsOpen;
|
|
1850
|
+
setAppsOpen(next);
|
|
1851
|
+
if (next) loadInstalledApps();
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function loadSetup(force) {
|
|
1855
|
+
if (!mountedRef.current && !force) return;
|
|
1856
|
+
setSetupBusy(true);
|
|
1857
|
+
requestJson('/apk-vm/setup' + (force ? '?force=1' : ''), null, T).then(function (result) {
|
|
1858
|
+
if (!mountedRef.current) return;
|
|
1859
|
+
setSetupBusy(false);
|
|
1860
|
+
if (result.ok && isObject(result.data)) setSetup(result.data);
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
React.useEffect(function () {
|
|
1865
|
+
loadSetup(false);
|
|
1866
|
+
requestJson('/apk-vm/avds', null, T).then(function (result) {
|
|
1867
|
+
if (!mountedRef.current) return;
|
|
1868
|
+
if (result.ok && isObject(result.data) && Array.isArray(result.data.avds)) {
|
|
1869
|
+
setAvds(result.data.avds);
|
|
1870
|
+
if (result.data.current) setAvdPick(result.data.current);
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
}, []);
|
|
1874
|
+
|
|
1875
|
+
function switchAvd() {
|
|
1876
|
+
if (!avdPick || setupBusy) return;
|
|
1877
|
+
setSetupBusy(true);
|
|
1878
|
+
requestJson('/apk-vm/avd', { method: 'POST', body: JSON.stringify({ avdName: avdPick }) }, T).then(function (result) {
|
|
1879
|
+
if (!mountedRef.current) return;
|
|
1880
|
+
setSetupBusy(false);
|
|
1881
|
+
if (!result.ok) {
|
|
1882
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 500));
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
pushToast('success', T('setup.switch') + ': ' + avdPick);
|
|
1886
|
+
loadState();
|
|
1887
|
+
loadSetup(true);
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function loadDevices() {
|
|
1892
|
+
return requestJson('/apk-vm/devices', null, T).then(function (result) {
|
|
1893
|
+
if (!mountedRef.current) return result;
|
|
1894
|
+
if (result.ok && isObject(result.data) && Array.isArray(result.data.devices)) {
|
|
1895
|
+
setDevices(result.data.devices);
|
|
1896
|
+
setDeviceError('');
|
|
1897
|
+
var online = result.data.devices.filter(function (entry) {
|
|
1898
|
+
return entry && entry.state === 'device';
|
|
1899
|
+
});
|
|
1900
|
+
if (!devicePick && online.length) setDevicePick(String(online[0].serial || ''));
|
|
1901
|
+
} else if (!result.ok) {
|
|
1902
|
+
setDeviceError(result.error || T('err.action'));
|
|
1903
|
+
}
|
|
1904
|
+
return result;
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function attachDevice() {
|
|
1909
|
+
if (!devicePick || deviceBusy) return;
|
|
1910
|
+
setDeviceBusy(true);
|
|
1911
|
+
setDeviceError('');
|
|
1912
|
+
requestJson('/apk-vm/device', { method: 'POST', body: JSON.stringify({ serial: devicePick }) }, T).then(function (result) {
|
|
1913
|
+
if (!mountedRef.current) return;
|
|
1914
|
+
setDeviceBusy(false);
|
|
1915
|
+
if (!result.ok) {
|
|
1916
|
+
var data = isObject(result.data) ? result.data : {};
|
|
1917
|
+
pushToast('error', (data.error || result.error || T('err.action')).slice(0, 500));
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
pushToast('success', T('devices.attached', { serial: devicePick }));
|
|
1921
|
+
loadState();
|
|
1922
|
+
loadDevices();
|
|
1923
|
+
loadSetup(true);
|
|
1924
|
+
loadApks();
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function detachDevice() {
|
|
1929
|
+
if (deviceBusy) return;
|
|
1930
|
+
setDeviceBusy(true);
|
|
1931
|
+
requestJson('/apk-vm/device', { method: 'POST', body: JSON.stringify({ serial: '' }) }, T).then(function (result) {
|
|
1932
|
+
if (!mountedRef.current) return;
|
|
1933
|
+
setDeviceBusy(false);
|
|
1934
|
+
if (!result.ok) {
|
|
1935
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 500));
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
pushToast('success', T('devices.detached'));
|
|
1939
|
+
setDevicePick('');
|
|
1940
|
+
loadState();
|
|
1941
|
+
loadDevices();
|
|
1942
|
+
loadSetup(true);
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function runSetupAction(action) {
|
|
1947
|
+
if (setupBusy) return;
|
|
1948
|
+
setSetupBusy(true);
|
|
1949
|
+
requestJson('/apk-vm/setup', { method: 'POST', body: JSON.stringify({ action: action }) }, T).then(function (result) {
|
|
1950
|
+
if (!mountedRef.current) return;
|
|
1951
|
+
setSetupBusy(false);
|
|
1952
|
+
var data = isObject(result.data) ? result.data : {};
|
|
1953
|
+
var detail = data.result && data.result.error ? data.result.error : (data.result && data.result.output ? data.result.output : '');
|
|
1954
|
+
if (!result.ok || data.ok === false) {
|
|
1955
|
+
pushToast('error', (detail || result.error || T('err.state')).slice(0, 500));
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
pushToast('success', action === 'create-avd' ? T('setup.createAvd') : T('setup.ok'));
|
|
1959
|
+
loadSetup(true);
|
|
1960
|
+
loadState();
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/* ---- actions -------------------------------------------------- */
|
|
1965
|
+
function pushToast(kind, text) {
|
|
1966
|
+
if (!mountedRef.current) return;
|
|
1967
|
+
setToast({ kind: kind, text: text, id: String(Date.now()) + ':' + String(Math.random()) });
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function runVmAction(action) {
|
|
1971
|
+
if (vmBusyRef.current) return;
|
|
1972
|
+
vmBusyRef.current = true;
|
|
1973
|
+
setVmBusy(true);
|
|
1974
|
+
requestJson(instancePath('/vm'), { method: 'POST', body: JSON.stringify({ action: action }) }, T).then(function (result) {
|
|
1975
|
+
vmBusyRef.current = false;
|
|
1976
|
+
if (!mountedRef.current) return;
|
|
1977
|
+
setVmBusy(false);
|
|
1978
|
+
if (!result.ok) {
|
|
1979
|
+
pushToast('error', T('err.action') + ': ' + result.error);
|
|
1980
|
+
} else if (action === 'stop') {
|
|
1981
|
+
pushToast('success', T('toast.stopped'));
|
|
1982
|
+
} else if (action === 'restart') {
|
|
1983
|
+
pushToast('success', T('toast.restarted'));
|
|
1984
|
+
} else {
|
|
1985
|
+
pushToast('success', T('toast.started'));
|
|
1986
|
+
}
|
|
1987
|
+
loadState();
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function installApk(rawPath) {
|
|
1992
|
+
var target = String(rawPath == null ? '' : rawPath).trim();
|
|
1993
|
+
if (!target) {
|
|
1994
|
+
pushToast('error', T('err.pathEmpty'));
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (installingRef.current) return;
|
|
1998
|
+
installingRef.current = target;
|
|
1999
|
+
setInstalling(target);
|
|
2000
|
+
requestJson(instancePath('/install'), { method: 'POST', body: JSON.stringify({ path: target }) }, T).then(function (result) {
|
|
2001
|
+
installingRef.current = '';
|
|
2002
|
+
if (!mountedRef.current) return;
|
|
2003
|
+
setInstalling('');
|
|
2004
|
+
if (!result.ok) {
|
|
2005
|
+
pushToast('error', T('err.install') + ': ' + result.error);
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
var data = isObject(result.data) ? result.data : {};
|
|
2009
|
+
var name = data.label
|
|
2010
|
+
? String(data.label)
|
|
2011
|
+
: data.packageName
|
|
2012
|
+
? String(data.packageName)
|
|
2013
|
+
: baseName(target);
|
|
2014
|
+
pushToast('success', T(data.launched === true ? 'toast.installedLaunched' : 'toast.installed', { name: name }));
|
|
2015
|
+
if (pathInput.trim() === target) setPathInput('');
|
|
2016
|
+
loadState();
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function uploadApkFile(file) {
|
|
2021
|
+
if (!file) return;
|
|
2022
|
+
var name = String(file.name || 'upload.apk');
|
|
2023
|
+
if (!/\.apk$/i.test(name)) {
|
|
2024
|
+
pushToast('error', T('upload.invalid'));
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (installingRef.current) return;
|
|
2028
|
+
installingRef.current = 'upload:' + name;
|
|
2029
|
+
setUpload({ name: name, pct: 0 });
|
|
2030
|
+
var xhr = new XMLHttpRequest();
|
|
2031
|
+
xhr.open('POST', instancePath('/upload'), true);
|
|
2032
|
+
xhr.setRequestHeader('x-apk-name', encodeURIComponent(name));
|
|
2033
|
+
xhr.upload.onprogress = function (event) {
|
|
2034
|
+
if (event && event.lengthComputable && mountedRef.current) {
|
|
2035
|
+
setUpload({ name: name, pct: Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))) });
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
xhr.onload = function () {
|
|
2039
|
+
installingRef.current = '';
|
|
2040
|
+
if (!mountedRef.current) return;
|
|
2041
|
+
setUpload(null);
|
|
2042
|
+
var data = null;
|
|
2043
|
+
try {
|
|
2044
|
+
data = JSON.parse(xhr.responseText || '{}');
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
data = null;
|
|
2047
|
+
}
|
|
2048
|
+
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
|
|
2049
|
+
pushToast('success', T('upload.done', { name: String(data.label || data.packageName || name) }));
|
|
2050
|
+
loadState();
|
|
2051
|
+
loadApks();
|
|
2052
|
+
} else {
|
|
2053
|
+
pushToast('error', T('upload.failed') + ': ' + String((data && data.error) || ('HTTP ' + xhr.status)));
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
xhr.onerror = function () {
|
|
2057
|
+
installingRef.current = '';
|
|
2058
|
+
if (!mountedRef.current) return;
|
|
2059
|
+
setUpload(null);
|
|
2060
|
+
pushToast('error', T('upload.failed'));
|
|
2061
|
+
};
|
|
2062
|
+
xhr.send(file);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function pickApkFile() {
|
|
2066
|
+
if (fileInputRef.current && typeof fileInputRef.current.click === 'function') fileInputRef.current.click();
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function onFileInputChange(event) {
|
|
2070
|
+
var files = event && event.target && event.target.files;
|
|
2071
|
+
var file = files && files.length ? files[0] : null;
|
|
2072
|
+
if (event && event.target) event.target.value = '';
|
|
2073
|
+
if (file) uploadApkFile(file);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function onPageDragEnter(event) {
|
|
2077
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
2078
|
+
dragDepthRef.current += 1;
|
|
2079
|
+
setDragging(true);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function onPageDragOver(event) {
|
|
2083
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
2084
|
+
if (event && event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function onPageDragLeave(event) {
|
|
2088
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
2089
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
2090
|
+
if (dragDepthRef.current === 0) setDragging(false);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function onPageDrop(event) {
|
|
2094
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
2095
|
+
dragDepthRef.current = 0;
|
|
2096
|
+
setDragging(false);
|
|
2097
|
+
var files = event && event.dataTransfer && event.dataTransfer.files;
|
|
2098
|
+
if (files && files.length) uploadApkFile(files[0]);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function sendInput(payload) {
|
|
2102
|
+
requestJson(instancePath('/input'), { method: 'POST', body: JSON.stringify(payload) }, T).then(function (result) {
|
|
2103
|
+
if (!result.ok && mountedRef.current) {
|
|
2104
|
+
pushToast('error', T('err.input') + ': ' + result.error);
|
|
2105
|
+
}
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function sendText() {
|
|
2110
|
+
var value = String(textInput == null ? '' : textInput);
|
|
2111
|
+
if (!value || !ready) return;
|
|
2112
|
+
sendInput({ type: 'text', text: value });
|
|
2113
|
+
setTextInput('');
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/* ---- rooted debug console ------------------------------------ */
|
|
2117
|
+
function runDebugRequest(url, options, format) {
|
|
2118
|
+
if (debugBusy || !ready) return;
|
|
2119
|
+
setDebugBusy(true);
|
|
2120
|
+
requestJson(url, options, T).then(function (result) {
|
|
2121
|
+
if (!mountedRef.current) return;
|
|
2122
|
+
setDebugBusy(false);
|
|
2123
|
+
if (!result.ok) {
|
|
2124
|
+
setDebugOutput((result.error || T('err.state')) + (result.data ? '\n' + JSON.stringify(result.data, null, 2) : ''));
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
var data = isObject(result.data) ? result.data : {};
|
|
2128
|
+
setDebugOutput(typeof format === 'function' ? format(data) : JSON.stringify(data, null, 2));
|
|
2129
|
+
loadState();
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function runDebugCommand() {
|
|
2134
|
+
var command = String(debugCommand == null ? '' : debugCommand).trim();
|
|
2135
|
+
if (!command || debugBusy || !ready) return;
|
|
2136
|
+
runDebugRequest(
|
|
2137
|
+
instancePath('/debug/shell'),
|
|
2138
|
+
{ method: 'POST', body: JSON.stringify({ command: command }) },
|
|
2139
|
+
function (data) {
|
|
2140
|
+
var text = '';
|
|
2141
|
+
if (data.stdout) text += data.stdout;
|
|
2142
|
+
if (data.stderr) text += (text ? '\n' : '') + data.stderr;
|
|
2143
|
+
return (text || '(no output)') + '\n[exit ' + data.code + (data.root ? ', root' : '') + ']';
|
|
2144
|
+
}
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function runDebugLogcat() {
|
|
2149
|
+
runDebugRequest(instancePath('/debug/logcat?lines=200'), null, function (data) {
|
|
2150
|
+
return (data.lines || []).join('\n') || '(empty logcat)';
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function stopLiveLogcat() {
|
|
2155
|
+
if (logLiveTimerRef.current) {
|
|
2156
|
+
clearInterval(logLiveTimerRef.current);
|
|
2157
|
+
logLiveTimerRef.current = null;
|
|
2158
|
+
}
|
|
2159
|
+
setLogLive(false);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function liveLogcatTick() {
|
|
2163
|
+
if (!mountedRef.current || !ready) return;
|
|
2164
|
+
requestJson(instancePath('/debug/logcat?lines=200'), null, T).then(function (result) {
|
|
2165
|
+
if (!mountedRef.current || !result.ok) return;
|
|
2166
|
+
var data = isObject(result.data) ? result.data : {};
|
|
2167
|
+
setDebugOutput((data.lines || []).join('\n') || '(empty logcat)');
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function toggleLiveLogcat() {
|
|
2172
|
+
if (!ready) return;
|
|
2173
|
+
if (logLiveTimerRef.current) {
|
|
2174
|
+
stopLiveLogcat();
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
setLogLive(true);
|
|
2178
|
+
liveLogcatTick();
|
|
2179
|
+
logLiveTimerRef.current = setInterval(liveLogcatTick, 2000);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function runCrashLogcat() {
|
|
2183
|
+
runDebugRequest(instancePath('/debug/logcat?lines=400&buffer=crash'), null, function (data) {
|
|
2184
|
+
return (data.lines || []).join('\n') || '(no crash log)';
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function setDeviceClipboard() {
|
|
2189
|
+
if (!ready) return;
|
|
2190
|
+
var text = String(textInput == null ? '' : textInput);
|
|
2191
|
+
if (!text) {
|
|
2192
|
+
pushToast('error', T('debug.clipboardEmpty'));
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
requestJson(instancePath('/clipboard'), {
|
|
2196
|
+
method: 'POST',
|
|
2197
|
+
body: JSON.stringify({ text: text, paste: false }),
|
|
2198
|
+
}, T).then(function (result) {
|
|
2199
|
+
if (!mountedRef.current) return;
|
|
2200
|
+
if (!result.ok) {
|
|
2201
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 300));
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
pushToast('success', T('debug.clipboardSetDone'));
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function getDeviceClipboard() {
|
|
2209
|
+
if (!ready) return;
|
|
2210
|
+
requestJson(instancePath('/clipboard'), null, T).then(function (result) {
|
|
2211
|
+
if (!mountedRef.current) return;
|
|
2212
|
+
if (!result.ok) {
|
|
2213
|
+
pushToast('error', (result.error || T('err.action')).slice(0, 300));
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
var text = isObject(result.data) ? String(result.data.text || '') : '';
|
|
2217
|
+
setDebugOutput('clipboard (' + text.length + ' chars):\n' + text);
|
|
2218
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
2219
|
+
navigator.clipboard.writeText(text).catch(function () {});
|
|
2220
|
+
}
|
|
2221
|
+
pushToast('success', T('debug.clipboardGot'));
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
function runDebugInfo() {
|
|
2226
|
+
runDebugRequest(instancePath('/debug/info'), null, function (data) {
|
|
2227
|
+
return JSON.stringify(data, null, 2);
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function runDebugRoot() {
|
|
2232
|
+
runDebugRequest(instancePath('/debug/root'), { method: 'POST', body: '{}' }, function (data) {
|
|
2233
|
+
return data.root ? 'adb root enabled (uid=0)' : 'adb root unavailable on this image';
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function runDebugUi() {
|
|
2238
|
+
runDebugRequest(instancePath('/debug/ui'), null, function (data) {
|
|
2239
|
+
var xml = String(data.xml || '');
|
|
2240
|
+
return 'nodes=' + data.nodes + ' bytes=' + data.bytes + '\n' + xml.slice(0, 20000);
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function runDebugPerf() {
|
|
2245
|
+
runDebugRequest(instancePath('/debug/perf'), null, function (data) {
|
|
2246
|
+
return JSON.stringify(data, null, 2);
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function runDebugRecord() {
|
|
2251
|
+
runDebugRequest(instancePath('/debug/record'), { method: 'POST', body: JSON.stringify({ seconds: 5 }) }, function (data) {
|
|
2252
|
+
var localPath = String(data.localPath || '');
|
|
2253
|
+
var base = localPath.split(/[\\/]/).pop();
|
|
2254
|
+
if (base && /^(apk-vm-shot-|dsh-apk-vm-record-)[A-Za-z0-9._-]+\.(png|mp4)$/.test(base)) {
|
|
2255
|
+
var anchor = document.createElement('a');
|
|
2256
|
+
anchor.href = '/apk-vm/artifacts?name=' + encodeURIComponent(base);
|
|
2257
|
+
anchor.download = base;
|
|
2258
|
+
anchor.click();
|
|
2259
|
+
}
|
|
2260
|
+
return 'recorded ' + data.seconds + 's -> ' + localPath + (data.bytes ? ' (' + data.bytes + ' bytes)' : '');
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function runDebugRotate() {
|
|
2265
|
+
var next = inputScreen && inputScreen.width > inputScreen.height ? 'portrait' : 'landscape';
|
|
2266
|
+
runDebugRequest(instancePath('/debug/rotate'), { method: 'POST', body: JSON.stringify({ orientation: next }) }, function (data) {
|
|
2267
|
+
return 'rotation locked to ' + data.rotation + ' (' + next + ')';
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function downloadScreenshot() {
|
|
2272
|
+
if (!ready) return;
|
|
2273
|
+
if (videoMode === 'h264' && canvasRef.current && typeof canvasRef.current.toBlob === 'function') {
|
|
2274
|
+
canvasRef.current.toBlob(function (blob) {
|
|
2275
|
+
if (!blob) return;
|
|
2276
|
+
var url = URL.createObjectURL(blob);
|
|
2277
|
+
var link = document.createElement('a');
|
|
2278
|
+
link.href = url;
|
|
2279
|
+
link.download = 'apk-vm-' + Date.now() + '.png';
|
|
2280
|
+
link.click();
|
|
2281
|
+
setTimeout(function () {
|
|
2282
|
+
URL.revokeObjectURL(url);
|
|
2283
|
+
}, 1000);
|
|
2284
|
+
});
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
var anchor = document.createElement('a');
|
|
2288
|
+
anchor.href = instancePath('/debug/screenshot');
|
|
2289
|
+
anchor.download = 'apk-vm-' + Date.now() + '.png';
|
|
2290
|
+
anchor.click();
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
function setStreamTarget(fps) {
|
|
2294
|
+
var value = Math.min(10, Math.max(1, Math.round(Number(fps) || 2)));
|
|
2295
|
+
requestJson('/apk-vm/stream', { method: 'POST', body: JSON.stringify({ fps: value }) }, T).then(function (result) {
|
|
2296
|
+
if (!mountedRef.current) return;
|
|
2297
|
+
if (!result.ok) {
|
|
2298
|
+
pushToast('error', T('fps.target') + ': ' + result.error);
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
pushToast('success', T('fps.target') + ': ' + value + ' fps');
|
|
2302
|
+
loadState();
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/* ---- screen input geometry ----------------------------------- */
|
|
2307
|
+
// Map a pointer position inside the rendered screen box to device
|
|
2308
|
+
// pixels, honoring the object-fit:contain letterboxing.
|
|
2309
|
+
function toDevice(clientX, clientY) {
|
|
2310
|
+
var node = screenRef.current;
|
|
2311
|
+
if (!node || !inputScreen || typeof node.getBoundingClientRect !== 'function') return null;
|
|
2312
|
+
var rect = node.getBoundingClientRect();
|
|
2313
|
+
if (!rect || !(rect.width > 0) || !(rect.height > 0)) return null;
|
|
2314
|
+
var scale = Math.min(rect.width / inputScreen.width, rect.height / inputScreen.height);
|
|
2315
|
+
if (!isFinite(scale) || scale <= 0) return null;
|
|
2316
|
+
var offsetX = (rect.width - inputScreen.width * scale) / 2;
|
|
2317
|
+
var offsetY = (rect.height - inputScreen.height * scale) / 2;
|
|
2318
|
+
return {
|
|
2319
|
+
x: clamp(Math.round((clientX - rect.left - offsetX) / scale), 0, inputScreen.width - 1),
|
|
2320
|
+
y: clamp(Math.round((clientY - rect.top - offsetY) / scale), 0, inputScreen.height - 1),
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function toVideo(clientX, clientY) {
|
|
2325
|
+
var node = screenRef.current;
|
|
2326
|
+
var target = displayScreen || screen;
|
|
2327
|
+
if (!node || !target || typeof node.getBoundingClientRect !== 'function') return null;
|
|
2328
|
+
var rect = node.getBoundingClientRect();
|
|
2329
|
+
if (!rect || !(rect.width > 0) || !(rect.height > 0)) return null;
|
|
2330
|
+
var scale = Math.min(rect.width / target.width, rect.height / target.height);
|
|
2331
|
+
if (!isFinite(scale) || scale <= 0) return null;
|
|
2332
|
+
var offsetX = (rect.width - target.width * scale) / 2;
|
|
2333
|
+
var offsetY = (rect.height - target.height * scale) / 2;
|
|
2334
|
+
return {
|
|
2335
|
+
x: clamp(Math.round((clientX - rect.left - offsetX) / scale), 0, target.width - 1),
|
|
2336
|
+
y: clamp(Math.round((clientY - rect.top - offsetY) / scale), 0, target.height - 1),
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function postTouch(action, pointerId, clientX, clientY) {
|
|
2341
|
+
var point = toVideo(clientX, clientY);
|
|
2342
|
+
if (!point) return;
|
|
2343
|
+
var id = activeInstanceRef.current || 'default';
|
|
2344
|
+
var path = id === 'default' ? '/apk-vm/touch' : '/apk-vm/instances/' + encodeURIComponent(id) + '/touch';
|
|
2345
|
+
try {
|
|
2346
|
+
fetch(path, {
|
|
2347
|
+
method: 'POST',
|
|
2348
|
+
headers: { 'content-type': 'application/json' },
|
|
2349
|
+
body: JSON.stringify({ action: action, pointerId: pointerId, x: point.x, y: point.y }),
|
|
2350
|
+
cache: 'no-store',
|
|
2351
|
+
}).catch(function () {});
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
/* multi-touch is best-effort */
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function sendMultiTouch(action) {
|
|
2358
|
+
var multi = multiTouchRef.current;
|
|
2359
|
+
if (!multi) return;
|
|
2360
|
+
multi.pointers.forEach(function (point, pointerId) {
|
|
2361
|
+
postTouch(action, pointerId, point.x, point.y);
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function onPointerDown(event) {
|
|
2366
|
+
if (!ready || !inputScreen) return;
|
|
2367
|
+
if (event.pointerType === 'mouse' && event.button !== 0 && event.button !== undefined) return;
|
|
2368
|
+
var node = screenRef.current;
|
|
2369
|
+
if (!node) return;
|
|
2370
|
+
try {
|
|
2371
|
+
if (typeof node.focus === 'function') node.focus();
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
/* focus is best-effort */
|
|
2374
|
+
}
|
|
2375
|
+
try {
|
|
2376
|
+
if (typeof node.setPointerCapture === 'function') node.setPointerCapture(event.pointerId);
|
|
2377
|
+
} catch (error) {
|
|
2378
|
+
/* pointer capture is best-effort */
|
|
2379
|
+
}
|
|
2380
|
+
if (event.pointerType !== 'mouse') {
|
|
2381
|
+
var multi = multiTouchRef.current;
|
|
2382
|
+
if (multi) {
|
|
2383
|
+
multi.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
2384
|
+
postTouch('down', event.pointerId, event.clientX, event.clientY);
|
|
2385
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
var first = dragRef.current;
|
|
2389
|
+
if (first && first.pointerId !== event.pointerId) {
|
|
2390
|
+
// Second finger: promote the gesture to a scrcpy multi-touch pinch.
|
|
2391
|
+
dragRef.current = null;
|
|
2392
|
+
multiTouchRef.current = { pointers: new Map(), lastMove: 0 };
|
|
2393
|
+
multiTouchRef.current.pointers.set(first.pointerId, {
|
|
2394
|
+
x: first.lastX === undefined ? first.x0 : first.lastX,
|
|
2395
|
+
y: first.lastY === undefined ? first.y0 : first.lastY,
|
|
2396
|
+
});
|
|
2397
|
+
multiTouchRef.current.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
2398
|
+
sendMultiTouch('down');
|
|
2399
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
dragRef.current = {
|
|
2404
|
+
x0: event.clientX,
|
|
2405
|
+
y0: event.clientY,
|
|
2406
|
+
lastX: event.clientX,
|
|
2407
|
+
lastY: event.clientY,
|
|
2408
|
+
startedAt: Date.now(),
|
|
2409
|
+
pointerId: event.pointerId,
|
|
2410
|
+
};
|
|
2411
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function onPointerMove(event) {
|
|
2415
|
+
var multi = multiTouchRef.current;
|
|
2416
|
+
if (multi) {
|
|
2417
|
+
if (multi.pointers.has(event.pointerId)) {
|
|
2418
|
+
multi.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
2419
|
+
var now = Date.now();
|
|
2420
|
+
if (now - multi.lastMove >= 16) {
|
|
2421
|
+
multi.lastMove = now;
|
|
2422
|
+
sendMultiTouch('move');
|
|
2423
|
+
}
|
|
2424
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2425
|
+
}
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
var drag = dragRef.current;
|
|
2429
|
+
if (drag && drag.pointerId === event.pointerId) {
|
|
2430
|
+
drag.lastX = event.clientX;
|
|
2431
|
+
drag.lastY = event.clientY;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function onPointerUp(event) {
|
|
2436
|
+
var multi = multiTouchRef.current;
|
|
2437
|
+
if (multi && multi.pointers.has(event.pointerId)) {
|
|
2438
|
+
postTouch('up', event.pointerId, event.clientX, event.clientY);
|
|
2439
|
+
multi.pointers.delete(event.pointerId);
|
|
2440
|
+
if (multi.pointers.size === 0) multiTouchRef.current = null;
|
|
2441
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
var drag = dragRef.current;
|
|
2445
|
+
dragRef.current = null;
|
|
2446
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
2447
|
+
var node = screenRef.current;
|
|
2448
|
+
if (node) {
|
|
2449
|
+
try {
|
|
2450
|
+
if (typeof node.releasePointerCapture === 'function') node.releasePointerCapture(event.pointerId);
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
/* ignore */
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (!ready || !inputScreen) return;
|
|
2456
|
+
var dx = event.clientX - drag.x0;
|
|
2457
|
+
var dy = event.clientY - drag.y0;
|
|
2458
|
+
var distance = Math.sqrt(dx * dx + dy * dy);
|
|
2459
|
+
var from = toDevice(drag.x0, drag.y0);
|
|
2460
|
+
var to = toDevice(event.clientX, event.clientY);
|
|
2461
|
+
if (!from || !to) return;
|
|
2462
|
+
if (distance >= SWIPE_THRESHOLD_PX) {
|
|
2463
|
+
sendInput({
|
|
2464
|
+
type: 'swipe',
|
|
2465
|
+
x1: from.x,
|
|
2466
|
+
y1: from.y,
|
|
2467
|
+
x2: to.x,
|
|
2468
|
+
y2: to.y,
|
|
2469
|
+
duration: clamp(Date.now() - drag.startedAt, 80, 800),
|
|
2470
|
+
});
|
|
2471
|
+
} else {
|
|
2472
|
+
sendInput({ type: 'tap', x: from.x, y: from.y });
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function onPointerCancel(event) {
|
|
2477
|
+
var multi = multiTouchRef.current;
|
|
2478
|
+
if (multi) {
|
|
2479
|
+
multi.pointers.forEach(function (point, pointerId) {
|
|
2480
|
+
postTouch('cancel', pointerId, point.x, point.y);
|
|
2481
|
+
});
|
|
2482
|
+
multiTouchRef.current = null;
|
|
2483
|
+
}
|
|
2484
|
+
dragRef.current = null;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
function onContextMenu(event) {
|
|
2488
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/* ---- small render helpers ------------------------------------ */
|
|
2492
|
+
function actionBtn(label, options) {
|
|
2493
|
+
var opts = isObject(options) ? options : {};
|
|
2494
|
+
var className =
|
|
2495
|
+
'apkvm-btn' +
|
|
2496
|
+
(opts.variant ? ' apkvm-btn-' + opts.variant : '') +
|
|
2497
|
+
(opts.small ? ' apkvm-btn-sm' : '');
|
|
2498
|
+
var children = [];
|
|
2499
|
+
if (opts.icon) {
|
|
2500
|
+
children.push(
|
|
2501
|
+
h('span', { key: 'icon', style: { display: 'inline-flex', alignItems: 'center' }, 'aria-hidden': 'true' }, opts.icon)
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
children.push(h('span', { key: 'label' }, label));
|
|
2505
|
+
return h(
|
|
2506
|
+
'button',
|
|
2507
|
+
{
|
|
2508
|
+
key: opts.key,
|
|
2509
|
+
type: 'button',
|
|
2510
|
+
className: className,
|
|
2511
|
+
disabled: opts.disabled === true,
|
|
2512
|
+
title: opts.title || label,
|
|
2513
|
+
'aria-label': opts.ariaLabel || label,
|
|
2514
|
+
onClick: opts.onClick,
|
|
2515
|
+
},
|
|
2516
|
+
children
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function keyBtn(label, icon, key) {
|
|
2521
|
+
return actionBtn(label, {
|
|
2522
|
+
key: 'key-' + key,
|
|
2523
|
+
small: true,
|
|
2524
|
+
icon: icon,
|
|
2525
|
+
disabled: !ready,
|
|
2526
|
+
onClick: function () {
|
|
2527
|
+
sendInput({ type: 'key', key: key });
|
|
2528
|
+
},
|
|
2529
|
+
});
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function onPathChange(event) {
|
|
2533
|
+
setPathInput(event && event.target ? String(event.target.value) : '');
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
function onPathKeyDown(event) {
|
|
2537
|
+
if (event && event.key === 'Enter') {
|
|
2538
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2539
|
+
installApk(pathInput);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function onTextChange(event) {
|
|
2544
|
+
setTextInput(event && event.target ? String(event.target.value) : '');
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
function onTextKeyDown(event) {
|
|
2548
|
+
if (event && event.key === 'Enter') {
|
|
2549
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
2550
|
+
sendText();
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
function renderApkRow(apk, index) {
|
|
2555
|
+
var isInstalling = installing !== '' && installing === apk.path;
|
|
2556
|
+
var isCurrent = currentApkPath !== '' && currentApkPath === apk.path;
|
|
2557
|
+
var tag = null;
|
|
2558
|
+
if (isCurrent && apkInfo) {
|
|
2559
|
+
tag = h(
|
|
2560
|
+
'span',
|
|
2561
|
+
{ key: 'tag', className: 'apkvm-tag' + (apkInfo.launched ? ' ok' : '') },
|
|
2562
|
+
T(apkInfo.launched ? 'tag.launched' : 'tag.installed')
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
return h(
|
|
2566
|
+
'div',
|
|
2567
|
+
{ key: apk.path + ':' + index, className: 'apkvm-apk' },
|
|
2568
|
+
h(
|
|
2569
|
+
'div',
|
|
2570
|
+
{ style: { display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 } },
|
|
2571
|
+
h('img', {
|
|
2572
|
+
className: 'apkvm-apk-icon',
|
|
2573
|
+
src: '/apk-vm/apk-icon?path=' + encodeURIComponent(apk.path),
|
|
2574
|
+
alt: '',
|
|
2575
|
+
loading: 'lazy',
|
|
2576
|
+
onError: function (event) {
|
|
2577
|
+
if (event && event.currentTarget) event.currentTarget.style.display = 'none';
|
|
2578
|
+
},
|
|
2579
|
+
}),
|
|
2580
|
+
h('span', { className: 'apkvm-apk-name', title: apk.path }, apk.name),
|
|
2581
|
+
h('span', { className: 'apkvm-dim', style: { flex: 'none' } }, formatBytes(apk.size))
|
|
2582
|
+
),
|
|
2583
|
+
h(
|
|
2584
|
+
'div',
|
|
2585
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
|
|
2586
|
+
h('span', { className: 'apkvm-dim', style: { flex: '1 1 auto' }, title: apk.path }, apk.path),
|
|
2587
|
+
tag,
|
|
2588
|
+
actionBtn(isInstalling ? T('apks.installing') : T('action.installLaunch'), {
|
|
2589
|
+
key: 'install',
|
|
2590
|
+
small: true,
|
|
2591
|
+
disabled: !ready || installing !== '',
|
|
2592
|
+
onClick: function () {
|
|
2593
|
+
installApk(apk.path);
|
|
2594
|
+
},
|
|
2595
|
+
})
|
|
2596
|
+
)
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function renderOverlay() {
|
|
2601
|
+
if (ready) {
|
|
2602
|
+
if (frameUrl || videoMode === 'h264') return null;
|
|
2603
|
+
return h(
|
|
2604
|
+
'div',
|
|
2605
|
+
{ className: 'apkvm-overlay' },
|
|
2606
|
+
h('div', { className: 'apkvm-spinner' }),
|
|
2607
|
+
h('div', { className: 'apkvm-overlay-title' }, T('overlay.waitingFrame'))
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
if (displayState === 'loading') {
|
|
2611
|
+
return h(
|
|
2612
|
+
'div',
|
|
2613
|
+
{ className: 'apkvm-overlay' },
|
|
2614
|
+
h('div', { className: 'apkvm-spinner' }),
|
|
2615
|
+
h('div', { className: 'apkvm-overlay-title' }, T('overlay.connecting'))
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
if (busyState) {
|
|
2619
|
+
return h(
|
|
2620
|
+
'div',
|
|
2621
|
+
{ className: 'apkvm-overlay' },
|
|
2622
|
+
h('div', { className: 'apkvm-spinner' }),
|
|
2623
|
+
h('div', { className: 'apkvm-overlay-title' }, T('overlay.booting')),
|
|
2624
|
+
h('div', { className: 'apkvm-overlay-detail' }, T('overlay.bootingHint')),
|
|
2625
|
+
actionBtn(vmBusy ? T('action.stopping') : T('action.stop'), {
|
|
2626
|
+
key: 'overlay-stop',
|
|
2627
|
+
variant: 'danger',
|
|
2628
|
+
small: true,
|
|
2629
|
+
disabled: vmBusy,
|
|
2630
|
+
onClick: function () {
|
|
2631
|
+
runVmAction('stop');
|
|
2632
|
+
},
|
|
2633
|
+
})
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
if (displayState === 'error') {
|
|
2637
|
+
var errorDetail = vmInfo && vmInfo.error ? vmInfo.error : stateError;
|
|
2638
|
+
return h(
|
|
2639
|
+
'div',
|
|
2640
|
+
{ className: 'apkvm-overlay' },
|
|
2641
|
+
h('span', { className: 'apkvm-overlay-icon' }, h(PhoneIcon, { size: 34 })),
|
|
2642
|
+
h('div', { className: 'apkvm-overlay-title' }, T('overlay.error')),
|
|
2643
|
+
errorDetail ? h('div', { className: 'apkvm-overlay-detail' }, errorDetail) : null,
|
|
2644
|
+
actionBtn(vmBusy ? T('action.starting') : T('overlay.retry'), {
|
|
2645
|
+
key: 'overlay-retry',
|
|
2646
|
+
variant: 'primary',
|
|
2647
|
+
disabled: vmBusy,
|
|
2648
|
+
onClick: function () {
|
|
2649
|
+
runVmAction('start');
|
|
2650
|
+
},
|
|
2651
|
+
})
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
return h(
|
|
2655
|
+
'div',
|
|
2656
|
+
{ className: 'apkvm-overlay' },
|
|
2657
|
+
h('span', { className: 'apkvm-overlay-icon' }, h(PhoneIcon, { size: 34 })),
|
|
2658
|
+
h('div', { className: 'apkvm-overlay-title' }, T('overlay.stopped')),
|
|
2659
|
+
h('div', { className: 'apkvm-overlay-detail' }, T('overlay.startedHint')),
|
|
2660
|
+
actionBtn(vmBusy ? T('action.starting') : T('action.start'), {
|
|
2661
|
+
key: 'overlay-start',
|
|
2662
|
+
variant: 'primary',
|
|
2663
|
+
disabled: vmBusy,
|
|
2664
|
+
onClick: function () {
|
|
2665
|
+
runVmAction('start');
|
|
2666
|
+
},
|
|
2667
|
+
})
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/* ---- elements ------------------------------------------------ */
|
|
2672
|
+
var diagCard = null;
|
|
2673
|
+
if (busyState || displayState === 'error' || stateError) {
|
|
2674
|
+
diagCard = h(
|
|
2675
|
+
'div',
|
|
2676
|
+
{ className: 'apkvm-card' },
|
|
2677
|
+
h(
|
|
2678
|
+
'div',
|
|
2679
|
+
{ className: 'apkvm-cardhead' },
|
|
2680
|
+
h('span', { className: 'apkvm-card-title' }, T('log.title')),
|
|
2681
|
+
h('span', { className: 'apkvm-dim', style: { marginLeft: 'auto' } }, stateLabel)
|
|
2682
|
+
),
|
|
2683
|
+
vmInfo && vmInfo.error ? h('div', { className: 'apkvm-inline-error', title: vmInfo.error }, vmInfo.error) : null,
|
|
2684
|
+
h('div', { ref: logRef, className: 'apkvm-log apkvm-scroll' }, logText ? logText : T('log.waiting'))
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
var setupCard = null;
|
|
2689
|
+
if (setup) {
|
|
2690
|
+
var setupMissing = Array.isArray(setup.missing) ? setup.missing.filter(Boolean) : [];
|
|
2691
|
+
var setupActions = (Array.isArray(setup.actions) ? setup.actions : []).filter(function (entry) {
|
|
2692
|
+
return entry && entry.available && (entry.id === 'create-avd' || entry.id === 'install-image' || entry.id === 'install-tools' || entry.id === 'bootstrap-sdk');
|
|
2693
|
+
});
|
|
2694
|
+
var setupBadgeOk = setup.ok === true;
|
|
2695
|
+
var setupBadgeStyle = {
|
|
2696
|
+
marginLeft: 'auto',
|
|
2697
|
+
fontSize: '10px',
|
|
2698
|
+
lineHeight: '15px',
|
|
2699
|
+
padding: '0 6px',
|
|
2700
|
+
borderRadius: '999px',
|
|
2701
|
+
border: '1px solid ' + (setupBadgeOk ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)'),
|
|
2702
|
+
color: setupBadgeOk ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)',
|
|
2703
|
+
whiteSpace: 'nowrap',
|
|
2704
|
+
};
|
|
2705
|
+
function setupRow(label, ok, detail) {
|
|
2706
|
+
return h(
|
|
2707
|
+
'div',
|
|
2708
|
+
{ className: 'apkvm-setup-row', title: detail || label },
|
|
2709
|
+
h('span', { className: 'apkvm-setup-dot ' + (ok ? 'ok' : 'bad') }, ok ? '✓' : '✕'),
|
|
2710
|
+
h('span', { className: 'apkvm-setup-label' }, label),
|
|
2711
|
+
h('span', { className: 'apkvm-dim apkvm-setup-detail' }, detail || (ok ? 'OK' : 'missing'))
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
setupCard = h(
|
|
2715
|
+
'details',
|
|
2716
|
+
{ className: 'apkvm-card apkvm-debug apkvm-setup' },
|
|
2717
|
+
h(
|
|
2718
|
+
'summary',
|
|
2719
|
+
{ className: 'apkvm-debug-summary' },
|
|
2720
|
+
h('span', { className: 'apkvm-card-title' }, T('setup.title')),
|
|
2721
|
+
h('span', { style: setupBadgeStyle }, setupBusy ? '…' : setupBadgeOk ? T('setup.ok') : T('setup.missing')),
|
|
2722
|
+
h(
|
|
2723
|
+
'span',
|
|
2724
|
+
{ className: 'apkvm-dim', style: { marginLeft: '6px' }, title: setupMissing.join('\n') },
|
|
2725
|
+
setup.sdkRoot || setup.platform || ''
|
|
2726
|
+
),
|
|
2727
|
+
h('span', { className: 'apkvm-chevron', 'aria-hidden': 'true' }, '▸')
|
|
2728
|
+
),
|
|
2729
|
+
h(
|
|
2730
|
+
'div',
|
|
2731
|
+
{ className: 'apkvm-debug-body' },
|
|
2732
|
+
setupRow(T('setup.sdk'), Boolean(setup.sdkRoot && setup.adbPath), setup.sdkRoot || ''),
|
|
2733
|
+
setupRow(T('setup.accel'), Boolean(setup.accel && setup.accel.available), setup.accel ? setup.accel.backend || setup.accel.detail || '' : ''),
|
|
2734
|
+
setupRow(T('setup.image'), setup.systemImagePresent === true, setup.systemImage || ''),
|
|
2735
|
+
setupRow(T('setup.avd'), setup.avdPresent === true, setup.avdName || ''),
|
|
2736
|
+
setupMissing.length
|
|
2737
|
+
? h('div', { className: 'apkvm-inline-error', title: setupMissing.join('\n') }, setupMissing.slice(0, 3).join(' · '))
|
|
2738
|
+
: null,
|
|
2739
|
+
setup.hypervisorNotes && setup.accel && !setup.accel.available
|
|
2740
|
+
? h(
|
|
2741
|
+
'div',
|
|
2742
|
+
{ className: 'apkvm-dim', title: (setup.hypervisorNotes || []).join('\n') },
|
|
2743
|
+
(setup.hypervisorNotes || []).slice(0, 2).join(' · ')
|
|
2744
|
+
)
|
|
2745
|
+
: null,
|
|
2746
|
+
avds.length > 1
|
|
2747
|
+
? h(
|
|
2748
|
+
'div',
|
|
2749
|
+
{ className: 'apkvm-toolsline' },
|
|
2750
|
+
h(
|
|
2751
|
+
'select',
|
|
2752
|
+
{
|
|
2753
|
+
value: avdPick,
|
|
2754
|
+
disabled: setupBusy,
|
|
2755
|
+
onChange: function (event) {
|
|
2756
|
+
setAvdPick(event && event.target ? String(event.target.value) : '');
|
|
2757
|
+
},
|
|
2758
|
+
style: {
|
|
2759
|
+
font: 'inherit',
|
|
2760
|
+
fontSize: '11px',
|
|
2761
|
+
flex: '1 1 auto',
|
|
2762
|
+
minWidth: 0,
|
|
2763
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
2764
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
2765
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
2766
|
+
borderRadius: '6px',
|
|
2767
|
+
padding: '2px 4px',
|
|
2768
|
+
},
|
|
2769
|
+
},
|
|
2770
|
+
avds.map(function (name) {
|
|
2771
|
+
return h('option', { key: name, value: name }, name);
|
|
2772
|
+
})
|
|
2773
|
+
),
|
|
2774
|
+
actionBtn(T('setup.switch'), {
|
|
2775
|
+
key: 'switch-avd',
|
|
2776
|
+
small: true,
|
|
2777
|
+
disabled: setupBusy || !avdPick,
|
|
2778
|
+
onClick: switchAvd,
|
|
2779
|
+
})
|
|
2780
|
+
)
|
|
2781
|
+
: null,
|
|
2782
|
+
h(
|
|
2783
|
+
'div',
|
|
2784
|
+
{ className: 'apkvm-toolsline' },
|
|
2785
|
+
h(
|
|
2786
|
+
'select',
|
|
2787
|
+
{
|
|
2788
|
+
value: devicePick,
|
|
2789
|
+
disabled: deviceBusy,
|
|
2790
|
+
title: T('devices.title'),
|
|
2791
|
+
onChange: function (event) {
|
|
2792
|
+
setDevicePick(event && event.target ? String(event.target.value) : '');
|
|
2793
|
+
},
|
|
2794
|
+
style: {
|
|
2795
|
+
font: 'inherit',
|
|
2796
|
+
fontSize: '11px',
|
|
2797
|
+
flex: '1 1 auto',
|
|
2798
|
+
minWidth: 0,
|
|
2799
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
2800
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
2801
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
2802
|
+
borderRadius: '6px',
|
|
2803
|
+
padding: '2px 4px',
|
|
2804
|
+
},
|
|
2805
|
+
},
|
|
2806
|
+
h('option', { value: '' }, T('devices.select')),
|
|
2807
|
+
devices.map(function (entry) {
|
|
2808
|
+
var detail = entry.serial + ' · ' + entry.state + (entry.model ? ' · ' + entry.model : '');
|
|
2809
|
+
return h('option', { key: entry.serial, value: entry.serial }, detail);
|
|
2810
|
+
})
|
|
2811
|
+
),
|
|
2812
|
+
actionBtn(T('devices.attach'), {
|
|
2813
|
+
key: 'attach-device',
|
|
2814
|
+
small: true,
|
|
2815
|
+
disabled: deviceBusy || !devicePick,
|
|
2816
|
+
onClick: attachDevice,
|
|
2817
|
+
}),
|
|
2818
|
+
vmInfo && vmInfo.external
|
|
2819
|
+
? actionBtn(T('devices.detach'), {
|
|
2820
|
+
key: 'detach-device',
|
|
2821
|
+
small: true,
|
|
2822
|
+
disabled: deviceBusy,
|
|
2823
|
+
onClick: detachDevice,
|
|
2824
|
+
})
|
|
2825
|
+
: null
|
|
2826
|
+
),
|
|
2827
|
+
vmInfo && vmInfo.external
|
|
2828
|
+
? h('div', { className: 'apkvm-dim' }, T('devices.current', { serial: vmInfo.externalSerial || '' }))
|
|
2829
|
+
: null,
|
|
2830
|
+
deviceError ? h('div', { className: 'apkvm-inline-error' }, deviceError) : null,
|
|
2831
|
+
h(
|
|
2832
|
+
'div',
|
|
2833
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
|
|
2834
|
+
setupActions.map(function (entry) {
|
|
2835
|
+
var label = entry.id === 'create-avd'
|
|
2836
|
+
? T('setup.createAvd')
|
|
2837
|
+
: entry.id === 'install-tools'
|
|
2838
|
+
? T('setup.installTools')
|
|
2839
|
+
: entry.id === 'bootstrap-sdk'
|
|
2840
|
+
? T('setup.bootstrapSdk')
|
|
2841
|
+
: T('setup.installImage');
|
|
2842
|
+
return actionBtn(label, {
|
|
2843
|
+
key: 'setup-' + entry.id,
|
|
2844
|
+
small: true,
|
|
2845
|
+
disabled: setupBusy,
|
|
2846
|
+
onClick: function () {
|
|
2847
|
+
runSetupAction(entry.id);
|
|
2848
|
+
},
|
|
2849
|
+
});
|
|
2850
|
+
}),
|
|
2851
|
+
actionBtn(T('setup.refresh'), {
|
|
2852
|
+
key: 'setup-refresh',
|
|
2853
|
+
small: true,
|
|
2854
|
+
disabled: setupBusy,
|
|
2855
|
+
onClick: function () {
|
|
2856
|
+
loadSetup(true);
|
|
2857
|
+
},
|
|
2858
|
+
})
|
|
2859
|
+
)
|
|
2860
|
+
)
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
var apkCard = h(
|
|
2865
|
+
'div',
|
|
2866
|
+
{ className: 'apkvm-card apkvm-apkcard' },
|
|
2867
|
+
h(
|
|
2868
|
+
'div',
|
|
2869
|
+
{ className: 'apkvm-cardhead' },
|
|
2870
|
+
h('span', { className: 'apkvm-card-title' }, T('apks.title')),
|
|
2871
|
+
h(
|
|
2872
|
+
'span',
|
|
2873
|
+
{ className: 'apkvm-dim', style: { marginLeft: 'auto' } },
|
|
2874
|
+
apks === null ? (apksBusy ? T('apks.scanning') : '') : T('apks.count', { n: apks.length })
|
|
2875
|
+
),
|
|
2876
|
+
actionBtn(T('upload.button'), {
|
|
2877
|
+
key: 'upload-apk',
|
|
2878
|
+
small: true,
|
|
2879
|
+
disabled: !ready || installing !== '' || Boolean(upload),
|
|
2880
|
+
onClick: pickApkFile,
|
|
2881
|
+
}),
|
|
2882
|
+
actionBtn(apksBusy ? T('apks.scanning') : T('action.rescan'), {
|
|
2883
|
+
key: 'rescan',
|
|
2884
|
+
small: true,
|
|
2885
|
+
disabled: apksBusy,
|
|
2886
|
+
onClick: function () {
|
|
2887
|
+
loadApks();
|
|
2888
|
+
},
|
|
2889
|
+
})
|
|
2890
|
+
),
|
|
2891
|
+
h('input', {
|
|
2892
|
+
ref: fileInputRef,
|
|
2893
|
+
className: 'apkvm-fileinput',
|
|
2894
|
+
type: 'file',
|
|
2895
|
+
accept: '.apk,application/vnd.android.package-archive',
|
|
2896
|
+
onChange: onFileInputChange,
|
|
2897
|
+
'aria-label': T('upload.button'),
|
|
2898
|
+
}),
|
|
2899
|
+
upload
|
|
2900
|
+
? h(
|
|
2901
|
+
'div',
|
|
2902
|
+
{ className: 'apkvm-uploadbar' },
|
|
2903
|
+
h('div', { className: 'apkvm-uploadbar-text' }, T('upload.uploading', { name: upload.name })),
|
|
2904
|
+
h('div', { className: 'apkvm-uploadbar-pct' }, upload.pct + '%'),
|
|
2905
|
+
h(
|
|
2906
|
+
'div',
|
|
2907
|
+
{ className: 'apkvm-progress' },
|
|
2908
|
+
h('div', { className: 'apkvm-progress-fill', style: { width: Math.max(2, upload.pct) + '%' } })
|
|
2909
|
+
)
|
|
2910
|
+
)
|
|
2911
|
+
: null,
|
|
2912
|
+
apksError ? h('div', { className: 'apkvm-inline-error' }, T('err.apks') + ': ' + apksError) : null,
|
|
2913
|
+
h(
|
|
2914
|
+
'div',
|
|
2915
|
+
{ className: 'apkvm-list apkvm-scroll' },
|
|
2916
|
+
apks === null
|
|
2917
|
+
? h(
|
|
2918
|
+
'div',
|
|
2919
|
+
{ className: 'apkvm-empty' },
|
|
2920
|
+
apksBusy ? h('span', { className: 'apkvm-spinner apkvm-spinner-sm' }) : null,
|
|
2921
|
+
h('span', null, T('apks.scanning'))
|
|
2922
|
+
)
|
|
2923
|
+
: apks.length === 0
|
|
2924
|
+
? h('div', { className: 'apkvm-empty' }, T('apks.empty'))
|
|
2925
|
+
: apks.map(renderApkRow)
|
|
2926
|
+
),
|
|
2927
|
+
h(
|
|
2928
|
+
'div',
|
|
2929
|
+
{ className: 'apkvm-toolsline', title: T('manual.hint') },
|
|
2930
|
+
h('input', {
|
|
2931
|
+
className: 'apkvm-input',
|
|
2932
|
+
type: 'text',
|
|
2933
|
+
value: pathInput,
|
|
2934
|
+
placeholder: apkInfo && apkInfo.path ? apkInfo.path : T('manual.placeholder'),
|
|
2935
|
+
spellCheck: false,
|
|
2936
|
+
autoComplete: 'off',
|
|
2937
|
+
onChange: onPathChange,
|
|
2938
|
+
onKeyDown: onPathKeyDown,
|
|
2939
|
+
disabled: installing !== '',
|
|
2940
|
+
'aria-label': T('manual.title'),
|
|
2941
|
+
}),
|
|
2942
|
+
actionBtn(installing !== '' ? T('apks.installing') : T('action.installLaunch'), {
|
|
2943
|
+
key: 'install-path',
|
|
2944
|
+
variant: 'primary',
|
|
2945
|
+
small: true,
|
|
2946
|
+
disabled: !ready || installing !== '' || !pathInput.trim(),
|
|
2947
|
+
onClick: function () {
|
|
2948
|
+
installApk(pathInput);
|
|
2949
|
+
},
|
|
2950
|
+
})
|
|
2951
|
+
),
|
|
2952
|
+
roots.length
|
|
2953
|
+
? h('div', { className: 'apkvm-dim', title: roots.join('\n') }, T('apks.roots', { roots: rootsLabel }))
|
|
2954
|
+
: null
|
|
2955
|
+
);
|
|
2956
|
+
|
|
2957
|
+
var debugBadgeStyle = {
|
|
2958
|
+
fontSize: '10px',
|
|
2959
|
+
lineHeight: '15px',
|
|
2960
|
+
padding: '0 6px',
|
|
2961
|
+
borderRadius: '999px',
|
|
2962
|
+
border: '1px solid ' + (rootActive ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'),
|
|
2963
|
+
color: rootActive ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
2964
|
+
whiteSpace: 'nowrap',
|
|
2965
|
+
};
|
|
2966
|
+
var instancesCard = instancesList.length > 1
|
|
2967
|
+
? h(
|
|
2968
|
+
'details',
|
|
2969
|
+
{ className: 'apkvm-card apkvm-instances' },
|
|
2970
|
+
h(
|
|
2971
|
+
'summary',
|
|
2972
|
+
{ className: 'apkvm-debug-summary' },
|
|
2973
|
+
h('span', { className: 'apkvm-card-title' }, T('instances.manage')),
|
|
2974
|
+
h('span', { className: 'apkvm-dim', style: { marginLeft: 'auto' } }, String(instancesList.length)),
|
|
2975
|
+
h('span', { className: 'apkvm-chevron', 'aria-hidden': 'true' }, '▸')
|
|
2976
|
+
),
|
|
2977
|
+
h(
|
|
2978
|
+
'div',
|
|
2979
|
+
{ className: 'apkvm-instlist' },
|
|
2980
|
+
instancesList.map(function (entry) {
|
|
2981
|
+
var isDefault = entry.id === 'default';
|
|
2982
|
+
var owner = entry.owner ? String(entry.owner).replace(/^agent-/, '').slice(0, 14) : '';
|
|
2983
|
+
var idle = Number(entry.idleMs) >= 0 ? Math.max(0, Math.round(Number(entry.idleMs) / 1000)) : null;
|
|
2984
|
+
return h(
|
|
2985
|
+
'div',
|
|
2986
|
+
{ className: 'apkvm-instrow', key: entry.id },
|
|
2987
|
+
h(
|
|
2988
|
+
'div',
|
|
2989
|
+
{ className: 'apkvm-instname', title: entry.id },
|
|
2990
|
+
isDefault ? T('instances.default') : entry.id,
|
|
2991
|
+
h('span', { className: 'apkvm-dim' }, ' · ' + (entry.state || '?') + (owner ? ' · ' + owner : ''))
|
|
2992
|
+
),
|
|
2993
|
+
h(
|
|
2994
|
+
'div',
|
|
2995
|
+
{ className: 'apkvm-instmeta' },
|
|
2996
|
+
idle !== null && !isDefault ? T('instances.idle', { seconds: idle }) : ''
|
|
2997
|
+
),
|
|
2998
|
+
isDefault
|
|
2999
|
+
? null
|
|
3000
|
+
: h(
|
|
3001
|
+
'div',
|
|
3002
|
+
{ className: 'apkvm-instactions' },
|
|
3003
|
+
h(
|
|
3004
|
+
'button',
|
|
3005
|
+
{
|
|
3006
|
+
type: 'button',
|
|
3007
|
+
className: 'apkvm-mini',
|
|
3008
|
+
title: T('instances.stop'),
|
|
3009
|
+
onClick: function (event) {
|
|
3010
|
+
event.preventDefault();
|
|
3011
|
+
stopInstanceById(entry.id);
|
|
3012
|
+
},
|
|
3013
|
+
},
|
|
3014
|
+
T('instances.stop')
|
|
3015
|
+
),
|
|
3016
|
+
h(
|
|
3017
|
+
'button',
|
|
3018
|
+
{
|
|
3019
|
+
type: 'button',
|
|
3020
|
+
className: 'apkvm-mini danger',
|
|
3021
|
+
title: T('instances.delete'),
|
|
3022
|
+
onClick: function (event) {
|
|
3023
|
+
event.preventDefault();
|
|
3024
|
+
destroyInstanceById(entry.id);
|
|
3025
|
+
},
|
|
3026
|
+
},
|
|
3027
|
+
T('instances.delete')
|
|
3028
|
+
)
|
|
3029
|
+
)
|
|
3030
|
+
);
|
|
3031
|
+
})
|
|
3032
|
+
)
|
|
3033
|
+
)
|
|
3034
|
+
: null;
|
|
3035
|
+
|
|
3036
|
+
var appsCard = h(
|
|
3037
|
+
'details',
|
|
3038
|
+
{
|
|
3039
|
+
className: 'apkvm-card apkvm-apps',
|
|
3040
|
+
open: appsOpen,
|
|
3041
|
+
onToggle: function (event) {
|
|
3042
|
+
var next = Boolean(event && event.target && event.target.open);
|
|
3043
|
+
if (next !== appsOpen) {
|
|
3044
|
+
setAppsOpen(next);
|
|
3045
|
+
if (next) loadInstalledApps();
|
|
3046
|
+
}
|
|
3047
|
+
},
|
|
3048
|
+
},
|
|
3049
|
+
h(
|
|
3050
|
+
'summary',
|
|
3051
|
+
{ className: 'apkvm-debug-summary' },
|
|
3052
|
+
h('span', { className: 'apkvm-card-title' }, T('apps.title')),
|
|
3053
|
+
h('span', { className: 'apkvm-dim', style: { marginLeft: 'auto' } }, installedApps.length ? String(installedApps.length) : ''),
|
|
3054
|
+
h('span', { className: 'apkvm-chevron', 'aria-hidden': 'true' }, '▸')
|
|
3055
|
+
),
|
|
3056
|
+
h(
|
|
3057
|
+
'div',
|
|
3058
|
+
{ className: 'apkvm-appsbody' },
|
|
3059
|
+
h(
|
|
3060
|
+
'div',
|
|
3061
|
+
{ className: 'apkvm-toolsline' },
|
|
3062
|
+
h(
|
|
3063
|
+
'button',
|
|
3064
|
+
{
|
|
3065
|
+
type: 'button',
|
|
3066
|
+
className: 'apkvm-mini',
|
|
3067
|
+
disabled: !ready || appsBusy,
|
|
3068
|
+
onClick: function (event) {
|
|
3069
|
+
event.preventDefault();
|
|
3070
|
+
loadInstalledApps();
|
|
3071
|
+
},
|
|
3072
|
+
},
|
|
3073
|
+
appsBusy ? T('apps.loading') : T('apps.refresh')
|
|
3074
|
+
),
|
|
3075
|
+
h('span', { className: 'apkvm-dim' }, T('apps.thirdParty'))
|
|
3076
|
+
),
|
|
3077
|
+
appsError ? h('div', { className: 'apkvm-error' }, appsError) : null,
|
|
3078
|
+
installedApps.length
|
|
3079
|
+
? h(
|
|
3080
|
+
'div',
|
|
3081
|
+
{ className: 'apkvm-appslist apkvm-scroll' },
|
|
3082
|
+
installedApps.map(function (app) {
|
|
3083
|
+
var pkg = app.packageName;
|
|
3084
|
+
var busyKey = appActionBusy && appActionBusy.indexOf(':' + pkg) >= 0;
|
|
3085
|
+
return h(
|
|
3086
|
+
'div',
|
|
3087
|
+
{ className: 'apkvm-approw', key: pkg },
|
|
3088
|
+
h('span', { className: 'apkvm-appname', title: pkg }, pkg),
|
|
3089
|
+
h(
|
|
3090
|
+
'span',
|
|
3091
|
+
{ className: 'apkvm-appactions' },
|
|
3092
|
+
h(
|
|
3093
|
+
'button',
|
|
3094
|
+
{
|
|
3095
|
+
type: 'button',
|
|
3096
|
+
className: 'apkvm-mini',
|
|
3097
|
+
disabled: !ready || Boolean(appActionBusy),
|
|
3098
|
+
onClick: function (event) {
|
|
3099
|
+
event.preventDefault();
|
|
3100
|
+
runAppAction('force-stop', pkg);
|
|
3101
|
+
},
|
|
3102
|
+
},
|
|
3103
|
+
T('apps.stop')
|
|
3104
|
+
),
|
|
3105
|
+
h(
|
|
3106
|
+
'button',
|
|
3107
|
+
{
|
|
3108
|
+
type: 'button',
|
|
3109
|
+
className: 'apkvm-mini',
|
|
3110
|
+
disabled: !ready || Boolean(appActionBusy),
|
|
3111
|
+
onClick: function (event) {
|
|
3112
|
+
event.preventDefault();
|
|
3113
|
+
runAppAction('clear-data', pkg);
|
|
3114
|
+
},
|
|
3115
|
+
},
|
|
3116
|
+
T('apps.clear')
|
|
3117
|
+
),
|
|
3118
|
+
h(
|
|
3119
|
+
'button',
|
|
3120
|
+
{
|
|
3121
|
+
type: 'button',
|
|
3122
|
+
className: 'apkvm-mini danger',
|
|
3123
|
+
disabled: !ready || Boolean(appActionBusy),
|
|
3124
|
+
onClick: function (event) {
|
|
3125
|
+
event.preventDefault();
|
|
3126
|
+
runAppAction('uninstall', pkg);
|
|
3127
|
+
},
|
|
3128
|
+
},
|
|
3129
|
+
T('apps.uninstall')
|
|
3130
|
+
)
|
|
3131
|
+
),
|
|
3132
|
+
busyKey ? h('span', { className: 'apkvm-dim' }, T('debug.busy')) : null
|
|
3133
|
+
);
|
|
3134
|
+
})
|
|
3135
|
+
)
|
|
3136
|
+
: h('div', { className: 'apkvm-dim' }, appsBusy ? T('apps.loading') : T('apps.empty'))
|
|
3137
|
+
)
|
|
3138
|
+
);
|
|
3139
|
+
|
|
3140
|
+
var debugCard = h(
|
|
3141
|
+
'details',
|
|
3142
|
+
{ className: 'apkvm-card apkvm-debug' },
|
|
3143
|
+
h(
|
|
3144
|
+
'summary',
|
|
3145
|
+
{ className: 'apkvm-debug-summary' },
|
|
3146
|
+
h('span', { className: 'apkvm-card-title' }, T('debug.title')),
|
|
3147
|
+
h('span', { style: debugBadgeStyle, title: 'adbd uid' }, rootActive ? T('debug.state.root') : T('debug.state.shell')),
|
|
3148
|
+
h(
|
|
3149
|
+
'span',
|
|
3150
|
+
{ className: 'apkvm-dim', style: { marginLeft: 'auto' }, title: identityText || T('debug.title') },
|
|
3151
|
+
videoMode === 'h264'
|
|
3152
|
+
? T('stream.h264', { fps: h264Meta && h264Meta.maxFps ? h264Meta.maxFps : 60 })
|
|
3153
|
+
: identityText
|
|
3154
|
+
),
|
|
3155
|
+
h('span', { className: 'apkvm-chevron', 'aria-hidden': 'true' }, '▸')
|
|
3156
|
+
),
|
|
3157
|
+
h(
|
|
3158
|
+
'div',
|
|
3159
|
+
{ className: 'apkvm-debug-body' },
|
|
3160
|
+
h(
|
|
3161
|
+
'div',
|
|
3162
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
|
|
3163
|
+
h('input', {
|
|
3164
|
+
className: 'apkvm-input',
|
|
3165
|
+
type: 'text',
|
|
3166
|
+
value: debugCommand,
|
|
3167
|
+
placeholder: T('debug.placeholder'),
|
|
3168
|
+
spellCheck: false,
|
|
3169
|
+
autoComplete: 'off',
|
|
3170
|
+
disabled: !ready || debugBusy,
|
|
3171
|
+
onChange: function (event) {
|
|
3172
|
+
setDebugCommand(event && event.target ? String(event.target.value) : '');
|
|
3173
|
+
},
|
|
3174
|
+
onKeyDown: function (event) {
|
|
3175
|
+
if (event && event.key === 'Enter') {
|
|
3176
|
+
event.preventDefault();
|
|
3177
|
+
runDebugCommand();
|
|
3178
|
+
}
|
|
3179
|
+
},
|
|
3180
|
+
'aria-label': T('debug.placeholder'),
|
|
3181
|
+
}),
|
|
3182
|
+
actionBtn(debugBusy ? T('debug.busy') : T('debug.run'), {
|
|
3183
|
+
key: 'debug-run',
|
|
3184
|
+
variant: 'primary',
|
|
3185
|
+
small: true,
|
|
3186
|
+
disabled: !ready || debugBusy || !debugCommand.trim(),
|
|
3187
|
+
onClick: runDebugCommand,
|
|
3188
|
+
})
|
|
3189
|
+
),
|
|
3190
|
+
h(
|
|
3191
|
+
'div',
|
|
3192
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
|
|
3193
|
+
actionBtn(T('debug.logcat'), { key: 'debug-logcat', small: true, disabled: !ready || debugBusy, onClick: runDebugLogcat }),
|
|
3194
|
+
actionBtn(logLive ? T('debug.liveStop') : T('debug.live'), {
|
|
3195
|
+
key: 'debug-live-logcat',
|
|
3196
|
+
small: true,
|
|
3197
|
+
disabled: !ready,
|
|
3198
|
+
onClick: toggleLiveLogcat,
|
|
3199
|
+
}),
|
|
3200
|
+
actionBtn(T('debug.crash'), { key: 'debug-crash', small: true, disabled: !ready || debugBusy, onClick: runCrashLogcat }),
|
|
3201
|
+
actionBtn(T('debug.clipboardSet'), {
|
|
3202
|
+
key: 'debug-clipboard-set',
|
|
3203
|
+
small: true,
|
|
3204
|
+
disabled: !ready,
|
|
3205
|
+
onClick: setDeviceClipboard,
|
|
3206
|
+
}),
|
|
3207
|
+
actionBtn(T('debug.clipboardGet'), {
|
|
3208
|
+
key: 'debug-clipboard-get',
|
|
3209
|
+
small: true,
|
|
3210
|
+
disabled: !ready,
|
|
3211
|
+
onClick: getDeviceClipboard,
|
|
3212
|
+
}),
|
|
3213
|
+
actionBtn(T('debug.props'), { key: 'debug-props', small: true, disabled: !ready || debugBusy, onClick: runDebugInfo }),
|
|
3214
|
+
actionBtn(T('debug.rootAction'), { key: 'debug-root', small: true, disabled: !ready || debugBusy || rootActive, onClick: runDebugRoot }),
|
|
3215
|
+
actionBtn(T('debug.ui'), { key: 'debug-ui', small: true, disabled: !ready || debugBusy, onClick: runDebugUi }),
|
|
3216
|
+
actionBtn(T('debug.perf'), { key: 'debug-perf', small: true, disabled: !ready || debugBusy, onClick: runDebugPerf }),
|
|
3217
|
+
actionBtn(T('debug.record'), { key: 'debug-record', small: true, disabled: !ready || debugBusy, onClick: runDebugRecord }),
|
|
3218
|
+
actionBtn(T('debug.rotate'), { key: 'debug-rotate', small: true, disabled: !ready || debugBusy, onClick: runDebugRotate })
|
|
3219
|
+
),
|
|
3220
|
+
h(
|
|
3221
|
+
'label',
|
|
3222
|
+
{
|
|
3223
|
+
title: T('fps.hint'),
|
|
3224
|
+
style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' },
|
|
3225
|
+
},
|
|
3226
|
+
h('span', null, T('fps.target')),
|
|
3227
|
+
h(
|
|
3228
|
+
'select',
|
|
3229
|
+
{
|
|
3230
|
+
value: String(targetFps),
|
|
3231
|
+
disabled: !ready || videoMode === 'h264' || activeInstance !== 'default',
|
|
3232
|
+
onChange: function (event) {
|
|
3233
|
+
setStreamTarget(event && event.target ? event.target.value : targetFps);
|
|
3234
|
+
},
|
|
3235
|
+
style: {
|
|
3236
|
+
font: 'inherit',
|
|
3237
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
3238
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
3239
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
3240
|
+
borderRadius: '6px',
|
|
3241
|
+
padding: '1px 4px',
|
|
3242
|
+
},
|
|
3243
|
+
},
|
|
3244
|
+
[1, 2, 4, 6, 8].map(function (n) {
|
|
3245
|
+
return h('option', { key: n, value: String(n) }, n + ' fps');
|
|
3246
|
+
})
|
|
3247
|
+
),
|
|
3248
|
+
videoMode === 'h264'
|
|
3249
|
+
? h('span', null, T('stream.h264', { fps: h264Meta && h264Meta.maxFps ? h264Meta.maxFps : 60 }))
|
|
3250
|
+
: actualFps > 0
|
|
3251
|
+
? h('span', null, '· ' + T('fps.actual', { fps: actualFps.toFixed(1) }))
|
|
3252
|
+
: null
|
|
3253
|
+
),
|
|
3254
|
+
h(
|
|
3255
|
+
'pre',
|
|
3256
|
+
{
|
|
3257
|
+
className: 'apkvm-scroll',
|
|
3258
|
+
style: {
|
|
3259
|
+
margin: 0,
|
|
3260
|
+
maxHeight: '170px',
|
|
3261
|
+
overflow: 'auto',
|
|
3262
|
+
padding: '6px 7px',
|
|
3263
|
+
borderRadius: '8px',
|
|
3264
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
3265
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
3266
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
3267
|
+
font: '11px/15px ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
3268
|
+
whiteSpace: 'pre-wrap',
|
|
3269
|
+
wordBreak: 'break-word',
|
|
3270
|
+
},
|
|
3271
|
+
},
|
|
3272
|
+
debugOutput || T('debug.empty')
|
|
3273
|
+
)
|
|
3274
|
+
)
|
|
3275
|
+
);
|
|
3276
|
+
|
|
3277
|
+
var primaryAction =
|
|
3278
|
+
ready || busyState
|
|
3279
|
+
? actionBtn(vmBusy ? T('action.stopping') : T('action.stop'), {
|
|
3280
|
+
key: 'vm-stop',
|
|
3281
|
+
variant: 'danger',
|
|
3282
|
+
disabled: vmBusy,
|
|
3283
|
+
onClick: function () {
|
|
3284
|
+
runVmAction('stop');
|
|
3285
|
+
},
|
|
3286
|
+
})
|
|
3287
|
+
: actionBtn(vmBusy ? T('action.starting') : T('action.start'), {
|
|
3288
|
+
key: 'vm-start',
|
|
3289
|
+
variant: 'primary',
|
|
3290
|
+
disabled: vmBusy || displayState === 'loading',
|
|
3291
|
+
onClick: function () {
|
|
3292
|
+
runVmAction('start');
|
|
3293
|
+
},
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
function openInBetterSidebar() {
|
|
3297
|
+
var service = getBetterSidebar();
|
|
3298
|
+
if (!service || typeof service.openTab !== 'function') {
|
|
3299
|
+
setToast({ id: Date.now(), kind: 'error', text: T('sidebar.unavailable') });
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
var snapshot = null;
|
|
3303
|
+
try {
|
|
3304
|
+
snapshot = typeof service.getSnapshot === 'function' ? service.getSnapshot() : null;
|
|
3305
|
+
} catch (error) {
|
|
3306
|
+
snapshot = null;
|
|
3307
|
+
}
|
|
3308
|
+
if (!snapshot || !snapshot.sessionId) {
|
|
3309
|
+
setToast({ id: Date.now(), kind: 'error', text: T('sidebar.noSession') });
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
try {
|
|
3313
|
+
service.openTab({ type: BETTER_SIDEBAR_TAB_ID, target: 'right' });
|
|
3314
|
+
setToast({ id: Date.now(), kind: 'success', text: T('sidebar.opened') });
|
|
3315
|
+
} catch (error) {
|
|
3316
|
+
setToast({
|
|
3317
|
+
id: Date.now(),
|
|
3318
|
+
kind: 'error',
|
|
3319
|
+
text: (error && error.message) || T('sidebar.unavailable'),
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
var instanceOptions = instancesList.length
|
|
3325
|
+
? instancesList
|
|
3326
|
+
: [{ id: 'default', kind: 'default', state: displayState }];
|
|
3327
|
+
var instanceSelector = h(
|
|
3328
|
+
'select',
|
|
3329
|
+
{
|
|
3330
|
+
className: 'apkvm-instance-select',
|
|
3331
|
+
value: activeInstance,
|
|
3332
|
+
title: T('instances.label'),
|
|
3333
|
+
'aria-label': T('instances.label'),
|
|
3334
|
+
disabled: instanceBusy,
|
|
3335
|
+
onChange: function (event) {
|
|
3336
|
+
setActiveInstanceId(event && event.target ? String(event.target.value) : 'default');
|
|
3337
|
+
},
|
|
3338
|
+
},
|
|
3339
|
+
instanceOptions.map(function (entry) {
|
|
3340
|
+
var label = entry.id === 'default' ? T('instances.default') : entry.id;
|
|
3341
|
+
var owner = entry.owner ? String(entry.owner).replace(/^agent-/, '').slice(0, 10) : '';
|
|
3342
|
+
return h(
|
|
3343
|
+
'option',
|
|
3344
|
+
{ key: entry.id, value: entry.id },
|
|
3345
|
+
label + (entry.state ? ' · ' + entry.state : '') + (owner ? ' · ' + owner : '')
|
|
3346
|
+
);
|
|
3347
|
+
})
|
|
3348
|
+
);
|
|
3349
|
+
|
|
3350
|
+
var headerEl = h(
|
|
3351
|
+
'header',
|
|
3352
|
+
{ className: 'apkvm-header' },
|
|
3353
|
+
h(
|
|
3354
|
+
'div',
|
|
3355
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 } },
|
|
3356
|
+
h('span', { className: 'apkvm-headicon' }, h(PhoneIcon, { size: 18 })),
|
|
3357
|
+
h(
|
|
3358
|
+
'div',
|
|
3359
|
+
{ style: { minWidth: 0 } },
|
|
3360
|
+
h('h1', { className: 'apkvm-title' }, T('title')),
|
|
3361
|
+
h('p', { className: 'apkvm-subtitle', title: subtitleText }, subtitleText)
|
|
3362
|
+
)
|
|
3363
|
+
),
|
|
3364
|
+
h('span', { className: 'apkvm-spacer' }),
|
|
3365
|
+
h(
|
|
3366
|
+
'span',
|
|
3367
|
+
{ className: 'apkvm-pill', title: pillMeta.join(' · ') || stateLabel },
|
|
3368
|
+
h('span', { className: 'apkvm-dot ' + displayState }),
|
|
3369
|
+
h('span', null, stateLabel),
|
|
3370
|
+
pillMeta.length ? h('span', { className: 'apkvm-pillmeta' }, '· ' + pillMeta.join(' · ')) : null
|
|
3371
|
+
),
|
|
3372
|
+
instanceSelector,
|
|
3373
|
+
actionBtn(T('instances.new'), {
|
|
3374
|
+
key: 'new-instance',
|
|
3375
|
+
small: true,
|
|
3376
|
+
disabled: instanceBusy,
|
|
3377
|
+
onClick: createInstance,
|
|
3378
|
+
}),
|
|
3379
|
+
activeInstance !== 'default'
|
|
3380
|
+
? actionBtn(T('instances.delete'), {
|
|
3381
|
+
key: 'destroy-instance',
|
|
3382
|
+
small: true,
|
|
3383
|
+
variant: 'danger',
|
|
3384
|
+
disabled: instanceBusy,
|
|
3385
|
+
onClick: destroyActiveInstance,
|
|
3386
|
+
})
|
|
3387
|
+
: null,
|
|
3388
|
+
h(
|
|
3389
|
+
'span',
|
|
3390
|
+
{
|
|
3391
|
+
className: 'apkvm-fps',
|
|
3392
|
+
title: T('fps.hint'),
|
|
3393
|
+
style: {
|
|
3394
|
+
fontSize: '11px',
|
|
3395
|
+
lineHeight: '16px',
|
|
3396
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
3397
|
+
fontVariantNumeric: 'tabular-nums',
|
|
3398
|
+
whiteSpace: 'nowrap',
|
|
3399
|
+
flex: 'none',
|
|
3400
|
+
},
|
|
3401
|
+
},
|
|
3402
|
+
T('fps.label') +
|
|
3403
|
+
' ' +
|
|
3404
|
+
(actualFps > 0 ? T('fps.actual', { fps: actualFps.toFixed(1) }) : '—') +
|
|
3405
|
+
' · ' +
|
|
3406
|
+
(videoMode === 'h264' ? T('fps.mode.h264') : T('fps.mode.png'))
|
|
3407
|
+
),
|
|
3408
|
+
primaryAction,
|
|
3409
|
+
!embedded && sidebarService
|
|
3410
|
+
? actionBtn(T('action.openSidebar'), {
|
|
3411
|
+
key: 'open-sidebar',
|
|
3412
|
+
icon: h(PhoneIcon, { size: 13 }),
|
|
3413
|
+
ariaLabel: T('a11y.openSidebar'),
|
|
3414
|
+
onClick: openInBetterSidebar,
|
|
3415
|
+
})
|
|
3416
|
+
: null,
|
|
3417
|
+
actionBtn(T('action.refresh'), {
|
|
3418
|
+
key: 'refresh',
|
|
3419
|
+
icon: h(IconRefresh, { size: 13 }),
|
|
3420
|
+
ariaLabel: T('a11y.refresh'),
|
|
3421
|
+
onClick: refreshAll,
|
|
3422
|
+
})
|
|
3423
|
+
);
|
|
3424
|
+
|
|
3425
|
+
var toastBanner = toast
|
|
3426
|
+
? h(
|
|
3427
|
+
'div',
|
|
3428
|
+
{ className: 'apkvm-banner ' + (toast.kind === 'success' ? 'success' : 'error'), role: 'status' },
|
|
3429
|
+
h('span', { className: 'apkvm-banner-text' }, toast.text),
|
|
3430
|
+
h(
|
|
3431
|
+
'button',
|
|
3432
|
+
{
|
|
3433
|
+
type: 'button',
|
|
3434
|
+
className: 'apkvm-banner-close',
|
|
3435
|
+
onClick: function () {
|
|
3436
|
+
setToast(null);
|
|
3437
|
+
},
|
|
3438
|
+
'aria-label': T('action.dismiss'),
|
|
3439
|
+
},
|
|
3440
|
+
'×'
|
|
3441
|
+
)
|
|
3442
|
+
)
|
|
3443
|
+
: null;
|
|
3444
|
+
|
|
3445
|
+
var stateErrorBanner =
|
|
3446
|
+
stateError && !toast
|
|
3447
|
+
? h(
|
|
3448
|
+
'div',
|
|
3449
|
+
{ className: 'apkvm-banner error', role: 'alert' },
|
|
3450
|
+
h('span', { className: 'apkvm-banner-text' }, T('err.state') + ': ' + stateError),
|
|
3451
|
+
actionBtn(T('overlay.retry'), {
|
|
3452
|
+
key: 'state-retry',
|
|
3453
|
+
small: true,
|
|
3454
|
+
ariaLabel: T('a11y.retry'),
|
|
3455
|
+
onClick: refreshAll,
|
|
3456
|
+
})
|
|
3457
|
+
)
|
|
3458
|
+
: null;
|
|
3459
|
+
|
|
3460
|
+
function onScreenKeyDown(event) {
|
|
3461
|
+
if (!ready || !event) return;
|
|
3462
|
+
var target = event.target;
|
|
3463
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
|
3464
|
+
var keyMap = {
|
|
3465
|
+
Backspace: 'DEL',
|
|
3466
|
+
Delete: 'FORWARD_DEL',
|
|
3467
|
+
Enter: 'ENTER',
|
|
3468
|
+
Escape: 'BACK',
|
|
3469
|
+
Tab: 'TAB',
|
|
3470
|
+
ArrowUp: 'DPAD_UP',
|
|
3471
|
+
ArrowDown: 'DPAD_DOWN',
|
|
3472
|
+
ArrowLeft: 'DPAD_LEFT',
|
|
3473
|
+
ArrowRight: 'DPAD_RIGHT',
|
|
3474
|
+
Home: 'MOVE_HOME',
|
|
3475
|
+
};
|
|
3476
|
+
if (keyMap[event.key]) {
|
|
3477
|
+
event.preventDefault();
|
|
3478
|
+
sendInput({ type: 'key', key: keyMap[event.key] });
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
3482
|
+
if (typeof event.key === 'string' && event.key.length === 1 && !event.isComposing) {
|
|
3483
|
+
event.preventDefault();
|
|
3484
|
+
sendInput({ type: 'text', text: event.key });
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
var screenEl = h(
|
|
3489
|
+
'div',
|
|
3490
|
+
{
|
|
3491
|
+
ref: screenRef,
|
|
3492
|
+
className: 'apkvm-screen' + (ready ? ' ready' : ''),
|
|
3493
|
+
style: { aspectRatio: screenAspect },
|
|
3494
|
+
tabIndex: 0,
|
|
3495
|
+
'aria-label': T('screen.hint'),
|
|
3496
|
+
onPointerDown: onPointerDown,
|
|
3497
|
+
onPointerMove: onPointerMove,
|
|
3498
|
+
onPointerUp: onPointerUp,
|
|
3499
|
+
onPointerCancel: onPointerCancel,
|
|
3500
|
+
onKeyDown: onScreenKeyDown,
|
|
3501
|
+
onContextMenu: onContextMenu,
|
|
3502
|
+
onDragStart: onContextMenu,
|
|
3503
|
+
},
|
|
3504
|
+
frameUrl && videoMode !== 'h264'
|
|
3505
|
+
? h('img', { className: 'apkvm-frame', src: frameUrl, alt: T('screen.alt'), draggable: false })
|
|
3506
|
+
: null,
|
|
3507
|
+
videoMode === 'h264'
|
|
3508
|
+
? h('canvas', {
|
|
3509
|
+
ref: canvasRef,
|
|
3510
|
+
className: 'apkvm-video',
|
|
3511
|
+
width: h264Meta && h264Meta.width ? h264Meta.width : 720,
|
|
3512
|
+
height: h264Meta && h264Meta.height ? h264Meta.height : 1280,
|
|
3513
|
+
style: {
|
|
3514
|
+
position: 'absolute',
|
|
3515
|
+
inset: 0,
|
|
3516
|
+
width: '100%',
|
|
3517
|
+
height: '100%',
|
|
3518
|
+
display: 'block',
|
|
3519
|
+
pointerEvents: 'none',
|
|
3520
|
+
},
|
|
3521
|
+
})
|
|
3522
|
+
: null,
|
|
3523
|
+
renderOverlay()
|
|
3524
|
+
);
|
|
3525
|
+
|
|
3526
|
+
var stageEl = h(
|
|
3527
|
+
'section',
|
|
3528
|
+
{ className: 'apkvm-stage' },
|
|
3529
|
+
h('div', { className: 'apkvm-phonewrap' }, h('div', { className: 'apkvm-phone' }, screenEl)),
|
|
3530
|
+
h(
|
|
3531
|
+
'div',
|
|
3532
|
+
{ className: 'apkvm-controls' },
|
|
3533
|
+
h(
|
|
3534
|
+
'div',
|
|
3535
|
+
{ className: 'apkvm-keys' },
|
|
3536
|
+
keyBtn(T('keys.back'), h(IconBack, { size: 13 }), 'back'),
|
|
3537
|
+
keyBtn(T('keys.home'), h(IconHome, { size: 13 }), 'home'),
|
|
3538
|
+
keyBtn(T('keys.recents'), h(IconRecents, { size: 13 }), 'recents'),
|
|
3539
|
+
keyBtn(T('keys.power'), h(IconPower, { size: 13 }), 'power')
|
|
3540
|
+
),
|
|
3541
|
+
actionBtn(T('debug.screenshot'), {
|
|
3542
|
+
key: 'save-shot',
|
|
3543
|
+
small: true,
|
|
3544
|
+
disabled: !ready,
|
|
3545
|
+
onClick: downloadScreenshot,
|
|
3546
|
+
}),
|
|
3547
|
+
h(
|
|
3548
|
+
'div',
|
|
3549
|
+
{ className: 'apkvm-textrow' },
|
|
3550
|
+
h('input', {
|
|
3551
|
+
className: 'apkvm-input',
|
|
3552
|
+
type: 'text',
|
|
3553
|
+
value: textInput,
|
|
3554
|
+
placeholder: T('input.placeholder'),
|
|
3555
|
+
disabled: !ready,
|
|
3556
|
+
spellCheck: false,
|
|
3557
|
+
autoComplete: 'off',
|
|
3558
|
+
onChange: onTextChange,
|
|
3559
|
+
onKeyDown: onTextKeyDown,
|
|
3560
|
+
'aria-label': T('input.send'),
|
|
3561
|
+
}),
|
|
3562
|
+
actionBtn(T('input.send'), {
|
|
3563
|
+
key: 'send-text',
|
|
3564
|
+
small: true,
|
|
3565
|
+
disabled: !ready || !textInput,
|
|
3566
|
+
onClick: sendText,
|
|
3567
|
+
})
|
|
3568
|
+
)
|
|
3569
|
+
),
|
|
3570
|
+
h('div', { className: 'apkvm-stagehint' }, T('screen.hint')),
|
|
3571
|
+
ready && videoMode !== 'h264' && clientReport && clientReport.h264Supported === false
|
|
3572
|
+
? h('div', { className: 'apkvm-stagehint' }, T('fps.fallbackHint'))
|
|
3573
|
+
: null,
|
|
3574
|
+
ready && frameError
|
|
3575
|
+
? h('div', { className: 'apkvm-stagehint error' }, T('err.frame') + ': ' + frameError)
|
|
3576
|
+
: null
|
|
3577
|
+
);
|
|
3578
|
+
|
|
3579
|
+
return h(
|
|
3580
|
+
'div',
|
|
3581
|
+
{
|
|
3582
|
+
className: 'apkvm-page' + (embedded ? ' apkvm-embedded' : ''),
|
|
3583
|
+
onDragEnter: onPageDragEnter,
|
|
3584
|
+
onDragOver: onPageDragOver,
|
|
3585
|
+
onDragLeave: onPageDragLeave,
|
|
3586
|
+
onDrop: onPageDrop,
|
|
3587
|
+
},
|
|
3588
|
+
h('style', { key: 'apkvm-style' }, CSS_TEXT),
|
|
3589
|
+
headerEl,
|
|
3590
|
+
stateErrorBanner,
|
|
3591
|
+
toastBanner,
|
|
3592
|
+
h(
|
|
3593
|
+
'div',
|
|
3594
|
+
{ className: 'apkvm-body' },
|
|
3595
|
+
h(
|
|
3596
|
+
'aside',
|
|
3597
|
+
{ className: 'apkvm-aside apkvm-scroll' },
|
|
3598
|
+
setupCard,
|
|
3599
|
+
apkCard,
|
|
3600
|
+
instancesCard,
|
|
3601
|
+
debugCard,
|
|
3602
|
+
appsCard,
|
|
3603
|
+
diagCard,
|
|
3604
|
+
h('div', { className: 'apkvm-footnote', title: T('hint.boot') }, T('hint.short')),
|
|
3605
|
+
h('span', { className: 'apkvm-sr-only' }, T('hint.boot'))
|
|
3606
|
+
),
|
|
3607
|
+
stageEl
|
|
3608
|
+
),
|
|
3609
|
+
dragging
|
|
3610
|
+
? h(
|
|
3611
|
+
'div',
|
|
3612
|
+
{ className: 'apkvm-drop-overlay' },
|
|
3613
|
+
h('span', null, T('upload.drop')),
|
|
3614
|
+
h('span', { className: 'apkvm-dim' }, T('upload.detail'))
|
|
3615
|
+
)
|
|
3616
|
+
: null
|
|
3617
|
+
);
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
ApkVmPage.displayName = 'ApkVmPage';
|
|
3621
|
+
return ApkVmPage;
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
/* ------------------------------------------------------------------ *
|
|
3625
|
+
* Cordis plugin wiring.
|
|
3626
|
+
* ------------------------------------------------------------------ */
|
|
3627
|
+
|
|
3628
|
+
function apply(ctx) {
|
|
3629
|
+
if (!isObject(ctx)) return;
|
|
3630
|
+
|
|
3631
|
+
var localeService = ctx.locale;
|
|
3632
|
+
var registered = function () {
|
|
3633
|
+
return localeService.register(NS, { en: EN, zh: ZH });
|
|
3634
|
+
};
|
|
3635
|
+
if (localeService && typeof localeService.register === 'function') {
|
|
3636
|
+
if (typeof ctx.effect === 'function') {
|
|
3637
|
+
ctx.effect(registered, NS + ': dictionaries');
|
|
3638
|
+
} else {
|
|
3639
|
+
registered();
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
var t = null;
|
|
3644
|
+
if (localeService && typeof localeService.bind === 'function') {
|
|
3645
|
+
try {
|
|
3646
|
+
t = localeService.bind(NS);
|
|
3647
|
+
} catch (error) {
|
|
3648
|
+
t = null;
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
if (typeof t !== 'function') {
|
|
3652
|
+
t = function (key) {
|
|
3653
|
+
return hasOwn(EN, key) ? EN[key] : String(key);
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
var Page = createPage(getTranslate);
|
|
3658
|
+
var Boundary = createBoundary();
|
|
3659
|
+
var BoundaryPage = function ApkVmSlot(props) {
|
|
3660
|
+
var translate = props && typeof props.t === 'function' ? props.t : getTranslate();
|
|
3661
|
+
return h(Boundary, { translate: translate }, h(Page, props));
|
|
3662
|
+
};
|
|
3663
|
+
BoundaryPage.displayName = 'ApkVmSlot';
|
|
3664
|
+
|
|
3665
|
+
var slots = ctx.slots;
|
|
3666
|
+
if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
slots.inject('main', function () {
|
|
3671
|
+
return slots.register({ name: 'main', key: NS, locale: NS }, BoundaryPage);
|
|
3672
|
+
});
|
|
3673
|
+
|
|
3674
|
+
slots.inject('sidebar.panellist', function () {
|
|
3675
|
+
return slots.register(
|
|
3676
|
+
{
|
|
3677
|
+
name: 'sidebar.panellist',
|
|
3678
|
+
id: NS,
|
|
3679
|
+
order: 30,
|
|
3680
|
+
label: function () {
|
|
3681
|
+
return t('nav.label');
|
|
3682
|
+
},
|
|
3683
|
+
locale: NS,
|
|
3684
|
+
},
|
|
3685
|
+
function (props) {
|
|
3686
|
+
return h(PhoneIcon, { size: props && typeof props.size === 'number' ? props.size : 18 });
|
|
3687
|
+
}
|
|
3688
|
+
);
|
|
3689
|
+
});
|
|
3690
|
+
|
|
3691
|
+
/* ---- optional dsh-better-sidebar tab integration ----------------- *
|
|
3692
|
+
* The service is published by that plugin as `ctx.betterSidebar`; it may
|
|
3693
|
+
* activate before or after this bundle, so defer through ctx.inject and
|
|
3694
|
+
* register one tab descriptor with the documented API. On deployments
|
|
3695
|
+
* without the plugin nothing is registered and the `main` panel remains
|
|
3696
|
+
* the only surface. */
|
|
3697
|
+
|
|
3698
|
+
function registerBetterSidebarTab(sub, service) {
|
|
3699
|
+
if (!service || typeof service.registerTab !== 'function') return;
|
|
3700
|
+
var mount = function () {
|
|
3701
|
+
var dispose = service.registerTab({
|
|
3702
|
+
id: BETTER_SIDEBAR_TAB_ID,
|
|
3703
|
+
title: function () {
|
|
3704
|
+
return t('sidebar.tab');
|
|
3705
|
+
},
|
|
3706
|
+
description: function () {
|
|
3707
|
+
return t('sidebar.desc');
|
|
3708
|
+
},
|
|
3709
|
+
icon: function (size) {
|
|
3710
|
+
return h(PhoneIcon, { size: typeof size === 'number' ? size : 18 });
|
|
3711
|
+
},
|
|
3712
|
+
order: 60,
|
|
3713
|
+
single: true,
|
|
3714
|
+
component: function (tabProps) {
|
|
3715
|
+
return h(Boundary, { translate: t }, h(Page, Object.assign({}, tabProps, { embedded: true })));
|
|
3716
|
+
},
|
|
3717
|
+
});
|
|
3718
|
+
publishBetterSidebar(service);
|
|
3719
|
+
return function () {
|
|
3720
|
+
if (typeof dispose === 'function') dispose();
|
|
3721
|
+
if (getBetterSidebar() === service) publishBetterSidebar(null);
|
|
3722
|
+
};
|
|
3723
|
+
};
|
|
3724
|
+
var effect =
|
|
3725
|
+
sub && typeof sub.effect === 'function'
|
|
3726
|
+
? sub.effect
|
|
3727
|
+
: typeof ctx.effect === 'function'
|
|
3728
|
+
? ctx.effect
|
|
3729
|
+
: null;
|
|
3730
|
+
if (effect) {
|
|
3731
|
+
effect(mount, NS + ': dsh-better-sidebar tab');
|
|
3732
|
+
} else {
|
|
3733
|
+
mount();
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
function attachBetterSidebar(root) {
|
|
3738
|
+
if (!isObject(root)) return;
|
|
3739
|
+
if (typeof root.inject === 'function') {
|
|
3740
|
+
try {
|
|
3741
|
+
root.inject(['betterSidebar'], function (sub) {
|
|
3742
|
+
var service = null;
|
|
3743
|
+
try {
|
|
3744
|
+
service =
|
|
3745
|
+
sub && (sub.betterSidebar || (typeof sub.get === 'function' ? sub.get('betterSidebar') : null));
|
|
3746
|
+
} catch (error) {
|
|
3747
|
+
service = null;
|
|
3748
|
+
}
|
|
3749
|
+
if (service) registerBetterSidebarTab(sub, service);
|
|
3750
|
+
});
|
|
3751
|
+
} catch (error) {
|
|
3752
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
3753
|
+
console.error('[apk-vm] optional dsh-better-sidebar service is unavailable', error);
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
return;
|
|
3757
|
+
}
|
|
3758
|
+
var existing = null;
|
|
3759
|
+
try {
|
|
3760
|
+
existing = typeof root.get === 'function' ? root.get('betterSidebar') : null;
|
|
3761
|
+
} catch (error) {
|
|
3762
|
+
existing = null;
|
|
3763
|
+
}
|
|
3764
|
+
if (existing) registerBetterSidebarTab(root, existing);
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
attachBetterSidebar(ctx);
|
|
3768
|
+
|
|
3769
|
+
function getTranslate() {
|
|
3770
|
+
return t;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
return {
|
|
3775
|
+
name: NS,
|
|
3776
|
+
inject: ['slots', 'locale'],
|
|
3777
|
+
apply: apply,
|
|
3778
|
+
};
|
|
3779
|
+
},
|
|
3780
|
+
});
|