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