@windypro-rourou/dsh-logcat 0.2.4 → 0.2.6
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/README.md +10 -5
- package/lib/client.js +48 -0
- package/lib/index.js +158 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,20 +10,25 @@ DSH Web GUI 的安卓实机调试面板(类似 Android Studio 的 Logcat 视
|
|
|
10
10
|
- **Logcat 面板**(侧边栏「Logcat」入口,右侧抽屉,**宽度可拖拽调整并记忆**):
|
|
11
11
|
- 设备下拉(显示型号/序列号/状态,记住上次选择)
|
|
12
12
|
- 级别过滤(V/D/I/W/E/F 单选,颜色与 Android Studio 一致)
|
|
13
|
-
-
|
|
13
|
+
- 关键词过滤、**测试包名输入框**(回车设置,与 agent 的 `logcat_set_package` 互通,状态栏实时显示)
|
|
14
|
+
- **截图按钮**:一键截取真机屏幕并下载 PNG(`exec-out screencap`)
|
|
15
|
+
- 暂停/继续(暂停时缓冲,恢复自动回放)、清空、复制、导出 .txt
|
|
14
16
|
- 窗口化渲染 + 自动滚动(滚动手动上翻时自动停用)
|
|
15
17
|
- 未授权设备提示「请在手机上点击允许 USB 调试」
|
|
16
18
|
- **Agent 工具**:
|
|
17
19
|
- `logcat_devices`:列出已连接设备(serial / model / state),判断能否实机调试。
|
|
18
|
-
- `adb_exec`:在指定设备执行 `adb shell`
|
|
20
|
+
- `adb_exec`:在指定设备执行 `adb shell` 命令(启动 Activity、查进程、dump UI 等),
|
|
19
21
|
破坏性操作(卸载 / 重启 / 清数据)需先确认。
|
|
22
|
+
- `adb_install`:把**本地 APK 安装到真机**(`adb install -r <本地路径>`,构建产物直接部署)。
|
|
23
|
+
- `adb_pull`:从设备拉取文件到本地(截图 / 日志 / bugreport)。
|
|
20
24
|
- `logcat_set_package`:设置 / 清除当前测试的 app 包名(安装 / 启动应用后调用)。
|
|
21
25
|
- `logcat_recent`:读取某设备最近 N 条日志,支持级别 / 关键词过滤;
|
|
22
26
|
设置了测试包名(或显式传 `package`)时自动按该 app 的 pid 过滤日志。
|
|
23
27
|
- **实机调试工作流**:构建安卓应用时,agent 的通告会动态列出当前已连接设备(serial + 型号)与当前测试包名,
|
|
24
|
-
可先向用户确认后用 `adb_exec`
|
|
25
|
-
|
|
26
|
-
- **附加能力**:`POST /api/dsh-logcat/exec`
|
|
28
|
+
可先向用户确认后用 `adb_install` 部署 APK → `adb_exec` 启动 → `logcat_set_package` 锁定目标 app →
|
|
29
|
+
`logcat_recent` 按包名查看崩溃日志 → `adb_pull` 拉取截图 / 日志,闭环真机调试。
|
|
30
|
+
- **附加能力**:`POST /api/dsh-logcat/exec` 执行 shell、`POST /api/dsh-logcat/package` 设置包名、
|
|
31
|
+
`GET /api/dsh-logcat/screenshot` 截屏。
|
|
27
32
|
|
|
28
33
|
## 安装
|
|
29
34
|
|
package/lib/client.js
CHANGED
|
@@ -108,6 +108,8 @@ window.__ModuleLoader__.load({
|
|
|
108
108
|
const [entries, setEntries] = useState([]);
|
|
109
109
|
const [connected, setConnected] = useState(false);
|
|
110
110
|
const [paused, setPaused] = useState(false);
|
|
111
|
+
const [currentPackage, setCurrentPackage] = useState("");
|
|
112
|
+
const [pkgInput, setPkgInput] = useState("");
|
|
111
113
|
const [level, setLevel] = useState("");
|
|
112
114
|
const [keyword, setKeyword] = useState("");
|
|
113
115
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
@@ -190,6 +192,8 @@ window.__ModuleLoader__.load({
|
|
|
190
192
|
appendEntries([frame.entry]);
|
|
191
193
|
} else if (frame.type === "device-state") {
|
|
192
194
|
setDevices(devicesRef.current.map((d) => d.serial === frame.serial ? { ...d, state: frame.state } : d));
|
|
195
|
+
} else if (frame.type === "package") {
|
|
196
|
+
setCurrentPackage(frame.package ?? "");
|
|
193
197
|
}
|
|
194
198
|
};
|
|
195
199
|
applyFrameRef.current = applyFrame;
|
|
@@ -240,6 +244,7 @@ window.__ModuleLoader__.load({
|
|
|
240
244
|
setAdbReady(body.ready === true);
|
|
241
245
|
setDevices(body.devices ?? []);
|
|
242
246
|
setStreaming(body.streaming ?? []);
|
|
247
|
+
setCurrentPackage(body.currentPackage ?? "");
|
|
243
248
|
devicesRef.current = body.devices ?? [];
|
|
244
249
|
if (serialRef.current === "" || !(body.devices ?? []).some((d) => d.serial === serialRef.current)) {
|
|
245
250
|
const first = (body.devices ?? []).find((d) => d.state === "device");
|
|
@@ -296,6 +301,34 @@ window.__ModuleLoader__.load({
|
|
|
296
301
|
const deviceState = device?.state ?? "";
|
|
297
302
|
const live = connected && serial !== "" && streaming.includes(serial);
|
|
298
303
|
|
|
304
|
+
const setPackageFilter = (pkg) => {
|
|
305
|
+
const value = (pkg ?? "").trim();
|
|
306
|
+
setPkgInput(value);
|
|
307
|
+
fetch(API_BASE + "/package", {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "content-type": "application/json" },
|
|
310
|
+
body: JSON.stringify({ package: value }),
|
|
311
|
+
}).catch(() => { /* host not up yet */ });
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const takeScreenshot = () => {
|
|
315
|
+
fetch(API_BASE + "/screenshot?serial=" + encodeURIComponent(serial))
|
|
316
|
+
.then((res) => {
|
|
317
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
318
|
+
return res.blob();
|
|
319
|
+
})
|
|
320
|
+
.then((blob) => {
|
|
321
|
+
const a = document.createElement("a");
|
|
322
|
+
a.href = URL.createObjectURL(blob);
|
|
323
|
+
a.download = "logcat-shot-" + (serial || "device") + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".png";
|
|
324
|
+
document.body.appendChild(a);
|
|
325
|
+
a.click();
|
|
326
|
+
a.remove();
|
|
327
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
328
|
+
})
|
|
329
|
+
.catch(() => { /* device offline etc. */ });
|
|
330
|
+
};
|
|
331
|
+
|
|
299
332
|
return h("div", { className: "lc-panel" },
|
|
300
333
|
h("div", { className: "lc-header" },
|
|
301
334
|
h("button", { type: "button", className: "lc-back", onClick: () => controller.close() },
|
|
@@ -343,6 +376,18 @@ window.__ModuleLoader__.load({
|
|
|
343
376
|
value: keyword,
|
|
344
377
|
onChange: (e) => setKeyword(e.target.value),
|
|
345
378
|
}),
|
|
379
|
+
h("input", {
|
|
380
|
+
className: "lc-search",
|
|
381
|
+
type: "text",
|
|
382
|
+
placeholder: "测试包名(回车)…",
|
|
383
|
+
title: "设置/清除当前测试应用包名(与 agent 的 logcat_set_package 互通)",
|
|
384
|
+
value: pkgInput,
|
|
385
|
+
onChange: (e) => setPkgInput(e.target.value),
|
|
386
|
+
onKeyDown: (e) => { if (e.key === "Enter") setPackageFilter(pkgInput); },
|
|
387
|
+
style: { maxWidth: 180 },
|
|
388
|
+
}),
|
|
389
|
+
h("button", { type: "button", className: "lc-btn", onClick: () => setPackageFilter(pkgInput), title: "按包名过滤日志(agent 侧 logcat_recent 同步生效)" }, "包名"),
|
|
390
|
+
h("button", { type: "button", className: "lc-btn", onClick: takeScreenshot, title: "截取真机屏幕并下载 PNG" }, "截图"),
|
|
346
391
|
h("button", { type: "button", className: "lc-btn", "data-on": paused ? "" : undefined, onClick: togglePause },
|
|
347
392
|
paused ? "继续" : "暂停"),
|
|
348
393
|
h("button", { type: "button", className: "lc-btn", onClick: clearLog }, "清空"),
|
|
@@ -365,6 +410,9 @@ window.__ModuleLoader__.load({
|
|
|
365
410
|
h("span", null, h("b", null, adbReady ? "adb 就绪" : "未找到 adb"), " · " + (adbPath || "—")),
|
|
366
411
|
h("span", null, "设备 " + devices.length + " · 在线 " + devices.filter((d) => d.state === "device").length),
|
|
367
412
|
h("span", null, "显示 " + filtered.length + " / 缓冲 " + entries.length + " 行"),
|
|
413
|
+
currentPackage !== ""
|
|
414
|
+
? h("span", { title: "当前测试应用包名(agent 通过 logcat_set_package 设置)" }, "测试: " + currentPackage)
|
|
415
|
+
: null,
|
|
368
416
|
deviceState === "unauthorized"
|
|
369
417
|
? h("span", { style: { color: "#ef5350" } }, "⚠ 设备未授权 — 请在手机上点击“允许 USB 调试”")
|
|
370
418
|
: null,
|
package/lib/index.js
CHANGED
|
@@ -102,6 +102,20 @@ function runAdbFull(adb, args, timeoutMs = 15000) {
|
|
|
102
102
|
})
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** Run one adb command that emits binary (e.g. exec-out screencap), returning a Buffer. */
|
|
106
|
+
function runAdbBinary(adb, args, timeoutMs = 20000) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
try {
|
|
109
|
+
execFile(adb, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 32 * 1024 * 1024, encoding: 'buffer' }, (error, stdout) => {
|
|
110
|
+
if (error) reject(error)
|
|
111
|
+
else resolve(stdout)
|
|
112
|
+
})
|
|
113
|
+
} catch (error) {
|
|
114
|
+
reject(error)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
/** threadtime line: "08-18 14:23:45.678 1234 5678 I Tag : message" */
|
|
106
120
|
const THREADTIME_RE = /^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+([^:]*?)\s*:\s?(.*)$/
|
|
107
121
|
|
|
@@ -432,6 +446,52 @@ function makeRoutes(engine) {
|
|
|
432
446
|
}
|
|
433
447
|
},
|
|
434
448
|
},
|
|
449
|
+
{
|
|
450
|
+
kind: 'exact',
|
|
451
|
+
path: API_BASE + '/package',
|
|
452
|
+
handler: async (req, res) => {
|
|
453
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
454
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
455
|
+
const chunks = []
|
|
456
|
+
for await (const chunk of req) {
|
|
457
|
+
if (chunks.length + chunk.length > 64 * 1024) { writeJson(res, 413, { error: 'body too large' }); return }
|
|
458
|
+
chunks.push(chunk)
|
|
459
|
+
}
|
|
460
|
+
let body = {}
|
|
461
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { /* fallthrough */ }
|
|
462
|
+
const pkg = typeof body.package === 'string' ? body.package.trim() : ''
|
|
463
|
+
engine.currentPackage = pkg
|
|
464
|
+
engine.broadcast({ type: 'package', package: pkg })
|
|
465
|
+
writeJson(res, 200, { package: pkg })
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
kind: 'exact',
|
|
470
|
+
path: API_BASE + '/screenshot',
|
|
471
|
+
handler: async (req, res) => {
|
|
472
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
473
|
+
if ((req.method ?? 'GET') !== 'GET' && (req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
474
|
+
if (engine.adb === null) { writeJson(res, 500, { error: 'adb not found' }); return }
|
|
475
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
476
|
+
const requested = url.searchParams.get('serial') ?? ''
|
|
477
|
+
const serial = requested !== '' && engine.devices.has(requested)
|
|
478
|
+
? requested
|
|
479
|
+
: [...engine.devices.keys()][0] ?? ''
|
|
480
|
+
if (serial === '') { writeJson(res, 400, { error: 'no device attached' }); return }
|
|
481
|
+
try {
|
|
482
|
+
const png = await runAdbBinary(engine.adb, ['-s', serial, 'exec-out', 'screencap', '-p'], 20000)
|
|
483
|
+
res.writeHead(200, {
|
|
484
|
+
'content-type': 'image/png',
|
|
485
|
+
'cache-control': 'no-store',
|
|
486
|
+
'content-length': png.length,
|
|
487
|
+
'content-disposition': `inline; filename="logcat-${serial}-${Date.now()}.png"`,
|
|
488
|
+
})
|
|
489
|
+
res.end(png)
|
|
490
|
+
} catch (error) {
|
|
491
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
},
|
|
435
495
|
]
|
|
436
496
|
|
|
437
497
|
const upgrade = {
|
|
@@ -573,6 +633,8 @@ function logcatSetPackageTool(engine) {
|
|
|
573
633
|
async execute(args) {
|
|
574
634
|
const pkg = typeof args.package === 'string' ? args.package.trim() : ''
|
|
575
635
|
engine.currentPackage = pkg
|
|
636
|
+
// Let open panels reflect the current test package immediately.
|
|
637
|
+
engine.broadcast({ type: 'package', package: pkg })
|
|
576
638
|
return { package: pkg }
|
|
577
639
|
},
|
|
578
640
|
})
|
|
@@ -668,6 +730,100 @@ function adbExecTool(engine) {
|
|
|
668
730
|
})
|
|
669
731
|
}
|
|
670
732
|
|
|
733
|
+
/** The adb_install agent tool: install a local APK onto a device (adb install -r). */
|
|
734
|
+
function adbInstallTool(engine) {
|
|
735
|
+
return defineTool({
|
|
736
|
+
name: 'adb_install',
|
|
737
|
+
description: 'Install a local APK file onto an attached Android device (adb install -r). ' +
|
|
738
|
+
'Use after building an APK to deploy it to the real device for debugging. ' +
|
|
739
|
+
'The apkPath is a LOCAL path on this host (e.g. a build output).',
|
|
740
|
+
parameters: {
|
|
741
|
+
serial: { type: 'string', description: 'Device serial from logcat_devices (optional; defaults to the first attached device).' },
|
|
742
|
+
apkPath: { type: 'string', description: 'Absolute local path to the APK, e.g. "F:/app/build/outputs/apk/debug/app-debug.apk".' },
|
|
743
|
+
},
|
|
744
|
+
output: {
|
|
745
|
+
schema: {
|
|
746
|
+
type: 'object',
|
|
747
|
+
additionalProperties: false,
|
|
748
|
+
properties: {
|
|
749
|
+
ok: { type: 'boolean', required: true },
|
|
750
|
+
code: { type: 'integer' },
|
|
751
|
+
stdout: { type: 'string', required: true },
|
|
752
|
+
stderr: { type: 'string', required: true },
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
render: (_args, value) => {
|
|
756
|
+
const parts = []
|
|
757
|
+
if (value?.stdout) parts.push(value.stdout.trimEnd())
|
|
758
|
+
if (value?.stderr) parts.push('stderr: ' + value.stderr.trimEnd())
|
|
759
|
+
if (parts.length === 0) parts.push(value?.ok ? 'installed' : `install failed (exit ${value?.code ?? '?'})`)
|
|
760
|
+
return [{ type: 'text', text: parts.join('\n') }]
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
async execute(args) {
|
|
764
|
+
const devices = engine.deviceList()
|
|
765
|
+
const serial = typeof args.serial === 'string' && args.serial !== ''
|
|
766
|
+
? args.serial
|
|
767
|
+
: devices.find((d) => d.state === 'device')?.serial ?? ''
|
|
768
|
+
const apkPath = typeof args.apkPath === 'string' ? args.apkPath.trim() : ''
|
|
769
|
+
if (engine.adb === null) return { ok: false, code: null, stdout: '', stderr: 'adb not found on this host' }
|
|
770
|
+
if (serial === '') return { ok: false, code: null, stdout: '', stderr: 'no attached device; connect and authorize one first' }
|
|
771
|
+
if (apkPath === '') return { ok: false, code: null, stdout: '', stderr: 'apkPath is required' }
|
|
772
|
+
if (!existsSync(apkPath)) return { ok: false, code: null, stdout: '', stderr: `APK not found: ${apkPath}` }
|
|
773
|
+
const result = await runAdbFull(engine.adb, ['-s', serial, 'install', '-r', apkPath], 120000)
|
|
774
|
+
return { ok: result.ok, code: result.code, stdout: result.stdout, stderr: result.stderr }
|
|
775
|
+
},
|
|
776
|
+
})
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** The adb_pull agent tool: copy a file from the device to this host (adb pull). */
|
|
780
|
+
function adbPullTool(engine) {
|
|
781
|
+
return defineTool({
|
|
782
|
+
name: 'adb_pull',
|
|
783
|
+
description: 'Copy a file from an attached Android device to this host (adb pull) — ' +
|
|
784
|
+
'e.g. pull a screenshot saved via adb_exec (screencap -p /sdcard/x.png), a log file, or a bugreport.',
|
|
785
|
+
parameters: {
|
|
786
|
+
serial: { type: 'string', description: 'Device serial from logcat_devices (optional; defaults to the first attached device).' },
|
|
787
|
+
remotePath: { type: 'string', description: 'Path on the device, e.g. "/sdcard/screen.png".' },
|
|
788
|
+
localPath: { type: 'string', description: 'Absolute local destination path, e.g. "F:/shots/screen.png".' },
|
|
789
|
+
},
|
|
790
|
+
output: {
|
|
791
|
+
schema: {
|
|
792
|
+
type: 'object',
|
|
793
|
+
additionalProperties: false,
|
|
794
|
+
properties: {
|
|
795
|
+
ok: { type: 'boolean', required: true },
|
|
796
|
+
code: { type: 'integer' },
|
|
797
|
+
stdout: { type: 'string', required: true },
|
|
798
|
+
stderr: { type: 'string', required: true },
|
|
799
|
+
localPath: { type: 'string' },
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
render: (_args, value) => {
|
|
803
|
+
const parts = []
|
|
804
|
+
if (value?.ok && value?.localPath) parts.push(`pulled to ${value.localPath}`)
|
|
805
|
+
if (value?.stdout) parts.push(value.stdout.trimEnd())
|
|
806
|
+
if (value?.stderr) parts.push('stderr: ' + value.stderr.trimEnd())
|
|
807
|
+
if (parts.length === 0) parts.push(`pull failed (exit ${value?.code ?? '?'})`)
|
|
808
|
+
return [{ type: 'text', text: parts.join('\n') }]
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
async execute(args) {
|
|
812
|
+
const devices = engine.deviceList()
|
|
813
|
+
const serial = typeof args.serial === 'string' && args.serial !== ''
|
|
814
|
+
? args.serial
|
|
815
|
+
: devices.find((d) => d.state === 'device')?.serial ?? ''
|
|
816
|
+
const remotePath = typeof args.remotePath === 'string' ? args.remotePath.trim() : ''
|
|
817
|
+
const localPath = typeof args.localPath === 'string' ? args.localPath.trim() : ''
|
|
818
|
+
if (engine.adb === null) return { ok: false, code: null, stdout: '', stderr: 'adb not found on this host' }
|
|
819
|
+
if (serial === '') return { ok: false, code: null, stdout: '', stderr: 'no attached device; connect and authorize one first' }
|
|
820
|
+
if (remotePath === '' || localPath === '') return { ok: false, code: null, stdout: '', stderr: 'remotePath and localPath are required' }
|
|
821
|
+
const result = await runAdbFull(engine.adb, ['-s', serial, 'pull', remotePath, localPath], 120000)
|
|
822
|
+
return { ok: result.ok, code: result.code, stdout: result.stdout, stderr: result.stderr, localPath: result.ok ? localPath : '' }
|
|
823
|
+
},
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
671
827
|
/** ------------------------------------------------------------------ */
|
|
672
828
|
|
|
673
829
|
/** Mount the adb engine, routes, tool, and announcement. */
|
|
@@ -737,6 +893,8 @@ export function apply(ctx, config) {
|
|
|
737
893
|
logcatDevicesTool(engine),
|
|
738
894
|
adbExecTool(engine),
|
|
739
895
|
logcatSetPackageTool(engine),
|
|
896
|
+
adbInstallTool(engine),
|
|
897
|
+
adbPullTool(engine),
|
|
740
898
|
].map((tool) => ctx.tools.register(tool))
|
|
741
899
|
return () => { for (const dispose of disposers) dispose() }
|
|
742
900
|
}, 'dsh-logcat: tools')
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windypro-rourou/dsh-logcat",
|
|
3
3
|
"description": "Android Logcat viewer for the dsh web GUI: auto-connects to any adb device in debug mode, live logcat stream with level/keyword filters, pause/clear/export, plus agent tools (logcat_recent). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules copy, no dsh source changes.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|