@windypro-rourou/dsh-logcat 0.2.5 → 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 +41 -0
- package/lib/index.js +156 -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
|
@@ -109,6 +109,7 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
const [connected, setConnected] = useState(false);
|
|
110
110
|
const [paused, setPaused] = useState(false);
|
|
111
111
|
const [currentPackage, setCurrentPackage] = useState("");
|
|
112
|
+
const [pkgInput, setPkgInput] = useState("");
|
|
112
113
|
const [level, setLevel] = useState("");
|
|
113
114
|
const [keyword, setKeyword] = useState("");
|
|
114
115
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
@@ -300,6 +301,34 @@ window.__ModuleLoader__.load({
|
|
|
300
301
|
const deviceState = device?.state ?? "";
|
|
301
302
|
const live = connected && serial !== "" && streaming.includes(serial);
|
|
302
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
|
+
|
|
303
332
|
return h("div", { className: "lc-panel" },
|
|
304
333
|
h("div", { className: "lc-header" },
|
|
305
334
|
h("button", { type: "button", className: "lc-back", onClick: () => controller.close() },
|
|
@@ -347,6 +376,18 @@ window.__ModuleLoader__.load({
|
|
|
347
376
|
value: keyword,
|
|
348
377
|
onChange: (e) => setKeyword(e.target.value),
|
|
349
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" }, "截图"),
|
|
350
391
|
h("button", { type: "button", className: "lc-btn", "data-on": paused ? "" : undefined, onClick: togglePause },
|
|
351
392
|
paused ? "继续" : "暂停"),
|
|
352
393
|
h("button", { type: "button", className: "lc-btn", onClick: clearLog }, "清空"),
|
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 = {
|
|
@@ -670,6 +730,100 @@ function adbExecTool(engine) {
|
|
|
670
730
|
})
|
|
671
731
|
}
|
|
672
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
|
+
|
|
673
827
|
/** ------------------------------------------------------------------ */
|
|
674
828
|
|
|
675
829
|
/** Mount the adb engine, routes, tool, and announcement. */
|
|
@@ -739,6 +893,8 @@ export function apply(ctx, config) {
|
|
|
739
893
|
logcatDevicesTool(engine),
|
|
740
894
|
adbExecTool(engine),
|
|
741
895
|
logcatSetPackageTool(engine),
|
|
896
|
+
adbInstallTool(engine),
|
|
897
|
+
adbPullTool(engine),
|
|
742
898
|
].map((tool) => ctx.tools.register(tool))
|
|
743
899
|
return () => { for (const dispose of disposers) dispose() }
|
|
744
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": {
|