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
package/lib/perf.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 性能采样:CPU / 内存 / GPU / 电池。
|
|
3
|
+
*
|
|
4
|
+
* GPU 没有 Android 统一接口,只能按 SoC 试探 sysfs;读不到就明确返回
|
|
5
|
+
* `available:false`,绝不编造数字。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
9
|
+
|
|
10
|
+
/** 解析 /proc/stat 第一行。 */
|
|
11
|
+
export function parseProcStat(text) {
|
|
12
|
+
for (const line of String(text ?? '').split(/\r?\n/u)) {
|
|
13
|
+
if (!line.startsWith('cpu ')) continue
|
|
14
|
+
const parts = line.trim().split(/\s+/u).slice(1).map(Number)
|
|
15
|
+
if (parts.length < 4 || parts.some((item) => !Number.isFinite(item))) return undefined
|
|
16
|
+
const idle = parts[3] + (parts[4] ?? 0)
|
|
17
|
+
const total = parts.reduce((sum, value) => sum + value, 0)
|
|
18
|
+
return { idle, total }
|
|
19
|
+
}
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 两次 /proc/stat 采样算 CPU 使用率。 */
|
|
24
|
+
export function computeCpuUsage(prev, curr) {
|
|
25
|
+
if (prev === undefined || curr === undefined) return undefined
|
|
26
|
+
const totalDelta = curr.total - prev.total
|
|
27
|
+
const idleDelta = curr.idle - prev.idle
|
|
28
|
+
if (totalDelta <= 0) return undefined
|
|
29
|
+
const usage = 100 * (1 - idleDelta / totalDelta)
|
|
30
|
+
return Math.max(0, Math.min(100, Math.round(usage * 10) / 10))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 解析 /proc/meminfo(KB)。 */
|
|
34
|
+
export function parseMeminfo(text) {
|
|
35
|
+
const values = {}
|
|
36
|
+
for (const line of String(text ?? '').split(/\r?\n/u)) {
|
|
37
|
+
const match = /^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):\s+(\d+)/u.exec(line.trim())
|
|
38
|
+
if (match !== null) values[match[1]] = Number(match[2])
|
|
39
|
+
}
|
|
40
|
+
const totalKb = values.MemTotal ?? 0
|
|
41
|
+
const availableKb = values.MemAvailable ?? values.MemFree ?? 0
|
|
42
|
+
return {
|
|
43
|
+
totalKb,
|
|
44
|
+
freeKb: values.MemFree ?? 0,
|
|
45
|
+
availableKb,
|
|
46
|
+
usedKb: Math.max(0, totalKb - availableKb),
|
|
47
|
+
buffersKb: values.Buffers ?? 0,
|
|
48
|
+
cachedKb: values.Cached ?? 0,
|
|
49
|
+
swapTotalKb: values.SwapTotal ?? 0,
|
|
50
|
+
swapFreeKb: values.SwapFree ?? 0,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 从 dumpsys meminfo 里取应用总 PSS(KB)。 */
|
|
55
|
+
export function parseAppPss(text) {
|
|
56
|
+
const source = String(text ?? '')
|
|
57
|
+
const totalPss = /TOTAL PSS:\s*(\d+)/iu.exec(source)
|
|
58
|
+
if (totalPss !== null) return Number(totalPss[1])
|
|
59
|
+
const total = /^\s*TOTAL\s+(\d+)/imu.exec(source)
|
|
60
|
+
if (total !== null) return Number(total[1])
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 解析 dumpsys cpuinfo。 */
|
|
65
|
+
export function parseCpuInfo(text, pkg) {
|
|
66
|
+
const source = String(text ?? '')
|
|
67
|
+
let totalPercent
|
|
68
|
+
const total = /^\s*([\d.]+)%\s+TOTAL:/imu.exec(source)
|
|
69
|
+
if (total !== null) totalPercent = Number(total[1])
|
|
70
|
+
|
|
71
|
+
let appPercent
|
|
72
|
+
const target = String(pkg ?? '').trim()
|
|
73
|
+
if (target.length > 0) {
|
|
74
|
+
const escaped = target.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
75
|
+
const pattern = new RegExp(`^\\s*([\\d.]+)%\\s+\\d+\\/${escaped}(?::|\\s)`, 'imu')
|
|
76
|
+
const match = pattern.exec(source)
|
|
77
|
+
if (match !== null) appPercent = Number(match[1])
|
|
78
|
+
}
|
|
79
|
+
return { totalPercent, appPercent }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 解析 dumpsys battery。 */
|
|
83
|
+
export function parseBattery(text) {
|
|
84
|
+
const source = String(text ?? '')
|
|
85
|
+
const level = /(?:^|\n)\s*level:\s*(\d+)/iu.exec(source)
|
|
86
|
+
const status = /(?:^|\n)\s*status:\s*(\d+)/iu.exec(source)
|
|
87
|
+
const temperature = /(?:^|\n)\s*temperature:\s*(-?\d+)/iu.exec(source)
|
|
88
|
+
const statusText = {
|
|
89
|
+
1: '未知',
|
|
90
|
+
2: '充电中',
|
|
91
|
+
3: '放电中',
|
|
92
|
+
4: '未充电',
|
|
93
|
+
5: '已充满',
|
|
94
|
+
}
|
|
95
|
+
const statusCode = status !== null ? Number(status[1]) : undefined
|
|
96
|
+
return {
|
|
97
|
+
level: level !== null ? Number(level[1]) : undefined,
|
|
98
|
+
statusCode,
|
|
99
|
+
status: statusCode !== undefined ? statusText[statusCode] ?? `状态 ${statusCode}` : undefined,
|
|
100
|
+
temperatureC: temperature !== null ? Math.round(Number(temperature[1]) / 10) : undefined,
|
|
101
|
+
charging: /AC powered:\s*true/iu.test(source) || /USB powered:\s*true/iu.test(source) || statusCode === 2,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 解析 GPU sysfs 试探输出。 */
|
|
106
|
+
export function parseGpuOutput(text) {
|
|
107
|
+
const sections = []
|
|
108
|
+
let current
|
|
109
|
+
for (const line of String(text ?? '').split(/\r?\n/u)) {
|
|
110
|
+
const header = /^==(.+)$/u.exec(line.trim())
|
|
111
|
+
if (header !== null) {
|
|
112
|
+
current = { path: header[1], values: [] }
|
|
113
|
+
sections.push(current)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (current !== undefined && line.trim().length > 0) current.values.push(line.trim())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let usagePercent
|
|
120
|
+
let clockMHz
|
|
121
|
+
const raw = {}
|
|
122
|
+
for (const section of sections) {
|
|
123
|
+
const value = section.values[0]
|
|
124
|
+
if (value === undefined) continue
|
|
125
|
+
raw[section.path] = value
|
|
126
|
+
if (section.path.includes('gpubusy')) {
|
|
127
|
+
const parts = value.split(/\s+/u).map(Number)
|
|
128
|
+
if (parts.length >= 2 && Number.isFinite(parts[0]) && Number.isFinite(parts[1]) && parts[1] > 0) {
|
|
129
|
+
usagePercent = Math.round((parts[0] / parts[1]) * 1000) / 10
|
|
130
|
+
}
|
|
131
|
+
} else if (section.path.includes('utilisation') || section.path.includes('gpu_busy') || section.path.includes('busy')) {
|
|
132
|
+
const numeric = Number(value)
|
|
133
|
+
if (Number.isFinite(numeric)) {
|
|
134
|
+
usagePercent = numeric > 100 ? Math.round((numeric / 100) * 10) / 10 : numeric
|
|
135
|
+
}
|
|
136
|
+
} else if (section.path.includes('gpuclk') || section.path.includes('cur_freq') || section.path.includes('clock')) {
|
|
137
|
+
const numeric = Number(value)
|
|
138
|
+
if (Number.isFinite(numeric)) clockMHz = Math.round(numeric > 1000000 ? numeric / 1000000 : numeric > 10000 ? numeric / 1000 : numeric)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { available: usagePercent !== undefined, usagePercent, clockMHz, raw }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 性能采样服务。 */
|
|
145
|
+
export class PerfService {
|
|
146
|
+
/**
|
|
147
|
+
* @param {object} options
|
|
148
|
+
* @param {import('./adb.js').Adb} options.adb
|
|
149
|
+
* @param {object} [options.logger]
|
|
150
|
+
*/
|
|
151
|
+
constructor(options) {
|
|
152
|
+
this.adb = options.adb
|
|
153
|
+
this.logger = options.logger
|
|
154
|
+
this.cache = new Map()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 取一次性能快照(1 秒内复用)。 */
|
|
158
|
+
async sample(serial, pkg) {
|
|
159
|
+
const target = String(serial ?? '').trim()
|
|
160
|
+
if (target.length === 0) return { ok: false, error: '没有选中设备。' }
|
|
161
|
+
const wanted = String(pkg ?? '').trim()
|
|
162
|
+
const key = `${target}|${wanted}`
|
|
163
|
+
const cached = this.cache.get(key)
|
|
164
|
+
if (cached !== undefined && Date.now() - cached.at < 1000) return cached.value
|
|
165
|
+
|
|
166
|
+
const firstStat = await this.adb.shell(target, 'cat /proc/stat', { timeoutMs: 8000 })
|
|
167
|
+
await delay(260)
|
|
168
|
+
const secondStat = await this.adb.shell(target, 'cat /proc/stat', { timeoutMs: 8000 })
|
|
169
|
+
const cpuUsage = computeCpuUsage(parseProcStat(firstStat.stdout), parseProcStat(secondStat.stdout))
|
|
170
|
+
|
|
171
|
+
const meminfoResult = await this.adb.shell(target, 'cat /proc/meminfo', { timeoutMs: 8000 })
|
|
172
|
+
const memory = parseMeminfo(meminfoResult.stdout)
|
|
173
|
+
|
|
174
|
+
const cpuinfoResult = await this.adb.shell(target, 'dumpsys cpuinfo', { timeoutMs: 12000 })
|
|
175
|
+
const cpuInfo = parseCpuInfo(cpuinfoResult.stdout, wanted)
|
|
176
|
+
|
|
177
|
+
let appPssKb
|
|
178
|
+
if (wanted.length > 0) {
|
|
179
|
+
const appMem = await this.adb.shell(target, `dumpsys meminfo ${wanted}`, { timeoutMs: 12000 })
|
|
180
|
+
appPssKb = parseAppPss(appMem.stdout)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const gpuCommand = [
|
|
184
|
+
"for p in /sys/class/kgsl/kgsl-3d0/gpubusy /sys/class/kgsl/kgsl-3d0/gpuclk /sys/class/misc/mali0/device/utilisation /sys/kernel/gpu/gpu_busy /sys/class/devfreq/gpufreq/cur_freq; do echo \"==$p\"; cat \"$p\" 2>/dev/null; done",
|
|
185
|
+
"echo '==prop.egl'",
|
|
186
|
+
'getprop ro.hardware.egl',
|
|
187
|
+
"echo '==prop.platform'",
|
|
188
|
+
'getprop ro.board.platform',
|
|
189
|
+
].join('; ')
|
|
190
|
+
const gpuResult = await this.adb.shell(target, gpuCommand, { timeoutMs: 12000 })
|
|
191
|
+
const gpu = parseGpuOutput(gpuResult.stdout)
|
|
192
|
+
const rendererMatch = /==prop\.egl\r?\n([^\r\n]+)/u.exec(gpuResult.stdout)
|
|
193
|
+
const platformMatch = /==prop\.platform\r?\n([^\r\n]+)/u.exec(gpuResult.stdout)
|
|
194
|
+
gpu.renderer = rendererMatch !== null ? rendererMatch[1].trim() : ''
|
|
195
|
+
gpu.platform = platformMatch !== null ? platformMatch[1].trim() : ''
|
|
196
|
+
if (gpu.available !== true) {
|
|
197
|
+
gpu.reason = '设备没有暴露可读的 GPU 占用接口(不同 SoC 的 sysfs 不一样)。'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const batteryResult = await this.adb.shell(target, 'dumpsys battery', { timeoutMs: 8000 })
|
|
201
|
+
const battery = parseBattery(batteryResult.stdout)
|
|
202
|
+
|
|
203
|
+
const value = {
|
|
204
|
+
ok: true,
|
|
205
|
+
serial: target,
|
|
206
|
+
package: wanted || undefined,
|
|
207
|
+
cpu: {
|
|
208
|
+
usagePercent: cpuUsage,
|
|
209
|
+
appPercent: cpuInfo.appPercent,
|
|
210
|
+
totalPercent: cpuInfo.totalPercent,
|
|
211
|
+
},
|
|
212
|
+
memory: {
|
|
213
|
+
totalMb: Math.round(memory.totalKb / 1024),
|
|
214
|
+
usedMb: Math.round(memory.usedKb / 1024),
|
|
215
|
+
availableMb: Math.round(memory.availableKb / 1024),
|
|
216
|
+
appPssMb: appPssKb === undefined ? undefined : Math.round(appPssKb / 1024),
|
|
217
|
+
},
|
|
218
|
+
gpu,
|
|
219
|
+
battery,
|
|
220
|
+
sampledAt: Date.now(),
|
|
221
|
+
}
|
|
222
|
+
this.cache.set(key, { at: Date.now(), value })
|
|
223
|
+
return value
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scrcpy 协议的最小解析器(纯函数,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 依据:https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md
|
|
5
|
+
* 视频 socket:codec id(u32) → session packet(12) → 媒体包(12 字节帧头 + payload)。
|
|
6
|
+
* 帧头首字节 bit7=0(媒体包)、bit6=config、bit5=key,低 61 位是 PTS,后 4 字节是包长。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** codec id 的数字 → 名字。 */
|
|
10
|
+
const CODEC_IDS = {
|
|
11
|
+
'0x68323634': 'h264',
|
|
12
|
+
'0x68323635': 'h265',
|
|
13
|
+
'0x00617631': 'av1',
|
|
14
|
+
'0x00767038': 'vp8',
|
|
15
|
+
'0x00767039': 'vp9',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* scrcpy server 使用的 abstract socket 名。
|
|
20
|
+
*
|
|
21
|
+
* 注意:server 把 scid 当十进制 int 解析,再用 `%08x` 格式化成 8 位十六进制,
|
|
22
|
+
* 所以本地 adb forward 必须指向 `scrcpy_0074cbb1` 这种形式,不能直接拼十进制。
|
|
23
|
+
*
|
|
24
|
+
* @param {number} scid
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function scrcpySocketName(scid) {
|
|
28
|
+
const value = Number(scid)
|
|
29
|
+
if (!Number.isFinite(value) || value === -1) return 'scrcpy'
|
|
30
|
+
return `scrcpy_${(value >>> 0).toString(16).padStart(8, '0')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 读 4 字节 codec id。 */
|
|
34
|
+
export function readCodecId(buffer) {
|
|
35
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4) return undefined
|
|
36
|
+
const hex = `0x${buffer.readUInt32BE(0).toString(16).padStart(8, '0')}`
|
|
37
|
+
const name = CODEC_IDS[hex]
|
|
38
|
+
return name === undefined ? { id: hex, name: undefined } : { id: hex, name }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 解析 12 字节 session packet。
|
|
43
|
+
* byte0 最高位=1;bit0=client resized;byte4-7 宽;byte8-11 高。
|
|
44
|
+
*/
|
|
45
|
+
export function parseSessionPacket(buffer) {
|
|
46
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return undefined
|
|
47
|
+
if ((buffer[0] & 0x80) === 0) return undefined
|
|
48
|
+
return {
|
|
49
|
+
clientResized: (buffer[0] & 0x01) !== 0,
|
|
50
|
+
width: buffer.readUInt32BE(4),
|
|
51
|
+
height: buffer.readUInt32BE(8),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 解析 12 字节媒体包帧头。
|
|
57
|
+
* @returns {{media:boolean,config:boolean,key:boolean,pts:number,size:number}|undefined}
|
|
58
|
+
*/
|
|
59
|
+
export function parseFrameHeader(buffer) {
|
|
60
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return undefined
|
|
61
|
+
const first = buffer[0]
|
|
62
|
+
const media = (first & 0x80) === 0
|
|
63
|
+
const config = (first & 0x40) !== 0
|
|
64
|
+
const key = (first & 0x20) !== 0
|
|
65
|
+
let pts = 0n
|
|
66
|
+
for (let index = 0; index < 8; index += 1) pts = (pts << 8n) | BigInt(buffer[index])
|
|
67
|
+
pts &= 0x1fffffffffffffffn
|
|
68
|
+
return { media, config, key, pts: Number(pts), size: buffer.readUInt32BE(8) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 按 Annex-B 起始码拆 NAL。
|
|
73
|
+
* @param {Buffer} buffer
|
|
74
|
+
* @returns {Array<{type:number,data:Buffer}>}
|
|
75
|
+
*/
|
|
76
|
+
export function splitAnnexB(buffer) {
|
|
77
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return []
|
|
78
|
+
const starts = []
|
|
79
|
+
for (let index = 0; index + 3 < buffer.length; index += 1) {
|
|
80
|
+
if (buffer[index] === 0x00 && buffer[index + 1] === 0x00 && buffer[index + 2] === 0x01) {
|
|
81
|
+
starts.push({ at: index, length: 3 })
|
|
82
|
+
index += 2
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (buffer[index] === 0x00 && buffer[index + 1] === 0x00 && buffer[index + 2] === 0x00 && buffer[index + 3] === 0x01) {
|
|
86
|
+
starts.push({ at: index, length: 4 })
|
|
87
|
+
index += 3
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (starts.length === 0) return []
|
|
91
|
+
const nals = []
|
|
92
|
+
for (let index = 0; index < starts.length; index += 1) {
|
|
93
|
+
const start = starts[index]
|
|
94
|
+
const payloadStart = start.at + start.length
|
|
95
|
+
const payloadEnd = index + 1 < starts.length ? starts[index + 1].at : buffer.length
|
|
96
|
+
if (payloadEnd <= payloadStart) continue
|
|
97
|
+
const data = buffer.subarray(payloadStart, payloadEnd)
|
|
98
|
+
nals.push({ type: data[0] & 0x1f, data })
|
|
99
|
+
}
|
|
100
|
+
return nals
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 从 NAL 列表里找 SPS/PPS。 */
|
|
104
|
+
export function extractSpsPps(nals) {
|
|
105
|
+
let sps
|
|
106
|
+
let pps
|
|
107
|
+
for (const nal of Array.isArray(nals) ? nals : []) {
|
|
108
|
+
if (nal.type === 7 && sps === undefined) sps = nal.data
|
|
109
|
+
if (nal.type === 8 && pps === undefined) pps = nal.data
|
|
110
|
+
}
|
|
111
|
+
return { sps, pps }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 从 SPS 的前三个字节构造 WebCodecs 的 codec 字符串。
|
|
116
|
+
* @param {Buffer} sps - 含 NAL header 的 SPS
|
|
117
|
+
*/
|
|
118
|
+
export function spsToCodecString(sps) {
|
|
119
|
+
if (!Buffer.isBuffer(sps) || sps.length < 4) return undefined
|
|
120
|
+
const profile = sps[1]
|
|
121
|
+
const constraints = sps[2]
|
|
122
|
+
const level = sps[3]
|
|
123
|
+
return `avc1.${profile.toString(16).padStart(2, '0')}${constraints.toString(16).padStart(2, '0')}${level.toString(16).padStart(2, '0')}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 把若干 NAL 拼回 Annex-B 字节流(前面补 4 字节起始码)。 */
|
|
127
|
+
export function joinAnnexB(nals) {
|
|
128
|
+
const parts = []
|
|
129
|
+
for (const nal of Array.isArray(nals) ? nals : []) {
|
|
130
|
+
if (!Buffer.isBuffer(nal) || nal.length === 0) continue
|
|
131
|
+
parts.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
|
|
132
|
+
parts.push(nal)
|
|
133
|
+
}
|
|
134
|
+
return Buffer.concat(parts)
|
|
135
|
+
}
|