dsh-android-tool 0.0.0-stage → 0.1.1

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/client.js ADDED
@@ -0,0 +1,1753 @@
1
+ // dsh-android-tool 的浏览器半边:右侧「安卓设备」四 Tab + 设置里的「安卓调试」。
2
+ //
3
+ // 约束与其它 dsh-yisu 子插件一致:
4
+ // 1. 自注册脚本:window.__ModuleLoader__.load({ id, factory }),不 import;
5
+ // 2. 只 require('react'),其余能力走 /x-android-tool 的 HTTP/WS;
6
+ // 3. apply 绝不抛:一侧注册失败不影响另一侧。
7
+ window.__ModuleLoader__.load({
8
+ id: 'dsh-android-tool',
9
+ factory: (require) => {
10
+ const React = require('react')
11
+ const h = React.createElement
12
+
13
+ const API = '/x-android-tool'
14
+ const TAB_KIND = 'android-tool'
15
+ const TAB_ID = 'dsh-android-tool'
16
+ const AUTO_OPEN_KEY = 'dsh-android-tool.lastOpenedAt'
17
+
18
+ const S = {
19
+ root: {
20
+ display: 'flex',
21
+ flexDirection: 'column',
22
+ height: '100%',
23
+ minHeight: 0,
24
+ background: 'var(--dsw-alias-bg-base, transparent)',
25
+ color: 'inherit',
26
+ },
27
+ header: {
28
+ display: 'flex',
29
+ alignItems: 'center',
30
+ gap: 6,
31
+ padding: '6px 8px',
32
+ borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.24))',
33
+ flexWrap: 'wrap',
34
+ },
35
+ tabBar: { display: 'flex', gap: 4, flex: 1, minWidth: 0, flexWrap: 'wrap' },
36
+ tab: {
37
+ font: 'inherit',
38
+ fontSize: 12.5,
39
+ lineHeight: '20px',
40
+ padding: '2px 9px',
41
+ borderRadius: 999,
42
+ border: '1px solid transparent',
43
+ background: 'transparent',
44
+ color: 'inherit',
45
+ cursor: 'pointer',
46
+ opacity: 0.72,
47
+ },
48
+ tabActive: {
49
+ borderColor: 'var(--dsw-alias-border-l2, rgba(127,127,127,.38))',
50
+ background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.12))',
51
+ opacity: 1,
52
+ },
53
+ device: {
54
+ font: 'inherit',
55
+ fontSize: 12,
56
+ maxWidth: 200,
57
+ padding: '3px 6px',
58
+ borderRadius: 6,
59
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.38))',
60
+ background: 'transparent',
61
+ color: 'inherit',
62
+ },
63
+ body: { flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' },
64
+ toolbar: {
65
+ display: 'flex',
66
+ alignItems: 'center',
67
+ gap: 6,
68
+ padding: '7px 9px',
69
+ borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
70
+ flexWrap: 'wrap',
71
+ },
72
+ btn: {
73
+ font: 'inherit',
74
+ fontSize: 12.5,
75
+ lineHeight: '20px',
76
+ minWidth: 28,
77
+ padding: '2px 8px',
78
+ borderRadius: 6,
79
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.38))',
80
+ background: 'transparent',
81
+ color: 'inherit',
82
+ cursor: 'pointer',
83
+ },
84
+ btnPrimary: {
85
+ font: 'inherit',
86
+ fontSize: 12.5,
87
+ lineHeight: '20px',
88
+ padding: '3px 12px',
89
+ borderRadius: 6,
90
+ border: '1px solid var(--dsw-alias-accent, #3b82f6)',
91
+ background: 'var(--dsw-alias-accent, #3b82f6)',
92
+ color: '#fff',
93
+ cursor: 'pointer',
94
+ },
95
+ btnDisabled: { opacity: 0.4, cursor: 'default' },
96
+ input: {
97
+ font: 'inherit',
98
+ fontSize: 12.5,
99
+ padding: '3px 7px',
100
+ borderRadius: 6,
101
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.38))',
102
+ background: 'transparent',
103
+ color: 'inherit',
104
+ boxSizing: 'border-box',
105
+ },
106
+ grow: { flex: 1, minWidth: 100 },
107
+ screenWrap: { flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#000' },
108
+ canvas: { width: '100%', height: '100%', display: 'block', touchAction: 'none', cursor: 'crosshair' },
109
+ empty: {
110
+ margin: '28px 16px',
111
+ padding: '14px 16px',
112
+ borderRadius: 10,
113
+ border: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.35))',
114
+ opacity: 0.78,
115
+ fontSize: 13,
116
+ lineHeight: 1.65,
117
+ textAlign: 'center',
118
+ maxWidth: 460,
119
+ },
120
+ statusBar: {
121
+ display: 'flex',
122
+ alignItems: 'center',
123
+ gap: 8,
124
+ padding: '5px 9px',
125
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
126
+ fontSize: 11.5,
127
+ opacity: 0.8,
128
+ flexWrap: 'wrap',
129
+ },
130
+ chip: (ok) => ({
131
+ fontSize: 11,
132
+ padding: '0 7px',
133
+ borderRadius: 999,
134
+ border: '1px solid currentColor',
135
+ color: ok ? 'var(--dsw-alias-text-positive, #2e8b57)' : 'var(--dsw-alias-text-warning, #c98a00)',
136
+ }),
137
+ err: {
138
+ margin: '6px 9px',
139
+ padding: '5px 9px',
140
+ borderRadius: 6,
141
+ fontSize: 12,
142
+ color: 'var(--dsw-alias-text-warning, #c98a00)',
143
+ background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.08))',
144
+ whiteSpace: 'pre-wrap',
145
+ },
146
+ muted: { opacity: 0.68, fontSize: 12, lineHeight: 1.6 },
147
+ mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' },
148
+ logBox: { flex: 1, minHeight: 0, overflow: 'auto', padding: '4px 8px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 11.5, lineHeight: 1.55 },
149
+ logRow: { display: 'flex', gap: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', padding: '1px 0' },
150
+ logTime: { opacity: 0.5, flex: '0 0 auto' },
151
+ logTag: { opacity: 0.82, flex: '0 0 auto', maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis' },
152
+ logMsg: { flex: 1, minWidth: 0 },
153
+ perfWrap: { flex: 1, minHeight: 0, overflow: 'auto', padding: 10, display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', alignContent: 'start' },
154
+ card: { border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.28))', borderRadius: 10, padding: '10px 12px', background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.05))' },
155
+ cardTitle: { fontSize: 12, opacity: 0.7, marginBottom: 6 },
156
+ big: { fontSize: 26, fontWeight: 600, lineHeight: 1.1 },
157
+ bar: { height: 6, borderRadius: 99, background: 'rgba(127,127,127,.2)', overflow: 'hidden', marginTop: 6 },
158
+ barFill: (pct) => ({ height: '100%', width: `${Math.max(0, Math.min(100, Number(pct) || 0))}%`, background: 'var(--dsw-alias-accent, #3b82f6)' }),
159
+ cmdWrap: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' },
160
+ pre: { flex: 1, minHeight: 0, overflow: 'auto', margin: '0 9px 9px', padding: '8px 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.25))', background: 'rgba(0,0,0,.12)', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
161
+ page: { padding: '18px 20px 40px', maxWidth: 760 },
162
+ pageTitle: { fontSize: 17, fontWeight: 600, margin: '0 0 4px' },
163
+ section: { marginTop: 16 },
164
+ field: { display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 },
165
+ fieldLabel: { fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10 },
166
+ fieldKey: { opacity: 0.45, fontSize: 11, fontFamily: 'ui-monospace, monospace' },
167
+ row: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 8 },
168
+ table: { width: '100%', borderCollapse: 'collapse', fontSize: 12.5 },
169
+ th: { textAlign: 'left', padding: '5px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.28))', opacity: 0.65, fontWeight: 500 },
170
+ td: { padding: '5px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.14))' },
171
+ }
172
+
173
+ async function requestJson(url, body) {
174
+ try {
175
+ const options =
176
+ body === undefined
177
+ ? { headers: { accept: 'application/json' } }
178
+ : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
179
+ const response = await fetch(url, options)
180
+ const text = await response.text()
181
+ if (text.length === 0) return { ok: response.ok }
182
+ try {
183
+ return JSON.parse(text)
184
+ } catch {
185
+ return { ok: false, error: text.slice(0, 200) }
186
+ }
187
+ } catch (error) {
188
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
189
+ }
190
+ }
191
+
192
+ function readCursor() {
193
+ try {
194
+ return Number(window.localStorage.getItem(AUTO_OPEN_KEY) ?? '0') || 0
195
+ } catch {
196
+ return 0
197
+ }
198
+ }
199
+ function writeCursor(value) {
200
+ try {
201
+ window.localStorage.setItem(AUTO_OPEN_KEY, String(value))
202
+ } catch {
203
+ // 忽略
204
+ }
205
+ }
206
+
207
+ function fmtPct(value) {
208
+ return value === undefined || value === null || Number.isNaN(Number(value)) ? '—' : `${Number(value)}%`
209
+ }
210
+
211
+ /**
212
+ * 视频帧渲染器:优先 WebGL,失败退回 Canvas 2D。
213
+ *
214
+ * 参考 ya-webadb(Tango)的 scrcpy 播放器思路:
215
+ * - canvas 的 **CSS 尺寸固定** 为面板可视区,视频做 letterbox 适配;
216
+ * 不再让 canvas 的 width/height 跟着每一帧的分辨率跳,所以不会“一会儿大一会儿小”;
217
+ * - 解码回调只保存“最新一帧”,用 requestAnimationFrame 合并绘制;
218
+ * 上一帧还没画完就直接丢掉,避免延迟越积越大。
219
+ */
220
+ function createFrameRenderer(canvas) {
221
+ const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1))
222
+ let gl
223
+ let program
224
+ let positionBuffer
225
+ let texcoordBuffer
226
+ let texture
227
+ let mode = '2d'
228
+ let context2d
229
+ let pendingFrame
230
+ let raf = 0
231
+ let lastW = 0
232
+ let lastH = 0
233
+
234
+ // Canvas 2D 渲染器:稳妥、兼容性最好。只保留最新一帧 + 固定画布尺寸做 letterbox,
235
+ // 这正是 ya-webadb 播放器最关键的两点(帧合并 + 不随分辨率改画布)。
236
+ const twoD = canvas.getContext('2d')
237
+ if (twoD) {
238
+ const fit = (cw, ch, sw, sh) => {
239
+ if (!(sw > 0) || !(sh > 0)) return { x: 0, y: 0, width: cw, height: ch }
240
+ const scale = Math.min(cw / sw, ch / sh)
241
+ const width = Math.max(1, Math.floor(sw * scale))
242
+ const height = Math.max(1, Math.floor(sh * scale))
243
+ return { x: Math.floor((cw - width) / 2), y: Math.floor((ch - height) / 2), width, height }
244
+ }
245
+ const size = () => {
246
+ const parent = canvas.parentElement
247
+ const width = Math.max(1, Math.floor((parent?.clientWidth || 640) * dpr))
248
+ const height = Math.max(1, Math.floor((parent?.clientHeight || 360) * dpr))
249
+ if (canvas.width !== width || canvas.height !== height) {
250
+ canvas.width = width
251
+ canvas.height = height
252
+ }
253
+ }
254
+ let pending
255
+ let pendingRaf = 0
256
+ let rendered = 0
257
+ let renderedAt = window.performance.now()
258
+ let currentFps = 0
259
+ const paint = () => {
260
+ pendingRaf = 0
261
+ const frame = pending
262
+ pending = undefined
263
+ if (frame === undefined) return
264
+ rendered += 1
265
+ const now = window.performance.now()
266
+ if (now - renderedAt >= 1000) {
267
+ currentFps = Math.round((rendered * 1000) / (now - renderedAt))
268
+ rendered = 0
269
+ renderedAt = now
270
+ }
271
+ try {
272
+ size()
273
+ const viewport = fit(canvas.width, canvas.height, frame.displayWidth || frame.codedWidth, frame.displayHeight || frame.codedHeight)
274
+ twoD.fillStyle = '#000'
275
+ twoD.fillRect(0, 0, canvas.width, canvas.height)
276
+ twoD.drawImage(frame, viewport.x, viewport.y, viewport.width, viewport.height)
277
+ } catch {
278
+ // 单帧失败不影响后续
279
+ }
280
+ try {
281
+ frame.close()
282
+ } catch {
283
+ // 忽略
284
+ }
285
+ }
286
+ const schedule = () => {
287
+ if (pendingRaf !== 0) return
288
+ pendingRaf = window.requestAnimationFrame(paint)
289
+ }
290
+ return {
291
+ mode: '2d',
292
+ get fps() {
293
+ return currentFps
294
+ },
295
+ drawFrame(frame) {
296
+ if (pending !== undefined) {
297
+ try {
298
+ pending.close()
299
+ } catch {
300
+ // 忽略
301
+ }
302
+ }
303
+ pending = frame
304
+ schedule()
305
+ },
306
+ drawImage(image) {
307
+ size()
308
+ const viewport = fit(canvas.width, canvas.height, image.naturalWidth || image.width, image.naturalHeight || image.height)
309
+ twoD.fillStyle = '#000'
310
+ twoD.fillRect(0, 0, canvas.width, canvas.height)
311
+ twoD.drawImage(image, viewport.x, viewport.y, viewport.width, viewport.height)
312
+ },
313
+ dispose() {
314
+ if (pendingRaf !== 0) window.cancelAnimationFrame(pendingRaf)
315
+ pendingRaf = 0
316
+ if (pending !== undefined) {
317
+ try {
318
+ pending.close()
319
+ } catch {
320
+ // 忽略
321
+ }
322
+ }
323
+ pending = undefined
324
+ },
325
+ }
326
+ }
327
+
328
+ const fitViewport = (canvasWidth, canvasHeight, sourceWidth, sourceHeight) => {
329
+ if (!(sourceWidth > 0) || !(sourceHeight > 0)) return { x: 0, y: 0, width: canvasWidth, height: canvasHeight }
330
+ const scale = Math.min(canvasWidth / sourceWidth, canvasHeight / sourceHeight)
331
+ const width = Math.max(1, Math.floor(sourceWidth * scale))
332
+ const height = Math.max(1, Math.floor(sourceHeight * scale))
333
+ return { x: Math.floor((canvasWidth - width) / 2), y: Math.floor((canvasHeight - height) / 2), width, height }
334
+ }
335
+
336
+ const sizeCanvas = () => {
337
+ const parent = canvas.parentElement
338
+ const width = Math.max(1, Math.floor((parent?.clientWidth || canvas.clientWidth || 640) * dpr))
339
+ const height = Math.max(1, Math.floor((parent?.clientHeight || canvas.clientHeight || 360) * dpr))
340
+ if (canvas.width !== width || canvas.height !== height) {
341
+ canvas.width = width
342
+ canvas.height = height
343
+ }
344
+ }
345
+
346
+ const compile = (glContext, type, source) => {
347
+ const shader = glContext.createShader(type)
348
+ glContext.shaderSource(shader, source)
349
+ glContext.compileShader(shader)
350
+ if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
351
+ throw new Error(glContext.getShaderInfoLog(shader) || 'shader compile failed')
352
+ }
353
+ return shader
354
+ }
355
+
356
+ const initWebgl = () => {
357
+ try {
358
+ gl = canvas.getContext('webgl2', { alpha: false, antialias: false, desynchronized: true }) ||
359
+ canvas.getContext('webgl', { alpha: false, antialias: false, desynchronized: true })
360
+ if (!gl) return false
361
+ const vertex = compile(gl, gl.VERTEX_SHADER, [
362
+ 'attribute vec2 a_position;',
363
+ 'attribute vec2 a_texcoord;',
364
+ 'varying vec2 v_texcoord;',
365
+ 'void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_texcoord = a_texcoord; }',
366
+ ].join('\n'))
367
+ const fragment = compile(gl, gl.FRAGMENT_SHADER, [
368
+ 'precision mediump float;',
369
+ 'varying vec2 v_texcoord;',
370
+ 'uniform sampler2D u_texture;',
371
+ 'void main() { gl_FragColor = texture2D(u_texture, v_texcoord); }',
372
+ ].join('\n'))
373
+ program = gl.createProgram()
374
+ gl.attachShader(program, vertex)
375
+ gl.attachShader(program, fragment)
376
+ gl.linkProgram(program)
377
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) || 'program link failed')
378
+ gl.useProgram(program)
379
+
380
+ positionBuffer = gl.createBuffer()
381
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
382
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
383
+ const positionLocation = gl.getAttribLocation(program, 'a_position')
384
+ gl.enableVertexAttribArray(positionLocation)
385
+ gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
386
+
387
+ texcoordBuffer = gl.createBuffer()
388
+ gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
389
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW)
390
+ const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
391
+ gl.enableVertexAttribArray(texcoordLocation)
392
+ gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
393
+
394
+ texture = gl.createTexture()
395
+ gl.bindTexture(gl.TEXTURE_2D, texture)
396
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
397
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
398
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
399
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
400
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
401
+ gl.clearColor(0, 0, 0, 1)
402
+ mode = 'webgl'
403
+ return true
404
+ } catch {
405
+ gl = undefined
406
+ return false
407
+ }
408
+ }
409
+
410
+ if (!initWebgl()) {
411
+ context2d = canvas.getContext('2d')
412
+ mode = context2d ? '2d' : 'none'
413
+ }
414
+
415
+ const paint = () => {
416
+ raf = 0
417
+ const frame = pendingFrame
418
+ pendingFrame = undefined
419
+ if (frame === undefined) return
420
+ const sourceWidth = frame.displayWidth || frame.codedWidth || 0
421
+ const sourceHeight = frame.displayHeight || frame.codedHeight || 0
422
+ sizeCanvas()
423
+ if (mode === 'webgl') {
424
+ try {
425
+ const viewport = fitViewport(canvas.width, canvas.height, sourceWidth, sourceHeight)
426
+ gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
427
+ gl.clear(gl.COLOR_BUFFER_BIT)
428
+ gl.bindTexture(gl.TEXTURE_2D, texture)
429
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame)
430
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
431
+ } catch {
432
+ // 一帧失败不影响后续
433
+ }
434
+ } else if (mode === '2d') {
435
+ try {
436
+ const viewport = fitViewport(canvas.width, canvas.height, sourceWidth, sourceHeight)
437
+ context2d.fillStyle = '#000'
438
+ context2d.fillRect(0, 0, canvas.width, canvas.height)
439
+ context2d.drawImage(frame, viewport.x, viewport.y, viewport.width, viewport.height)
440
+ } catch {
441
+ // 忽略
442
+ }
443
+ }
444
+ try {
445
+ frame.close()
446
+ } catch {
447
+ // 忽略
448
+ }
449
+ }
450
+
451
+ const schedule = () => {
452
+ if (raf !== 0) return
453
+ raf = window.requestAnimationFrame(paint)
454
+ }
455
+
456
+ return {
457
+ get mode() {
458
+ return mode
459
+ },
460
+ drawFrame(frame) {
461
+ if (pendingFrame !== undefined) {
462
+ try {
463
+ pendingFrame.close()
464
+ } catch {
465
+ // 忽略
466
+ }
467
+ }
468
+ pendingFrame = frame
469
+ schedule()
470
+ },
471
+ drawImage(image) {
472
+ const parent = canvas.parentElement
473
+ const width = Math.max(1, Math.floor((parent?.clientWidth || 640) * dpr))
474
+ const height = Math.max(1, Math.floor((parent?.clientHeight || 360) * dpr))
475
+ if (canvas.width !== width || canvas.height !== height) {
476
+ canvas.width = width
477
+ canvas.height = height
478
+ lastW = width
479
+ lastH = height
480
+ }
481
+ if (mode === '2d' && context2d) {
482
+ const viewport = fitViewport(canvas.width, canvas.height, image.naturalWidth || image.width, image.naturalHeight || image.height)
483
+ context2d.fillStyle = '#000'
484
+ context2d.fillRect(0, 0, canvas.width, canvas.height)
485
+ context2d.drawImage(image, viewport.x, viewport.y, viewport.width, viewport.height)
486
+ return
487
+ }
488
+ // WebGL 画图片要走 canvas 中转
489
+ try {
490
+ const off = document.createElement('canvas')
491
+ off.width = image.naturalWidth || image.width
492
+ off.height = image.naturalHeight || image.height
493
+ off.getContext('2d').drawImage(image, 0, 0)
494
+ const bitmap = off
495
+ const viewport = fitViewport(canvas.width, canvas.height, off.width, off.height)
496
+ gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
497
+ gl.clear(gl.COLOR_BUFFER_BIT)
498
+ gl.bindTexture(gl.TEXTURE_2D, texture)
499
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap)
500
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
501
+ } catch {
502
+ // 忽略
503
+ }
504
+ },
505
+ dispose() {
506
+ if (raf !== 0) window.cancelAnimationFrame(raf)
507
+ raf = 0
508
+ if (pendingFrame !== undefined) {
509
+ try {
510
+ pendingFrame.close()
511
+ } catch {
512
+ // 忽略
513
+ }
514
+ }
515
+ pendingFrame = undefined
516
+ },
517
+ }
518
+ }
519
+
520
+ function levelColor(level) {
521
+ return (
522
+ {
523
+ V: 'var(--dsw-alias-text-secondary, #8a8a8a)',
524
+ D: 'var(--dsw-alias-text-info, #4f9cf9)',
525
+ I: 'var(--dsw-alias-text-positive, #2e8b57)',
526
+ W: 'var(--dsw-alias-text-warning, #c98a00)',
527
+ E: 'var(--dsw-alias-text-danger, #e5534b)',
528
+ F: 'var(--dsw-alias-text-danger, #e5534b)',
529
+ S: 'var(--dsw-alias-text-danger, #e5534b)',
530
+ }[String(level).toUpperCase()] ?? 'inherit'
531
+ )
532
+ }
533
+
534
+ // ── 面板主体 ────────────────────────────────────────────────────────────
535
+ function AndroidPanel() {
536
+ const [tab, setTab] = React.useState('screen')
537
+ const [state, setState] = React.useState(undefined)
538
+ const [error, setError] = React.useState('')
539
+
540
+ const refresh = React.useCallback(async () => {
541
+ const next = await requestJson(`${API}/state.json`)
542
+ if (next !== undefined && next !== null) {
543
+ setState(next)
544
+ if (next.ok === false && next.error) setError(next.error)
545
+ }
546
+ }, [])
547
+
548
+ React.useEffect(() => {
549
+ let stopped = false
550
+ let timer
551
+ const tick = async () => {
552
+ await refresh()
553
+ if (!stopped) timer = window.setTimeout(tick, 2000)
554
+ }
555
+ void tick()
556
+ return () => {
557
+ stopped = true
558
+ if (timer !== undefined) window.clearTimeout(timer)
559
+ }
560
+ }, [refresh])
561
+
562
+ const choose = async (serial) => {
563
+ await requestJson(`${API}/command`, { action: 'select', serial })
564
+ await refresh()
565
+ }
566
+
567
+ const tabs = [
568
+ { id: 'screen', label: '界面' },
569
+ { id: 'logcat', label: 'Logcat' },
570
+ { id: 'perf', label: '性能' },
571
+ { id: 'command', label: '指令' },
572
+ ]
573
+
574
+ let content
575
+ if (tab === 'screen') content = h(ScreenTab, { state, refresh })
576
+ else if (tab === 'logcat') content = h(LogcatTab, { state, refresh })
577
+ else if (tab === 'perf') content = h(PerfTab, { state, refresh })
578
+ else content = h(CommandTab, { state, refresh })
579
+
580
+ const devices = Array.isArray(state?.devices) ? state.devices : []
581
+ return h(
582
+ 'div',
583
+ { style: S.root, 'data-dsh-android-tool': '' },
584
+ h(
585
+ 'div',
586
+ { style: S.header },
587
+ h(
588
+ 'div',
589
+ { style: S.tabBar, role: 'tablist' },
590
+ tabs.map((item) =>
591
+ h(
592
+ 'button',
593
+ {
594
+ key: item.id,
595
+ type: 'button',
596
+ role: 'tab',
597
+ 'aria-selected': tab === item.id,
598
+ style: tab === item.id ? { ...S.tab, ...S.tabActive } : S.tab,
599
+ onClick: () => setTab(item.id),
600
+ },
601
+ item.label,
602
+ ),
603
+ ),
604
+ ),
605
+ h(
606
+ 'select',
607
+ {
608
+ style: S.device,
609
+ value: state?.selectedSerial ?? '',
610
+ title: '当前调试设备',
611
+ onChange: (event) => void choose(event.target.value),
612
+ },
613
+ devices.length === 0
614
+ ? h('option', { value: '' }, '无设备')
615
+ : devices.map((device) =>
616
+ h(
617
+ 'option',
618
+ { key: device.serial, value: device.serial },
619
+ `${device.model || device.serial}${device.state === 'device' ? '' : ` (${device.state})`}`,
620
+ ),
621
+ ),
622
+ ),
623
+ ),
624
+ state?.deviceError || error ? h('div', { style: S.err }, state?.deviceError || error) : null,
625
+ content,
626
+ )
627
+ }
628
+
629
+ // ── Tab1 界面 ──────────────────────────────────────────────────────────
630
+ function ScreenTab({ state }) {
631
+ const canvasRef = React.useRef(null)
632
+ const metaRef = React.useRef(undefined)
633
+ const decoderRef = React.useRef(undefined)
634
+ const rendererRef = React.useRef(undefined)
635
+ const wsRef = React.useRef(undefined)
636
+ const pollRef = React.useRef(undefined)
637
+ const modeRef = React.useRef('idle')
638
+ const pointerRef = React.useRef(undefined)
639
+ const [mode, setMode] = React.useState('idle')
640
+ const [error, setError] = React.useState('')
641
+ const [busy, setBusy] = React.useState(false)
642
+ const [fps, setFps] = React.useState(0)
643
+
644
+ const setModeBoth = (next) => {
645
+ modeRef.current = next
646
+ setMode(next)
647
+ }
648
+
649
+ const stopPoll = () => {
650
+ if (pollRef.current !== undefined) {
651
+ window.clearInterval(pollRef.current)
652
+ pollRef.current = undefined
653
+ }
654
+ }
655
+
656
+ const stopWs = () => {
657
+ if (wsRef.current !== undefined) {
658
+ const ws = wsRef.current
659
+ wsRef.current = undefined
660
+ try {
661
+ ws.close()
662
+ } catch {
663
+ // 忽略
664
+ }
665
+ }
666
+ }
667
+
668
+ const ensureRenderer = () => {
669
+ if (rendererRef.current === undefined && canvasRef.current !== null) {
670
+ rendererRef.current = createFrameRenderer(canvasRef.current)
671
+ }
672
+ return rendererRef.current
673
+ }
674
+
675
+ const setupCanvas = (meta) => {
676
+ // canvas 的尺寸由面板决定,视频只做 letterbox;不要在每帧里改 canvas.width,
677
+ // 否则横竖屏/分辨率一变,画面就会突然放大缩小。
678
+ metaRef.current = meta
679
+ ensureRenderer()
680
+ }
681
+
682
+ const configureDecoder = (meta) => {
683
+ if (typeof window.VideoDecoder === 'undefined') {
684
+ startPoll()
685
+ return
686
+ }
687
+ const previous = metaRef.current
688
+ if (decoderRef.current !== undefined && previous !== undefined && previous.codec === meta.codec) {
689
+ metaRef.current = meta
690
+ setupCanvas(meta)
691
+ return
692
+ }
693
+ try {
694
+ decoderRef.current?.close?.()
695
+ } catch {
696
+ // 忽略
697
+ }
698
+ metaRef.current = meta
699
+ setupCanvas(meta)
700
+ try {
701
+ const decoder = new window.VideoDecoder({
702
+ output: (frame) => {
703
+ const renderer = ensureRenderer()
704
+ if (renderer === undefined) {
705
+ frame.close()
706
+ return
707
+ }
708
+ // 只保留最新帧,由 requestAnimationFrame 合并绘制;渲染器负责 close(frame)
709
+ renderer.drawFrame(frame)
710
+ },
711
+ error: (decoderError) => {
712
+ setError(`视频解码失败:${decoderError?.message ?? String(decoderError)},已切换截图模式。`)
713
+ startPoll()
714
+ },
715
+ })
716
+ decoder.configure({ codec: meta.codec || 'avc1.42e01e', optimizeForLatency: true })
717
+ decoderRef.current = decoder
718
+ } catch (decoderError) {
719
+ setError(`初始化视频解码失败:${decoderError?.message ?? String(decoderError)},已切换截图模式。`)
720
+ startPoll()
721
+ }
722
+ }
723
+
724
+ const decodeFrame = (buffer) => {
725
+ const decoder = decoderRef.current
726
+ if (decoder === undefined) return
727
+ try {
728
+ const view = new DataView(buffer)
729
+ if (view.byteLength < 10) return
730
+ const flags = view.getUint8(0)
731
+ const isKey = (flags & 1) === 1
732
+ // 实时优先:解码积压时丢掉非关键帧,避免延迟越滚越大。
733
+ if (decoder.decodeQueueSize > 30 && !isKey) return
734
+ const timestamp = view.getFloat64(1)
735
+ const payload = new Uint8Array(buffer, 9)
736
+ decoder.decode(
737
+ new window.EncodedVideoChunk({
738
+ type: isKey ? 'key' : 'delta',
739
+ timestamp: Math.max(0, Math.round(timestamp)),
740
+ data: payload,
741
+ }),
742
+ )
743
+ } catch (decodeError) {
744
+ setError(`送入解码器失败:${decodeError?.message ?? String(decodeError)}`)
745
+ }
746
+ }
747
+
748
+ const openWs = () => {
749
+ // 实时流与截图轮询必须互斥:两个同时画到同一张 canvas 上会一边 1024×608、
750
+ // 一边 1200×2000 地来回改尺寸,看起来就是“画面一会儿大一会儿小”,而且卡。
751
+ stopPoll()
752
+ stopWs()
753
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
754
+ let ws
755
+ try {
756
+ ws = new WebSocket(`${scheme}://${window.location.host}${API}/video.ws`)
757
+ } catch (wsError) {
758
+ setError(`WebSocket 打开失败:${wsError?.message ?? String(wsError)}`)
759
+ startPoll()
760
+ return
761
+ }
762
+ ws.binaryType = 'arraybuffer'
763
+ wsRef.current = ws
764
+ ws.onopen = () => {
765
+ setModeBoth('live')
766
+ setError('')
767
+ }
768
+ ws.onmessage = (event) => {
769
+ if (typeof event.data === 'string') {
770
+ try {
771
+ const message = JSON.parse(event.data)
772
+ if (message.type === 'meta') {
773
+ setError('')
774
+ configureDecoder(message)
775
+ } else if (message.type === 'status' && message.running === false && message.error) setError(message.error)
776
+ } catch {
777
+ // 忽略非 JSON
778
+ }
779
+ return
780
+ }
781
+ decodeFrame(event.data)
782
+ }
783
+ ws.onerror = () => {
784
+ if (wsRef.current === ws) setError('视频流连接失败,已切换截图模式。')
785
+ }
786
+ ws.onclose = () => {
787
+ if (wsRef.current !== ws) return
788
+ wsRef.current = undefined
789
+ if (modeRef.current === 'live') startPoll()
790
+ }
791
+ }
792
+
793
+ const startPoll = () => {
794
+ stopWs()
795
+ stopPoll()
796
+ try {
797
+ decoderRef.current?.close?.()
798
+ } catch {
799
+ // 忽略
800
+ }
801
+ decoderRef.current = undefined
802
+ metaRef.current = undefined
803
+ setModeBoth('poll')
804
+ const fps = Math.max(1, Math.min(10, Number(state?.previewFps) || 3))
805
+ let stopped = false
806
+ const tick = () => {
807
+ if (stopped) return
808
+ const image = new Image()
809
+ image.onload = () => {
810
+ ensureRenderer()?.drawImage(image)
811
+ }
812
+ image.onerror = () => {
813
+ // 截图还不可用(例如视频刚启动)时静默重试
814
+ }
815
+ image.src = `${API}/screenshot.png?t=${Date.now()}${state?.selectedSerial ? `&serial=${encodeURIComponent(state.selectedSerial)}` : ''}`
816
+ }
817
+ tick()
818
+ pollRef.current = window.setInterval(tick, Math.round(1000 / fps))
819
+ void stopped
820
+ }
821
+
822
+ const stopAll = () => {
823
+ stopWs()
824
+ stopPoll()
825
+ try {
826
+ decoderRef.current?.close?.()
827
+ } catch {
828
+ // 忽略
829
+ }
830
+ decoderRef.current = undefined
831
+ metaRef.current = undefined
832
+ rendererRef.current?.dispose?.()
833
+ rendererRef.current = undefined
834
+ setModeBoth('idle')
835
+ }
836
+
837
+ const startVideo = async () => {
838
+ if (!state?.selectedSerial) {
839
+ setError('没有选中设备,请先在设置里连接并选择设备。')
840
+ return
841
+ }
842
+ setBusy(true)
843
+ setError('')
844
+ openWs()
845
+ const result = await requestJson(`${API}/command`, { action: 'startVideo', serial: state.selectedSerial })
846
+ if (result?.ok !== true) {
847
+ setError(result?.error ?? '启动视频失败,已切换截图模式。')
848
+ startPoll()
849
+ }
850
+ setBusy(false)
851
+ }
852
+
853
+ React.useEffect(() => {
854
+ const timer = window.setInterval(() => {
855
+ setFps(rendererRef.current?.fps ?? 0)
856
+ }, 1000)
857
+ return () => window.clearInterval(timer)
858
+ }, [])
859
+
860
+ React.useEffect(() => {
861
+ if (state?.selectedSerial) void startVideo()
862
+ else stopAll()
863
+ return () => {
864
+ stopAll()
865
+ // 离开界面 tab / 面板时把 host 侧推流也停掉,别让 scrcpy server 白跑。
866
+ void requestJson(`${API}/command`, { action: 'stopVideo' })
867
+ }
868
+ // 只在设备变化时重开
869
+ }, [state?.selectedSerial])
870
+
871
+ const post = async (action, extra) => {
872
+ const result = await requestJson(`${API}/command`, { action, serial: state?.selectedSerial, ...extra })
873
+ if (result?.ok !== true) setError(result?.error ?? '操作失败')
874
+ return result
875
+ }
876
+
877
+ const canvasPoint = (event) => {
878
+ const canvas = canvasRef.current
879
+ const meta = metaRef.current
880
+ if (canvas === null || meta === undefined) return undefined
881
+ const rect = canvas.getBoundingClientRect()
882
+ if (rect.width <= 0 || rect.height <= 0) return undefined
883
+ return {
884
+ x: Math.round(((event.clientX - rect.left) / rect.width) * meta.width),
885
+ y: Math.round(((event.clientY - rect.top) / rect.height) * meta.height),
886
+ }
887
+ }
888
+
889
+ /** 走已建立的视频 WebSocket 发触控,省掉每次 HTTP 往返。 */
890
+ const sendWsTouch = (action, point) => {
891
+ const ws = wsRef.current
892
+ if (ws === undefined || ws.readyState !== 1 || point === undefined) return false
893
+ try {
894
+ ws.send(JSON.stringify({ type: 'touch', action, x: point.x, y: point.y }))
895
+ return true
896
+ } catch {
897
+ return false
898
+ }
899
+ }
900
+
901
+ const onPointerDown = (event) => {
902
+ const point = canvasPoint(event)
903
+ if (point === undefined) return
904
+ const sent = sendWsTouch('down', point)
905
+ pointerRef.current = { start: point, last: point, at: Date.now(), lastSentAt: 0, sent }
906
+ try {
907
+ event.currentTarget.setPointerCapture?.(event.pointerId)
908
+ } catch {
909
+ // 忽略
910
+ }
911
+ }
912
+
913
+ const onPointerMove = (event) => {
914
+ const state = pointerRef.current
915
+ if (state === undefined || state.sent !== true) return
916
+ if ((event.buttons & 1) !== 1 && event.pointerType === 'mouse') return
917
+ const point = canvasPoint(event)
918
+ if (point === undefined) return
919
+ const now = Date.now()
920
+ // 最高约 60Hz,别把没有变化的消息塞满 WebSocket。
921
+ if (now - state.lastSentAt < 16 && Math.hypot(point.x - state.last.x, point.y - state.last.y) < 2) return
922
+ state.last = point
923
+ state.lastSentAt = now
924
+ sendWsTouch('move', point)
925
+ }
926
+
927
+ const finishPointer = (event, cancelled) => {
928
+ const state = pointerRef.current
929
+ pointerRef.current = undefined
930
+ if (state === undefined) return
931
+ const point = canvasPoint(event) ?? state.last
932
+ if (state.sent === true) {
933
+ sendWsTouch(cancelled ? 'cancel' : 'up', point)
934
+ return
935
+ }
936
+ // 截图模式(没有 WebSocket)才退回 HTTP 的 tap/swipe。
937
+ const distance = Math.hypot(point.x - state.start.x, point.y - state.start.y)
938
+ if (distance < 14) void post('tap', { x: point.x, y: point.y })
939
+ else void post('swipe', { x: state.start.x, y: state.start.y, x2: point.x, y2: point.y, durationMs: Math.max(120, Math.min(1200, Date.now() - state.at)) })
940
+ }
941
+
942
+ const onPointerUp = (event) => finishPointer(event, false)
943
+ const onPointerCancel = (event) => finishPointer(event, true)
944
+
945
+ const stream = state?.stream ?? {}
946
+ const device = state?.device
947
+ return h(
948
+ 'div',
949
+ { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } },
950
+ h(
951
+ 'div',
952
+ { style: S.toolbar },
953
+ h('button', { style: S.btn, title: '音量减', onClick: () => void post('volume', { delta: -1 }) }, '音量 −'),
954
+ h('button', { style: S.btn, title: '音量加', onClick: () => void post('volume', { delta: 1 }) }, '音量 +'),
955
+ h('button', { style: S.btn, onClick: () => void post('key', { key: 'back' }) }, '返回'),
956
+ h('button', { style: S.btn, onClick: () => void post('key', { key: 'home' }) }, '主页'),
957
+ h('button', { style: S.btn, onClick: () => void post('key', { key: 'wakeup' }) }, '亮屏'),
958
+ h('span', { style: { flex: 1 } }),
959
+ h('button', { style: busy ? { ...S.btn, ...S.btnDisabled } : S.btn, disabled: busy, onClick: () => void startVideo() }, '重连画面'),
960
+ state?.adb?.scrcpyExists === true
961
+ ? h('button', { style: S.btn, onClick: () => void post('startScrcpy') }, '打开 scrcpy 窗口')
962
+ : null,
963
+ state?.adb?.scrcpyExists === true
964
+ ? h('button', { style: S.btn, onClick: () => void post('stopScrcpy') }, '关闭窗口')
965
+ : null,
966
+ ),
967
+ error.length > 0 ? h('div', { style: S.err }, error) : null,
968
+ h(
969
+ 'div',
970
+ { style: S.screenWrap },
971
+ state?.selectedSerial
972
+ ? h('canvas', {
973
+ ref: canvasRef,
974
+ style: S.canvas,
975
+ onPointerDown,
976
+ onPointerMove,
977
+ onPointerUp,
978
+ onPointerCancel,
979
+ })
980
+ : h(
981
+ 'div',
982
+ { style: S.empty },
983
+ h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '还没有连接 Android 设备'),
984
+ h('div', null, '去「设置 → 安卓调试」填 adb 路径、连接无线调试,再选一台设备。'),
985
+ h('div', { style: { marginTop: 6, opacity: 0.72 } }, '连接后这里会显示画面;点画面可以直接触控。'),
986
+ ),
987
+ ),
988
+ h(
989
+ 'div',
990
+ { style: S.statusBar },
991
+ h('span', { style: S.chip(mode === 'live') }, mode === 'live' ? `实时画面 · ${fps} fps` : mode === 'poll' ? '截图模式' : '未启动'),
992
+ device ? h('span', null, `${device.model || device.serial}`) : null,
993
+ stream.width ? h('span', null, `${stream.width}×${stream.height}`) : null,
994
+ stream.error ? h('span', { style: { opacity: 0.8 } }, stream.error) : null,
995
+ ),
996
+ )
997
+ }
998
+
999
+ // ── Tab2 Logcat ────────────────────────────────────────────────────────
1000
+ function LogcatTab({ state }) {
1001
+ const [entries, setEntries] = React.useState([])
1002
+ const [cursor, setCursor] = React.useState(0)
1003
+ const [paused, setPaused] = React.useState(false)
1004
+ const [autoScroll, setAutoScroll] = React.useState(true)
1005
+ const [level, setLevel] = React.useState('V')
1006
+ const [pkg, setPkg] = React.useState('')
1007
+ const [keyword, setKeyword] = React.useState('')
1008
+ const [packages, setPackages] = React.useState({ running: [], installed: [], foreground: '' })
1009
+ const [error, setError] = React.useState('')
1010
+ const cursorRef = React.useRef(0)
1011
+ const pausedRef = React.useRef(false)
1012
+ const autoScrollRef = React.useRef(true)
1013
+ const filtersRef = React.useRef({ pkg: '', q: '', level: 'V' })
1014
+ const logRef = React.useRef(null)
1015
+
1016
+ React.useEffect(() => {
1017
+ cursorRef.current = cursor
1018
+ }, [cursor])
1019
+ React.useEffect(() => {
1020
+ pausedRef.current = paused
1021
+ }, [paused])
1022
+ React.useEffect(() => {
1023
+ autoScrollRef.current = autoScroll
1024
+ }, [autoScroll])
1025
+ React.useEffect(() => {
1026
+ filtersRef.current = { pkg, q: keyword, level }
1027
+ }, [pkg, keyword, level])
1028
+
1029
+ const loadPackages = async () => {
1030
+ const result = await requestJson(`${API}/packages.json${state?.selectedSerial ? `?serial=${encodeURIComponent(state.selectedSerial)}` : ''}`)
1031
+ if (result?.ok === true) setPackages(result)
1032
+ }
1033
+
1034
+ React.useEffect(() => {
1035
+ void loadPackages()
1036
+ const timer = window.setInterval(() => void loadPackages(), 5000)
1037
+ return () => {
1038
+ window.clearInterval(timer)
1039
+ // 离开 Logcat tab 时停掉 host 的 logcat 子进程,别让它一直白读日志。
1040
+ void requestJson(`${API}/command`, { action: 'logcatStop' })
1041
+ }
1042
+ }, [state?.selectedSerial])
1043
+
1044
+ const fetchOnce = async () => {
1045
+ if (pausedRef.current) return
1046
+ const filters = filtersRef.current
1047
+ const query = new URLSearchParams({
1048
+ cursor: String(cursorRef.current),
1049
+ pkg: filters.pkg,
1050
+ q: filters.q,
1051
+ level: filters.level,
1052
+ limit: '500',
1053
+ })
1054
+ if (state?.selectedSerial) query.set('serial', state.selectedSerial)
1055
+ const result = await requestJson(`${API}/logcat.json?${query.toString()}`)
1056
+ if (result?.ok !== true) {
1057
+ if (result?.error) setError(result.error)
1058
+ return
1059
+ }
1060
+ setError('')
1061
+ if (typeof result.cursor === 'number' && result.cursor !== cursorRef.current) {
1062
+ cursorRef.current = result.cursor
1063
+ setCursor(result.cursor)
1064
+ }
1065
+ const nextEntries = Array.isArray(result.entries) ? result.entries : []
1066
+ if (nextEntries.length > 0) {
1067
+ setEntries((previous) => {
1068
+ const seen = new Set(previous.map((entry) => entry.id))
1069
+ const fresh = nextEntries.filter((entry) => !seen.has(entry.id))
1070
+ if (fresh.length === 0) return previous
1071
+ const merged = [...previous, ...fresh]
1072
+ return merged.length > 2000 ? merged.slice(merged.length - 2000) : merged
1073
+ })
1074
+ }
1075
+ }
1076
+
1077
+ React.useEffect(() => {
1078
+ const timer = window.setInterval(() => void fetchOnce(), 700)
1079
+ void fetchOnce()
1080
+ return () => window.clearInterval(timer)
1081
+ // 用 ref 读取最新过滤条件
1082
+ }, [state?.selectedSerial])
1083
+
1084
+ // 过滤条件变化时重置本地日志
1085
+ React.useEffect(() => {
1086
+ cursorRef.current = 0
1087
+ setCursor(0)
1088
+ setEntries([])
1089
+ void fetchOnce()
1090
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1091
+ }, [pkg, keyword, level, state?.selectedSerial])
1092
+
1093
+ React.useEffect(() => {
1094
+ if (autoScroll !== true) return
1095
+ const node = logRef.current
1096
+ if (node === null) return
1097
+ node.scrollTop = node.scrollHeight
1098
+ }, [entries, autoScroll])
1099
+
1100
+ const jumpBottom = () => {
1101
+ setAutoScroll(true)
1102
+ autoScrollRef.current = true
1103
+ window.requestAnimationFrame(() => {
1104
+ const node = logRef.current
1105
+ if (node !== null) node.scrollTop = node.scrollHeight
1106
+ })
1107
+ }
1108
+
1109
+ const clear = () => {
1110
+ setEntries([])
1111
+ }
1112
+
1113
+ const exportFile = () => {
1114
+ const query = new URLSearchParams({ pkg, q: keyword, level, ...(state?.selectedSerial ? { serial: state.selectedSerial } : {}) })
1115
+ const anchor = document.createElement('a')
1116
+ anchor.href = `${API}/logcat/export.txt?${query.toString()}`
1117
+ anchor.download = `logcat-${Date.now()}.txt`
1118
+ anchor.click()
1119
+ }
1120
+
1121
+ const runningOptions = []
1122
+ const seen = new Set()
1123
+ if (packages.foreground) {
1124
+ runningOptions.push({ value: packages.foreground, label: `前台:${packages.foreground}` })
1125
+ seen.add(packages.foreground)
1126
+ }
1127
+ for (const item of Array.isArray(packages.running) ? packages.running : []) {
1128
+ if (seen.has(item.package)) continue
1129
+ seen.add(item.package)
1130
+ runningOptions.push({ value: item.package, label: `${item.package}${item.isSystem ? '(系统)' : ''}` })
1131
+ }
1132
+
1133
+ return h(
1134
+ 'div',
1135
+ { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } },
1136
+ h(
1137
+ 'div',
1138
+ { style: S.toolbar },
1139
+ h(
1140
+ 'select',
1141
+ { style: { ...S.input, maxWidth: 220 }, value: pkg, onChange: (event) => setPkg(event.target.value), title: '按包名过滤' },
1142
+ h('option', { value: '' }, '全部包名'),
1143
+ runningOptions.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
1144
+ ),
1145
+ h('input', {
1146
+ style: { ...S.input, width: 150 },
1147
+ placeholder: '关键词',
1148
+ value: keyword,
1149
+ spellCheck: false,
1150
+ onChange: (event) => setKeyword(event.target.value),
1151
+ }),
1152
+ h(
1153
+ 'select',
1154
+ { style: S.input, value: level, onChange: (event) => setLevel(event.target.value), title: '最低级别' },
1155
+ ['V', 'D', 'I', 'W', 'E', 'F'].map((item) => h('option', { key: item, value: item }, item)),
1156
+ ),
1157
+ h('button', { style: S.btn, onClick: () => setPaused((value) => !value) }, paused ? '继续' : '暂停'),
1158
+ h(
1159
+ 'label',
1160
+ { style: { ...S.muted, display: 'flex', alignItems: 'center', gap: 4 } },
1161
+ h('input', { type: 'checkbox', checked: autoScroll, onChange: (event) => setAutoScroll(event.target.checked) }),
1162
+ '自动滚动',
1163
+ ),
1164
+ h('button', { style: S.btn, onClick: jumpBottom }, '回到底部'),
1165
+ h('button', { style: S.btn, onClick: clear }, '清空'),
1166
+ h('button', { style: S.btn, onClick: exportFile }, '导出'),
1167
+ ),
1168
+ error.length > 0 ? h('div', { style: S.err }, error) : null,
1169
+ h(
1170
+ 'div',
1171
+ { ref: logRef, style: S.logBox },
1172
+ entries.length === 0
1173
+ ? h('div', { style: S.empty }, state?.selectedSerial ? '等待日志…(设备没连上或当前过滤条件下没有日志)' : '先在设置里连接设备')
1174
+ : entries.map((entry) =>
1175
+ h(
1176
+ 'div',
1177
+ { key: entry.id, style: S.logRow },
1178
+ h('span', { style: S.logTime }, `${entry.time ?? ''}`),
1179
+ h('span', { style: { ...S.logTag, color: levelColor(entry.level) } }, `${entry.level ?? ''}/${entry.tag ?? ''}`),
1180
+ h(
1181
+ 'span',
1182
+ { style: S.logMsg },
1183
+ (Array.isArray(entry.segments) && entry.segments.length > 0
1184
+ ? entry.segments
1185
+ : [{ text: entry.msg ?? '' }]
1186
+ ).map((segment, index) => h('span', { key: index, style: segment.color ? { color: segment.color } : undefined }, segment.text)),
1187
+ ),
1188
+ ),
1189
+ ),
1190
+ ),
1191
+ h(
1192
+ 'div',
1193
+ { style: S.statusBar },
1194
+ h('span', { style: S.chip(state?.logcat?.running === true) }, state?.logcat?.running === true ? '采集中' : '未采集'),
1195
+ h('span', null, `${entries.length} 行(本地上限 2000)`),
1196
+ packages.foreground ? h('span', null, `前台:${packages.foreground}`) : null,
1197
+ ),
1198
+ )
1199
+ }
1200
+
1201
+ // ── Tab3 性能 ──────────────────────────────────────────────────────────
1202
+ function PerfTab({ state }) {
1203
+ const [snapshot, setSnapshot] = React.useState(undefined)
1204
+ const [pkg, setPkg] = React.useState('')
1205
+ const [packages, setPackages] = React.useState({ running: [], foreground: '' })
1206
+ const [error, setError] = React.useState('')
1207
+
1208
+ const sample = async () => {
1209
+ const query = new URLSearchParams()
1210
+ if (pkg) query.set('pkg', pkg)
1211
+ if (state?.selectedSerial) query.set('serial', state.selectedSerial)
1212
+ const result = await requestJson(`${API}/perf.json?${query.toString()}`)
1213
+ if (result?.ok === true) {
1214
+ setSnapshot(result)
1215
+ setError('')
1216
+ } else if (result?.error) setError(result.error)
1217
+ }
1218
+
1219
+ React.useEffect(() => {
1220
+ void sample()
1221
+ const timer = window.setInterval(() => void sample(), 2000)
1222
+ return () => window.clearInterval(timer)
1223
+ }, [state?.selectedSerial, pkg])
1224
+
1225
+ React.useEffect(() => {
1226
+ const load = async () => {
1227
+ const result = await requestJson(`${API}/packages.json${state?.selectedSerial ? `?serial=${encodeURIComponent(state.selectedSerial)}` : ''}`)
1228
+ if (result?.ok === true) setPackages(result)
1229
+ }
1230
+ void load()
1231
+ }, [state?.selectedSerial])
1232
+
1233
+ const cpu = snapshot?.cpu ?? {}
1234
+ const memory = snapshot?.memory ?? {}
1235
+ const gpu = snapshot?.gpu ?? {}
1236
+ const battery = snapshot?.battery ?? {}
1237
+ const memPct = memory.totalMb ? (Number(memory.usedMb) / Number(memory.totalMb)) * 100 : 0
1238
+
1239
+ return h(
1240
+ 'div',
1241
+ { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } },
1242
+ h(
1243
+ 'div',
1244
+ { style: S.toolbar },
1245
+ h(
1246
+ 'select',
1247
+ { style: { ...S.input, maxWidth: 240 }, value: pkg, onChange: (event) => setPkg(event.target.value) },
1248
+ h('option', { value: '' }, '前台应用'),
1249
+ (Array.isArray(packages.running) ? packages.running : []).map((item) => h('option', { key: item.package, value: item.package }, item.package)),
1250
+ ),
1251
+ h('span', { style: { flex: 1 } }),
1252
+ h('span', { style: S.muted }, snapshot?.sampledAt ? `采样于 ${new Date(snapshot.sampledAt).toLocaleTimeString()}` : ''),
1253
+ ),
1254
+ error.length > 0 ? h('div', { style: S.err }, error) : null,
1255
+ h(
1256
+ 'div',
1257
+ { style: S.perfWrap },
1258
+ h(
1259
+ 'div',
1260
+ { style: S.card },
1261
+ h('div', { style: S.cardTitle }, 'CPU(整机)'),
1262
+ h('div', { style: S.big }, fmtPct(cpu.usagePercent)),
1263
+ h('div', { style: S.bar }, h('div', { style: S.barFill(cpu.usagePercent) })),
1264
+ h('div', { style: { ...S.muted, marginTop: 6 } }, `应用:${fmtPct(cpu.appPercent)} · 系统总:${fmtPct(cpu.totalPercent)}`),
1265
+ ),
1266
+ h(
1267
+ 'div',
1268
+ { style: S.card },
1269
+ h('div', { style: S.cardTitle }, '内存'),
1270
+ h('div', { style: S.big }, `${memory.usedMb ?? '—'} / ${memory.totalMb ?? '—'} MB`),
1271
+ h('div', { style: S.bar }, h('div', { style: S.barFill(memPct) })),
1272
+ h('div', { style: { ...S.muted, marginTop: 6 } }, `应用 PSS:${memory.appPssMb === undefined ? '—' : `${memory.appPssMb} MB`}`),
1273
+ ),
1274
+ h(
1275
+ 'div',
1276
+ { style: S.card },
1277
+ h('div', { style: S.cardTitle }, 'GPU'),
1278
+ h('div', { style: S.big }, gpu.available === true ? fmtPct(gpu.usagePercent) : '不可用'),
1279
+ gpu.available === true ? h('div', { style: S.bar }, h('div', { style: S.barFill(gpu.usagePercent) })) : null,
1280
+ h(
1281
+ 'div',
1282
+ { style: { ...S.muted, marginTop: 6 } },
1283
+ [gpu.renderer, gpu.platform, gpu.clockMHz ? `${gpu.clockMHz} MHz` : ''].filter(Boolean).join(' · ') || gpu.reason || '',
1284
+ ),
1285
+ ),
1286
+ h(
1287
+ 'div',
1288
+ { style: S.card },
1289
+ h('div', { style: S.cardTitle }, '电池'),
1290
+ h('div', { style: S.big }, battery.level === undefined ? '—' : `${battery.level}%`),
1291
+ h('div', { style: { ...S.muted, marginTop: 6 } }, `${battery.status ?? ''}${battery.temperatureC === undefined ? '' : ` · ${battery.temperatureC}°C`}`),
1292
+ ),
1293
+ ),
1294
+ h(
1295
+ 'div',
1296
+ { style: S.statusBar },
1297
+ h('span', { style: S.chip(state?.selectedSerial !== undefined && state?.selectedSerial !== '') }, state?.selectedSerial ? `设备 ${state.selectedSerial}` : '未连接'),
1298
+ snapshot?.package ? h('span', null, `目标应用:${snapshot.package}`) : null,
1299
+ ),
1300
+ )
1301
+ }
1302
+
1303
+ // ── Tab4 指令 ──────────────────────────────────────────────────────────
1304
+ function CommandTab({ state }) {
1305
+ const [command, setCommand] = React.useState('')
1306
+ const [mode, setMode] = React.useState('shell')
1307
+ const [output, setOutput] = React.useState('')
1308
+ const [busy, setBusy] = React.useState(false)
1309
+ const [history, setHistory] = React.useState(() => {
1310
+ try {
1311
+ return JSON.parse(window.localStorage.getItem('dsh-android-tool.history') ?? '[]')
1312
+ } catch {
1313
+ return []
1314
+ }
1315
+ })
1316
+
1317
+ const remember = (item) => {
1318
+ setHistory((previous) => {
1319
+ const next = [item, ...previous.filter((row) => row !== item)].slice(0, 20)
1320
+ try {
1321
+ window.localStorage.setItem('dsh-android-tool.history', JSON.stringify(next))
1322
+ } catch {
1323
+ // 忽略
1324
+ }
1325
+ return next
1326
+ })
1327
+ }
1328
+
1329
+ const run = async () => {
1330
+ const text = command.trim()
1331
+ if (text.length === 0) return
1332
+ setBusy(true)
1333
+ setOutput('运行中…')
1334
+ const result = await requestJson(`${API}/exec`, { command: text, mode, serial: state?.selectedSerial })
1335
+ setBusy(false)
1336
+ remember(text)
1337
+ if (result?.ok === true) {
1338
+ const parts = [`$ ${mode === 'shell' ? `adb shell ${text}` : `adb ${text}`}`]
1339
+ if (result.stdout) parts.push(result.stdout)
1340
+ if (result.stderr) parts.push(`[stderr]\n${result.stderr}`)
1341
+ parts.push(`[exit ${result.code ?? 0} · ${result.elapsedMs ?? 0}ms${result.truncated ? ' · 输出已截断' : ''}]`)
1342
+ setOutput(parts.join('\n'))
1343
+ } else {
1344
+ setOutput(`${result?.error ?? '执行失败'}\n${result?.stderr ?? ''}`)
1345
+ }
1346
+ }
1347
+
1348
+ const quick = [
1349
+ { label: '型号', command: 'getprop ro.product.model' },
1350
+ { label: '三方包', command: 'pm list packages -3' },
1351
+ { label: '电池', command: 'dumpsys battery' },
1352
+ { label: '前台', command: "dumpsys activity activities | grep mResumedActivity" },
1353
+ ]
1354
+
1355
+ return h(
1356
+ 'div',
1357
+ { style: S.cmdWrap },
1358
+ h(
1359
+ 'div',
1360
+ { style: S.toolbar },
1361
+ h('input', {
1362
+ style: { ...S.input, ...S.grow },
1363
+ placeholder: mode === 'shell' ? '输入 shell 命令,例如 getprop ro.product.model' : '输入 adb 参数,例如 shell getprop ro.product.model',
1364
+ value: command,
1365
+ spellCheck: false,
1366
+ onChange: (event) => setCommand(event.target.value),
1367
+ onKeyDown: (event) => {
1368
+ if (event.key === 'Enter' && !event.shiftKey) void run()
1369
+ },
1370
+ }),
1371
+ h(
1372
+ 'select',
1373
+ { style: S.input, value: mode, onChange: (event) => setMode(event.target.value) },
1374
+ h('option', { value: 'shell' }, 'shell'),
1375
+ h('option', { value: 'adb' }, 'adb'),
1376
+ ),
1377
+ h('button', { style: busy ? { ...S.btnPrimary, ...S.btnDisabled } : S.btnPrimary, disabled: busy, onClick: () => void run() }, '运行'),
1378
+ ),
1379
+ h(
1380
+ 'div',
1381
+ { style: { ...S.toolbar, borderBottom: 'none', paddingTop: 0 } },
1382
+ quick.map((item) => h('button', { key: item.label, style: S.btn, onClick: () => setCommand(item.command) }, item.label)),
1383
+ h('span', { style: { flex: 1 } }),
1384
+ history.slice(0, 4).map((item) => h('button', { key: item, style: S.btn, title: item, onClick: () => setCommand(item) }, item.length > 16 ? `${item.slice(0, 16)}…` : item)),
1385
+ ),
1386
+ h('pre', { style: S.pre }, output || '输出会显示在这里。'),
1387
+ h(
1388
+ 'div',
1389
+ { style: S.statusBar },
1390
+ h('span', { style: S.chip(state?.adb?.exists === true) }, state?.adb?.exists === true ? 'adb 就绪' : 'adb 未找到'),
1391
+ state?.selectedSerial ? h('span', null, state.selectedSerial) : h('span', null, '未选择设备'),
1392
+ ),
1393
+ )
1394
+ }
1395
+
1396
+ // ── 设置页 ─────────────────────────────────────────────────────────────
1397
+ const SETTINGS_NAMESPACE = 'dsh-android-tool'
1398
+ const MARKER_FIELD = 'adbPath'
1399
+ const EMPTY_MIRROR = { status: 'idle' }
1400
+ const EMPTY_FORM = { status: 'loading' }
1401
+
1402
+ function findNamespace(snapshot) {
1403
+ const list = snapshot?.view?.namespaces ?? []
1404
+ const byField = list.find((item) => {
1405
+ const dict = item?.schema?.dict
1406
+ return dict !== undefined && dict !== null && typeof dict === 'object' && MARKER_FIELD in dict
1407
+ })
1408
+ if (byField !== undefined) return byField
1409
+ return list.find((item) => item?.ns === SETTINGS_NAMESPACE) ?? list.find((item) => item?.ns === `include:${SETTINGS_NAMESPACE}`)
1410
+ }
1411
+
1412
+ function AndroidSettingsSection(props) {
1413
+ const configForms = props?.configForms
1414
+ const mirror = React.useMemo(() => (configForms === undefined ? undefined : configForms.describe()), [configForms])
1415
+ const mirrorSnap = React.useSyncExternalStore(
1416
+ (listener) => (mirror === undefined ? () => {} : mirror.subscribe(listener)),
1417
+ () => (mirror === undefined ? EMPTY_MIRROR : mirror.getSnapshot()),
1418
+ () => (mirror === undefined ? EMPTY_MIRROR : mirror.getSnapshot()),
1419
+ )
1420
+ const view = findNamespace(mirrorSnap)
1421
+ const namespace = view?.ns
1422
+ const form = React.useMemo(() => (namespace === undefined || configForms === undefined ? undefined : configForms.get(namespace)), [configForms, namespace])
1423
+ const formSnap = React.useSyncExternalStore(
1424
+ (listener) => (form === undefined ? () => {} : form.subscribe(listener)),
1425
+ () => (form === undefined ? EMPTY_FORM : form.getSnapshot()),
1426
+ () => (form === undefined ? EMPTY_FORM : form.getSnapshot()),
1427
+ )
1428
+
1429
+ const [draft, setDraft] = React.useState({})
1430
+ const [status, setStatus] = React.useState('')
1431
+ const [deviceState, setDeviceState] = React.useState(undefined)
1432
+ const [wire, setWire] = React.useState({ host: '', port: '5555', pairPort: '', code: '' })
1433
+ const wireTouched = React.useRef(false)
1434
+ const [actionOutput, setActionOutput] = React.useState('')
1435
+
1436
+ const current = formSnap?.value ?? {}
1437
+ const pick = (key, fallback) => (Object.hasOwn(draft, key) ? draft[key] : current[key] !== undefined ? current[key] : fallback)
1438
+ const setField = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }))
1439
+
1440
+ React.useEffect(() => {
1441
+ if (mirrorSnap.status === 'idle' && typeof mirror?.ensure === 'function') void mirror.ensure()
1442
+ }, [mirror, mirrorSnap.status])
1443
+
1444
+ React.useEffect(() => {
1445
+ if (wireTouched.current === true) return
1446
+ if (current.wirelessHost !== undefined || current.wirelessPort !== undefined) {
1447
+ setWire((previous) => ({
1448
+ ...previous,
1449
+ host: current.wirelessHost ?? previous.host,
1450
+ port: String(current.wirelessPort ?? previous.port),
1451
+ pairPort: current.pairingPort ? String(current.pairingPort) : previous.pairPort,
1452
+ }))
1453
+ }
1454
+ }, [current.wirelessHost, current.wirelessPort, current.pairingPort])
1455
+
1456
+ const refreshState = async () => {
1457
+ const next = await requestJson(`${API}/state.json`)
1458
+ if (next !== undefined) setDeviceState(next)
1459
+ }
1460
+
1461
+ React.useEffect(() => {
1462
+ void refreshState()
1463
+ const timer = window.setInterval(() => void refreshState(), 2500)
1464
+ return () => window.clearInterval(timer)
1465
+ }, [])
1466
+
1467
+ const save = async () => {
1468
+ if (form === undefined) return
1469
+ const ops = Object.entries(draft).map(([key, value]) => (value === undefined ? { op: 'unset', path: [key] } : { op: 'set', path: [key], value }))
1470
+ if (ops.length === 0) {
1471
+ setStatus('没有改动。')
1472
+ return
1473
+ }
1474
+ setStatus('正在保存…')
1475
+ try {
1476
+ const ok = await form.mutate(ops, formSnap.revision)
1477
+ if (ok === false) {
1478
+ setStatus('保存被拒绝,请检查取值(端口 1–65535)。')
1479
+ return
1480
+ }
1481
+ setDraft({})
1482
+ setStatus('已保存,立即生效。')
1483
+ } catch (saveError) {
1484
+ setStatus(`保存失败:${saveError?.message ?? String(saveError)}`)
1485
+ }
1486
+ }
1487
+
1488
+ const action = async (name, extra) => {
1489
+ setActionOutput('处理中…')
1490
+ const result = await requestJson(`${API}/command`, { action: name, ...extra })
1491
+ setActionOutput(result?.ok === true ? `${name} 成功${result.output ? `:${result.output}` : result.version ? `:${result.version}` : ''}` : `${name} 失败:${result?.error ?? '未知错误'}`)
1492
+ await refreshState()
1493
+ return result
1494
+ }
1495
+
1496
+ if (configForms === undefined) {
1497
+ return h('div', { style: S.page }, h('h2', { style: S.pageTitle }, '安卓调试'), h('p', { style: S.muted }, '配置服务未就绪,无法在这里改配置。'))
1498
+ }
1499
+ if (namespace === undefined) {
1500
+ if (mirrorSnap.status === 'idle' || mirrorSnap.status === 'loading') {
1501
+ return h('div', { style: S.page }, h('h2', { style: S.pageTitle }, '安卓调试'), h('p', { style: S.muted }, '正在读取插件配置…'))
1502
+ }
1503
+ const namespaces = (mirrorSnap.view?.namespaces ?? []).map((item) => item.ns)
1504
+ return h(
1505
+ 'div',
1506
+ { style: S.page },
1507
+ h('h2', { style: S.pageTitle }, '安卓调试'),
1508
+ h('p', { style: S.muted }, '没有读到这个插件的配置项。宿主当前命名空间:'),
1509
+ h('pre', { style: S.pre }, namespaces.length === 0 ? '(空)' : namespaces.join('\n')),
1510
+ )
1511
+ }
1512
+
1513
+ const devices = Array.isArray(deviceState?.devices) ? deviceState.devices : []
1514
+ const dirty = Object.keys(draft).length > 0
1515
+
1516
+ return h(
1517
+ 'div',
1518
+ { style: S.page },
1519
+ h('h2', { style: S.pageTitle }, '安卓调试'),
1520
+ h('p', { style: S.muted }, '配置无线调试与 adb。保存后立即生效,不用重启。无线配对码只在配对时用一次,不保存。'),
1521
+
1522
+ h(
1523
+ 'div',
1524
+ { style: S.section },
1525
+ h('h3', { style: { fontSize: 14, margin: '0 0 8px' } }, '工具路径'),
1526
+ h(
1527
+ 'div',
1528
+ { style: S.field },
1529
+ h('label', { style: S.fieldLabel }, h('span', null, 'adb 路径'), h('span', { style: S.fieldKey }, 'adbPath')),
1530
+ h('input', { style: S.input, value: pick('adbPath', ''), placeholder: '留空 = 随包 scrcpy 目录里的 adb.exe', onChange: (event) => setField('adbPath', event.target.value) }),
1531
+ h('span', { style: S.muted }, deviceState?.adb?.path ? `当前解析:${deviceState.adb.path}${deviceState.adb.exists ? '' : '(不存在)'}` : ''),
1532
+ ),
1533
+ h(
1534
+ 'div',
1535
+ { style: S.field },
1536
+ h('label', { style: S.fieldLabel }, h('span', null, 'scrcpy 路径'), h('span', { style: S.fieldKey }, 'scrcpyPath')),
1537
+ h('input', { style: S.input, value: pick('scrcpyPath', ''), placeholder: '精简包不含 scrcpy.exe;需要独立窗口时填本机 scrcpy.exe 路径', onChange: (event) => setField('scrcpyPath', event.target.value) }),
1538
+ ),
1539
+ h(
1540
+ 'div',
1541
+ { style: S.row },
1542
+ h('button', { style: S.btn, onClick: () => void action('testAdb') }, '测试 adb'),
1543
+ h('button', { style: S.btn, onClick: () => void action('refreshDevices') }, '扫描设备'),
1544
+ deviceState?.adb ? h('span', { style: S.muted }, `adb ${deviceState.adb.exists ? '已找到' : '未找到'} · scrcpy ${deviceState.adb.scrcpyExists ? '已找到' : '未找到'}`) : null,
1545
+ ),
1546
+ ),
1547
+
1548
+ h(
1549
+ 'div',
1550
+ { style: S.section },
1551
+ h('h3', { style: { fontSize: 14, margin: '0 0 8px' } }, '无线调试'),
1552
+ h(
1553
+ 'div',
1554
+ { style: { display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 8 } },
1555
+ h('input', { style: S.input, value: wire.host, placeholder: '设备 IP,例如 192.168.1.23', onChange: (event) => { wireTouched.current = true; setWire((previous) => ({ ...previous, host: event.target.value })); setField('wirelessHost', event.target.value) } }),
1556
+ h('input', { style: S.input, value: wire.port, placeholder: '连接端口 5555', onChange: (event) => { wireTouched.current = true; setWire((previous) => ({ ...previous, port: event.target.value })); setField('wirelessPort', Number(event.target.value) || 5555) } }),
1557
+ h('input', { style: S.input, value: wire.pairPort, placeholder: '配对端口(Android 11+)', onChange: (event) => { wireTouched.current = true; setWire((previous) => ({ ...previous, pairPort: event.target.value })); setField('pairingPort', Number(event.target.value) || 0) } }),
1558
+ h('input', { style: S.input, value: wire.code, placeholder: '六位配对码', onChange: (event) => setWire((previous) => ({ ...previous, code: event.target.value })) }),
1559
+ ),
1560
+ h(
1561
+ 'div',
1562
+ { style: S.row },
1563
+ h('button', { style: S.btn, onClick: () => void action('pair', { host: wire.host, port: Number(wire.pairPort), code: wire.code }) }, '配对'),
1564
+ h('button', { style: S.btnPrimary, onClick: () => void action('connect', { host: wire.host, port: Number(wire.port) || 5555 }) }, '连接'),
1565
+ h('button', { style: S.btn, onClick: () => void action('disconnect', { host: wire.host, port: Number(wire.port) || 5555 }) }, '断开'),
1566
+ h('button', { style: S.btn, onClick: () => void action('enableTcpip') }, 'USB 开启 TCP 模式'),
1567
+ ),
1568
+ h('p', { style: S.muted }, 'Android 11+:先在「无线调试」里点“使用配对码配对设备”,把 IP、配对端口和配对码填到这里点配对;成功后再用上面的连接端口点连接。'),
1569
+ ),
1570
+
1571
+ h(
1572
+ 'div',
1573
+ { style: S.section },
1574
+ h('h3', { style: { fontSize: 14, margin: '0 0 8px' } }, '当前调试设备'),
1575
+ devices.length === 0
1576
+ ? h('p', { style: S.muted }, deviceState?.deviceError ?? '没有检测到设备。')
1577
+ : h(
1578
+ 'table',
1579
+ { style: S.table },
1580
+ h('thead', null, h('tr', null, h('th', { style: S.th }, ''), h('th', { style: S.th }, '序列号'), h('th', { style: S.th }, '状态'), h('th', { style: S.th }, '型号'))),
1581
+ h(
1582
+ 'tbody',
1583
+ null,
1584
+ devices.map((device) =>
1585
+ h(
1586
+ 'tr',
1587
+ { key: device.serial },
1588
+ h(
1589
+ 'td',
1590
+ { style: S.td },
1591
+ h('input', {
1592
+ type: 'radio',
1593
+ name: 'android-device',
1594
+ checked: deviceState?.selectedSerial === device.serial,
1595
+ onChange: () => void action('select', { serial: device.serial }),
1596
+ }),
1597
+ ),
1598
+ h('td', { style: S.td }, device.serial),
1599
+ h('td', { style: S.td }, device.state),
1600
+ h('td', { style: S.td }, device.model || '—'),
1601
+ ),
1602
+ ),
1603
+ ),
1604
+ ),
1605
+ actionOutput ? h('p', { style: S.muted }, actionOutput) : null,
1606
+ ),
1607
+
1608
+ h(
1609
+ 'div',
1610
+ { style: S.section },
1611
+ h('h3', { style: { fontSize: 14, margin: '0 0 8px' } }, '偏好'),
1612
+ h(
1613
+ 'label',
1614
+ { style: { ...S.muted, display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 } },
1615
+ h('input', { type: 'checkbox', checked: pick('autoConnect', false) === true, onChange: (event) => setField('autoConnect', event.target.checked) }),
1616
+ '启动后自动连接上面的无线设备',
1617
+ ),
1618
+ h(
1619
+ 'div',
1620
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 } },
1621
+ numberField('视频最大边', 'videoMaxSize', pick('videoMaxSize', 1024), setField, { min: 320, max: 4096, step: 1 }),
1622
+ numberField('视频码率 (bps)', 'videoBitRate', pick('videoBitRate', 4000000), setField, { min: 100000, max: 50000000, step: 100000 }),
1623
+ numberField('视频帧率', 'videoFps', pick('videoFps', 30), setField, { min: 1, max: 120, step: 1 }),
1624
+ numberField('截图模式 FPS', 'previewFps', pick('previewFps', 3), setField, { min: 1, max: 10, step: 1 }),
1625
+ numberField('Logcat 缓冲行数', 'logcatMaxLines', pick('logcatMaxLines', 5000), setField, { min: 100, max: 100000, step: 100 }),
1626
+ numberField('adb 超时 (ms)', 'adbTimeoutMs', pick('adbTimeoutMs', 15000), setField, { min: 1000, max: 120000, step: 500 }),
1627
+ ),
1628
+ h(
1629
+ 'div',
1630
+ { style: S.field },
1631
+ h('label', { style: S.fieldLabel }, h('span', null, '额外 scrcpy 参数'), h('span', { style: S.fieldKey }, 'scrcpyExtraArgs')),
1632
+ h('input', { style: S.input, value: pick('scrcpyExtraArgs', ''), placeholder: '例如 --no-audio --always-on-top', onChange: (event) => setField('scrcpyExtraArgs', event.target.value) }),
1633
+ ),
1634
+ ),
1635
+
1636
+ h(
1637
+ 'div',
1638
+ { style: { ...S.row, marginTop: 22, position: 'sticky', bottom: 0, background: 'var(--dsw-alias-bg-base, transparent)', padding: '10px 0' } },
1639
+ h('button', { style: dirty ? S.btnPrimary : { ...S.btnPrimary, ...S.btnDisabled }, disabled: !dirty, onClick: () => void save() }, '保存'),
1640
+ h('button', { style: S.btn, disabled: !dirty, onClick: () => setDraft({}) }, '撤销'),
1641
+ h('span', { style: S.muted }, status),
1642
+ ),
1643
+ )
1644
+ }
1645
+
1646
+ function numberField(label, key, value, setField, range) {
1647
+ return h(
1648
+ 'div',
1649
+ { style: S.field, key },
1650
+ h('label', { style: S.fieldLabel }, h('span', null, label), h('span', { style: S.fieldKey }, key)),
1651
+ h('input', {
1652
+ type: 'number',
1653
+ style: S.input,
1654
+ value: value === undefined || value === null ? '' : String(value),
1655
+ min: range.min,
1656
+ max: range.max,
1657
+ step: range.step,
1658
+ onChange: (event) => setField(key, event.target.value === '' ? undefined : Number(event.target.value)),
1659
+ }),
1660
+ )
1661
+ }
1662
+
1663
+ return {
1664
+ inject: ['slots', 'sidebarRight', 'sidebarRightTabs'],
1665
+ __components: { AndroidPanel, ScreenTab, LogcatTab, PerfTab, CommandTab, AndroidSettingsSection },
1666
+ apply(ctx) {
1667
+ // 1) 右侧 Sidebar 的「安卓设备」页
1668
+ try {
1669
+ ctx.effect(
1670
+ () =>
1671
+ ctx.sidebarRightTabs.register({
1672
+ id: TAB_ID,
1673
+ kind: TAB_KIND,
1674
+ multiple: false,
1675
+ keepMounted: true,
1676
+ title: () => '安卓设备',
1677
+ guide: [
1678
+ {
1679
+ id: 'android-tool',
1680
+ order: 26,
1681
+ title: () => '安卓设备',
1682
+ description: () => '查看并控制 Android 设备',
1683
+ },
1684
+ ],
1685
+ }),
1686
+ 'dsh-android-tool: tab type',
1687
+ )
1688
+
1689
+ ctx.effect(
1690
+ () =>
1691
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
1692
+ ctx.slots.register({ name: 'sidebar.right.pane.tab', key: TAB_ID }, AndroidPanel),
1693
+ ),
1694
+ 'dsh-android-tool: tab body',
1695
+ )
1696
+
1697
+ // AI 一调用工具,就把面板拉到右侧
1698
+ ctx.effect(() => {
1699
+ let stopped = false
1700
+ let timer
1701
+ const tick = async () => {
1702
+ const next = await requestJson(`${API}/state.json`)
1703
+ if (stopped) return
1704
+ const at = Number(next?.agentActionAt ?? 0)
1705
+ if (at > 0 && at > readCursor()) {
1706
+ try {
1707
+ ctx.sidebarRight.openTab(TAB_KIND, { params: {} })
1708
+ writeCursor(at)
1709
+ } catch {
1710
+ // 当前没有选中会话时 openTab 会拒绝,等下一次轮询重试
1711
+ }
1712
+ }
1713
+ timer = window.setTimeout(tick, 1200)
1714
+ }
1715
+ void tick()
1716
+ return () => {
1717
+ stopped = true
1718
+ if (timer !== undefined) window.clearTimeout(timer)
1719
+ }
1720
+ }, 'dsh-android-tool: auto-open panel')
1721
+ } catch (error) {
1722
+ try {
1723
+ console.warn('[dsh-android-tool] 侧边栏注册失败:', error)
1724
+ } catch {
1725
+ // 忽略
1726
+ }
1727
+ }
1728
+
1729
+ // 2) 设置 →「安卓调试」
1730
+ try {
1731
+ ctx.slots.inject('settings.section', () =>
1732
+ ctx.slots.register(
1733
+ {
1734
+ name: 'settings.section',
1735
+ id: 'android-tool',
1736
+ order: 90,
1737
+ label: () => '安卓调试',
1738
+ inject: () => ({ configForms: ctx.get('configForms') }),
1739
+ },
1740
+ AndroidSettingsSection,
1741
+ ),
1742
+ )
1743
+ } catch (error) {
1744
+ try {
1745
+ console.warn('[dsh-android-tool] 设置页注册失败:', error)
1746
+ } catch {
1747
+ // 忽略
1748
+ }
1749
+ }
1750
+ },
1751
+ }
1752
+ },
1753
+ })