@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 +5 -2
- package/lib/client.js +130 -3
- package/lib/index.js +165 -1
- package/package.json +1 -1
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
|
-
-
|
|
26
|
+
- **屏幕实时投屏与远程操控**:「屏幕」tab 开启 ~1fps 实时画面(WS 二进制帧推送),**点击=点按、拖动=滑动**、
|
|
27
|
+
文字输入框直接发到手机、快捷按键(Home/返回/唤醒/音量)、一键下载当前帧 —— 不用拿起手机
|
|
28
|
+
- **逆向工作台(日志/屏幕/逆向三 tab)**:进程列表 → 内存 hex/字符串搜索 → 匹配地址 → 点击转储 256B 查看
|
|
27
29
|
- 暂停/继续(暂停时缓冲,恢复自动回放)、清空、复制、导出 .txt
|
|
28
30
|
- 窗口化渲染 + 自动滚动(滚动手动上翻时自动停用)
|
|
29
31
|
- 未授权设备提示「请在手机上点击允许 USB 调试」
|
|
30
|
-
- **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
|
-
:
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.6.0-preview.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|