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