dsh-android-tool 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +138 -2
- package/client.js +1753 -0
- package/cordis.patch.yml +28 -0
- package/icon.svg +7 -0
- package/index.js +74 -0
- package/lib/adb.js +422 -0
- package/lib/config-schema.js +41 -0
- package/lib/config.js +175 -0
- package/lib/logcat.js +405 -0
- package/lib/paths.js +20 -0
- package/lib/perf.js +225 -0
- package/lib/scrcpy-protocol.js +135 -0
- package/lib/scrcpy-stream.js +681 -0
- package/lib/scrcpy-window.js +98 -0
- package/lib/service.js +748 -0
- package/lib/tools.js +179 -0
- package/lib/webapi.js +187 -0
- package/lib/ws.js +187 -0
- package/package.json +67 -4
- package/scrcpy-win64-v5.0.1/AdbWinApi.dll +0 -0
- package/scrcpy-win64-v5.0.1/AdbWinUsbApi.dll +0 -0
- package/scrcpy-win64-v5.0.1/LICENSE.txt +204 -0
- package/scrcpy-win64-v5.0.1/adb.exe +0 -0
- package/scrcpy-win64-v5.0.1/scrcpy-server +0 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scrcpy 视频推流(host 侧最小客户端)。
|
|
3
|
+
*
|
|
4
|
+
* 做法:
|
|
5
|
+
* 1. adb push 随包的 scrcpy-server;
|
|
6
|
+
* 2. adb forward 一个本地端口到设备 localabstract:scrcpy_<scid>;
|
|
7
|
+
* 3. 启动 com.genymobile.scrcpy.Server 5.0.1(audio=false / control=false);
|
|
8
|
+
* 4. 连本地端口,解析 codec id、session packet、12 字节帧头;
|
|
9
|
+
* 5. 通过 WebSocket 把每一帧推给浏览器,WebCodecs 解码到 canvas。
|
|
10
|
+
*
|
|
11
|
+
* 任何一步失败都返回结构化错误,由上层回退到截图轮询 / 独立 scrcpy 窗口。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { randomInt } from 'node:crypto'
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import net from 'node:net'
|
|
18
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
19
|
+
import {
|
|
20
|
+
AndroidKeyEventAction,
|
|
21
|
+
AndroidMotionEventAction,
|
|
22
|
+
ScrcpyControlMessageSerializer,
|
|
23
|
+
ScrcpyOptions5_0_1,
|
|
24
|
+
} from '@yume-chan/scrcpy'
|
|
25
|
+
import {
|
|
26
|
+
joinAnnexB,
|
|
27
|
+
parseFrameHeader,
|
|
28
|
+
parseSessionPacket,
|
|
29
|
+
readCodecId,
|
|
30
|
+
scrcpySocketName,
|
|
31
|
+
spsToCodecString,
|
|
32
|
+
splitAnnexB,
|
|
33
|
+
extractSpsPps,
|
|
34
|
+
} from './scrcpy-protocol.js'
|
|
35
|
+
import { handshakeResponse, WsConnection } from './ws.js'
|
|
36
|
+
|
|
37
|
+
/** 远端 server 路径。 */
|
|
38
|
+
const REMOTE_SERVER = '/data/local/tmp/scrcpy-server.jar'
|
|
39
|
+
|
|
40
|
+
/** 杀进程树。 */
|
|
41
|
+
function killTree(child) {
|
|
42
|
+
if (child === undefined || child === null || child.killed) return
|
|
43
|
+
try {
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
46
|
+
killer.on('error', () => {})
|
|
47
|
+
} else {
|
|
48
|
+
child.kill('SIGKILL')
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// 忽略
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 找一个空闲的本地 TCP 端口。 */
|
|
56
|
+
function getFreePort() {
|
|
57
|
+
return new Promise((resolvePromise) => {
|
|
58
|
+
const server = net.createServer()
|
|
59
|
+
server.unref()
|
|
60
|
+
server.on('error', () => resolvePromise(0))
|
|
61
|
+
server.listen({ port: 0, host: '127.0.0.1' }, () => {
|
|
62
|
+
const address = server.address()
|
|
63
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
64
|
+
server.close(() => resolvePromise(port))
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 连一次本地端口(只看 TCP connect,不等业务数据)。 */
|
|
70
|
+
function connectOnce(port, timeoutMs = 1500) {
|
|
71
|
+
return new Promise((resolvePromise) => {
|
|
72
|
+
const client = net.connect({ port, host: '127.0.0.1' })
|
|
73
|
+
let settled = false
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
if (settled) return
|
|
76
|
+
settled = true
|
|
77
|
+
client.destroy()
|
|
78
|
+
resolvePromise({ ok: false, error: 'TCP 连接超时' })
|
|
79
|
+
}, timeoutMs)
|
|
80
|
+
client.once('error', (error) => {
|
|
81
|
+
if (settled) return
|
|
82
|
+
settled = true
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
client.destroy()
|
|
85
|
+
resolvePromise({ ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
86
|
+
})
|
|
87
|
+
client.once('connect', () => {
|
|
88
|
+
if (settled) return
|
|
89
|
+
settled = true
|
|
90
|
+
clearTimeout(timer)
|
|
91
|
+
resolvePromise({ ok: true, socket: client })
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 连 scrcpy 的两个 socket(video + control),并等 video 真的收到首包。
|
|
98
|
+
*
|
|
99
|
+
* 为什么不能只看 TCP connect:adb forward 在 device 端还没有 LocalServerSocket
|
|
100
|
+
* 监听时也会先 accept 再 close。另外开启 control 后,server 会先 accept video,
|
|
101
|
+
* 再 accept control,两个都连上之后才开始发视频数据——所以必须成对连接。
|
|
102
|
+
*/
|
|
103
|
+
async function connectVideoControl(port, timeoutMs) {
|
|
104
|
+
const deadline = Date.now() + timeoutMs
|
|
105
|
+
let lastError = ''
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
const video = await connectOnce(port)
|
|
108
|
+
if (video.ok !== true) {
|
|
109
|
+
lastError = video.error
|
|
110
|
+
await delay(220)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const control = await connectOnce(port)
|
|
114
|
+
if (control.ok !== true) {
|
|
115
|
+
video.socket.destroy()
|
|
116
|
+
lastError = control.error
|
|
117
|
+
await delay(220)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const firstChunk = await new Promise((resolvePromise) => {
|
|
121
|
+
let settled = false
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
if (settled) return
|
|
124
|
+
settled = true
|
|
125
|
+
resolvePromise(undefined)
|
|
126
|
+
}, 1800)
|
|
127
|
+
const finish = (chunk) => {
|
|
128
|
+
if (settled) return
|
|
129
|
+
settled = true
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
resolvePromise(chunk)
|
|
132
|
+
}
|
|
133
|
+
video.socket.once('data', (chunk) => {
|
|
134
|
+
video.socket.pause()
|
|
135
|
+
finish(chunk)
|
|
136
|
+
})
|
|
137
|
+
video.socket.once('close', () => finish(undefined))
|
|
138
|
+
video.socket.once('error', () => finish(undefined))
|
|
139
|
+
})
|
|
140
|
+
if (Buffer.isBuffer(firstChunk) && firstChunk.length > 0) {
|
|
141
|
+
return { ok: true, video: video.socket, control: control.socket, firstChunk }
|
|
142
|
+
}
|
|
143
|
+
video.socket.destroy()
|
|
144
|
+
control.socket.destroy()
|
|
145
|
+
lastError = '连接在首包前被关闭'
|
|
146
|
+
await delay(220)
|
|
147
|
+
}
|
|
148
|
+
return { ok: false, error: lastError || '连接 scrcpy server 超时' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class ScrcpyStream {
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} options
|
|
154
|
+
* @param {import('./adb.js').Adb} options.adb
|
|
155
|
+
* @param {() => object} options.getConfig
|
|
156
|
+
* @param {string} options.pluginRoot
|
|
157
|
+
* @param {object} [options.logger]
|
|
158
|
+
*/
|
|
159
|
+
constructor(options) {
|
|
160
|
+
this.adb = options.adb
|
|
161
|
+
this.getConfig = options.getConfig
|
|
162
|
+
this.pluginRoot = options.pluginRoot
|
|
163
|
+
this.logger = options.logger
|
|
164
|
+
|
|
165
|
+
this.clients = new Set()
|
|
166
|
+
this.serverChild = undefined
|
|
167
|
+
this.socket = undefined
|
|
168
|
+
this.controlSocket = undefined
|
|
169
|
+
this.controlSerializer = new ScrcpyControlMessageSerializer(new ScrcpyOptions5_0_1({ control: true }))
|
|
170
|
+
this.controlError = ''
|
|
171
|
+
this.port = 0
|
|
172
|
+
this.scid = 0
|
|
173
|
+
this.serial = ''
|
|
174
|
+
this.running = false
|
|
175
|
+
this.error = ''
|
|
176
|
+
this.width = 0
|
|
177
|
+
this.height = 0
|
|
178
|
+
this.codec = ''
|
|
179
|
+
this.sps = undefined
|
|
180
|
+
this.pps = undefined
|
|
181
|
+
this.buffer = Buffer.alloc(0)
|
|
182
|
+
this.phase = 'codec'
|
|
183
|
+
this.frameCounter = 0
|
|
184
|
+
this.framesSent = 0
|
|
185
|
+
this.bytesSent = 0
|
|
186
|
+
this.starting = undefined
|
|
187
|
+
this.stopping = false
|
|
188
|
+
this.lastStage = 'idle'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
status() {
|
|
192
|
+
return {
|
|
193
|
+
running: this.running,
|
|
194
|
+
serial: this.serial,
|
|
195
|
+
width: this.width,
|
|
196
|
+
height: this.height,
|
|
197
|
+
codec: this.codec,
|
|
198
|
+
clients: this.clients.size,
|
|
199
|
+
error: this.error || undefined,
|
|
200
|
+
control: this.controlReady(),
|
|
201
|
+
controlError: this.controlError || undefined,
|
|
202
|
+
port: this.port,
|
|
203
|
+
scid: this.scid,
|
|
204
|
+
socketName: this.scid ? scrcpySocketName(this.scid) : '',
|
|
205
|
+
stage: this.lastStage,
|
|
206
|
+
framesSent: this.framesSent,
|
|
207
|
+
bytesSent: this.bytesSent,
|
|
208
|
+
build: 'android-tool-scrcpy-2',
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 当前元信息。 */
|
|
213
|
+
meta() {
|
|
214
|
+
return {
|
|
215
|
+
type: 'meta',
|
|
216
|
+
serial: this.serial,
|
|
217
|
+
codec: this.codec || 'avc1.42e01e',
|
|
218
|
+
width: this.width,
|
|
219
|
+
height: this.height,
|
|
220
|
+
running: this.running,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 处理面板通过 WebSocket 发来的实时触控消息。 */
|
|
225
|
+
handleControlMessage(message) {
|
|
226
|
+
if (message === null || typeof message !== 'object') return
|
|
227
|
+
if (message.type === 'touch') {
|
|
228
|
+
const action = { down: 0, up: 1, move: 2, cancel: 3 }[String(message.action)]
|
|
229
|
+
if (action === undefined) return
|
|
230
|
+
this.touch(action, Number(message.x), Number(message.y), action === 1 ? 0 : 1)
|
|
231
|
+
} else if (message.type === 'key' && Number.isFinite(Number(message.keyCode))) {
|
|
232
|
+
void this.controlKey(Number(message.keyCode))
|
|
233
|
+
} else if (message.type === 'scroll') {
|
|
234
|
+
this.controlScroll(String(message.direction ?? 'down'), Number(message.amount) || 1)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 按名字发触摸事件(面板拖拽用)。 */
|
|
239
|
+
controlTouch(action, x, y) {
|
|
240
|
+
const map = { down: AndroidMotionEventAction.Down, up: AndroidMotionEventAction.Up, move: AndroidMotionEventAction.Move, cancel: AndroidMotionEventAction.Cancel }
|
|
241
|
+
const mapped = map[String(action)]
|
|
242
|
+
if (mapped === undefined) return false
|
|
243
|
+
return this.touch(mapped, x, y, mapped === AndroidMotionEventAction.Up ? 0 : 1)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** control 通道是否可用(scrcpy 控制消息比 adb shell input 延迟低很多)。 */
|
|
247
|
+
controlReady() {
|
|
248
|
+
return this.controlSocket !== undefined && this.controlSocket.destroyed !== true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 写一条 scrcpy 控制消息。 */
|
|
252
|
+
writeControl(bytes) {
|
|
253
|
+
if (this.controlReady() !== true) return false
|
|
254
|
+
try {
|
|
255
|
+
this.controlSocket.write(Buffer.from(bytes))
|
|
256
|
+
return true
|
|
257
|
+
} catch (error) {
|
|
258
|
+
this.controlError = error instanceof Error ? error.message : String(error)
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 触摸事件(坐标是视频坐标系 0..videoWidth / 0..videoHeight)。 */
|
|
264
|
+
touch(action, x, y, pressure = 1) {
|
|
265
|
+
const width = this.width || 0
|
|
266
|
+
const height = this.height || 0
|
|
267
|
+
if (!(width > 0) || !(height > 0)) return false
|
|
268
|
+
const bytes = this.controlSerializer.injectTouch({
|
|
269
|
+
action,
|
|
270
|
+
pointerId: -2n,
|
|
271
|
+
pointerX: Math.max(0, Math.min(width, Math.round(x))),
|
|
272
|
+
pointerY: Math.max(0, Math.min(height, Math.round(y))),
|
|
273
|
+
videoWidth: width,
|
|
274
|
+
videoHeight: height,
|
|
275
|
+
pressure,
|
|
276
|
+
buttons: 0,
|
|
277
|
+
})
|
|
278
|
+
return this.writeControl(bytes)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async controlTap(x, y) {
|
|
282
|
+
if (this.touch(AndroidMotionEventAction.Down, x, y, 1) !== true) return false
|
|
283
|
+
await delay(10)
|
|
284
|
+
return this.touch(AndroidMotionEventAction.Up, x, y, 0)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async controlSwipe(x1, y1, x2, y2, durationMs = 250) {
|
|
288
|
+
const duration = Math.max(60, Math.min(2000, Number(durationMs) || 250))
|
|
289
|
+
const steps = Math.max(3, Math.min(40, Math.round(duration / 20)))
|
|
290
|
+
if (this.touch(AndroidMotionEventAction.Down, x1, y1, 1) !== true) return false
|
|
291
|
+
for (let index = 1; index < steps; index += 1) {
|
|
292
|
+
await delay(Math.max(8, Math.round(duration / steps)))
|
|
293
|
+
this.touch(AndroidMotionEventAction.Move, x1 + ((x2 - x1) * index) / steps, y1 + ((y2 - y1) * index) / steps, 1)
|
|
294
|
+
}
|
|
295
|
+
return this.touch(AndroidMotionEventAction.Up, x2, y2, 0)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async controlKey(keyCode) {
|
|
299
|
+
if (this.writeControl(this.controlSerializer.injectKeyCode({ action: AndroidKeyEventAction.Down, keyCode, repeat: 0, metaState: 0 })) !== true) return false
|
|
300
|
+
await delay(8)
|
|
301
|
+
return this.writeControl(this.controlSerializer.injectKeyCode({ action: AndroidKeyEventAction.Up, keyCode, repeat: 0, metaState: 0 }))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
controlText(text) {
|
|
305
|
+
return this.writeControl(this.controlSerializer.injectText(String(text ?? '')))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
controlScroll(direction, amount = 1) {
|
|
309
|
+
const width = this.width || 0
|
|
310
|
+
const height = this.height || 0
|
|
311
|
+
if (!(width > 0) || !(height > 0)) return false
|
|
312
|
+
const value = Math.max(0.1, Math.min(20, Number(amount) || 1))
|
|
313
|
+
const scrollX = direction === 'left' ? value : direction === 'right' ? -value : 0
|
|
314
|
+
const scrollY = direction === 'up' ? value : direction === 'down' ? -value : 0
|
|
315
|
+
return this.writeControl(
|
|
316
|
+
this.controlSerializer.injectScroll({
|
|
317
|
+
pointerX: Math.round(width / 2),
|
|
318
|
+
pointerY: Math.round(height / 2),
|
|
319
|
+
videoWidth: width,
|
|
320
|
+
videoHeight: height,
|
|
321
|
+
scrollX,
|
|
322
|
+
scrollY,
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
controlPower(on) {
|
|
328
|
+
return this.writeControl(this.controlSerializer.setDisplayPower(on === true))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 广播文本。 */
|
|
332
|
+
broadcastText(text) {
|
|
333
|
+
for (const client of this.clients) client.sendText(text)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 广播二进制帧。 */
|
|
337
|
+
broadcastBinary(payload) {
|
|
338
|
+
for (const client of this.clients) client.sendBinary(payload)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** WebSocket upgrade 入口。 */
|
|
342
|
+
handleUpgrade(req, socket, head) {
|
|
343
|
+
const response = handshakeResponse(req)
|
|
344
|
+
if (response === undefined) {
|
|
345
|
+
try {
|
|
346
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
347
|
+
} catch {
|
|
348
|
+
// 忽略
|
|
349
|
+
}
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
socket.write(response)
|
|
354
|
+
} catch {
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
const connection = new WsConnection(socket, req)
|
|
358
|
+
this.clients.add(connection)
|
|
359
|
+
connection.onClose = () => this.clients.delete(connection)
|
|
360
|
+
connection.onMessage = (frame) => {
|
|
361
|
+
if (frame.opcode !== 0x1) return
|
|
362
|
+
try {
|
|
363
|
+
const message = JSON.parse(frame.payload.toString('utf8'))
|
|
364
|
+
this.handleControlMessage(message)
|
|
365
|
+
} catch {
|
|
366
|
+
// 忽略坏消息
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
connection.sendText(JSON.stringify(this.meta()))
|
|
370
|
+
if (Buffer.isBuffer(head) && head.length > 0) connection.handleData(head)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* push server。
|
|
375
|
+
*
|
|
376
|
+
* 不能只推一次就缓存:scrcpy.exe(用户点「打开 scrcpy 窗口」)退出时会按
|
|
377
|
+
* 自己的 cleanup 规则删掉 /data/local/tmp/scrcpy-server.jar。缓存会导致下一次
|
|
378
|
+
* 启动 app_process 时 ClassNotFoundException。733KB 的推送只要几毫秒,每次都推。
|
|
379
|
+
*/
|
|
380
|
+
async pushServer(serial) {
|
|
381
|
+
const paths = this.adb.resolveScrcpy()
|
|
382
|
+
if (!existsSync(paths.serverPath)) {
|
|
383
|
+
return { ok: false, error: `找不到 scrcpy-server:${paths.serverPath}` }
|
|
384
|
+
}
|
|
385
|
+
const pushed = await this.adb.run(['push', paths.serverPath, REMOTE_SERVER], { serial, timeoutMs: 90000 })
|
|
386
|
+
if (pushed.ok !== true) {
|
|
387
|
+
return { ok: false, error: `推送 scrcpy-server 失败:${pushed.error ?? ''}` }
|
|
388
|
+
}
|
|
389
|
+
return { ok: true }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 启动推流。 */
|
|
393
|
+
async start(serial) {
|
|
394
|
+
const target = String(serial ?? '').trim()
|
|
395
|
+
if (target.length === 0) return { ok: false, error: '没有选中设备。' }
|
|
396
|
+
if (this.running && this.serial === target) return { ok: true, ...this.status() }
|
|
397
|
+
if (this.starting !== undefined) return this.starting
|
|
398
|
+
this.starting = this.#start(target)
|
|
399
|
+
try {
|
|
400
|
+
return await this.starting
|
|
401
|
+
} finally {
|
|
402
|
+
this.starting = undefined
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async #start(serial) {
|
|
407
|
+
await this.stop()
|
|
408
|
+
this.stopping = false
|
|
409
|
+
this.error = ''
|
|
410
|
+
this.controlError = ''
|
|
411
|
+
|
|
412
|
+
const adb = this.adb.resolve()
|
|
413
|
+
if (!adb.ok) return { ok: false, error: adb.error }
|
|
414
|
+
this.lastStage = 'push'
|
|
415
|
+
const pushed = await this.pushServer(serial)
|
|
416
|
+
if (pushed.ok !== true) return { ok: false, error: pushed.error }
|
|
417
|
+
|
|
418
|
+
const settings = this.getConfig()
|
|
419
|
+
this.scid = randomInt(1, 0x7fffffff)
|
|
420
|
+
this.port = await getFreePort()
|
|
421
|
+
if (!this.port) return { ok: false, error: '找不到空闲端口给 scrcpy 使用。' }
|
|
422
|
+
|
|
423
|
+
this.lastStage = 'forward'
|
|
424
|
+
const forward = await this.adb.run(['forward', `tcp:${this.port}`, `localabstract:${scrcpySocketName(this.scid)}`], {
|
|
425
|
+
serial,
|
|
426
|
+
timeoutMs: 15000,
|
|
427
|
+
})
|
|
428
|
+
if (forward.ok !== true) {
|
|
429
|
+
return { ok: false, error: `adb forward 失败:${forward.error ?? ''}` }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const args = [
|
|
433
|
+
'-s',
|
|
434
|
+
serial,
|
|
435
|
+
'shell',
|
|
436
|
+
`CLASSPATH=${REMOTE_SERVER}`,
|
|
437
|
+
'app_process',
|
|
438
|
+
'/',
|
|
439
|
+
'com.genymobile.scrcpy.Server',
|
|
440
|
+
'5.0.1',
|
|
441
|
+
// server 端 Options.parse 用 Integer.parseInt(value, 0x10) 解析 scid,
|
|
442
|
+
// 即这个值本身按十六进制读;socket 名又是 %08x。因此这里必须传十六进制。
|
|
443
|
+
`scid=${this.scid.toString(16)}`,
|
|
444
|
+
'log_level=info',
|
|
445
|
+
'audio=false',
|
|
446
|
+
'control=true',
|
|
447
|
+
'cleanup=false',
|
|
448
|
+
'tunnel_forward=true',
|
|
449
|
+
'send_device_meta=false',
|
|
450
|
+
'send_dummy_byte=false',
|
|
451
|
+
`max_size=${settings.videoMaxSize}`,
|
|
452
|
+
`video_bit_rate=${settings.videoBitRate}`,
|
|
453
|
+
`max_fps=${settings.videoFps}`,
|
|
454
|
+
'video_codec=h264',
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
this.lastStage = 'spawn'
|
|
458
|
+
let child
|
|
459
|
+
try {
|
|
460
|
+
child = spawn(adb.path, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
|
|
461
|
+
} catch (error) {
|
|
462
|
+
await this.removeForward()
|
|
463
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
464
|
+
}
|
|
465
|
+
this.serverChild = child
|
|
466
|
+
let stderr = ''
|
|
467
|
+
child.stderr.on('data', (chunk) => {
|
|
468
|
+
stderr += String(chunk)
|
|
469
|
+
if (stderr.length > 4000) stderr = stderr.slice(-4000)
|
|
470
|
+
})
|
|
471
|
+
child.on('exit', () => {
|
|
472
|
+
if (this.serverChild === child) {
|
|
473
|
+
this.serverChild = undefined
|
|
474
|
+
if (this.running) {
|
|
475
|
+
this.running = false
|
|
476
|
+
this.error = stderr.trim() || 'scrcpy server 已退出'
|
|
477
|
+
this.broadcastText(JSON.stringify({ type: 'status', running: false, error: this.error }))
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
this.lastStage = 'connect'
|
|
483
|
+
const connected = await connectVideoControl(this.port, 12000)
|
|
484
|
+
if (connected.ok !== true) {
|
|
485
|
+
killTree(child)
|
|
486
|
+
this.serverChild = undefined
|
|
487
|
+
await this.removeForward()
|
|
488
|
+
return { ok: false, error: connected.error || stderr.trim() || '连接 scrcpy server 失败' }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
this.socket = connected.video
|
|
492
|
+
this.controlSocket = connected.control
|
|
493
|
+
// 触摸/按键都是很小的包,Nagle 会攒包导致 40–200ms 的控制延迟。
|
|
494
|
+
try {
|
|
495
|
+
this.socket.setNoDelay(true)
|
|
496
|
+
this.controlSocket.setNoDelay(true)
|
|
497
|
+
} catch {
|
|
498
|
+
// 忽略
|
|
499
|
+
}
|
|
500
|
+
this.controlSocket.on('error', (error) => {
|
|
501
|
+
this.controlError = error instanceof Error ? error.message : String(error)
|
|
502
|
+
})
|
|
503
|
+
this.controlSocket.on('close', () => {
|
|
504
|
+
if (this.controlSocket === undefined) return
|
|
505
|
+
this.controlSocket = undefined
|
|
506
|
+
})
|
|
507
|
+
// 设备可能通过 control socket 回传剪贴板等消息,先读掉,避免缓冲堆积。
|
|
508
|
+
this.controlSocket.on('data', () => {})
|
|
509
|
+
this.serial = serial
|
|
510
|
+
this.running = true
|
|
511
|
+
this.lastStage = 'streaming'
|
|
512
|
+
this.buffer = Buffer.alloc(0)
|
|
513
|
+
this.phase = 'codec'
|
|
514
|
+
this.sps = undefined
|
|
515
|
+
this.pps = undefined
|
|
516
|
+
this.codec = ''
|
|
517
|
+
this.width = 0
|
|
518
|
+
this.height = 0
|
|
519
|
+
|
|
520
|
+
if (Buffer.isBuffer(connected.firstChunk) && connected.firstChunk.length > 0) this.feed(connected.firstChunk)
|
|
521
|
+
this.socket.on('data', (chunk) => this.feed(chunk))
|
|
522
|
+
this.socket.resume()
|
|
523
|
+
this.socket.on('error', (error) => {
|
|
524
|
+
if (this.stopping) return
|
|
525
|
+
this.error = error instanceof Error ? error.message : String(error)
|
|
526
|
+
void this.stop()
|
|
527
|
+
})
|
|
528
|
+
this.socket.on('close', () => {
|
|
529
|
+
if (this.stopping) return
|
|
530
|
+
if (this.running) {
|
|
531
|
+
this.error = this.error || 'scrcpy 视频连接已关闭'
|
|
532
|
+
void this.stop()
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
return { ok: true, ...this.status() }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** 解析输入字节流。 */
|
|
540
|
+
feed(chunk) {
|
|
541
|
+
this.buffer = Buffer.concat([this.buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)])
|
|
542
|
+
let progressed = true
|
|
543
|
+
while (progressed) {
|
|
544
|
+
progressed = false
|
|
545
|
+
if (this.phase === 'codec') {
|
|
546
|
+
if (this.buffer.length < 4) return
|
|
547
|
+
const codec = readCodecId(this.buffer.subarray(0, 4))
|
|
548
|
+
this.buffer = this.buffer.subarray(4)
|
|
549
|
+
if (codec?.name !== 'h264') {
|
|
550
|
+
this.error = `不支持的视频编码:${codec?.name ?? codec?.id ?? '未知'}`
|
|
551
|
+
this.running = false
|
|
552
|
+
this.broadcastText(JSON.stringify({ type: 'status', running: false, error: this.error }))
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
this.phase = 'session'
|
|
556
|
+
progressed = true
|
|
557
|
+
continue
|
|
558
|
+
}
|
|
559
|
+
if (this.phase === 'session') {
|
|
560
|
+
if (this.buffer.length < 12) return
|
|
561
|
+
const session = parseSessionPacket(this.buffer.subarray(0, 12))
|
|
562
|
+
if (session === undefined) {
|
|
563
|
+
this.error = 'scrcpy session packet 解析失败'
|
|
564
|
+
this.running = false
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
this.buffer = this.buffer.subarray(12)
|
|
568
|
+
this.width = session.width
|
|
569
|
+
this.height = session.height
|
|
570
|
+
this.phase = 'frame'
|
|
571
|
+
this.broadcastText(JSON.stringify(this.meta()))
|
|
572
|
+
progressed = true
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// phase === 'frame'
|
|
577
|
+
if (this.buffer.length < 12) return
|
|
578
|
+
// 旋转时会重新插一个 session packet。
|
|
579
|
+
if ((this.buffer[0] & 0x80) !== 0) {
|
|
580
|
+
const session = parseSessionPacket(this.buffer.subarray(0, 12))
|
|
581
|
+
if (session !== undefined) {
|
|
582
|
+
this.buffer = this.buffer.subarray(12)
|
|
583
|
+
this.width = session.width
|
|
584
|
+
this.height = session.height
|
|
585
|
+
this.broadcastText(JSON.stringify(this.meta()))
|
|
586
|
+
progressed = true
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const header = parseFrameHeader(this.buffer.subarray(0, 12))
|
|
591
|
+
if (header === undefined || header.size <= 0) {
|
|
592
|
+
this.buffer = this.buffer.subarray(1)
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
if (this.buffer.length < 12 + header.size) return
|
|
596
|
+
const payload = this.buffer.subarray(12, 12 + header.size)
|
|
597
|
+
this.buffer = this.buffer.subarray(12 + header.size)
|
|
598
|
+
|
|
599
|
+
if (header.config === true) {
|
|
600
|
+
const nals = splitAnnexB(payload)
|
|
601
|
+
const { sps, pps } = extractSpsPps(nals)
|
|
602
|
+
if (sps !== undefined) this.sps = sps
|
|
603
|
+
if (pps !== undefined) this.pps = pps
|
|
604
|
+
if (this.sps !== undefined && this.codec === '') {
|
|
605
|
+
this.codec = spsToCodecString(this.sps) ?? ''
|
|
606
|
+
this.broadcastText(JSON.stringify(this.meta()))
|
|
607
|
+
}
|
|
608
|
+
progressed = true
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (header.media === true) {
|
|
613
|
+
let data = Buffer.from(payload)
|
|
614
|
+
if (header.key === true && this.sps !== undefined) {
|
|
615
|
+
data = Buffer.concat([joinAnnexB([this.sps, this.pps].filter((item) => item !== undefined)), data])
|
|
616
|
+
}
|
|
617
|
+
this.frameCounter += 1
|
|
618
|
+
const stamps = header.pts > 0 ? header.pts : this.frameCounter * 16666
|
|
619
|
+
const frame = Buffer.allocUnsafe(9 + data.length)
|
|
620
|
+
frame[0] = header.key === true ? 1 : 0
|
|
621
|
+
frame.writeDoubleBE(stamps, 1)
|
|
622
|
+
data.copy(frame, 9)
|
|
623
|
+
this.framesSent += 1
|
|
624
|
+
this.bytesSent += frame.length
|
|
625
|
+
this.broadcastBinary(frame)
|
|
626
|
+
progressed = true
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** 移除 forward。 */
|
|
632
|
+
async removeForward() {
|
|
633
|
+
if (this.port === 0) return
|
|
634
|
+
try {
|
|
635
|
+
await this.adb.run(['forward', '--remove', `tcp:${this.port}`], { timeoutMs: 5000 })
|
|
636
|
+
} catch {
|
|
637
|
+
// 忽略
|
|
638
|
+
}
|
|
639
|
+
this.port = 0
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 停止。 */
|
|
643
|
+
async stop() {
|
|
644
|
+
this.stopping = true
|
|
645
|
+
this.running = false
|
|
646
|
+
if (this.socket !== undefined) {
|
|
647
|
+
try {
|
|
648
|
+
this.socket.destroy()
|
|
649
|
+
} catch {
|
|
650
|
+
// 忽略
|
|
651
|
+
}
|
|
652
|
+
this.socket = undefined
|
|
653
|
+
}
|
|
654
|
+
if (this.controlSocket !== undefined) {
|
|
655
|
+
try {
|
|
656
|
+
this.controlSocket.destroy()
|
|
657
|
+
} catch {
|
|
658
|
+
// 忽略
|
|
659
|
+
}
|
|
660
|
+
this.controlSocket = undefined
|
|
661
|
+
}
|
|
662
|
+
if (this.serverChild !== undefined) {
|
|
663
|
+
killTree(this.serverChild)
|
|
664
|
+
this.serverChild = undefined
|
|
665
|
+
}
|
|
666
|
+
await this.removeForward()
|
|
667
|
+
this.width = 0
|
|
668
|
+
this.height = 0
|
|
669
|
+
this.phase = 'codec'
|
|
670
|
+
this.buffer = Buffer.alloc(0)
|
|
671
|
+
this.broadcastText(JSON.stringify({ type: 'status', running: false, ...(this.error ? { error: this.error } : {}) }))
|
|
672
|
+
this.stopping = false
|
|
673
|
+
return { ok: true }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
dispose() {
|
|
677
|
+
void this.stop()
|
|
678
|
+
for (const client of this.clients) client.close()
|
|
679
|
+
this.clients.clear()
|
|
680
|
+
}
|
|
681
|
+
}
|