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/config.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-android-tool 的配置纯逻辑层(不 import 宿主包,可在纯 Node 下单测)。
|
|
3
|
+
*
|
|
4
|
+
* config-schema.js 才 import schemastery;这里只做「取值 + 规范化 + 兜底」。
|
|
5
|
+
* 配置写坏不该让插件挂掉,因此每个字段都有默认值。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** scrcpy 随包目录名。 */
|
|
9
|
+
export const SCRCPY_DIR = 'scrcpy-win64-v5.0.1'
|
|
10
|
+
|
|
11
|
+
/** 合理默认值。字段缺失、类型不对时都回落到这里。 */
|
|
12
|
+
export const DEFAULTS = {
|
|
13
|
+
adbPath: '',
|
|
14
|
+
scrcpyPath: '',
|
|
15
|
+
wirelessHost: '',
|
|
16
|
+
wirelessPort: 5555,
|
|
17
|
+
pairingPort: 0,
|
|
18
|
+
autoConnect: false,
|
|
19
|
+
videoMaxSize: 720,
|
|
20
|
+
videoBitRate: 2500000,
|
|
21
|
+
videoFps: 30,
|
|
22
|
+
previewFps: 3,
|
|
23
|
+
logcatMaxLines: 5000,
|
|
24
|
+
adbTimeoutMs: 15000,
|
|
25
|
+
scrcpyExtraArgs: '',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 读取可能是 Volatile 引用的配置值。 */
|
|
29
|
+
export function readLive(value, fallback) {
|
|
30
|
+
if (value === undefined || value === null) return fallback
|
|
31
|
+
if (typeof value === 'object' && typeof value.get === 'function') {
|
|
32
|
+
try {
|
|
33
|
+
const current = value.get()
|
|
34
|
+
return current === undefined || current === null ? fallback : current
|
|
35
|
+
} catch {
|
|
36
|
+
return fallback
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 整数钳制。 */
|
|
43
|
+
export function clampInt(value, min, max, fallback) {
|
|
44
|
+
const numeric = typeof value === 'number' ? value : Number(value)
|
|
45
|
+
if (!Number.isFinite(numeric)) return fallback
|
|
46
|
+
return Math.min(max, Math.max(min, Math.round(numeric)))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 布尔读取:只有明确写成 false / 'false' / '0' / 'off' 才算关。 */
|
|
50
|
+
export function readBool(value, fallback) {
|
|
51
|
+
if (value === undefined || value === null) return fallback
|
|
52
|
+
if (typeof value === 'boolean') return value
|
|
53
|
+
if (typeof value === 'number') return value !== 0
|
|
54
|
+
const text = String(value).trim().toLowerCase()
|
|
55
|
+
if (text === '') return fallback
|
|
56
|
+
if (['false', '0', 'off', 'no', 'n'].includes(text)) return false
|
|
57
|
+
if (['true', '1', 'on', 'yes', 'y'].includes(text)) return true
|
|
58
|
+
return fallback
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 把运行期配置收成普通对象。 */
|
|
62
|
+
export function snapshotConfig(config) {
|
|
63
|
+
const source = config ?? {}
|
|
64
|
+
return {
|
|
65
|
+
adbPath: String(readLive(source.adbPath, DEFAULTS.adbPath) ?? '').trim(),
|
|
66
|
+
scrcpyPath: String(readLive(source.scrcpyPath, DEFAULTS.scrcpyPath) ?? '').trim(),
|
|
67
|
+
wirelessHost: String(readLive(source.wirelessHost, DEFAULTS.wirelessHost) ?? '').trim(),
|
|
68
|
+
wirelessPort: clampInt(readLive(source.wirelessPort, DEFAULTS.wirelessPort), 1, 65535, DEFAULTS.wirelessPort),
|
|
69
|
+
pairingPort: clampInt(readLive(source.pairingPort, DEFAULTS.pairingPort), 0, 65535, DEFAULTS.pairingPort),
|
|
70
|
+
autoConnect: readBool(readLive(source.autoConnect, DEFAULTS.autoConnect), DEFAULTS.autoConnect),
|
|
71
|
+
videoMaxSize: clampInt(readLive(source.videoMaxSize, DEFAULTS.videoMaxSize), 320, 4096, DEFAULTS.videoMaxSize),
|
|
72
|
+
videoBitRate: clampInt(readLive(source.videoBitRate, DEFAULTS.videoBitRate), 100000, 50000000, DEFAULTS.videoBitRate),
|
|
73
|
+
videoFps: clampInt(readLive(source.videoFps, DEFAULTS.videoFps), 1, 120, DEFAULTS.videoFps),
|
|
74
|
+
previewFps: clampInt(readLive(source.previewFps, DEFAULTS.previewFps), 1, 10, DEFAULTS.previewFps),
|
|
75
|
+
logcatMaxLines: clampInt(readLive(source.logcatMaxLines, DEFAULTS.logcatMaxLines), 100, 100000, DEFAULTS.logcatMaxLines),
|
|
76
|
+
adbTimeoutMs: clampInt(readLive(source.adbTimeoutMs, DEFAULTS.adbTimeoutMs), 1000, 120000, DEFAULTS.adbTimeoutMs),
|
|
77
|
+
scrcpyExtraArgs: String(readLive(source.scrcpyExtraArgs, DEFAULTS.scrcpyExtraArgs) ?? '').trim(),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 解析 adb / scrcpy / scrcpy-server 三个随包路径。
|
|
83
|
+
* 显式路径优先;否则用 <pluginRoot>/scrcpy-win64-v5.0.1 下的可执行文件。
|
|
84
|
+
*
|
|
85
|
+
* @param {object} options
|
|
86
|
+
* @param {string} options.pluginRoot
|
|
87
|
+
* @param {string} [options.adbPath]
|
|
88
|
+
* @param {string} [options.scrcpyPath]
|
|
89
|
+
* @param {string} [options.platform]
|
|
90
|
+
* @param {typeof import('node:path').join} options.join
|
|
91
|
+
* @returns {{adbPath:string, scrcpyPath:string, serverPath:string, bundledDir:string}}
|
|
92
|
+
*/
|
|
93
|
+
export function resolveExecutablePaths(options) {
|
|
94
|
+
const join = options.join
|
|
95
|
+
const platform = options.platform ?? process.platform
|
|
96
|
+
const exe = platform === 'win32' ? '.exe' : ''
|
|
97
|
+
const bundledDir = join(options.pluginRoot, SCRCPY_DIR)
|
|
98
|
+
const adbPath = String(options.adbPath ?? '').trim() || join(bundledDir, `adb${exe}`)
|
|
99
|
+
const scrcpyPath = String(options.scrcpyPath ?? '').trim() || join(bundledDir, `scrcpy${exe}`)
|
|
100
|
+
const serverPath = join(bundledDir, 'scrcpy-server')
|
|
101
|
+
return { adbPath, scrcpyPath, serverPath, bundledDir }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 数据根目录:显式 dataDir → DSH profile → 用户主目录。
|
|
106
|
+
* 存放 state.json 与导出的 logcat 文件。
|
|
107
|
+
*
|
|
108
|
+
* @param {object} options
|
|
109
|
+
* @param {string} [options.dataDir]
|
|
110
|
+
* @param {string} [options.profileDir]
|
|
111
|
+
* @param {string} [options.homeDir]
|
|
112
|
+
* @param {typeof import('node:path').join} options.join
|
|
113
|
+
* @returns {{root:string, source:'dataDir'|'profile'|'home'}}
|
|
114
|
+
*/
|
|
115
|
+
export function resolveDataRoot(options) {
|
|
116
|
+
const join = options.join
|
|
117
|
+
const explicit = String(options.dataDir ?? '').trim()
|
|
118
|
+
if (explicit.length > 0) return { root: explicit, source: 'dataDir' }
|
|
119
|
+
const profile = String(options.profileDir ?? '').trim()
|
|
120
|
+
if (profile.length > 0) return { root: join(profile, '.dsh-android-tool'), source: 'profile' }
|
|
121
|
+
const home = String(options.homeDir ?? '').trim()
|
|
122
|
+
return { root: join(home || '.', '.dsh-android-tool'), source: 'home' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 按空格拆参数,支持单双引号与反斜杠转义。
|
|
127
|
+
* 用于 scrcpyExtraArgs 与「指令」页的 adb 模式。
|
|
128
|
+
*
|
|
129
|
+
* @param {string} text
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function splitArgs(text) {
|
|
133
|
+
const source = String(text ?? '')
|
|
134
|
+
const out = []
|
|
135
|
+
let current = ''
|
|
136
|
+
let quote = ''
|
|
137
|
+
let escaped = false
|
|
138
|
+
let started = false
|
|
139
|
+
for (const char of source) {
|
|
140
|
+
if (escaped) {
|
|
141
|
+
current += char
|
|
142
|
+
escaped = false
|
|
143
|
+
started = true
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (char === '\\') {
|
|
147
|
+
escaped = true
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (quote !== '') {
|
|
151
|
+
if (char === quote) quote = ''
|
|
152
|
+
else current += char
|
|
153
|
+
started = true
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (char === '"' || char === "'") {
|
|
157
|
+
quote = char
|
|
158
|
+
started = true
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (/\s/u.test(char)) {
|
|
162
|
+
if (started) {
|
|
163
|
+
out.push(current)
|
|
164
|
+
current = ''
|
|
165
|
+
started = false
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
current += char
|
|
170
|
+
started = true
|
|
171
|
+
}
|
|
172
|
+
if (escaped) current += '\\'
|
|
173
|
+
if (started) out.push(current)
|
|
174
|
+
return out
|
|
175
|
+
}
|
package/lib/logcat.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logcat:设备日志采集、过滤、包名扫描与导出。
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* - 一个设备一个 logcat 子进程,环形缓冲有上限,内存不会无限涨。
|
|
6
|
+
* - 过滤(包名 / 关键词 / 级别)在服务端做;包名过滤靠 ps 的 PID 映射。
|
|
7
|
+
* - Unity 富文本只解析 <color=...> 标签,输出 segments,前端不用 innerHTML。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
12
|
+
|
|
13
|
+
/** 日志级别排序(越大越严重)。 */
|
|
14
|
+
const LEVEL_ORDER = { V: 0, D: 1, I: 2, W: 3, E: 4, F: 5, S: 6 }
|
|
15
|
+
|
|
16
|
+
/** threadtime 行:`12-21 16:30:45.123 1234 5678 I Tag: message` */
|
|
17
|
+
const LINE_RE = /^(\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEFS])\s+(.*?)\s*:\s?(.*)$/u
|
|
18
|
+
|
|
19
|
+
/** 把 Unity 颜色名/十六进制规范化;非法值返回 undefined。 */
|
|
20
|
+
export function normalizeColor(raw) {
|
|
21
|
+
const text = String(raw ?? '').trim()
|
|
22
|
+
if (/^#[0-9a-f]{3}$/iu.test(text)) {
|
|
23
|
+
return `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`
|
|
24
|
+
}
|
|
25
|
+
if (/^#[0-9a-f]{4}$/iu.test(text)) {
|
|
26
|
+
return `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`
|
|
27
|
+
}
|
|
28
|
+
if (/^#[0-9a-f]{6}$/iu.test(text)) return text
|
|
29
|
+
if (/^#[0-9a-f]{8}$/iu.test(text)) return text.slice(0, 7)
|
|
30
|
+
if (/^[a-z]{3,20}$/iu.test(text)) return text.toLowerCase()
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 解析 Unity 富文本颜色,返回 [{ text, color? }]。
|
|
36
|
+
* 只认 <color=...> / </color>,其它标签原样保留在文本里。
|
|
37
|
+
*
|
|
38
|
+
* @param {string} input
|
|
39
|
+
* @returns {Array<{text:string,color?:string}>}
|
|
40
|
+
*/
|
|
41
|
+
export function parseUnityColor(input) {
|
|
42
|
+
const text = String(input ?? '')
|
|
43
|
+
const segments = []
|
|
44
|
+
const stack = []
|
|
45
|
+
let current
|
|
46
|
+
let last = 0
|
|
47
|
+
let matched = false
|
|
48
|
+
const re = /<color=(?:"([^"]*)"|([^>]+))>|<\/color>/giu
|
|
49
|
+
let match
|
|
50
|
+
while ((match = re.exec(text)) !== null) {
|
|
51
|
+
matched = true
|
|
52
|
+
if (match.index > last) {
|
|
53
|
+
segments.push({ text: text.slice(last, match.index), ...(current === undefined ? {} : { color: current }) })
|
|
54
|
+
}
|
|
55
|
+
const whole = match[0]
|
|
56
|
+
if (/^<\/color>/iu.test(whole)) {
|
|
57
|
+
stack.pop()
|
|
58
|
+
current = stack.length > 0 ? stack[stack.length - 1] : undefined
|
|
59
|
+
} else {
|
|
60
|
+
const color = normalizeColor(match[1] ?? match[2])
|
|
61
|
+
stack.push(color)
|
|
62
|
+
current = color
|
|
63
|
+
}
|
|
64
|
+
last = re.lastIndex
|
|
65
|
+
}
|
|
66
|
+
if (!matched) return [{ text }]
|
|
67
|
+
if (last < text.length) {
|
|
68
|
+
segments.push({ text: text.slice(last), ...(current === undefined ? {} : { color: current }) })
|
|
69
|
+
}
|
|
70
|
+
if (segments.length === 0) return [{ text: '' }]
|
|
71
|
+
return segments
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 解析一行 threadtime 日志。
|
|
76
|
+
* @param {string} line
|
|
77
|
+
* @returns {{date:string,time:string,pid:number,tid:number,level:string,tag:string,msg:string}|undefined}
|
|
78
|
+
*/
|
|
79
|
+
export function parseThreadTimeLine(line) {
|
|
80
|
+
const match = LINE_RE.exec(String(line ?? '').trimEnd())
|
|
81
|
+
if (match === null) return undefined
|
|
82
|
+
return {
|
|
83
|
+
date: match[1],
|
|
84
|
+
time: match[2],
|
|
85
|
+
pid: Number(match[3]),
|
|
86
|
+
tid: Number(match[4]),
|
|
87
|
+
level: match[5],
|
|
88
|
+
tag: match[6].trim(),
|
|
89
|
+
msg: match[7],
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 级别是否满足最低要求。 */
|
|
94
|
+
export function levelPasses(entryLevel, minLevel) {
|
|
95
|
+
const min = String(minLevel ?? 'V').toUpperCase()
|
|
96
|
+
if (!(min in LEVEL_ORDER)) return true
|
|
97
|
+
return (LEVEL_ORDER[String(entryLevel ?? 'V').toUpperCase()] ?? 0) >= LEVEL_ORDER[min]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 杀进程树。 */
|
|
101
|
+
function killTree(child) {
|
|
102
|
+
if (child === undefined || child === null || child.killed) return
|
|
103
|
+
try {
|
|
104
|
+
if (process.platform === 'win32') {
|
|
105
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
106
|
+
killer.on('error', () => {})
|
|
107
|
+
} else {
|
|
108
|
+
child.kill('SIGKILL')
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// 忽略
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Logcat 服务。 */
|
|
116
|
+
export class LogcatService {
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} options
|
|
119
|
+
* @param {import('./adb.js').Adb} options.adb
|
|
120
|
+
* @param {() => object} options.getConfig
|
|
121
|
+
* @param {object} [options.logger]
|
|
122
|
+
*/
|
|
123
|
+
constructor(options) {
|
|
124
|
+
this.adb = options.adb
|
|
125
|
+
this.getConfig = options.getConfig
|
|
126
|
+
this.logger = options.logger
|
|
127
|
+
|
|
128
|
+
this.entries = []
|
|
129
|
+
this.seq = 0
|
|
130
|
+
this.serial = ''
|
|
131
|
+
this.child = undefined
|
|
132
|
+
this.running = false
|
|
133
|
+
this.lastError = ''
|
|
134
|
+
this.lastExitAt = 0
|
|
135
|
+
this.startedAt = 0
|
|
136
|
+
|
|
137
|
+
this.pidMap = new Map()
|
|
138
|
+
this.pidMapAt = 0
|
|
139
|
+
this.packagesCache = undefined
|
|
140
|
+
this.packagesAt = 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get maxLines() {
|
|
144
|
+
return this.getConfig().logcatMaxLines
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 开始采集(同设备已在跑就复用)。 */
|
|
148
|
+
async ensure(serial) {
|
|
149
|
+
const target = String(serial ?? '').trim()
|
|
150
|
+
if (target.length === 0) return { ok: false, error: '没有选中设备。' }
|
|
151
|
+
if (this.running && this.serial === target) return { ok: true }
|
|
152
|
+
if (this.running && this.serial !== target) this.stop()
|
|
153
|
+
if (this.running !== true && this.lastExitAt > 0 && Date.now() - this.lastExitAt < 2000) {
|
|
154
|
+
return { ok: false, error: this.lastError || 'logcat 刚退出,稍后重试。' }
|
|
155
|
+
}
|
|
156
|
+
return this.start(target)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 真正启动 logcat 子进程。 */
|
|
160
|
+
async start(serial) {
|
|
161
|
+
const resolved = this.adb.resolve()
|
|
162
|
+
if (!resolved.ok) {
|
|
163
|
+
this.lastError = resolved.error
|
|
164
|
+
return { ok: false, error: resolved.error }
|
|
165
|
+
}
|
|
166
|
+
this.stop()
|
|
167
|
+
let child
|
|
168
|
+
try {
|
|
169
|
+
child = spawn(resolved.path, ['-s', serial, 'logcat', '-v', 'threadtime'], {
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
172
|
+
})
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.lastError = error instanceof Error ? error.message : String(error)
|
|
175
|
+
return { ok: false, error: this.lastError }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.child = child
|
|
179
|
+
this.serial = serial
|
|
180
|
+
this.running = true
|
|
181
|
+
this.lastError = ''
|
|
182
|
+
this.startedAt = Date.now()
|
|
183
|
+
this.entries = []
|
|
184
|
+
this.seq = 0
|
|
185
|
+
|
|
186
|
+
const decoder = new StringDecoder('utf8')
|
|
187
|
+
let buffer = ''
|
|
188
|
+
const consume = (text) => {
|
|
189
|
+
buffer += text
|
|
190
|
+
let index = buffer.indexOf('\n')
|
|
191
|
+
while (index >= 0) {
|
|
192
|
+
const line = buffer.slice(0, index).replace(/\r$/u, '')
|
|
193
|
+
buffer = buffer.slice(index + 1)
|
|
194
|
+
this.appendLine(line)
|
|
195
|
+
index = buffer.indexOf('\n')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
child.stdout.on('data', (chunk) => consume(decoder.write(chunk)))
|
|
200
|
+
child.stderr.on('data', (chunk) => {
|
|
201
|
+
const text = String(chunk).trim()
|
|
202
|
+
if (text.length > 0) {
|
|
203
|
+
this.lastError = text.slice(0, 500)
|
|
204
|
+
this.logger?.warn?.(`dsh-android-tool: logcat ${text.slice(0, 200)}`)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
child.on('error', (error) => {
|
|
208
|
+
this.running = false
|
|
209
|
+
this.lastError = error instanceof Error ? error.message : String(error)
|
|
210
|
+
})
|
|
211
|
+
child.on('close', (code) => {
|
|
212
|
+
if (this.child !== child) return
|
|
213
|
+
this.running = false
|
|
214
|
+
this.lastExitAt = Date.now()
|
|
215
|
+
if (code !== 0 && code !== null) this.lastError = this.lastError || `logcat 退出码 ${code}`
|
|
216
|
+
})
|
|
217
|
+
return { ok: true, serial }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 追加一行 / 合并续行。 */
|
|
221
|
+
appendLine(line) {
|
|
222
|
+
if (line.length === 0) return
|
|
223
|
+
if (/^-{5,}\s*beginning of/iu.test(line.trim())) {
|
|
224
|
+
this.entries.push({ id: ++this.seq, raw: line, marker: true, level: 'V', tag: '', msg: line, segments: [{ text: line }] })
|
|
225
|
+
this.trim()
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const parsed = parseThreadTimeLine(line)
|
|
229
|
+
if (parsed === undefined) {
|
|
230
|
+
const last = this.entries[this.entries.length - 1]
|
|
231
|
+
if (last !== undefined && last.marker !== true) {
|
|
232
|
+
last.raw += `\n${line}`
|
|
233
|
+
last.msg += `\n${line}`
|
|
234
|
+
last.segments = parseUnityColor(last.msg)
|
|
235
|
+
}
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const entry = {
|
|
239
|
+
id: ++this.seq,
|
|
240
|
+
raw: line,
|
|
241
|
+
date: parsed.date,
|
|
242
|
+
time: parsed.time,
|
|
243
|
+
pid: parsed.pid,
|
|
244
|
+
tid: parsed.tid,
|
|
245
|
+
level: parsed.level,
|
|
246
|
+
tag: parsed.tag,
|
|
247
|
+
msg: parsed.msg,
|
|
248
|
+
segments: parseUnityColor(parsed.msg),
|
|
249
|
+
}
|
|
250
|
+
this.entries.push(entry)
|
|
251
|
+
this.trim()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 环形缓冲裁剪。 */
|
|
255
|
+
trim() {
|
|
256
|
+
const max = this.maxLines
|
|
257
|
+
if (this.entries.length > max) this.entries.splice(0, this.entries.length - max)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 更新包名 → PID 映射。 */
|
|
261
|
+
async ensurePidMap(serial) {
|
|
262
|
+
if (Date.now() - this.pidMapAt < 2500) return
|
|
263
|
+
this.pidMapAt = Date.now()
|
|
264
|
+
const result = await this.adb.run(['shell', 'ps -A -o PID,NAME'], { serial, timeoutMs: 8000 })
|
|
265
|
+
if (result.ok !== true) return
|
|
266
|
+
const map = new Map()
|
|
267
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
268
|
+
const parts = line.trim().split(/\s+/u)
|
|
269
|
+
if (parts.length < 2) continue
|
|
270
|
+
const pid = Number(parts[0])
|
|
271
|
+
if (!Number.isFinite(pid)) continue
|
|
272
|
+
const name = parts.slice(1).join(' ')
|
|
273
|
+
const pkg = name.split(':')[0]
|
|
274
|
+
if (!pkg.includes('.')) continue
|
|
275
|
+
if (!map.has(pkg)) map.set(pkg, new Set())
|
|
276
|
+
map.get(pkg).add(pid)
|
|
277
|
+
}
|
|
278
|
+
this.pidMap = map
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 扫描运行中 / 已安装 / 前台包名。 */
|
|
282
|
+
async scanPackages(serial) {
|
|
283
|
+
if (this.packagesCache !== undefined && Date.now() - this.packagesAt < 3000) return this.packagesCache
|
|
284
|
+
const command = [
|
|
285
|
+
"echo '==PS'",
|
|
286
|
+
'ps -A -o PID,NAME 2>/dev/null',
|
|
287
|
+
"echo '==PKG'",
|
|
288
|
+
'pm list packages -3 2>/dev/null',
|
|
289
|
+
"echo '==FG'",
|
|
290
|
+
"dumpsys activity activities 2>/dev/null | grep -E 'mResumedActivity|topResumedActivity' | head -n 5",
|
|
291
|
+
].join('; ')
|
|
292
|
+
const result = await this.adb.run(['shell', command], { serial, timeoutMs: 12000 })
|
|
293
|
+
if (result.ok !== true) return { ok: false, error: result.error, running: [], installed: [], foreground: '' }
|
|
294
|
+
|
|
295
|
+
const sections = { PS: [], PKG: [], FG: [] }
|
|
296
|
+
let current = ''
|
|
297
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
298
|
+
const trimmed = line.trim()
|
|
299
|
+
const marker = /^==(PS|PKG|FG)$/u.exec(trimmed)
|
|
300
|
+
if (marker !== null) {
|
|
301
|
+
current = marker[1]
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
if (current !== '' && trimmed.length > 0) sections[current].push(trimmed)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const installed = sections.PKG.map((line) => line.replace(/^package:/u, '').trim()).filter((name) => name.length > 0)
|
|
308
|
+
const installedSet = new Set(installed)
|
|
309
|
+
const running = []
|
|
310
|
+
const seen = new Set()
|
|
311
|
+
for (const line of sections.PS) {
|
|
312
|
+
const parts = line.split(/\s+/u)
|
|
313
|
+
if (parts.length < 2) continue
|
|
314
|
+
const pid = Number(parts[0])
|
|
315
|
+
if (!Number.isFinite(pid)) continue
|
|
316
|
+
const name = parts.slice(1).join(' ')
|
|
317
|
+
const pkg = name.split(':')[0]
|
|
318
|
+
if (!pkg.includes('.')) continue
|
|
319
|
+
if (seen.has(pkg)) continue
|
|
320
|
+
seen.add(pkg)
|
|
321
|
+
running.push({ pid, name, package: pkg, isSystem: !installedSet.has(pkg) })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let foreground = ''
|
|
325
|
+
for (const line of sections.FG) {
|
|
326
|
+
const match = /([a-zA-Z][\w.]*\/[\w.$]+)/u.exec(line)
|
|
327
|
+
if (match !== null) {
|
|
328
|
+
foreground = match[1].split('/')[0]
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const cache = { ok: true, running, installed, foreground }
|
|
334
|
+
this.packagesCache = cache
|
|
335
|
+
this.packagesAt = Date.now()
|
|
336
|
+
return cache
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 读取增量日志。 */
|
|
340
|
+
async read(options = {}) {
|
|
341
|
+
const serial = String(options.serial ?? '').trim()
|
|
342
|
+
if (serial.length === 0) return { ok: false, error: '没有选中设备。', entries: [], cursor: 0 }
|
|
343
|
+
const ensured = await this.ensure(serial)
|
|
344
|
+
if (ensured.ok !== true && this.running !== true) {
|
|
345
|
+
return { ok: false, error: ensured.error, entries: [], cursor: 0, running: false }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const cursor = Number(options.cursor) || 0
|
|
349
|
+
const level = String(options.level ?? 'V')
|
|
350
|
+
const keyword = String(options.q ?? '').trim().toLowerCase()
|
|
351
|
+
const pkg = String(options.pkg ?? '').trim()
|
|
352
|
+
if (pkg.length > 0) await this.ensurePidMap(serial)
|
|
353
|
+
const pidSet = pkg.length > 0 ? this.pidMap.get(pkg) : undefined
|
|
354
|
+
if (pkg.length > 0 && pidSet === undefined) {
|
|
355
|
+
// 包名存在但当前没有进程:返回空,不把全量日志倒给前端。
|
|
356
|
+
return { ok: true, entries: [], cursor: this.seq, running: this.running, serial }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(5000, options.limit)) : 500
|
|
360
|
+
const entries = []
|
|
361
|
+
for (const entry of this.entries) {
|
|
362
|
+
if (entry.id <= cursor) continue
|
|
363
|
+
if (entry.marker === true) continue
|
|
364
|
+
if (levelPasses(entry.level, level) !== true) continue
|
|
365
|
+
if (keyword.length > 0 && entry.raw.toLowerCase().includes(keyword) !== true) continue
|
|
366
|
+
if (pidSet !== undefined && pidSet.has(entry.pid) !== true) continue
|
|
367
|
+
entries.push(entry)
|
|
368
|
+
}
|
|
369
|
+
const slice = entries.length > limit ? entries.slice(entries.length - limit) : entries
|
|
370
|
+
return { ok: true, entries: slice, cursor: this.seq, running: this.running, serial, error: this.lastError || undefined }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 当前缓冲里满足过滤条件的纯文本(导出用)。 */
|
|
374
|
+
exportText(options = {}) {
|
|
375
|
+
const level = String(options.level ?? 'V')
|
|
376
|
+
const keyword = String(options.q ?? '').trim().toLowerCase()
|
|
377
|
+
const pkg = String(options.pkg ?? '').trim()
|
|
378
|
+
const pidSet = pkg.length > 0 ? this.pidMap.get(pkg) : undefined
|
|
379
|
+
if (pkg.length > 0 && pidSet === undefined) return ''
|
|
380
|
+
const lines = []
|
|
381
|
+
for (const entry of this.entries) {
|
|
382
|
+
if (entry.marker === true) continue
|
|
383
|
+
if (levelPasses(entry.level, level) !== true) continue
|
|
384
|
+
if (keyword.length > 0 && entry.raw.toLowerCase().includes(keyword) !== true) continue
|
|
385
|
+
if (pidSet !== undefined && pidSet.has(entry.pid) !== true) continue
|
|
386
|
+
lines.push(entry.raw)
|
|
387
|
+
}
|
|
388
|
+
return lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 停止采集。 */
|
|
392
|
+
stop() {
|
|
393
|
+
if (this.child !== undefined) {
|
|
394
|
+
killTree(this.child)
|
|
395
|
+
this.child = undefined
|
|
396
|
+
}
|
|
397
|
+
this.running = false
|
|
398
|
+
this.serial = ''
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 插件卸载。 */
|
|
402
|
+
dispose() {
|
|
403
|
+
this.stop()
|
|
404
|
+
}
|
|
405
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径布局:插件根目录、数据目录、日志导出目录。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
/** 从入口 import.meta.url 取插件根目录。 */
|
|
9
|
+
export function pluginRootFrom(importMetaUrl) {
|
|
10
|
+
return fileURLToPath(new URL('.', importMetaUrl))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 数据根目录下各文件/目录的布局。 */
|
|
14
|
+
export function layoutAt(root) {
|
|
15
|
+
return {
|
|
16
|
+
root,
|
|
17
|
+
stateFile: join(root, 'state.json'),
|
|
18
|
+
logsDir: join(root, 'logs'),
|
|
19
|
+
}
|
|
20
|
+
}
|