@windypro-rourou/dsh-logcat 0.6.0-preview.4 → 0.6.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/lib/client.js CHANGED
@@ -136,9 +136,13 @@ window.__ModuleLoader__.load({
136
136
  .lc-screen { position: absolute; inset: 0; display: flex; flex-direction: column; }
137
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
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; }
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)); position: relative; }
140
+ .lc-screen-img img, .lc-screen-img canvas { max-width: 100%; max-height: 100%; object-fit: contain; user-select: none; }
141
141
  .lc-screen-hint { color: light-dark(#6b7078, #9aa0a8); }
142
+ .lc-pausedbar { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: light-dark(rgba(251,192,45,.16), rgba(251,192,45,.18)); border-bottom: 1px solid rgba(251,192,45,.45); flex: none; font-size: 12px; color: #fbc02d; }
143
+ .lc-pausedbar .lc-btn { padding: 2px 10px; }
144
+ .lc-fab { position: absolute; right: 14px; bottom: 14px; z-index: 4; border: 1px solid rgba(66,133,244,.45); background: light-dark(rgba(66,133,244,.14), rgba(66,133,244,.24)); color: #4285f4; font: inherit; font-size: 12px; font-weight: 700; padding: 8px 14px; border-radius: 999px; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.22); }
145
+ .lc-fab:hover { background: light-dark(rgba(66,133,244,.22), rgba(66,133,244,.34)); }
142
146
  `;
143
147
  //#endregion
144
148
 
@@ -183,6 +187,7 @@ window.__ModuleLoader__.load({
183
187
  const [entries, setEntries] = useState([]);
184
188
  const [connected, setConnected] = useState(false);
185
189
  const [paused, setPaused] = useState(false);
190
+ const [pendingCount, setPendingCount] = useState(0);
186
191
  const [currentPackage, setCurrentPackage] = useState("");
187
192
  const [pkgInput, setPkgInput] = useState("");
188
193
  const [installingAdb, setInstallingAdb] = useState(false);
@@ -225,6 +230,8 @@ window.__ModuleLoader__.load({
225
230
  const [screenImg, setScreenImg] = useState(null);
226
231
  const [screenText, setScreenText] = useState("");
227
232
  const [screenOn, setScreenOn] = useState(false);
233
+ const [screenFps, setScreenFps] = useState(0);
234
+ const [screenHasFrame, setScreenHasFrame] = useState(false);
228
235
  const [hostScreenCapable, setHostScreenCapable] = useState(false);
229
236
  const panelOpen = useSyncExternalStore(controller.subscribe, controller.getSnapshot).panelOpen;
230
237
 
@@ -241,10 +248,14 @@ window.__ModuleLoader__.load({
241
248
  const fileInputRef = useRef(null);
242
249
  const screenWrapRef = useRef(null);
243
250
  const screenImgRef = useRef(null);
251
+ const screenCanvasRef = useRef(null);
244
252
  const screenDragRef = useRef(null);
245
253
  const screenWantRef = useRef(false);
246
254
  const handleBinaryRef = useRef(null);
247
255
  const prevSerialRef = useRef(serial);
256
+ const screenDecodingRef = useRef(false);
257
+ const screenPendingRef = useRef(null);
258
+ const screenFpsTsRef = useRef([]);
248
259
 
249
260
  useEffect(() => { serialRef.current = serial; }, [serial]);
250
261
  useEffect(() => { pausedRef.current = paused; }, [paused]);
@@ -293,10 +304,19 @@ window.__ModuleLoader__.load({
293
304
 
294
305
  const appendEntries = (incoming) => {
295
306
  const next = entriesRef.current.concat(incoming);
296
- if (next.length > 2000) next.splice(0, next.length - 2000);
307
+ let removed = 0;
308
+ if (next.length > 5000) { removed = next.length - 5000; next.splice(0, removed); }
297
309
  entriesRef.current = next;
298
310
  setEntries(next);
299
- if (autoScrollRef.current) requestAnimationFrame(() => scrollToBottom());
311
+ if (autoScrollRef.current) {
312
+ requestAnimationFrame(() => scrollToBottom());
313
+ } else if (removed > 0) {
314
+ // Anchor the viewport: trimming old rows shifts array indexes, so
315
+ // compensate the scroll position to keep what the user is reading
316
+ // visually in place (no jumping while auto-scroll is off).
317
+ const body = bodyRef.current;
318
+ if (body !== null) body.scrollTop = Math.max(0, body.scrollTop - removed * 20);
319
+ }
300
320
  };
301
321
 
302
322
  const applyFrame = (frame) => {
@@ -328,6 +348,7 @@ window.__ModuleLoader__.load({
328
348
  if (pausedRef.current) {
329
349
  pendingRef.current.push(frame);
330
350
  if (pendingRef.current.length > 800) pendingRef.current.splice(0, pendingRef.current.length - 800);
351
+ setPendingCount(pendingRef.current.length);
331
352
  return;
332
353
  }
333
354
  if (frame.serial !== serialRef.current) return;
@@ -489,10 +510,13 @@ window.__ModuleLoader__.load({
489
510
  const togglePause = () => {
490
511
  const next = !paused;
491
512
  setPaused(next);
492
- if (!next && pendingRef.current.length > 0) {
493
- const replay = pendingRef.current.filter((f) => f.serial === serialRef.current);
494
- pendingRef.current = [];
495
- if (replay.length > 0) appendEntries(replay.map((f) => f.entry));
513
+ if (!next) {
514
+ setPendingCount(0);
515
+ if (pendingRef.current.length > 0) {
516
+ const replay = pendingRef.current.filter((f) => f.serial === serialRef.current);
517
+ pendingRef.current = [];
518
+ if (replay.length > 0) appendEntries(replay.map((f) => f.entry));
519
+ }
496
520
  }
497
521
  };
498
522
 
@@ -671,6 +695,11 @@ window.__ModuleLoader__.load({
671
695
  .catch(() => setWifiOut("网络错误"));
672
696
  };
673
697
 
698
+ // RE workbench: auto-load the process list when the tab opens.
699
+ useEffect(() => {
700
+ if (tab === "re" && serial !== "") loadProcesses();
701
+ }, [tab, serial]);
702
+
674
703
  const loadProcesses = () => {
675
704
  fetch(API_BASE + "/processes?serial=" + encodeURIComponent(serial) + "&filter=" + encodeURIComponent(reFilter))
676
705
  .then((res) => res.json())
@@ -728,6 +757,32 @@ window.__ModuleLoader__.load({
728
757
  }, [tab, serial, panelOpen]);
729
758
 
730
759
  // Binary WS frames: 4-byte JSON header length + header + PNG payload.
760
+ // Decoding goes through createImageBitmap into a canvas (no base64
761
+ // data-URL churn per frame); only the newest pending frame is kept.
762
+ const pumpScreenFrame = async () => {
763
+ if (screenDecodingRef.current) return;
764
+ screenDecodingRef.current = true;
765
+ try {
766
+ while (screenPendingRef.current !== null) {
767
+ const bytes = screenPendingRef.current;
768
+ screenPendingRef.current = null;
769
+ const canvas = screenCanvasRef.current;
770
+ if (canvas === null) break; // wait until the canvas mounts
771
+ try {
772
+ const bmp = await createImageBitmap(new Blob([bytes], { type: "image/png" }));
773
+ if (canvas.width !== bmp.width) canvas.width = bmp.width;
774
+ if (canvas.height !== bmp.height) canvas.height = bmp.height;
775
+ const c2d = canvas.getContext("2d");
776
+ c2d.drawImage(bmp, 0, 0);
777
+ bmp.close();
778
+ setScreenHasFrame(true);
779
+ } catch { /* undecodable frame — skip */ }
780
+ }
781
+ } finally {
782
+ screenDecodingRef.current = false;
783
+ }
784
+ };
785
+
731
786
  const handleBinary = (buffer) => {
732
787
  try {
733
788
  const view = new DataView(buffer);
@@ -735,12 +790,14 @@ window.__ModuleLoader__.load({
735
790
  const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, hlen)));
736
791
  if (header.type !== "simage" || header.serial !== serialRef.current) return;
737
792
  const bytes = new Uint8Array(buffer, 4 + hlen);
738
- let binary = "";
739
- const CHUNK = 0x8000;
740
- for (let i = 0; i < bytes.length; i += CHUNK) {
741
- binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
742
- }
743
- setScreenImg("data:image/png;base64," + btoa(binary));
793
+ // Sliding 1.5s window → rough fps readout.
794
+ const now = performance.now();
795
+ const ts = screenFpsTsRef.current;
796
+ ts.push(now);
797
+ while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
798
+ setScreenFps(Math.min(ts.length, 10));
799
+ screenPendingRef.current = bytes;
800
+ void pumpScreenFrame();
744
801
  } catch { /* malformed frame */ }
745
802
  };
746
803
  handleBinaryRef.current = handleBinary;
@@ -793,13 +850,16 @@ window.__ModuleLoader__.load({
793
850
  };
794
851
 
795
852
  const downloadScreen = () => {
796
- if (screenImg === null) return;
797
- const a = document.createElement("a");
798
- a.href = screenImg;
799
- a.download = "screen-" + (serial || "device") + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".png";
800
- document.body.appendChild(a);
801
- a.click();
802
- a.remove();
853
+ const canvas = screenCanvasRef.current;
854
+ if (canvas === null || canvas.width <= 1) return;
855
+ try {
856
+ const a = document.createElement("a");
857
+ a.href = canvas.toDataURL("image/png");
858
+ a.download = "screen-" + (serial || "device") + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".png";
859
+ document.body.appendChild(a);
860
+ a.click();
861
+ a.remove();
862
+ } catch { /* empty canvas */ }
803
863
  };
804
864
 
805
865
  return h("div", { className: "lc-panel" },
@@ -911,7 +971,7 @@ window.__ModuleLoader__.load({
911
971
  }),
912
972
  h("button", { type: "button", className: "lc-btn", onClick: () => fileInputRef.current?.click(), title: "选择本地 APK 安装到当前设备", disabled: installingApk ? true : undefined }, installingApk ? "安装中…" : "装APK"),
913
973
  h("button", { type: "button", className: "lc-btn", "data-on": paused ? "" : undefined, onClick: togglePause },
914
- paused ? "继续" : "暂停"),
974
+ paused ? "继续(" + pendingCount + ")" : "暂停"),
915
975
  h("button", { type: "button", className: "lc-btn", onClick: clearLog }, "清空"),
916
976
  h("button", { type: "button", className: "lc-btn", onClick: copyLog }, "复制"),
917
977
  h("button", { type: "button", className: "lc-btn", onClick: exportLog }, "导出"),
@@ -944,10 +1004,15 @@ window.__ModuleLoader__.load({
944
1004
  h("button", { type: "button", className: "lc-btn", onClick: () => setHistoryNote("") }, "关闭"),
945
1005
  )
946
1006
  : null,
1007
+ paused && tab === "log" && !eventsOn
1008
+ ? h("div", { className: "lc-pausedbar" },
1009
+ h("span", { style: { flex: 1 } }, "⏸ 已暂停 — 新日志缓冲中(" + pendingCount + " 条),列表已冻结"),
1010
+ h("button", { type: "button", className: "lc-btn", onClick: togglePause, title: "恢复实时,回放缓冲日志" }, "继续"),
1011
+ )
1012
+ : null,
947
1013
  h("div", { className: "lc-body", ref: bodyRef },
948
1014
  tab === "re"
949
- ? h("div", { className: "lc-re" },
950
- h("div", { className: "lc-re-top" },
1015
+ ? h("div", { className: "lc-re" }, h("div", { className: "lc-re-top" },
951
1016
  h("input", { placeholder: "进程过滤…", value: reFilter, onChange: (e) => setReFilter(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") loadProcesses(); }, style: { width: 120 } }),
952
1017
  h("button", { type: "button", className: "lc-btn", onClick: loadProcesses }, "进程"),
953
1018
  h("input", { placeholder: "搜索: hello 或 hex:48656c6c6f", value: rePattern, onChange: (e) => setRePattern(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") reSearch(); }, style: { flex: 1, minWidth: 140 } }),
@@ -957,7 +1022,9 @@ window.__ModuleLoader__.load({
957
1022
  h("div", { className: "lc-re-col" },
958
1023
  h("div", { className: "lc-re-head" }, "进程 (" + rePids.length + ")"),
959
1024
  h("div", { className: "lc-re-list" },
960
- rePids.map((p) =>
1025
+ rePids.length === 0
1026
+ ? h("div", { className: "lc-re-row", title: "点击「进程」或等待自动加载" }, "(尚未加载 — 点击「进程」刷新)")
1027
+ : rePids.map((p) =>
961
1028
  h("div", { key: p.pid, className: "lc-re-row", "data-on": rePid === p.pid ? "" : undefined, title: (p.user ?? "") + " " + p.name, onClick: () => { setRePid(p.pid); setReResults([]); setReDump(""); setReError(""); } },
962
1029
  p.pid + " " + p.name)))),
963
1030
  h("div", { className: "lc-re-col2" },
@@ -978,7 +1045,7 @@ window.__ModuleLoader__.load({
978
1045
  ? h("div", { className: "lc-screen" },
979
1046
  h("div", { className: "lc-screen-bar" },
980
1047
  h("span", { className: "lc-dot " + (screenOn && hostScreenCapable ? "on" : "off") }),
981
- h("span", null, !hostScreenCapable ? "宿主版本过旧" : (screenOn ? "投屏中 (~1fps)" : "投屏未开启")),
1048
+ h("span", null, !hostScreenCapable ? "宿主版本过旧" : (screenOn ? "投屏中 (~" + screenFps + "fps)" : "投屏未开启")),
982
1049
  h("input", { placeholder: "输入文字后回车发送…", value: screenText, onChange: (e) => setScreenText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") sendScreenText(); }, style: { flex: 1, minWidth: 140 } }),
983
1050
  h("button", { type: "button", className: "lc-btn", onClick: downloadScreen, title: "下载当前帧 PNG" }, "下载"),
984
1051
  h("span", { className: "lc-screen-hint" }, "点击=点按 · 拖动=滑动"),
@@ -986,9 +1053,8 @@ window.__ModuleLoader__.load({
986
1053
  !hostScreenCapable
987
1054
  ? h("div", { className: "lc-empty" }, "⚠ 运行中的 GUI 宿主版本过旧(0.6.0-preview.1)——请重启 GUI(dsh web)后刷新,投屏功能才会生效")
988
1055
  : h("div", { className: "lc-screen-img", ref: screenWrapRef, onMouseDown: screenMouseDown, onMouseUp: screenMouseUp, onMouseLeave: screenMouseUp },
989
- screenImg !== null
990
- ? h("img", { ref: screenImgRef, src: screenImg, alt: "设备屏幕", draggable: false })
991
- : h("div", { className: "lc-empty" }, "等待画面…(设备连接后自动开始,1fps)"),
1056
+ h("canvas", { ref: screenCanvasRef, alt: "设备屏幕", style: { width: "1080px", height: "2408px" } }),
1057
+ !screenHasFrame ? h("div", { className: "lc-empty" }, "等待画面…(设备连接后自动开始)") : null,
992
1058
  ),
993
1059
  )
994
1060
  : eventsOn
@@ -998,6 +1064,9 @@ window.__ModuleLoader__.load({
998
1064
  : (filtered.length === 0
999
1065
  ? h("div", { className: "lc-empty" }, paused ? "已暂停(" + entries.length + " 条已缓冲)" : "暂无日志")
1000
1066
  : h(VirtualLog, { entries: filtered, scrollTop, onScrollTop: setScrollTop, bodyRef })),
1067
+ tab === "log" && !eventsOn && live && !paused
1068
+ ? h("button", { type: "button", className: "lc-fab", onClick: togglePause, title: "暂停:冻结当前视图(新日志进入缓冲)" }, "⏸ 暂停")
1069
+ : null,
1001
1070
  ),
1002
1071
  crashModal
1003
1072
  ? h("div", { className: "lc-overlay", onClick: () => setCrashModal(false) },
package/lib/index.js CHANGED
@@ -687,19 +687,21 @@ export class AdbEngine {
687
687
  return picked.slice(-lines)
688
688
  }
689
689
 
690
- /** Start / stop the ~1fps screen-stream loop for a device (panel live view). */
690
+ /** Start / stop the screen-stream loop for a device (panel live view). */
691
691
  setScreen(serial, on) {
692
692
  const existing = this.screenLoops.get(serial)
693
693
  if (on) {
694
694
  if (existing === undefined && this.adb !== null && this.devices.get(serial)?.state === 'device') {
695
- const loop = { timer: null, busy: false }
695
+ const loop = { timer: null, busy: false, lastHash: null }
696
696
  this.screenLoops.set(serial, loop)
697
697
  const tick = () => {
698
698
  if (loop.busy || this.screenLoops.get(serial) !== loop) return
699
699
  loop.busy = true
700
- void this.captureScreenFrame(serial).finally(() => { loop.busy = false })
700
+ void this.captureScreenFrame(serial, loop).finally(() => { loop.busy = false })
701
701
  }
702
- loop.timer = setInterval(tick, 1000)
702
+ // 500ms cadence: screencap itself takes 300-700ms, so the busy flag
703
+ // throttles naturally; static screens are deduped by content hash.
704
+ loop.timer = setInterval(tick, 500)
703
705
  tick()
704
706
  }
705
707
  } else if (existing !== undefined) {
@@ -708,11 +710,20 @@ export class AdbEngine {
708
710
  }
709
711
  }
710
712
 
711
- /** Capture one frame and broadcast it as a binary WS frame. */
712
- async captureScreenFrame(serial) {
713
+ /** Capture one frame and broadcast it as a binary WS frame (deduped when the picture is unchanged). */
714
+ async captureScreenFrame(serial, loop) {
713
715
  try {
714
716
  const png = await runAdbBinary(this.adb, ['-s', serial, 'exec-out', 'screencap', '-p'], 8000)
715
717
  if (png.length < 100) return
718
+ // Cheap content hash (FNV-1a over the full buffer) — skip broadcasts of
719
+ // identical frames so idle screens cost nothing on the client.
720
+ let hash = 2166136261
721
+ for (let i = 0; i < png.length; i++) {
722
+ hash ^= png[i]
723
+ hash = Math.imul(hash, 16777619)
724
+ }
725
+ if (loop !== undefined && hash === loop.lastHash) return
726
+ if (loop !== undefined) loop.lastHash = hash
716
727
  this.broadcastBinary({ type: 'simage', serial, len: png.length, ts: Date.now() }, png)
717
728
  } catch { /* frame dropped */ }
718
729
  }
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",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {