@windypro-rourou/dsh-logcat 0.6.0-preview.1 → 0.6.0-preview.2

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
@@ -23,13 +23,16 @@ DSH Web GUI 的安卓实机调试面板(类似 Android Studio 的 Logcat 视
23
23
  - **装APK 按钮**:本地文件选择器选 APK 直接安装到当前设备(流式上传,1GB 上限)
24
24
  - **设备快捷按键**:Home / 返回 / 最近任务 / 唤醒 / 电源 / 音量 ± 一键发送
25
25
  - **性能曲线**:CPU / 内存 / 电量每 2s 采样,状态栏 sparkline 趋势图
26
- - **逆向工作台(日志/逆向双 tab)**:进程列表 → 内存 hex/字符串搜索 → 匹配地址 → 点击转储 256B 查看
26
+ - **屏幕实时投屏与远程操控**:「屏幕」tab 开启 ~1fps 实时画面(WS 二进制帧推送),**点击=点按、拖动=滑动**、
27
+ 文字输入框直接发到手机、快捷按键(Home/返回/唤醒/音量)、一键下载当前帧 —— 不用拿起手机
28
+ - **逆向工作台(日志/屏幕/逆向三 tab)**:进程列表 → 内存 hex/字符串搜索 → 匹配地址 → 点击转储 256B 查看
27
29
  - 暂停/继续(暂停时缓冲,恢复自动回放)、清空、复制、导出 .txt
28
30
  - 窗口化渲染 + 自动滚动(滚动手动上翻时自动停用)
29
31
  - 未授权设备提示「请在手机上点击允许 USB 调试」
30
- - **Agent 工具**(共 29 个,全部对 agent 开放,前置提示中已明示可调用):
32
+ - **Agent 工具**(共 30 个,全部对 agent 开放,前置提示中已明示可调用):
31
33
  - 设备:`logcat_devices`(列出设备)、`device_info`(型号/版本/SDK/分辨率/内存/电量)、`device_stats`(CPU/内存/电量实时采样)、
32
34
  `app_info`(已安装应用版本号/versionCode/APK 路径)
35
+ - 屏幕/多模态:`screen_capture`(截图存档 + 嵌入对话图片块,多模态模型可直接看图配合 `input_*` 修 bug)
33
36
  - 执行:`adb_exec`(shell)、`adb_install`(本地 APK 装真机)、`adb_pull`(拉文件)、
34
37
  `app_launch`(启动应用/指定 Activity)、`app_stop`(force-stop,破坏性先确认)
35
38
  - 输入:`input_tap` / `input_swipe` / `input_text` / `input_keyevent`(真机 UI 自动化)、`ui_dump`(界面层级 XML)、
package/lib/client.js CHANGED
@@ -18,7 +18,7 @@ window.__ModuleLoader__.load({
18
18
  var module = { exports: {} };
19
19
  var exports = module.exports;
20
20
 
21
- const { createElement: h, useEffect, useMemo, useRef, useState } = require("react");
21
+ const { createElement: h, useEffect, useMemo, useRef, useState, useSyncExternalStore } = require("react");
22
22
  const { createRoot } = require("react-dom/client");
23
23
 
24
24
  //#region styles
@@ -133,6 +133,12 @@ window.__ModuleLoader__.load({
133
133
  .lc-re-row[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.2)); }
134
134
  .lc-re-dump { flex: 1; overflow: auto; padding: 8px 10px; font-family: Consolas, "Cascadia Mono", monospace; font-size: 11px; line-height: 16px; white-space: pre-wrap; word-break: break-all; }
135
135
  .lc-re-err { padding: 8px 10px; color: #ef5350; font-size: 12px; white-space: pre-wrap; }
136
+ .lc-screen { position: absolute; inset: 0; display: flex; flex-direction: column; }
137
+ .lc-screen-bar { display: flex; gap: 6px; padding: 6px 12px; border-bottom: 1px solid light-dark(rgba(0,0,0,.08), rgba(255,255,255,.1)); flex: none; align-items: center; flex-wrap: wrap; font-size: 11px; }
138
+ .lc-screen-bar input { background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)); border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 7px; color: inherit; font: inherit; font-size: 12px; padding: 4px 8px; }
139
+ .lc-screen-img { flex: 1; min-height: 0; overflow: auto; display: flex; align-items: center; justify-content: center; cursor: crosshair; background: light-dark(rgba(0,0,0,.03), rgba(255,255,255,.03)); }
140
+ .lc-screen-img img { max-width: 100%; max-height: 100%; object-fit: contain; user-select: none; }
141
+ .lc-screen-hint { color: light-dark(#6b7078, #9aa0a8); }
136
142
  `;
137
143
  //#endregion
138
144
 
@@ -206,6 +212,10 @@ window.__ModuleLoader__.load({
206
212
  const [reDumpAddr, setReDumpAddr] = useState("");
207
213
  const [reError, setReError] = useState("");
208
214
  const [spark, setSpark] = useState({ cpu: [], mem: [], bat: [] });
215
+ const [screenImg, setScreenImg] = useState(null);
216
+ const [screenText, setScreenText] = useState("");
217
+ const [screenOn, setScreenOn] = useState(false);
218
+ const panelOpen = useSyncExternalStore(controller.subscribe, controller.getSnapshot).panelOpen;
209
219
 
210
220
  const wsRef = useRef(null);
211
221
  const entriesRef = useRef([]);
@@ -218,6 +228,12 @@ window.__ModuleLoader__.load({
218
228
  const applyFrameRef = useRef(null);
219
229
  const eventsRef = useRef([]);
220
230
  const fileInputRef = useRef(null);
231
+ const screenWrapRef = useRef(null);
232
+ const screenImgRef = useRef(null);
233
+ const screenDragRef = useRef(null);
234
+ const screenWantRef = useRef(false);
235
+ const handleBinaryRef = useRef(null);
236
+ const prevSerialRef = useRef(serial);
221
237
 
222
238
  useEffect(() => { serialRef.current = serial; }, [serial]);
223
239
  useEffect(() => { pausedRef.current = paused; }, [paused]);
@@ -335,14 +351,19 @@ window.__ModuleLoader__.load({
335
351
  if (closed) return;
336
352
  const scheme = window.location.protocol === "https:" ? "wss" : "ws";
337
353
  socket = new WebSocket(scheme + "://" + window.location.host + API_BASE + "/stream");
354
+ socket.binaryType = "arraybuffer";
338
355
  wsRef.current = socket;
339
356
  socket.onopen = () => {
340
357
  retry = 0;
341
358
  setConnected(true);
342
359
  const target = serialRef.current;
343
360
  if (target !== "") socket.send(JSON.stringify({ type: "replay", serial: target }));
361
+ if (screenWantRef.current && target !== "") {
362
+ socket.send(JSON.stringify({ type: "screen", serial: target, on: true }));
363
+ }
344
364
  };
345
365
  socket.onmessage = (event) => {
366
+ if (event.data instanceof ArrayBuffer) { handleBinaryRef.current?.(event.data); return; }
346
367
  let frame;
347
368
  try { frame = JSON.parse(event.data); } catch { return; }
348
369
  applyFrameRef.current(frame);
@@ -679,6 +700,96 @@ window.__ModuleLoader__.load({
679
700
  .catch(() => { setReDump(""); setReError("转储失败"); });
680
701
  };
681
702
 
703
+ // Live screen stream: on while the 屏幕 tab is open, the panel is open, and a device is selected.
704
+ useEffect(() => {
705
+ const prev = prevSerialRef.current;
706
+ prevSerialRef.current = serial;
707
+ const want = tab === "screen" && serial !== "" && panelOpen;
708
+ screenWantRef.current = want;
709
+ const ws = wsRef.current;
710
+ if (ws !== null && ws.readyState === WebSocket.OPEN) {
711
+ if (prev !== "" && prev !== serial) ws.send(JSON.stringify({ type: "screen", serial: prev, on: false }));
712
+ ws.send(JSON.stringify({ type: "screen", serial, on: want }));
713
+ }
714
+ setScreenOn(want);
715
+ if (!want) setScreenImg(null);
716
+ }, [tab, serial, panelOpen]);
717
+
718
+ // Binary WS frames: 4-byte JSON header length + header + PNG payload.
719
+ const handleBinary = (buffer) => {
720
+ try {
721
+ const view = new DataView(buffer);
722
+ const hlen = view.getUint32(0);
723
+ const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, hlen)));
724
+ if (header.type !== "simage" || header.serial !== serialRef.current) return;
725
+ const bytes = new Uint8Array(buffer, 4 + hlen);
726
+ let binary = "";
727
+ const CHUNK = 0x8000;
728
+ for (let i = 0; i < bytes.length; i += CHUNK) {
729
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
730
+ }
731
+ setScreenImg("data:image/png;base64," + btoa(binary));
732
+ } catch { /* malformed frame */ }
733
+ };
734
+ handleBinaryRef.current = handleBinary;
735
+
736
+ const screenToDevice = (e) => {
737
+ const img = screenImgRef.current;
738
+ if (img === null || img.naturalWidth === 0) return null;
739
+ const imgRect = img.getBoundingClientRect();
740
+ const x = Math.round(((e.clientX - imgRect.left) / imgRect.width) * img.naturalWidth);
741
+ const y = Math.round(((e.clientY - imgRect.top) / imgRect.height) * img.naturalHeight);
742
+ if (x < 0 || y < 0 || x >= img.naturalWidth || y >= img.naturalHeight) return null;
743
+ return { x, y };
744
+ };
745
+
746
+ const screenMouseDown = (e) => {
747
+ const pt = screenToDevice(e);
748
+ if (pt === null) return;
749
+ screenDragRef.current = { ...pt, clientX: e.clientX, clientY: e.clientY };
750
+ };
751
+
752
+ const screenMouseUp = (e) => {
753
+ const drag = screenDragRef.current;
754
+ screenDragRef.current = null;
755
+ if (drag === null) return;
756
+ const pt = screenToDevice(e);
757
+ if (pt === null) return;
758
+ const dx = pt.x - drag.x;
759
+ const dy = pt.y - drag.y;
760
+ const dist = Math.sqrt(dx * dx + dy * dy);
761
+ const command = dist < 12
762
+ ? `input tap ${drag.x} ${drag.y}`
763
+ : `input swipe ${drag.x} ${drag.y} ${pt.x} ${pt.y} 120`;
764
+ fetch(API_BASE + "/exec", {
765
+ method: "POST",
766
+ headers: { "content-type": "application/json" },
767
+ body: JSON.stringify({ serial, command }),
768
+ }).catch(() => { /* offline */ });
769
+ };
770
+
771
+ const sendScreenText = () => {
772
+ const text = screenText.trim();
773
+ if (text === "") return;
774
+ setScreenText("");
775
+ const safe = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/ /g, "%s");
776
+ fetch(API_BASE + "/exec", {
777
+ method: "POST",
778
+ headers: { "content-type": "application/json" },
779
+ body: JSON.stringify({ serial, command: `input text "${safe}"` }),
780
+ }).catch(() => { /* offline */ });
781
+ };
782
+
783
+ const downloadScreen = () => {
784
+ if (screenImg === null) return;
785
+ const a = document.createElement("a");
786
+ a.href = screenImg;
787
+ a.download = "screen-" + (serial || "device") + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".png";
788
+ document.body.appendChild(a);
789
+ a.click();
790
+ a.remove();
791
+ };
792
+
682
793
  return h("div", { className: "lc-panel" },
683
794
  h("div", { className: "lc-header" },
684
795
  h("button", { type: "button", className: "lc-back", onClick: () => controller.close() },
@@ -733,6 +844,7 @@ window.__ModuleLoader__.load({
733
844
  ),
734
845
  h("div", { className: "lc-tabs" },
735
846
  h("button", { type: "button", className: "lc-tab", "data-on": tab === "log" ? "" : undefined, onClick: () => setTab("log") }, "日志"),
847
+ h("button", { type: "button", className: "lc-tab", "data-on": tab === "screen" ? "" : undefined, onClick: () => setTab("screen") }, "屏幕"),
736
848
  h("button", { type: "button", className: "lc-tab", "data-on": tab === "re" ? "" : undefined, onClick: () => setTab("re") }, "逆向"),
737
849
  ),
738
850
  tab === "log"
@@ -800,7 +912,7 @@ window.__ModuleLoader__.load({
800
912
  }, "自动滚动"),
801
913
  )
802
914
  : null,
803
- tab === "log"
915
+ tab === "log" || tab === "screen"
804
916
  ? h("div", { className: "lc-keys" },
805
917
  [["home", "⌂ Home"], ["back", "← 返回"], ["recents", "▦ 最近"], ["wakeup", "☀ 唤醒"], ["power", "⏻ 电源"], ["volume_up", "🔊+"], ["volume_down", "🔉−"]].map(([key, label]) =>
806
918
  h("button", { key, type: "button", className: "lc-key", title: "发送按键 " + key, onClick: () => sendKey(key) }, label)))
@@ -850,7 +962,22 @@ window.__ModuleLoader__.load({
850
962
  ),
851
963
  ),
852
964
  )
853
- : eventsOn
965
+ : tab === "screen"
966
+ ? h("div", { className: "lc-screen" },
967
+ h("div", { className: "lc-screen-bar" },
968
+ h("span", { className: "lc-dot " + (screenOn ? "on" : "off") }),
969
+ h("span", null, screenOn ? "投屏中 (~1fps)" : "投屏未开启"),
970
+ h("input", { placeholder: "输入文字后回车发送…", value: screenText, onChange: (e) => setScreenText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") sendScreenText(); }, style: { flex: 1, minWidth: 140 } }),
971
+ h("button", { type: "button", className: "lc-btn", onClick: downloadScreen, title: "下载当前帧 PNG" }, "下载"),
972
+ h("span", { className: "lc-screen-hint" }, "点击=点按 · 拖动=滑动"),
973
+ ),
974
+ h("div", { className: "lc-screen-img", ref: screenWrapRef, onMouseDown: screenMouseDown, onMouseUp: screenMouseUp, onMouseLeave: screenMouseUp },
975
+ screenImg !== null
976
+ ? h("img", { ref: screenImgRef, src: screenImg, alt: "设备屏幕", draggable: false })
977
+ : h("div", { className: "lc-empty" }, "等待画面…(设备连接后自动开始,1fps)"),
978
+ ),
979
+ )
980
+ : eventsOn
854
981
  ? (filteredEvents.length === 0
855
982
  ? h("div", { className: "lc-empty" }, "events 事件缓冲(已开启流,等待事件…)")
856
983
  : h(VirtualLog, { entries: filteredEvents, scrollTop, onScrollTop: setScrollTop, bodyRef }))
package/lib/index.js CHANGED
@@ -99,9 +99,10 @@ const SECTION_ORDER = 152
99
99
 
100
100
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
101
101
  export const LOGCAT_GUIDANCE =
102
- '本机已安装 dsh-logcat 插件(DSH Web GUI 的安卓实机调试/逆向工作台):自动探测本机 adb 并对已连接的调试设备附加 logcat 流;日志自动落盘(~/.dsh/logcat/logs)支持历史回溯,崩溃/ANR 自动截图快照存档(~/.dsh/logcat/crashes,可用 crash_sessions 查阅);侧边栏「Logcat」入口打开面板(设备切换、级别/多关键词过滤、按测试包名过滤、截图、崩溃高亮、历史回溯、events 事件视图、内存搜索工作台、WiFi 无线连接、APK 安装、一键安装 adb)。' +
102
+ '本机已安装 dsh-logcat 插件(DSH Web GUI 的安卓实机调试/逆向工作台):自动探测本机 adb 并对已连接的调试设备附加 logcat 流;日志自动落盘(~/.dsh/logcat/logs)支持历史回溯,崩溃/ANR 自动截图快照存档(~/.dsh/logcat/crashes,可用 crash_sessions 查阅);侧边栏「Logcat」入口打开面板(设备切换、级别/多关键词过滤、按测试包名过滤、截图、崩溃高亮、历史回溯、events 事件视图、内存搜索工作台、屏幕实时投屏与远程操控(点击/滑动/输入)、WiFi 无线连接、APK 安装、一键安装 adb)。' +
103
103
  '以下 agent 工具均可用(串口为设备 serial,可用 logcat_devices 获取),构建安卓应用/实机调试/逆向分析时请主动调用:' +
104
104
  '设备与状态:logcat_devices(列出设备)、device_info(型号/版本/SDK/分辨率/内存/电量)、device_stats(CPU/内存/电量采样)、app_info(已安装应用的版本号/versionCode/APK 路径);' +
105
+ '屏幕与多模态:screen_capture(截图存档并嵌入对话图片,配合 input_* 做可视化调试循环,多模态模型可直接看图修 bug);' +
105
106
  '部署与执行:adb_install(本地 APK 装真机)、adb_exec(任意 adb shell 命令)、adb_pull(拉文件)、app_launch(启动应用/指定 Activity)、app_stop(停止应用,破坏性先确认)、input_tap/input_swipe/input_text/input_keyevent(模拟点击/滑动/输入/按键)、ui_dump(界面层级 XML)、activity_current(当前前台 Activity);' +
106
107
  '日志与崩溃:logcat_recent(按级别/关键词/包名读实时日志)、logcat_history(磁盘历史日志回溯)、logcat_crash(崩溃/ANR 捕获+上下文)、logcat_events(events 事件缓冲:应用启动/崩溃/生命周期事件)、crash_sessions(崩溃自动快照历史)、logcat_set_package(锁定当前测试包名);' +
107
108
  '逆向与内存:proc_list(进程列表)、proc_maps(内存映射+so 模块基址)、proc_status(进程状态/内存摘要)、proc_smaps(内存占用明细 Top 区域)、mem_dump(指定地址读内存 hex)、mem_search(内存搜 hex 模式/字符串)、frida_server(frida-server 部署/启停)、frida_script(hook/trace/scan/bypass/dump 常用脚本模板)。' +
@@ -376,6 +377,7 @@ export class AdbEngine {
376
377
  this.eventsBuffers = new Map() // serial -> ring buffer (logcat -b events)
377
378
  this.eventsStreams = new Map() // serial -> DeviceStream (-b events)
378
379
  this.lastCrash = new Map() // serial -> ms timestamp (snapshot throttle)
380
+ this.screenLoops = new Map() // serial -> { timer, busy } (1fps screen stream)
379
381
  }
380
382
 
381
383
  /** Serial -> filesystem-safe directory name. */
@@ -428,6 +430,8 @@ export class AdbEngine {
428
430
  this.logStreams.clear()
429
431
  for (const stream of this.eventsStreams.values()) stream.stop()
430
432
  this.eventsStreams.clear()
433
+ for (const loop of this.screenLoops.values()) clearInterval(loop.timer)
434
+ this.screenLoops.clear()
431
435
  for (const ws of this.clients) {
432
436
  try { ws.close(1001, 'plugin disposed') } catch { /* closed */ }
433
437
  }
@@ -467,6 +471,11 @@ export class AdbEngine {
467
471
  estream.stop()
468
472
  this.eventsStreams.delete(serial)
469
473
  }
474
+ const sloop = this.screenLoops.get(serial)
475
+ if (sloop !== undefined) {
476
+ clearInterval(sloop.timer)
477
+ this.screenLoops.delete(serial)
478
+ }
470
479
  changed = true
471
480
  }
472
481
  }
@@ -493,6 +502,11 @@ export class AdbEngine {
493
502
  estream.stop()
494
503
  this.eventsStreams.delete(serial)
495
504
  }
505
+ const sloop = this.screenLoops.get(serial)
506
+ if (sloop !== undefined) {
507
+ clearInterval(sloop.timer)
508
+ this.screenLoops.delete(serial)
509
+ }
496
510
  this.broadcast({ type: 'device-state', serial, state: info.state })
497
511
  }
498
512
  }
@@ -673,6 +687,50 @@ export class AdbEngine {
673
687
  return picked.slice(-lines)
674
688
  }
675
689
 
690
+ /** Start / stop the ~1fps screen-stream loop for a device (panel live view). */
691
+ setScreen(serial, on) {
692
+ const existing = this.screenLoops.get(serial)
693
+ if (on) {
694
+ if (existing === undefined && this.adb !== null && this.devices.get(serial)?.state === 'device') {
695
+ const loop = { timer: null, busy: false }
696
+ this.screenLoops.set(serial, loop)
697
+ const tick = () => {
698
+ if (loop.busy || this.screenLoops.get(serial) !== loop) return
699
+ loop.busy = true
700
+ void this.captureScreenFrame(serial).finally(() => { loop.busy = false })
701
+ }
702
+ loop.timer = setInterval(tick, 1000)
703
+ tick()
704
+ }
705
+ } else if (existing !== undefined) {
706
+ clearInterval(existing.timer)
707
+ this.screenLoops.delete(serial)
708
+ }
709
+ }
710
+
711
+ /** Capture one frame and broadcast it as a binary WS frame. */
712
+ async captureScreenFrame(serial) {
713
+ try {
714
+ const png = await runAdbBinary(this.adb, ['-s', serial, 'exec-out', 'screencap', '-p'], 8000)
715
+ if (png.length < 100) return
716
+ this.broadcastBinary({ type: 'simage', serial, len: png.length, ts: Date.now() }, png)
717
+ } catch { /* frame dropped */ }
718
+ }
719
+
720
+ /** Broadcast a binary frame: 4-byte header length + JSON header + payload. */
721
+ broadcastBinary(header, payload) {
722
+ const headerBuf = Buffer.from(JSON.stringify(header), 'utf8')
723
+ const frame = Buffer.alloc(4 + headerBuf.length + payload.length)
724
+ frame.writeUInt32BE(headerBuf.length, 0)
725
+ headerBuf.copy(frame, 4)
726
+ payload.copy(frame, 4 + headerBuf.length)
727
+ for (const ws of this.clients) {
728
+ if (ws.readyState !== WebSocket.OPEN) continue
729
+ if (ws.bufferedAmount > 4 * 1024 * 1024) continue
730
+ try { ws.send(frame) } catch { /* closed */ }
731
+ }
732
+ }
733
+
676
734
  /** Snapshot of the device list (for routes and the browser). */
677
735
  deviceList() {
678
736
  return [...this.devices.values()]
@@ -1247,6 +1305,9 @@ function makeRoutes(engine) {
1247
1305
  if (frame.on === true) {
1248
1306
  ws.send(JSON.stringify({ type: 'ehistory', serial: frame.serial, entries: engine.eventsRecent(frame.serial, 500) }))
1249
1307
  }
1308
+ } else if (frame?.type === 'screen' && typeof frame.serial === 'string') {
1309
+ // Panel toggles the per-device live screen stream (1fps screencap).
1310
+ engine.setScreen(frame.serial, frame.on === true)
1250
1311
  }
1251
1312
  })
1252
1313
  ws.on('close', () => { engine.clients.delete(ws) })
@@ -2840,6 +2901,108 @@ function appStopTool(engine) {
2840
2901
  })
2841
2902
  }
2842
2903
 
2904
+ /** The screen_capture agent tool: one-shot screenshot for multimodal flows. */
2905
+ function screenCaptureTool(ctx, engine) {
2906
+ return defineTool({
2907
+ name: 'screen_capture',
2908
+ description: 'Capture the device screen as a PNG — saved to a local file, and embedded into the conversation as an image block so a multimodal model can see the screen (deployment permitting). ' +
2909
+ 'Pair with input_tap / input_swipe / input_text / input_keyevent for visual debugging loops. Triggers: see the phone screen, screenshot for bug analysis.',
2910
+ parameters: {
2911
+ serial: { type: 'string', description: 'Device serial from logcat_devices (optional; defaults to the first attached device).' },
2912
+ path: { type: 'string', description: 'Optional local file path to save the PNG (default ~/.dsh/logcat/screens/<serial>-<ts>.png).' },
2913
+ },
2914
+ output: {
2915
+ schema: {
2916
+ type: 'object',
2917
+ additionalProperties: false,
2918
+ properties: {
2919
+ ok: { type: 'boolean', required: true },
2920
+ path: { type: 'string' },
2921
+ size: { type: 'integer' },
2922
+ width: { type: 'integer' },
2923
+ height: { type: 'integer' },
2924
+ embedded: { type: 'boolean' },
2925
+ embeddedRef: {
2926
+ type: 'object',
2927
+ additionalProperties: false,
2928
+ properties: {
2929
+ attachmentId: { type: 'string', required: true },
2930
+ mediaType: { type: 'string', required: true },
2931
+ bytes: { type: 'integer', required: true },
2932
+ width: { type: 'integer', required: true },
2933
+ height: { type: 'integer', required: true },
2934
+ },
2935
+ },
2936
+ error: { type: 'string' },
2937
+ },
2938
+ },
2939
+ render: (_args, value) => {
2940
+ if (value?.error) return [{ type: 'text', text: value.error }]
2941
+ const blocks = []
2942
+ if (value?.embeddedRef !== undefined && value?.embeddedRef !== null) {
2943
+ blocks.push({ type: 'image', attachment: value.embeddedRef })
2944
+ }
2945
+ const parts = [`screen captured: ${value?.path ?? '?'} (${value?.size ?? 0} bytes, ${value?.width ?? '?'}x${value?.height ?? '?'})`]
2946
+ if (value?.embedded === true) parts.push('(image embedded in this result)')
2947
+ blocks.push({ type: 'text', text: parts.join('\n') })
2948
+ return blocks
2949
+ },
2950
+ },
2951
+ async execute(args) {
2952
+ const devices = engine.deviceList()
2953
+ const serial = typeof args.serial === 'string' && args.serial !== ''
2954
+ ? args.serial
2955
+ : devices.find((d) => d.state === 'device')?.serial ?? ''
2956
+ if (engine.adb === null) return { ok: false, error: 'adb not found on this host' }
2957
+ if (serial === '') return { ok: false, error: 'no attached device; connect and authorize one first' }
2958
+ try {
2959
+ const png = await runAdbBinary(engine.adb, ['-s', serial, 'exec-out', 'screencap', '-p'], 15000)
2960
+ const screensRoot = join(engine.logRoot, 'screens')
2961
+ mkdirSync(screensRoot, { recursive: true })
2962
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
2963
+ const rel = `${engine.safeName(serial)}-${stamp}.png`
2964
+ const file = typeof args.path === 'string' && args.path.trim() !== ''
2965
+ ? args.path.trim()
2966
+ : join(screensRoot, rel)
2967
+ await fsp.writeFile(file, png)
2968
+ // PNG intrinsic dimensions from the IHDR chunk (bytes 16-23).
2969
+ let width
2970
+ let height
2971
+ if (png.length > 24 && png[0] === 0x89) {
2972
+ width = png.readUInt32BE(16)
2973
+ height = png.readUInt32BE(20)
2974
+ }
2975
+ // Embed into the conversation through the attachment service when available.
2976
+ let embeddedRef = null
2977
+ try {
2978
+ const store = typeof ctx.attachments?.saveImage === 'function' ? ctx.attachments : null
2979
+ if (store !== null) {
2980
+ const ref = await store.saveImage({ data: png, mediaType: 'image/png', name: rel })
2981
+ embeddedRef = {
2982
+ attachmentId: ref.attachmentId,
2983
+ mediaType: ref.mediaType,
2984
+ bytes: ref.bytes,
2985
+ width: ref.width,
2986
+ height: ref.height,
2987
+ }
2988
+ }
2989
+ } catch { /* attachment embed is best-effort */ }
2990
+ return {
2991
+ ok: true,
2992
+ path: file,
2993
+ size: png.length,
2994
+ width,
2995
+ height,
2996
+ embedded: embeddedRef !== null,
2997
+ embeddedRef,
2998
+ }
2999
+ } catch (error) {
3000
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
3001
+ }
3002
+ },
3003
+ })
3004
+ }
3005
+
2843
3006
  /** ------------------------------------------------------------------ */
2844
3007
 
2845
3008
  /** Mount the adb engine, routes, tool, and announcement. */
@@ -2922,6 +3085,7 @@ export function apply(ctx, config) {
2922
3085
  inputKeyeventTool(engine),
2923
3086
  appLaunchTool(engine),
2924
3087
  appStopTool(engine),
3088
+ screenCaptureTool(ctx, engine),
2925
3089
  deviceInfoTool(engine),
2926
3090
  deviceStatsTool(engine),
2927
3091
  inputTapTool(engine),
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.6.0-preview.1",
4
+ "version": "0.6.0-preview.2",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {