@windypro-rourou/dsh-logcat 0.2.5 → 0.2.7

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 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
- - 关键词过滤、暂停/继续(暂停时缓冲,恢复自动回放)、清空、复制、导出 .txt
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` 命令(安装 APK、启动 Activity、查进程、截图、dump UI 等),
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` 安装 / 启动、`logcat_set_package` 锁定目标 app、`logcat_recent` 按包名查看崩溃日志,
25
- 闭环真机调试。
26
- - **附加能力**:`POST /api/dsh-logcat/exec` 可对设备执行 `adb shell` 命令(UI 后续版本可扩展)。
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 = {
@@ -536,15 +596,18 @@ function logcatRecentTool(engine) {
536
596
  const level = typeof args.level === 'string' ? args.level : ''
537
597
  const filter = typeof args.filter === 'string' ? args.filter : ''
538
598
  const pkg = (typeof args.package === 'string' ? args.package.trim() : '') || engine.currentPackage
599
+ // The declared output schema is strict (additionalProperties: false), so strip the
600
+ // internal raw/cont fields before returning — otherwise DSH rejects the result.
601
+ const toOutput = (list) => list.map((e) => ({ ts: e.ts, pid: e.pid, tid: e.tid, level: e.level, tag: e.tag, msg: e.msg }))
539
602
  const picked = engine.recent(serial, lines, level, filter)
540
- if (pkg === '') return { entries: picked }
603
+ if (pkg === '') return { entries: toOutput(picked) }
541
604
  const pids = await engine.pidsOfPackage(serial, pkg)
542
605
  if (pids.length === 0) {
543
606
  return { entries: [], note: `package ${pkg} is not running on ${serial} — start it first (adb_exec: am start -n ...) or pass another package` }
544
607
  }
545
608
  const byPid = picked.filter((e) => e.pid > 0 && pids.includes(e.pid))
546
609
  return byPid.length > 0
547
- ? { entries: byPid }
610
+ ? { entries: toOutput(byPid) }
548
611
  : { entries: [], note: `no buffered logcat lines for package ${pkg} (pids ${pids.join(', ')}); the app may not have logged yet` }
549
612
  },
550
613
  })
@@ -670,6 +733,100 @@ function adbExecTool(engine) {
670
733
  })
671
734
  }
672
735
 
736
+ /** The adb_install agent tool: install a local APK onto a device (adb install -r). */
737
+ function adbInstallTool(engine) {
738
+ return defineTool({
739
+ name: 'adb_install',
740
+ description: 'Install a local APK file onto an attached Android device (adb install -r). ' +
741
+ 'Use after building an APK to deploy it to the real device for debugging. ' +
742
+ 'The apkPath is a LOCAL path on this host (e.g. a build output).',
743
+ parameters: {
744
+ serial: { type: 'string', description: 'Device serial from logcat_devices (optional; defaults to the first attached device).' },
745
+ apkPath: { type: 'string', description: 'Absolute local path to the APK, e.g. "F:/app/build/outputs/apk/debug/app-debug.apk".' },
746
+ },
747
+ output: {
748
+ schema: {
749
+ type: 'object',
750
+ additionalProperties: false,
751
+ properties: {
752
+ ok: { type: 'boolean', required: true },
753
+ code: { type: 'integer' },
754
+ stdout: { type: 'string', required: true },
755
+ stderr: { type: 'string', required: true },
756
+ },
757
+ },
758
+ render: (_args, value) => {
759
+ const parts = []
760
+ if (value?.stdout) parts.push(value.stdout.trimEnd())
761
+ if (value?.stderr) parts.push('stderr: ' + value.stderr.trimEnd())
762
+ if (parts.length === 0) parts.push(value?.ok ? 'installed' : `install failed (exit ${value?.code ?? '?'})`)
763
+ return [{ type: 'text', text: parts.join('\n') }]
764
+ },
765
+ },
766
+ async execute(args) {
767
+ const devices = engine.deviceList()
768
+ const serial = typeof args.serial === 'string' && args.serial !== ''
769
+ ? args.serial
770
+ : devices.find((d) => d.state === 'device')?.serial ?? ''
771
+ const apkPath = typeof args.apkPath === 'string' ? args.apkPath.trim() : ''
772
+ if (engine.adb === null) return { ok: false, code: null, stdout: '', stderr: 'adb not found on this host' }
773
+ if (serial === '') return { ok: false, code: null, stdout: '', stderr: 'no attached device; connect and authorize one first' }
774
+ if (apkPath === '') return { ok: false, code: null, stdout: '', stderr: 'apkPath is required' }
775
+ if (!existsSync(apkPath)) return { ok: false, code: null, stdout: '', stderr: `APK not found: ${apkPath}` }
776
+ const result = await runAdbFull(engine.adb, ['-s', serial, 'install', '-r', apkPath], 120000)
777
+ return { ok: result.ok, code: result.code, stdout: result.stdout, stderr: result.stderr }
778
+ },
779
+ })
780
+ }
781
+
782
+ /** The adb_pull agent tool: copy a file from the device to this host (adb pull). */
783
+ function adbPullTool(engine) {
784
+ return defineTool({
785
+ name: 'adb_pull',
786
+ description: 'Copy a file from an attached Android device to this host (adb pull) — ' +
787
+ 'e.g. pull a screenshot saved via adb_exec (screencap -p /sdcard/x.png), a log file, or a bugreport.',
788
+ parameters: {
789
+ serial: { type: 'string', description: 'Device serial from logcat_devices (optional; defaults to the first attached device).' },
790
+ remotePath: { type: 'string', description: 'Path on the device, e.g. "/sdcard/screen.png".' },
791
+ localPath: { type: 'string', description: 'Absolute local destination path, e.g. "F:/shots/screen.png".' },
792
+ },
793
+ output: {
794
+ schema: {
795
+ type: 'object',
796
+ additionalProperties: false,
797
+ properties: {
798
+ ok: { type: 'boolean', required: true },
799
+ code: { type: 'integer' },
800
+ stdout: { type: 'string', required: true },
801
+ stderr: { type: 'string', required: true },
802
+ localPath: { type: 'string' },
803
+ },
804
+ },
805
+ render: (_args, value) => {
806
+ const parts = []
807
+ if (value?.ok && value?.localPath) parts.push(`pulled to ${value.localPath}`)
808
+ if (value?.stdout) parts.push(value.stdout.trimEnd())
809
+ if (value?.stderr) parts.push('stderr: ' + value.stderr.trimEnd())
810
+ if (parts.length === 0) parts.push(`pull failed (exit ${value?.code ?? '?'})`)
811
+ return [{ type: 'text', text: parts.join('\n') }]
812
+ },
813
+ },
814
+ async execute(args) {
815
+ const devices = engine.deviceList()
816
+ const serial = typeof args.serial === 'string' && args.serial !== ''
817
+ ? args.serial
818
+ : devices.find((d) => d.state === 'device')?.serial ?? ''
819
+ const remotePath = typeof args.remotePath === 'string' ? args.remotePath.trim() : ''
820
+ const localPath = typeof args.localPath === 'string' ? args.localPath.trim() : ''
821
+ if (engine.adb === null) return { ok: false, code: null, stdout: '', stderr: 'adb not found on this host' }
822
+ if (serial === '') return { ok: false, code: null, stdout: '', stderr: 'no attached device; connect and authorize one first' }
823
+ if (remotePath === '' || localPath === '') return { ok: false, code: null, stdout: '', stderr: 'remotePath and localPath are required' }
824
+ const result = await runAdbFull(engine.adb, ['-s', serial, 'pull', remotePath, localPath], 120000)
825
+ return { ok: result.ok, code: result.code, stdout: result.stdout, stderr: result.stderr, localPath: result.ok ? localPath : '' }
826
+ },
827
+ })
828
+ }
829
+
673
830
  /** ------------------------------------------------------------------ */
674
831
 
675
832
  /** Mount the adb engine, routes, tool, and announcement. */
@@ -739,6 +896,8 @@ export function apply(ctx, config) {
739
896
  logcatDevicesTool(engine),
740
897
  adbExecTool(engine),
741
898
  logcatSetPackageTool(engine),
899
+ adbInstallTool(engine),
900
+ adbPullTool(engine),
742
901
  ].map((tool) => ctx.tools.register(tool))
743
902
  return () => { for (const dispose of disposers) dispose() }
744
903
  }, '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.5",
4
+ "version": "0.2.7",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {