@windypro-rourou/dsh-logcat 0.2.0

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 ADDED
@@ -0,0 +1,55 @@
1
+ # dsh-logcat
2
+
3
+ DSH Web GUI 的安卓实机调试面板(类似 Android Studio 的 Logcat 视图)。
4
+
5
+ ## 功能
6
+
7
+ - **自动连接**:探测本机 adb(`ANDROID_HOME` / `ANDROID_SDK_ROOT` / 默认 `%LOCALAPPDATA%\Android\Sdk` / PATH),
8
+ 每 2 秒轮询 `adb devices -l`;检测到处于调试模式的设备**自动附加 logcat 流**(`-v threadtime`),无需打开面板。
9
+ - **实时日志**:WebSocket 推送,每设备保留最近 2000 行环形缓冲;断线自动重连(指数退避)。
10
+ - **Logcat 面板**(侧边栏「Logcat」入口):
11
+ - 设备下拉(显示型号/序列号/状态,记住上次选择)
12
+ - 级别过滤(V/D/I/W/E/F 单选,颜色与 Android Studio 一致)
13
+ - 关键词过滤、暂停/继续(暂停时缓冲,恢复自动回放)、清空、复制、导出 .txt
14
+ - 窗口化渲染 + 自动滚动(滚动手动上翻时自动停用)
15
+ - 未授权设备提示「请在手机上点击允许 USB 调试」
16
+ - **Agent 工具**:`logcat_recent`(读取某设备最近 N 条日志,支持级别/关键词过滤)。
17
+ - **附加能力**:`POST /api/dsh-logcat/exec` 可对设备执行 `adb shell` 命令(UI 后续版本可扩展)。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ # 方式一(推荐,npm 安装):
23
+ dsh plugin --profile web add @windypro-rourou/dsh-logcat
24
+
25
+ # 方式二(源码本地链接,实时生效无需重启):把插件链进 web profile,
26
+ # 并在 ~/.dsh/profiles/web/cordis.patch.yml 增加一行:
27
+ pnpm --dir "%USERPROFILE%\.dsh\profiles\web" add link:F:\dsh-logcat
28
+ # 然后在 profile 的 cordis.patch.yml 追加:
29
+ # - insert:
30
+ # - id: logcat
31
+ # name: '@windypro-rourou/dsh-logcat'
32
+ # 该 patch 文件会被运行中的 GUI 热监听;若未生效,重启 GUI 即可。
33
+
34
+ # 方式三(bundle 层,README 原始流程,适合全新 profile;与方式二互斥,勿混用):
35
+ dsh plugin --profile web add link:<本目录绝对路径>
36
+ # 之后需要重启 GUI(dsh web)才会装载。
37
+ ```
38
+
39
+ > 注意:以上方式都会在 profile 树中插入同一行 `logcat`,不要同时使用,否则下次
40
+ > 启动会因重复插件 id 而失败。
41
+
42
+ 依赖解析:`ws` / `react` / `react-dom` / `@deepseek-ai/*` 通过本目录 `node_modules` 下的 junction 指向宿主
43
+ 实际加载的物理包(保证单例)。若宿主依赖升级,请同步更新 junction 目标。
44
+
45
+ ## 限制
46
+
47
+ - 需要设备开启 USB 调试并在手机上授权本机(`unauthorized` 状态会提示)。
48
+ - logcat 输出可能含敏感信息;`/api/dsh-logcat/*` 路由仅允许 loopback 访问。
49
+ - `adb shell` 命令消耗真实设备资源,先确认再执行。
50
+
51
+ ## 文件
52
+
53
+ - `lib/index.js` — 宿主端:adb 引擎、轮询、logcat 子进程、路由、WebSocket、agent 工具。
54
+ - `lib/client.js` — 浏览器端:侧边栏入口 + Logcat 面板(React,无构建步骤)。
55
+ - `cordis.patch.yml` — profile bundle patch(自动应用)。
@@ -0,0 +1,10 @@
1
+ # dsh-logcat bundle patch: inserts the logcat plugin row into the web
2
+ # profile roster. Applied as a profile bundle layer over dsh-base; the row is
3
+ # a bare plugin by package name — the node half (exports ".") runs in the
4
+ # host process (adb engine, /api/dsh-logcat routes, WebSocket stream, agent
5
+ # tools), and the `dsh.client` declaration in package.json makes the browser
6
+ # half (exports "./client", served at /plugins/logcat/client.js) load in the
7
+ # web GUI.
8
+ - insert:
9
+ - id: logcat
10
+ name: '@linxin666/dsh-logcat'
package/lib/client.js ADDED
@@ -0,0 +1,598 @@
1
+ /**
2
+ * dsh-logcat — browser half. Runs inside the dsh web GUI.
3
+ *
4
+ * Renders an Android-Studio-style Logcat panel:
5
+ * - sidebar entry row toggling the panel (DOM-level injection, self-healing),
6
+ * - device dropdown (auto-picks the first attached device / remembers the
7
+ * last choice), live severity + keyword filters, pause/resume, clear,
8
+ * copy / export .txt, auto-scroll with stick-to-bottom,
9
+ * - windowed rendering so a 2000-line buffer stays smooth,
10
+ * - WebSocket stream with automatic reconnect.
11
+ *
12
+ * Bundle format: `window.__ModuleLoader__.load({id, factory})` (lazy CJS) —
13
+ * the only client bundle format the web shell materializes.
14
+ */
15
+ window.__ModuleLoader__.load({
16
+ id: "@linxin666/dsh-logcat",
17
+ factory: (require) => {
18
+ var module = { exports: {} };
19
+ var exports = module.exports;
20
+
21
+ const { createElement: h, useEffect, useMemo, useRef, useState } = require("react");
22
+ const { createRoot } = require("react-dom/client");
23
+
24
+ //#region styles
25
+ const STYLE = `
26
+ [data-dsh-logcat-entry] {
27
+ display: flex; align-items: center; gap: 8px; width: 100%;
28
+ padding: 9px 12px; margin: 2px 0; border: 0; border-radius: 8px;
29
+ background: transparent; color: inherit; font: inherit; cursor: pointer;
30
+ text-align: left;
31
+ }
32
+ [data-dsh-logcat-entry]:hover { background: rgba(128,128,128,.14); }
33
+ [data-dsh-logcat-entry][data-active] { background: rgba(128,128,128,.22); }
34
+ [data-dsh-logcat-entry] .lc-entry-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; }
35
+ [data-dsh-logcat-entry] .lc-entry-label { font-size: 13px; line-height: 1.2; opacity: .92; }
36
+ .dsh-logcat-view { position: fixed; top: 0; right: 0; bottom: 0; width: min(620px, 94vw); display: flex; flex-direction: column; background: var(--lc-bg, #ffffff); border-left: 1px solid rgba(128,128,128,.3); box-shadow: -10px 0 28px rgba(0,0,0,.18); z-index: 9999; }
37
+ .dsh-logcat-view[hidden] { display: none !important; }
38
+ .lc-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
39
+ .lc-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid rgba(128,128,128,.25); flex: none; }
40
+ .lc-back { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 6px; font-size: 13px; }
41
+ .lc-back:hover { background: rgba(128,128,128,.14); }
42
+ .lc-title { font-size: 15px; font-weight: 600; margin: 0; }
43
+ .lc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
44
+ .lc-dot.on { background: #4caf50; }
45
+ .lc-dot.off { background: #9e9e9e; }
46
+ .lc-dot.warn { background: #ff9800; }
47
+ .lc-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-bottom: 1px solid rgba(128,128,128,.25); flex: none; flex-wrap: wrap; }
48
+ .lc-select, .lc-search { background: rgba(128,128,128,.08); border: 1px solid rgba(128,128,128,.3); border-radius: 6px; color: inherit; font: inherit; font-size: 12px; padding: 4px 8px; }
49
+ .lc-select:focus, .lc-search:focus { outline: 1px solid rgba(128,128,128,.5); }
50
+ .lc-search { flex: 1; min-width: 120px; max-width: 320px; }
51
+ .lc-levels { display: flex; gap: 2px; }
52
+ .lc-level { border: 1px solid transparent; background: transparent; color: inherit; font: inherit; font-size: 12px; font-weight: 600; width: 24px; height: 24px; border-radius: 5px; cursor: pointer; }
53
+ .lc-level:hover { background: rgba(128,128,128,.12); }
54
+ .lc-level[data-on] { background: rgba(128,128,128,.22); border-color: rgba(128,128,128,.4); }
55
+ .lc-level.v { color: #9e9e9e; } .lc-level.d { color: #4fc3f7; } .lc-level.i { color: #4caf50; }
56
+ .lc-level.w { color: #fbc02d; } .lc-level.e { color: #ef5350; } .lc-level.f { color: #ab47bc; }
57
+ .lc-btn { border: 1px solid rgba(128,128,128,.3); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
58
+ .lc-btn:hover { background: rgba(128,128,128,.12); }
59
+ .lc-btn[data-on] { background: rgba(128,128,128,.22); }
60
+ .lc-body { flex: 1; min-height: 0; position: relative; overflow: hidden; }
61
+ .lc-log { position: absolute; inset: 0; overflow: auto; font-family: Consolas, "Cascadia Mono", "Courier New", monospace; font-size: 12px; line-height: 20px; }
62
+ .lc-log-inner { position: relative; }
63
+ .lc-line { position: absolute; left: 0; right: 0; padding: 0 12px; white-space: pre; overflow: hidden; text-overflow: ellipsis; cursor: default; }
64
+ .lc-line:hover { background: rgba(128,128,128,.12); }
65
+ .lc-line .ts { color: var(--lc-dim, #9e9e9e); margin-right: 8px; }
66
+ .lc-line .pid { color: var(--lc-dim, #9e9e9e); margin-right: 6px; }
67
+ .lc-line .lv { display: inline-block; width: 14px; text-align: center; font-weight: 700; margin-right: 6px; }
68
+ .lc-line .lv.V { color: #9e9e9e; } .lc-line .lv.D { color: #4fc3f7; } .lc-line .lv.I { color: #4caf50; }
69
+ .lc-line .lv.W { color: #fbc02d; } .lc-line .lv.E { color: #ef5350; } .lc-line .lv.F { color: #ab47bc; }
70
+ .lc-line .tag { color: #29b6f6; margin-right: 8px; }
71
+ .lc-line.cont .msg { padding-left: 44px; color: var(--lc-dim, #9e9e9e); }
72
+ .lc-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--lc-dim, #9e9e9e); font-size: 13px; }
73
+ .lc-status { display: flex; align-items: center; gap: 14px; padding: 4px 12px; border-top: 1px solid rgba(128,128,128,.25); flex: none; font-size: 11px; color: var(--lc-dim, #9e9e9e); }
74
+ .lc-status b { font-weight: 600; color: inherit; }
75
+ `;
76
+ //#endregion
77
+
78
+ //#region panel state
79
+ /** The panel state owner the sidebar entry toggles and the view renders from. */
80
+ class PanelController {
81
+ constructor() {
82
+ this.panelOpen = false;
83
+ this.listeners = new Set();
84
+ }
85
+ getSnapshot() { return { panelOpen: this.panelOpen }; }
86
+ subscribe(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
87
+ open() { if (this.panelOpen) return; this.panelOpen = true; this.notify(); }
88
+ close() { if (!this.panelOpen) return; this.panelOpen = false; this.notify(); }
89
+ toggle() { if (this.panelOpen) this.close(); else this.open(); }
90
+ notify() { for (const fn of [...this.listeners]) fn(); }
91
+ }
92
+ //#endregion
93
+
94
+ //#region ws + data
95
+ const API_BASE = "/api/dsh-logcat";
96
+ const LEVELS = ["V", "D", "I", "W", "E", "F"];
97
+ const LEVEL_TITLES = { V: "详细", D: "调试", I: "信息", W: "警告", E: "错误", F: "致命" };
98
+
99
+ /** The Logcat panel view. */
100
+ function LogcatPanel({ controller }) {
101
+ const [adbPath, setAdbPath] = useState("");
102
+ const [adbReady, setAdbReady] = useState(false);
103
+ const [devices, setDevices] = useState([]);
104
+ const [streaming, setStreaming] = useState([]);
105
+ const [serial, setSerial] = useState(() => { try { return localStorage.getItem("dsh-logcat-serial") ?? ""; } catch { return ""; } });
106
+ const [entries, setEntries] = useState([]);
107
+ const [connected, setConnected] = useState(false);
108
+ const [paused, setPaused] = useState(false);
109
+ const [level, setLevel] = useState("");
110
+ const [keyword, setKeyword] = useState("");
111
+ const [autoScroll, setAutoScroll] = useState(true);
112
+ const [scrollTop, setScrollTop] = useState(0);
113
+
114
+ const wsRef = useRef(null);
115
+ const entriesRef = useRef([]);
116
+ const devicesRef = useRef([]);
117
+ const serialRef = useRef(serial);
118
+ const pausedRef = useRef(paused);
119
+ const pendingRef = useRef([]);
120
+ const bodyRef = useRef(null);
121
+ const autoScrollRef = useRef(autoScroll);
122
+ const applyFrameRef = useRef(null);
123
+
124
+ useEffect(() => { serialRef.current = serial; }, [serial]);
125
+ useEffect(() => { pausedRef.current = paused; }, [paused]);
126
+ useEffect(() => { autoScrollRef.current = autoScroll; }, [autoScroll]);
127
+
128
+ const pickSerial = (next) => {
129
+ try { localStorage.setItem("dsh-logcat-serial", next); } catch { /* private mode */ }
130
+ serialRef.current = next;
131
+ setSerial(next);
132
+ entriesRef.current = [];
133
+ setEntries([]);
134
+ requestReplay(next);
135
+ };
136
+
137
+ const requestReplay = (target) => {
138
+ const ws = wsRef.current;
139
+ if (ws !== null && ws.readyState === WebSocket.OPEN) {
140
+ ws.send(JSON.stringify({ type: "replay", serial: target }));
141
+ }
142
+ };
143
+
144
+ const scrollToBottom = () => {
145
+ const body = bodyRef.current;
146
+ if (body !== null) body.scrollTop = body.scrollHeight;
147
+ };
148
+
149
+ const appendEntries = (incoming) => {
150
+ const next = entriesRef.current.concat(incoming);
151
+ if (next.length > 2000) next.splice(0, next.length - 2000);
152
+ entriesRef.current = next;
153
+ setEntries(next);
154
+ if (autoScrollRef.current) requestAnimationFrame(() => scrollToBottom());
155
+ };
156
+
157
+ const applyFrame = (frame) => {
158
+ if (frame.type === "ready") {
159
+ setAdbPath(frame.adbPath ?? "");
160
+ setAdbReady(frame.adbPath != null && frame.adbPath !== "");
161
+ setStreaming(frame.streaming ?? []);
162
+ setDevices(frame.devices ?? []);
163
+ devicesRef.current = frame.devices ?? [];
164
+ if (serialRef.current === "" || !(frame.devices ?? []).some((d) => d.serial === serialRef.current)) {
165
+ const first = (frame.devices ?? []).find((d) => d.state === "device");
166
+ if (first !== undefined) pickSerial(first.serial);
167
+ }
168
+ } else if (frame.type === "devices") {
169
+ setDevices(frame.devices ?? []);
170
+ devicesRef.current = frame.devices ?? [];
171
+ if (serialRef.current === "" || !(frame.devices ?? []).some((d) => d.serial === serialRef.current)) {
172
+ const first = (frame.devices ?? []).find((d) => d.state === "device");
173
+ if (first !== undefined) pickSerial(first.serial);
174
+ }
175
+ } else if (frame.type === "history") {
176
+ if (frame.serial === serialRef.current) {
177
+ entriesRef.current = frame.entries ?? [];
178
+ setEntries(entriesRef.current);
179
+ requestAnimationFrame(() => { if (autoScrollRef.current) scrollToBottom(); });
180
+ }
181
+ } else if (frame.type === "line") {
182
+ if (pausedRef.current) {
183
+ pendingRef.current.push(frame);
184
+ if (pendingRef.current.length > 800) pendingRef.current.splice(0, pendingRef.current.length - 800);
185
+ return;
186
+ }
187
+ if (frame.serial !== serialRef.current) return;
188
+ appendEntries([frame.entry]);
189
+ } else if (frame.type === "device-state") {
190
+ setDevices(devicesRef.current.map((d) => d.serial === frame.serial ? { ...d, state: frame.state } : d));
191
+ }
192
+ };
193
+ applyFrameRef.current = applyFrame;
194
+
195
+ // WebSocket lifecycle (created once; handlers read latest state through refs).
196
+ useEffect(() => {
197
+ let closed = false;
198
+ let socket = null;
199
+ let retry = 0;
200
+ const connect = () => {
201
+ if (closed) return;
202
+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
203
+ socket = new WebSocket(scheme + "://" + window.location.host + API_BASE + "/stream");
204
+ wsRef.current = socket;
205
+ socket.onopen = () => {
206
+ retry = 0;
207
+ setConnected(true);
208
+ const target = serialRef.current;
209
+ if (target !== "") socket.send(JSON.stringify({ type: "replay", serial: target }));
210
+ };
211
+ socket.onmessage = (event) => {
212
+ let frame;
213
+ try { frame = JSON.parse(event.data); } catch { return; }
214
+ applyFrameRef.current(frame);
215
+ };
216
+ socket.onclose = () => {
217
+ wsRef.current = null;
218
+ setConnected(false);
219
+ if (closed) return;
220
+ retry = Math.min(retry + 1, 10);
221
+ setTimeout(connect, 800 * retry);
222
+ };
223
+ socket.onerror = () => { try { socket.close(); } catch { /* closed */ } };
224
+ };
225
+ connect();
226
+ return () => {
227
+ closed = true;
228
+ try { socket?.close(); } catch { /* closed */ }
229
+ };
230
+ }, []);
231
+
232
+ // Initial status fetch (panel may open long after the plugin loaded).
233
+ useEffect(() => {
234
+ fetch(API_BASE + "/status")
235
+ .then((res) => res.json())
236
+ .then((body) => {
237
+ setAdbPath(body.adbPath ?? "");
238
+ setAdbReady(body.ready === true);
239
+ setDevices(body.devices ?? []);
240
+ setStreaming(body.streaming ?? []);
241
+ devicesRef.current = body.devices ?? [];
242
+ if (serialRef.current === "" || !(body.devices ?? []).some((d) => d.serial === serialRef.current)) {
243
+ const first = (body.devices ?? []).find((d) => d.state === "device");
244
+ if (first !== undefined) pickSerial(first.serial);
245
+ }
246
+ })
247
+ .catch(() => { /* host not up yet */ });
248
+ }, []);
249
+
250
+ const filtered = useMemo(() => {
251
+ const needle = keyword.trim().toLowerCase();
252
+ return entries.filter((e) => {
253
+ if (level !== "" && e.level !== "" && e.level !== level) return false;
254
+ if (needle === "") return true;
255
+ return e.raw.toLowerCase().includes(needle);
256
+ });
257
+ }, [entries, level, keyword]);
258
+
259
+ const togglePause = () => {
260
+ const next = !paused;
261
+ setPaused(next);
262
+ if (!next && pendingRef.current.length > 0) {
263
+ const replay = pendingRef.current.filter((f) => f.serial === serialRef.current);
264
+ pendingRef.current = [];
265
+ if (replay.length > 0) appendEntries(replay.map((f) => f.entry));
266
+ }
267
+ };
268
+
269
+ const clearLog = () => {
270
+ entriesRef.current = [];
271
+ setEntries([]);
272
+ };
273
+
274
+ const exportLog = () => {
275
+ const text = filtered.map((e) => e.raw).join("\n");
276
+ const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
277
+ const a = document.createElement("a");
278
+ a.href = URL.createObjectURL(blob);
279
+ a.download = "logcat-" + (serial || "all") + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".txt";
280
+ document.body.appendChild(a);
281
+ a.click();
282
+ a.remove();
283
+ setTimeout(() => URL.revokeObjectURL(a.href), 5000);
284
+ };
285
+
286
+ const copyLog = () => {
287
+ const text = filtered.map((e) => e.raw).join("\n");
288
+ if (navigator.clipboard !== undefined) {
289
+ navigator.clipboard.writeText(text).catch(() => { /* denied */ });
290
+ }
291
+ };
292
+
293
+ const device = devices.find((d) => d.serial === serial);
294
+ const deviceState = device?.state ?? "";
295
+ const live = connected && serial !== "" && streaming.includes(serial);
296
+
297
+ return h("div", { className: "lc-panel" },
298
+ h("div", { className: "lc-header" },
299
+ h("button", { type: "button", className: "lc-back", onClick: () => controller.close() },
300
+ h("span", { "aria-hidden": true }, "‹"),
301
+ h("span", null, "关闭"),
302
+ ),
303
+ h("h2", { className: "lc-title" }, "Logcat"),
304
+ h("span", { className: "lc-dot " + (live ? "on" : connected ? "warn" : "off") }),
305
+ h("span", { style: { fontSize: 12, color: "var(--lc-dim, #9e9e9e)" } },
306
+ live ? "实机日志流中" : connected ? "未选设备" : "未连接"),
307
+ h("select", {
308
+ className: "lc-select",
309
+ value: serial,
310
+ onChange: (e) => pickSerial(e.target.value),
311
+ title: "选择设备",
312
+ },
313
+ devices.length === 0
314
+ ? h("option", { value: "" }, "无设备 — 请连接并开启 USB 调试")
315
+ : devices.map((d) =>
316
+ h("option", { key: d.serial, value: d.serial },
317
+ (d.model !== "" ? d.model + " · " : "") + d.serial + " [" + d.state + "]"))),
318
+ ),
319
+ h("div", { className: "lc-toolbar" },
320
+ h("div", { className: "lc-levels", role: "group", "aria-label": "日志级别" },
321
+ h("button", {
322
+ type: "button",
323
+ className: "lc-level i",
324
+ "data-on": level === "" ? "" : undefined,
325
+ title: "全部级别",
326
+ onClick: () => setLevel(""),
327
+ }, "A"),
328
+ LEVELS.map((lv) =>
329
+ h("button", {
330
+ type: "button",
331
+ key: lv,
332
+ className: "lc-level " + lv.toLowerCase(),
333
+ "data-on": level === lv ? "" : undefined,
334
+ title: LEVEL_TITLES[lv],
335
+ onClick: () => setLevel(level === lv ? "" : lv),
336
+ }, lv))),
337
+ h("input", {
338
+ className: "lc-search",
339
+ type: "search",
340
+ placeholder: "关键词过滤…",
341
+ value: keyword,
342
+ onChange: (e) => setKeyword(e.target.value),
343
+ }),
344
+ h("button", { type: "button", className: "lc-btn", "data-on": paused ? "" : undefined, onClick: togglePause },
345
+ paused ? "继续" : "暂停"),
346
+ h("button", { type: "button", className: "lc-btn", onClick: clearLog }, "清空"),
347
+ h("button", { type: "button", className: "lc-btn", onClick: copyLog }, "复制"),
348
+ h("button", { type: "button", className: "lc-btn", onClick: exportLog }, "导出"),
349
+ h("button", {
350
+ type: "button",
351
+ className: "lc-btn",
352
+ "data-on": autoScroll ? "" : undefined,
353
+ title: "新日志自动滚到底部",
354
+ onClick: () => setAutoScroll(!autoScroll),
355
+ }, "自动滚动"),
356
+ ),
357
+ h("div", { className: "lc-body", ref: bodyRef },
358
+ filtered.length === 0
359
+ ? h("div", { className: "lc-empty" }, paused ? "已暂停(" + entries.length + " 条已缓冲)" : "暂无日志")
360
+ : h(VirtualLog, { entries: filtered, scrollTop, onScrollTop: setScrollTop, bodyRef }),
361
+ ),
362
+ h("div", { className: "lc-status" },
363
+ h("span", null, h("b", null, adbReady ? "adb 就绪" : "未找到 adb"), " · " + (adbPath || "—")),
364
+ h("span", null, "设备 " + devices.length + " · 在线 " + devices.filter((d) => d.state === "device").length),
365
+ h("span", null, "显示 " + filtered.length + " / 缓冲 " + entries.length + " 行"),
366
+ deviceState === "unauthorized"
367
+ ? h("span", { style: { color: "#ef5350" } }, "⚠ 设备未授权 — 请在手机上点击“允许 USB 调试”")
368
+ : null,
369
+ ),
370
+ );
371
+ }
372
+
373
+ /** Windowed log list: fixed 20px rows, only the visible slice is rendered. */
374
+ function VirtualLog({ entries, scrollTop, onScrollTop, bodyRef }) {
375
+ const ROW = 20;
376
+ const height = useRef(0);
377
+ const [viewport, setViewport] = useState({ top: 0, bottom: 100 });
378
+
379
+ useEffect(() => {
380
+ const body = bodyRef.current;
381
+ if (body === null) return;
382
+ const measure = () => {
383
+ height.current = body.clientHeight;
384
+ const top = Math.max(0, Math.floor(scrollTop / ROW) - 8);
385
+ const bottom = Math.min(entries.length, Math.ceil((scrollTop + height.current) / ROW) + 8);
386
+ setViewport({ top, bottom });
387
+ };
388
+ measure();
389
+ if (typeof ResizeObserver !== "undefined") {
390
+ const observer = new ResizeObserver(measure);
391
+ observer.observe(body);
392
+ return () => observer.disconnect();
393
+ }
394
+ return undefined;
395
+ }, [entries.length]);
396
+
397
+ useEffect(() => {
398
+ const top = Math.max(0, Math.floor(scrollTop / ROW) - 8);
399
+ const bottom = Math.min(entries.length, Math.ceil((scrollTop + (height.current || 400)) / ROW) + 8);
400
+ setViewport({ top, bottom });
401
+ }, [scrollTop, entries.length]);
402
+
403
+ const rows = [];
404
+ for (let i = viewport.top; i < viewport.bottom && i < entries.length; i++) {
405
+ const e = entries[i];
406
+ const level = e.level !== "" ? e.level : " ";
407
+ rows.push(
408
+ h("div", {
409
+ key: i,
410
+ className: "lc-line" + (e.cont === true ? " cont" : ""),
411
+ style: { top: i * ROW },
412
+ title: e.raw,
413
+ },
414
+ e.ts !== "" ? h("span", { className: "ts" }, e.ts) : null,
415
+ e.pid > 0 ? h("span", { className: "pid" }, e.pid + "-" + e.tid) : null,
416
+ e.level !== "" ? h("span", { className: "lv " + level }, level) : null,
417
+ e.tag !== "" ? h("span", { className: "tag" }, e.tag) : null,
418
+ h("span", { className: "msg" }, e.msg !== "" ? e.msg : e.raw),
419
+ ),
420
+ );
421
+ }
422
+
423
+ return h("div",
424
+ {
425
+ className: "lc-log",
426
+ ref: bodyRef,
427
+ onScroll: (e) => { onScrollTop(e.target.scrollTop); },
428
+ },
429
+ h("div", { className: "lc-log-inner", style: { height: entries.length * ROW } }, rows),
430
+ );
431
+ }
432
+ //#endregion
433
+
434
+ //#region DOM mounts
435
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><rect x="3" y="1.5" width="10" height="13" rx="2"/><path d="M6.5 4.5h3M6.5 7h3M6.5 9.5h1.5" stroke-linecap="round"/><circle cx="8" cy="12" r="0.9" fill="currentColor" stroke="none"/></svg>';
436
+
437
+ function sidebarRoot() {
438
+ const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]');
439
+ if (column === null) return undefined;
440
+ const logoOwner = column.querySelector('[class*="logoRow"]')?.parentElement;
441
+ return logoOwner ?? (column.firstElementChild ?? undefined);
442
+ }
443
+
444
+ function newSessionButton(root) {
445
+ const nested = root.querySelector('button[class*="newSession"]');
446
+ if (nested !== null) return nested;
447
+ for (const child of root.children) {
448
+ if (child.tagName === "BUTTON") return child;
449
+ }
450
+ return undefined;
451
+ }
452
+
453
+ function createEntry(controller) {
454
+ const entry = document.createElement("button");
455
+ entry.type = "button";
456
+ entry.dataset.dshLogcatEntry = "";
457
+ entry.setAttribute("aria-label", "Logcat 实机调试");
458
+ entry.setAttribute("title", "Logcat 实机调试(自动连接 adb 设备)");
459
+ entry.innerHTML = '<span class="lc-entry-icon">' + ICON + '</span><span class="lc-entry-label">Logcat</span>';
460
+ entry.addEventListener("click", () => { controller.toggle(); });
461
+ return entry;
462
+ }
463
+
464
+ function placeEntry(root, entry) {
465
+ const button = newSessionButton(root);
466
+ if (button === undefined) return false;
467
+ if (entry.parentElement !== root) {
468
+ const row = button.closest('[class*="logoRow"]');
469
+ const base = (row !== null && row.parentElement === root) ? row : button;
470
+ const family = Array.from(root.children).filter(
471
+ (el) => el instanceof HTMLElement && el.matches("[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-logcat-entry]"),
472
+ );
473
+ const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
474
+ root.insertBefore(entry, anchor);
475
+ }
476
+ return true;
477
+ }
478
+
479
+ function mountSidebarEntry(controller) {
480
+ const entry = createEntry(controller);
481
+ let root;
482
+ let placed = false;
483
+ let rootObserver;
484
+
485
+ const tryPlace = () => {
486
+ if (root !== undefined && !root.isConnected) {
487
+ rootObserver?.disconnect();
488
+ root = undefined;
489
+ placed = false;
490
+ }
491
+ if (placed) {
492
+ if (document.body.contains(entry)) return;
493
+ rootObserver?.disconnect();
494
+ root = undefined;
495
+ placed = false;
496
+ }
497
+ root ??= sidebarRoot();
498
+ if (root === undefined) return;
499
+ placed = placeEntry(root, entry);
500
+ if (placed) {
501
+ rootObserver = new MutationObserver(() => {
502
+ if (root === undefined || !root.isConnected) { placed = false; tryPlace(); return; }
503
+ if (!root.contains(entry)) placed = placeEntry(root, entry);
504
+ });
505
+ rootObserver.observe(root, { childList: true, subtree: true });
506
+ }
507
+ };
508
+
509
+ const waitObserver = new MutationObserver(() => { tryPlace(); });
510
+ waitObserver.observe(document.body, { childList: true, subtree: true });
511
+
512
+ const syncActive = () => {
513
+ if (controller.getSnapshot().panelOpen) entry.dataset.active = "true";
514
+ else delete entry.dataset.active;
515
+ };
516
+ const unsubscribe = controller.subscribe(syncActive);
517
+ syncActive();
518
+ tryPlace();
519
+
520
+ return () => {
521
+ waitObserver.disconnect();
522
+ rootObserver?.disconnect();
523
+ unsubscribe();
524
+ entry.remove();
525
+ };
526
+ }
527
+
528
+ function mountPanel(controller) {
529
+ let root;
530
+ let container;
531
+
532
+ const ensure = () => {
533
+ if (container !== undefined && container.isConnected) return;
534
+ root?.unmount();
535
+ root = undefined;
536
+ container?.remove();
537
+ container = document.createElement("div");
538
+ container.dataset.dshLogcatView = "";
539
+ container.className = "dsh-logcat-view";
540
+ container.hidden = true; // side-drawer: hidden until the sidebar entry is clicked
541
+ document.body.appendChild(container);
542
+ root = createRoot(container);
543
+ root.render(h(LogcatPanel, { controller }));
544
+ };
545
+
546
+ // The drawer lives on <body>, which never gets rebuilt — mount once.
547
+ ensure();
548
+
549
+ const applyOpen = () => {
550
+ if (container !== undefined) container.hidden = !controller.getSnapshot().panelOpen;
551
+ };
552
+ const unsubscribe = controller.subscribe(applyOpen);
553
+ applyOpen();
554
+
555
+ return () => {
556
+ unsubscribe();
557
+ root?.unmount();
558
+ root = undefined;
559
+ container?.remove();
560
+ container = undefined;
561
+ };
562
+ }
563
+ //#endregion
564
+
565
+ //#region entry
566
+ /** Required services (fiber inject waiting — the runtime must be up first). */
567
+ const inject = ["slots"];
568
+
569
+ /**
570
+ * Mount the Logcat panel.
571
+ * @param ctx - client root context.
572
+ */
573
+ function apply(ctx) {
574
+ const style = document.createElement("style");
575
+ style.textContent = STYLE;
576
+ style.dataset.dshLogcatStyle = "";
577
+ document.head.appendChild(style);
578
+
579
+ const controller = new PanelController();
580
+ const disposers = [];
581
+ try {
582
+ disposers.push(mountSidebarEntry(controller));
583
+ disposers.push(mountPanel(controller));
584
+ } catch (error) {
585
+ console.warn("[dsh-logcat] mount failed:", error);
586
+ }
587
+ ctx.effect(() => () => {
588
+ for (const dispose of disposers.splice(0)) dispose();
589
+ style.remove();
590
+ }, "dsh-logcat: ui mounts");
591
+ }
592
+ //#endregion
593
+
594
+ exports.apply = apply;
595
+ exports.inject = inject;
596
+ return module.exports;
597
+ }
598
+ });
package/lib/index.js ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * dsh-logcat — host half.
3
+ *
4
+ * Mounts an adb-backed Android Logcat engine:
5
+ * - probes the adb binary (ANDROID_HOME / SDK defaults / PATH),
6
+ * - keeps `adb devices` under a 2s poll and AUTO-ATTACHES a `logcat -v
7
+ * threadtime` stream to every device in debug mode (no GUI needed),
8
+ * - keeps a per-device ring buffer (2000 lines) and broadcasts new lines
9
+ * over a WebSocket to every subscribed browser panel,
10
+ * - exposes /api/dsh-logcat/{status,exec} routes plus the stream upgrade,
11
+ * - registers the logcat_recent agent tool and a system-prompt section.
12
+ *
13
+ * The browser half (./client) renders the Logcat panel in the web GUI.
14
+ * Everything rides official NPM SDK packages — no dsh source changes.
15
+ */
16
+
17
+ import { spawn, execFile } from 'node:child_process'
18
+ import { existsSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import { createInterface } from 'node:readline'
22
+ import { WebSocket, WebSocketServer } from 'ws'
23
+ import { defineTool } from '@deepseek-ai/dsh-tools'
24
+
25
+ /** Stable cordis plugin name. */
26
+ export const name = 'logcat'
27
+
28
+ /** Services required before the logcat surfaces can mount. */
29
+ export const inject = ['webServer', 'tools', 'systemPrompt']
30
+
31
+ /** Services this plugin provides on ctx (ctx.logcat). */
32
+ export const provide = ['logcat']
33
+
34
+ /** Order of the announcement section within the tool-guidance band. */
35
+ const SECTION_ORDER = 152
36
+
37
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
38
+ export const LOGCAT_GUIDANCE =
39
+ '本机已安装 dsh-logcat 插件(DSH Web GUI 的安卓实机调试面板):侧边栏「Logcat」入口;自动探测本机 adb(ANDROID_HOME / 默认 SDK 路径),对处于调试模式的已连接设备自动附加 logcat 流(threadtime 格式,每设备保留最近 2000 行环形缓冲);Web 面板支持设备切换、级别/关键词过滤、暂停/清空/导出;agent 可用 logcat_recent 工具读取最近日志。限制:需设备开启 USB 调试并授权本机;logcat 输出可能含敏感信息;执行 adb 命令消耗真实设备资源,先确认再操作。用户提到「Logcat / 安卓日志 / 实机调试 / adb 日志」时即指本插件,请据此协作。'
40
+
41
+ /** ---------------------------------------------------------------- adb */
42
+
43
+ /** Candidate adb.exe locations, in probe order. */
44
+ function adbCandidates() {
45
+ const list = []
46
+ const envs = [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]
47
+ for (const root of envs) {
48
+ if (root) list.push(join(root, 'platform-tools', 'adb.exe'), join(root, 'platform-tools', 'adb'))
49
+ }
50
+ const sdk = join(homedir(), 'AppData', 'Local', 'Android', 'Sdk')
51
+ list.push(join(sdk, 'platform-tools', 'adb.exe'))
52
+ list.push(join(sdk, 'platform-tools', 'adb'))
53
+ return list
54
+ }
55
+
56
+ /** Run one short adb command, returning stdout (or null on failure). */
57
+ function runAdb(adb, args, timeoutMs = 8000) {
58
+ return new Promise((resolve) => {
59
+ try {
60
+ execFile(adb, args, { timeout: timeoutMs, windowsHide: true }, (error, stdout) => {
61
+ if (error) resolve(null)
62
+ else resolve(String(stdout))
63
+ })
64
+ } catch {
65
+ // spawn itself threw (EPERM/EACCES/ENOENT at spawn time) — degrade, never crash.
66
+ resolve(null)
67
+ }
68
+ })
69
+ }
70
+
71
+ /** Run one adb command and return { ok, code, stdout, stderr }. */
72
+ function runAdbFull(adb, args, timeoutMs = 15000) {
73
+ return new Promise((resolve) => {
74
+ try {
75
+ execFile(adb, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
76
+ if (error) {
77
+ const code = typeof error.code === 'number' ? error.code : null
78
+ const timedOut = error.killed === true || error.signal === 'SIGTERM'
79
+ resolve({ ok: false, code, timedOut, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })
80
+ } else {
81
+ resolve({ ok: true, code: 0, timedOut: false, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })
82
+ }
83
+ })
84
+ } catch {
85
+ resolve({ ok: false, code: null, timedOut: false, stdout: '', stderr: '' })
86
+ }
87
+ })
88
+ }
89
+
90
+ /** threadtime line: "08-18 14:23:45.678 1234 5678 I Tag : message" */
91
+ const THREADTIME_RE = /^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+([^:]*?)\s*:\s?(.*)$/
92
+
93
+ /** Parse one logcat line into an entry (null when not parseable). */
94
+ function parseLogcatLine(raw) {
95
+ const match = THREADTIME_RE.exec(raw)
96
+ if (match === null) return null
97
+ return {
98
+ ts: match[1],
99
+ pid: Number.parseInt(match[2], 10),
100
+ tid: Number.parseInt(match[3], 10),
101
+ level: match[4],
102
+ tag: match[5].trim(),
103
+ msg: match[6],
104
+ raw,
105
+ cont: false,
106
+ }
107
+ }
108
+
109
+ /** Parse `adb devices -l` output into serial -> { state, model } map. */
110
+ function parseDevices(output) {
111
+ const devices = new Map()
112
+ const lines = String(output ?? '').split(/\r?\n/)
113
+ for (const line of lines) {
114
+ const parts = line.split(/\s+/)
115
+ if (parts.length < 2 || parts[0] === 'List' || parts[0] === '*') continue
116
+ const serial = parts[0]
117
+ const state = parts[1]
118
+ let model = ''
119
+ for (const field of parts.slice(2)) {
120
+ if (field.startsWith('model:')) model = field.slice('model:'.length)
121
+ }
122
+ devices.set(serial, { serial, state, model })
123
+ }
124
+ return devices
125
+ }
126
+
127
+ /** ------------------------------------------------------------ engine */
128
+
129
+ /** One attached logcat stream (per device). */
130
+ class DeviceStream {
131
+ constructor(adb, serial, onLine, onExit) {
132
+ this.adb = adb
133
+ this.serial = serial
134
+ this.onLine = onLine
135
+ this.onExit = onExit
136
+ this.child = null
137
+ this.restartTimer = null
138
+ this.stopped = false
139
+ this.starts = 0
140
+ }
141
+
142
+ start() {
143
+ if (this.stopped || this.child !== null) return
144
+ this.starts += 1
145
+ let child
146
+ try {
147
+ child = spawn(this.adb, ['-s', this.serial, 'logcat', '-v', 'threadtime'], {
148
+ windowsHide: true,
149
+ stdio: ['ignore', 'pipe', 'pipe'],
150
+ })
151
+ } catch {
152
+ // spawn threw synchronously (e.g. adb binary vanished / blocked) — back off and retry.
153
+ if (!this.stopped) this.restartTimer = setTimeout(() => this.start(), 5000)
154
+ return
155
+ }
156
+ this.child = child
157
+ child.stderr.on('data', () => { /* adb warnings ignored */ })
158
+ createInterface({ input: child.stdout }).on('line', (line) => {
159
+ this.onLine(String(line))
160
+ })
161
+ child.on('exit', (code) => {
162
+ this.child = null
163
+ if (this.stopped) return
164
+ // Auto-restart with backoff (adb occasionally drops the stream).
165
+ this.restartTimer = setTimeout(() => this.start(), 2500)
166
+ this.onExit(code)
167
+ })
168
+ child.on('error', () => {
169
+ this.child = null
170
+ if (this.stopped) return
171
+ this.restartTimer = setTimeout(() => this.start(), 5000)
172
+ })
173
+ }
174
+
175
+ stop() {
176
+ this.stopped = true
177
+ if (this.restartTimer !== null) clearTimeout(this.restartTimer)
178
+ this.restartTimer = null
179
+ if (this.child !== null) {
180
+ try { this.child.kill() } catch { /* gone */ }
181
+ this.child = null
182
+ }
183
+ }
184
+ }
185
+
186
+ /** The adb engine: device poll, per-device ring buffers, ws fan-out. */
187
+ class AdbEngine {
188
+ constructor() {
189
+ this.adb = null
190
+ this.adbVersion = null
191
+ this.devices = new Map() // serial -> { serial, state, model }
192
+ this.buffers = new Map() // serial -> ring buffer array
193
+ this.streams = new Map() // serial -> DeviceStream
194
+ this.clients = new Set() // WebSocket panels
195
+ this.timer = null
196
+ this.polling = false
197
+ this.BUFFER_CAP = 2000
198
+ }
199
+
200
+ /** Probe and warm the adb server. Returns true when usable. */
201
+ async init() {
202
+ for (const candidate of adbCandidates()) {
203
+ if (!existsSync(candidate)) continue
204
+ const version = await runAdb(candidate, ['version'])
205
+ if (version !== null && version.includes('Android Debug Bridge')) {
206
+ this.adb = candidate
207
+ this.adbVersion = version.split(/\r?\n/)[0] ?? ''
208
+ break
209
+ }
210
+ }
211
+ if (this.adb === null) {
212
+ // Last resort: adb on PATH.
213
+ const version = await runAdb('adb', ['version'])
214
+ if (version !== null && version.includes('Android Debug Bridge')) {
215
+ this.adb = 'adb'
216
+ this.adbVersion = version.split(/\r?\n/)[0] ?? ''
217
+ }
218
+ }
219
+ if (this.adb === null) return false
220
+ await runAdb(this.adb, ['start-server'], 15000)
221
+ return true
222
+ }
223
+
224
+ startPolling() {
225
+ if (this.timer !== null) return
226
+ this.timer = setInterval(() => { void this.poll() }, 2000)
227
+ void this.poll()
228
+ }
229
+
230
+ stopPolling() {
231
+ if (this.timer !== null) {
232
+ clearInterval(this.timer)
233
+ this.timer = null
234
+ }
235
+ for (const stream of this.streams.values()) stream.stop()
236
+ this.streams.clear()
237
+ }
238
+
239
+ dispose() {
240
+ this.stopPolling()
241
+ for (const ws of this.clients) {
242
+ try { ws.close(1001, 'plugin disposed') } catch { /* closed */ }
243
+ }
244
+ this.clients.clear()
245
+ }
246
+
247
+ async poll() {
248
+ if (this.polling) return
249
+ this.polling = true
250
+ try {
251
+ const output = await runAdb(this.adb, ['devices', '-l'], 8000)
252
+ if (output === null) return
253
+ const next = parseDevices(output)
254
+ const changed = this.syncDevices(next)
255
+ if (changed) this.broadcast({ type: 'devices', devices: this.deviceList() })
256
+ } finally {
257
+ this.polling = false
258
+ }
259
+ }
260
+
261
+ /** Sync device map with a fresh poll; attach/detach streams. Returns true when the list changed. */
262
+ syncDevices(next) {
263
+ let changed = false
264
+ // Detach disappeared devices.
265
+ for (const serial of [...this.devices.keys()]) {
266
+ if (!next.has(serial)) {
267
+ this.devices.delete(serial)
268
+ this.buffers.delete(serial)
269
+ const stream = this.streams.get(serial)
270
+ if (stream !== undefined) {
271
+ stream.stop()
272
+ this.streams.delete(serial)
273
+ }
274
+ changed = true
275
+ }
276
+ }
277
+ // Attach new devices / update state.
278
+ for (const [serial, info] of next) {
279
+ const prev = this.devices.get(serial)
280
+ if (prev === undefined || prev.state !== info.state || prev.model !== info.model) changed = true
281
+ this.devices.set(serial, info)
282
+ if (info.state === 'device' && !this.streams.has(serial)) {
283
+ const stream = new DeviceStream(
284
+ this.adb,
285
+ serial,
286
+ (line) => this.pushLine(serial, line),
287
+ () => { /* restart handled inside the stream */ },
288
+ )
289
+ this.streams.set(serial, stream)
290
+ stream.start()
291
+ } else if (info.state !== 'device' && this.streams.has(serial)) {
292
+ const stream = this.streams.get(serial)
293
+ stream.stop()
294
+ this.streams.delete(serial)
295
+ this.broadcast({ type: 'device-state', serial, state: info.state })
296
+ }
297
+ }
298
+ return changed
299
+ }
300
+
301
+ /** Push one raw line into the device ring buffer and fan out. */
302
+ pushLine(serial, raw) {
303
+ const entry = parseLogcatLine(raw) ?? { ts: '', pid: 0, tid: 0, level: '', tag: '', msg: raw, raw, cont: true }
304
+ let buffer = this.buffers.get(serial)
305
+ if (buffer === undefined) {
306
+ buffer = []
307
+ this.buffers.set(serial, buffer)
308
+ }
309
+ buffer.push(entry)
310
+ if (buffer.length > this.BUFFER_CAP) buffer.splice(0, buffer.length - this.BUFFER_CAP)
311
+ this.broadcast({ type: 'line', serial, entry })
312
+ }
313
+
314
+ /** Snapshot of the device list (for routes and the browser). */
315
+ deviceList() {
316
+ return [...this.devices.values()]
317
+ }
318
+
319
+ /** Recent buffered entries of a device, newest-last. */
320
+ recent(serial, lines = 200, level = '', filter = '') {
321
+ const buffer = this.buffers.get(serial) ?? []
322
+ const needle = filter.trim().toLowerCase()
323
+ const picked = buffer.filter((entry) => {
324
+ if (level !== '' && entry.level !== '' && entry.level !== level) return false
325
+ if (needle === '') return true
326
+ return entry.raw.toLowerCase().includes(needle)
327
+ })
328
+ return picked.slice(-lines)
329
+ }
330
+
331
+ /** Broadcast one frame to every open panel socket. */
332
+ broadcast(frame) {
333
+ const payload = JSON.stringify(frame)
334
+ for (const ws of this.clients) {
335
+ if (ws.readyState !== WebSocket.OPEN) continue
336
+ if (ws.bufferedAmount > 1024 * 1024) continue // slow client: drop rather than stall
337
+ try { ws.send(payload) } catch { /* closed */ }
338
+ }
339
+ }
340
+ }
341
+
342
+ /** -------------------------------------------------------------- routes */
343
+
344
+ const API_BASE = '/api/dsh-logcat'
345
+
346
+ function isLoopbackRequest(req) {
347
+ const address = req.socket?.remoteAddress ?? ''
348
+ const host = req.headers?.host ?? ''
349
+ const okAddress = address === '::1' || address === '127.0.0.1' || address.startsWith('::ffff:127.') || address.startsWith('127.')
350
+ if (!okAddress) return false
351
+ let hostUrl
352
+ try { hostUrl = new URL('http://' + host) } catch { return false }
353
+ if (hostUrl.hostname !== 'localhost' && !hostUrl.hostname.startsWith('127.')) return false
354
+ return true
355
+ }
356
+
357
+ function writeJson(res, status, body) {
358
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
359
+ res.end(JSON.stringify(body))
360
+ }
361
+
362
+ /** The one WebSocket server for logcat fan-out. */
363
+ const streamWss = new WebSocketServer({ noServer: true })
364
+
365
+ /** Build every /api/dsh-logcat route plus the stream upgrade. */
366
+ function makeRoutes(engine) {
367
+ const routes = [
368
+ {
369
+ kind: 'exact',
370
+ path: API_BASE + '/status',
371
+ handler: async (req, res) => {
372
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
373
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
374
+ writeJson(res, 200, {
375
+ adbPath: engine.adb,
376
+ adbVersion: engine.adbVersion,
377
+ ready: engine.adb !== null,
378
+ devices: engine.deviceList(),
379
+ streaming: [...engine.streams.keys()],
380
+ })
381
+ },
382
+ },
383
+ {
384
+ kind: 'exact',
385
+ path: API_BASE + '/exec',
386
+ handler: async (req, res) => {
387
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
388
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
389
+ const chunks = []
390
+ let size = 0
391
+ for await (const chunk of req) {
392
+ size += chunk.length
393
+ if (size > 64 * 1024) { writeJson(res, 413, { error: 'body too large' }); return }
394
+ chunks.push(chunk)
395
+ }
396
+ let body = {}
397
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { /* fallthrough */ }
398
+ const serial = typeof body.serial === 'string' ? body.serial : ''
399
+ const command = typeof body.command === 'string' ? body.command : ''
400
+ if (engine.adb === null) { writeJson(res, 500, { error: 'adb not found' }); return }
401
+ if (serial === '' || command === '') { writeJson(res, 400, { error: 'serial and command are required' }); return }
402
+ const timeoutMs = typeof body.timeoutMs === 'number' ? body.timeoutMs : 15000
403
+ try {
404
+ const result = await runAdbFull(engine.adb, ['-s', serial, 'shell', command], timeoutMs)
405
+ writeJson(res, 200, result)
406
+ } catch (error) {
407
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
408
+ }
409
+ },
410
+ },
411
+ ]
412
+
413
+ const upgrade = {
414
+ path: API_BASE + '/stream',
415
+ handler: (req, socket, head) => {
416
+ if (!isLoopbackRequest(req)) {
417
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
418
+ socket.destroy()
419
+ return
420
+ }
421
+ streamWss.handleUpgrade(req, socket, head, (ws) => {
422
+ engine.clients.add(ws)
423
+ const url = new URL(req.url ?? '/', 'http://localhost')
424
+ const serial = url.searchParams.get('serial') ?? ''
425
+ // Send the current snapshot, then a history replay for the requested
426
+ // device (or the first attached one), then live lines keep flowing.
427
+ ws.send(JSON.stringify({
428
+ type: 'ready',
429
+ adbPath: engine.adb,
430
+ devices: engine.deviceList(),
431
+ streaming: [...engine.streams.keys()],
432
+ }))
433
+ const target = serial !== '' && engine.devices.has(serial)
434
+ ? serial
435
+ : engine.devices.get([...engine.devices.keys()][0])?.serial ?? ''
436
+ if (target !== '') {
437
+ ws.send(JSON.stringify({ type: 'history', serial: target, entries: engine.recent(target, 500) }))
438
+ }
439
+ // Client-driven replay (device switch, pause resume).
440
+ ws.on('message', (data) => {
441
+ let frame
442
+ try { frame = JSON.parse(String(data)) } catch { return }
443
+ if (frame?.type === 'replay' && typeof frame.serial === 'string') {
444
+ const entries = engine.devices.has(frame.serial) ? engine.recent(frame.serial, 500) : []
445
+ ws.send(JSON.stringify({ type: 'history', serial: frame.serial, entries }))
446
+ }
447
+ })
448
+ ws.on('close', () => { engine.clients.delete(ws) })
449
+ ws.on('error', () => { engine.clients.delete(ws) })
450
+ })
451
+ },
452
+ }
453
+
454
+ return { routes, upgrade }
455
+ }
456
+
457
+ /** The logcat_recent agent tool. */
458
+ function logcatRecentTool(engine) {
459
+ return defineTool({
460
+ name: 'logcat_recent',
461
+ description: 'Read recent Android logcat entries from an attached adb device (dsh-logcat plugin). ' +
462
+ 'Triggers: check android logcat, read device logs, debug the app on the phone, view crash logs.',
463
+ parameters: {
464
+ serial: { type: 'string', description: 'Device serial from logcat_devices (optional; defaults to the first attached device).' },
465
+ lines: { type: 'integer', description: 'Max entries to return (default 200, max 2000).' },
466
+ level: { type: 'string', enum: ['V', 'D', 'I', 'W', 'E', 'F'], description: 'Minimum severity filter (V=verbose … F=fatal).' },
467
+ filter: { type: 'string', description: 'Substring to filter the raw line (case-insensitive).' },
468
+ },
469
+ output: {
470
+ schema: {
471
+ type: 'object',
472
+ additionalProperties: false,
473
+ properties: {
474
+ entries: {
475
+ type: 'array',
476
+ required: true,
477
+ items: {
478
+ type: 'object',
479
+ additionalProperties: false,
480
+ properties: {
481
+ ts: { type: 'string', required: true },
482
+ pid: { type: 'integer', required: true },
483
+ tid: { type: 'integer', required: true },
484
+ level: { type: 'string', required: true },
485
+ tag: { type: 'string', required: true },
486
+ msg: { type: 'string', required: true },
487
+ },
488
+ },
489
+ },
490
+ },
491
+ },
492
+ render: (_args, value) => {
493
+ const entries = value?.entries ?? []
494
+ if (entries.length === 0) return [{ type: 'text', text: '(no logcat entries)' }]
495
+ const levels = ['V', 'D', 'I', 'W', 'E', 'F']
496
+ const lines = entries.map((e) => {
497
+ const level = e.level !== '' ? e.level : '?'
498
+ const min = levels.indexOf(level)
499
+ const shown = min >= 2 ? level : (min >= 0 ? '·' : ' ')
500
+ return `${e.ts} ${String(e.pid).padStart(5)} ${String(e.tid).padStart(5)} ${shown} ${e.tag.padEnd(16)} : ${e.msg}`
501
+ })
502
+ return [{ type: 'text', text: lines.join('\n') }]
503
+ },
504
+ },
505
+ async execute(args) {
506
+ const devices = engine.deviceList()
507
+ const serial = typeof args.serial === 'string' && args.serial !== '' ? args.serial : devices[0]?.serial ?? ''
508
+ if (serial === '') return { entries: [] }
509
+ const lines = Math.min(Math.max(Number(args.lines ?? 200) || 200, 1), 2000)
510
+ const level = typeof args.level === 'string' ? args.level : ''
511
+ const filter = typeof args.filter === 'string' ? args.filter : ''
512
+ return { entries: engine.recent(serial, lines, level, filter) }
513
+ },
514
+ })
515
+ }
516
+
517
+ /** ------------------------------------------------------------------ */
518
+
519
+ /** Mount the adb engine, routes, tool, and announcement. */
520
+ export function apply(ctx, config) {
521
+ const resolve = () => ({
522
+ enabled: config?.enabled ?? true,
523
+ announceToAgent: config?.announceToAgent ?? true,
524
+ })
525
+
526
+ const engine = new AdbEngine()
527
+ // Observable handle for diagnostics and self-checks. Real cordis contexts
528
+ // reject bare property assignment ("cannot set property without provide"),
529
+ // so publish through ctx.provide when available and fall back to direct
530
+ // assignment for the plain-object stub used by selfcheck.mjs.
531
+ const logcatHandle = {
532
+ engine,
533
+ status: () => ({
534
+ adbPath: engine.adb,
535
+ adbVersion: engine.adbVersion,
536
+ ready: engine.adb !== null,
537
+ devices: engine.deviceList(),
538
+ streaming: [...engine.streams.keys()],
539
+ bufferSizes: Object.fromEntries([...engine.buffers.entries()].map(([s, b]) => [s, b.length])),
540
+ }),
541
+ }
542
+ if (typeof ctx.provide === 'function') ctx.provide('logcat', logcatHandle)
543
+ else ctx.logcat = logcatHandle
544
+ let inited = false
545
+ const initOnce = () => {
546
+ if (inited) return
547
+ inited = true
548
+ void engine.init().then((ok) => {
549
+ if (ok) engine.startPolling()
550
+ })
551
+ }
552
+
553
+ const { routes, upgrade } = makeRoutes(engine)
554
+ let disposeRoutes
555
+ let disposeTools
556
+ let disposeSection
557
+
558
+ const sync = () => {
559
+ const value = resolve()
560
+ if (disposeSection !== undefined) { disposeSection(); disposeSection = undefined }
561
+ if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
562
+ if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
563
+ if (!value.enabled) return
564
+ if (value.announceToAgent) {
565
+ disposeSection = ctx.systemPrompt.section({
566
+ name: 'plugin:dsh-logcat',
567
+ order: SECTION_ORDER,
568
+ text: LOGCAT_GUIDANCE,
569
+ })
570
+ }
571
+ disposeRoutes = ctx.effect(() => {
572
+ const disposers = routes.map((route) => ctx.webServer.register(route))
573
+ const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade)
574
+ return () => {
575
+ for (const dispose of disposers) dispose()
576
+ upgradeDisposer()
577
+ }
578
+ }, 'dsh-logcat: routes')
579
+ disposeTools = ctx.effect(() => {
580
+ const disposers = [logcatRecentTool(engine)].map((tool) => ctx.tools.register(tool))
581
+ return () => { for (const dispose of disposers) dispose() }
582
+ }, 'dsh-logcat: tools')
583
+ initOnce()
584
+ }
585
+
586
+ ctx.effect(() => () => {
587
+ engine.dispose()
588
+ }, 'dsh-logcat: engine')
589
+
590
+ sync()
591
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@windypro-rourou/dsh-logcat",
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.0",
5
+ "type": "module",
6
+ "packageManager": "pnpm@11.22.0",
7
+ "engines": {
8
+ "node": "^22.19.0 || >=24.0.0"
9
+ },
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": {
13
+ "default": "./lib/index.js"
14
+ },
15
+ "./client": {
16
+ "default": "./lib/client.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "dsh": {
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ },
24
+ "client": {
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-runtime"
27
+ ],
28
+ "platform": "web"
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "ws": "^8.18.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.2.0",
36
+ "react-dom": "^18.2.0"
37
+ },
38
+ "files": [
39
+ "lib/**/*.js",
40
+ "cordis.patch.yml",
41
+ "README.md"
42
+ ],
43
+ "license": "Apache-2.0",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/zhu1090093659/dsh-web-ui.git"
47
+ }
48
+ }