@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 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
@@ -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",
4
+ "version": "0.2.6",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {