@windypro-rourou/dsh-logcat 0.6.6 → 0.8.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
@@ -73,6 +73,87 @@ window.__ModuleLoader__.load({
73
73
  .lc-line.lw-E { border-left: 3px solid #ef5350; }
74
74
  .lc-line.lw-F { border-left: 3px solid #ab47bc; }
75
75
  .lc-screen-img:fullscreen { background: #000; }
76
+
77
+ /* ---------------------------------------------------------------- v2 look */
78
+ /* Layered surface + tighter rhythm; appended last so it overrides the base. */
79
+ .dsh-logcat-panel {
80
+ background: light-dark(#fafbfc, #14171c);
81
+ }
82
+ .lc-header {
83
+ padding: 11px 14px; gap: 10px;
84
+ background: light-dark(rgba(0,0,0,.025), rgba(255,255,255,.025));
85
+ border-bottom: 1px solid light-dark(rgba(0,0,0,.09), rgba(255,255,255,.09));
86
+ }
87
+ .lc-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; margin-right: 2px; }
88
+ .lc-version { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 999px; background: light-dark(rgba(66,133,244,.14), rgba(66,133,244,.2)); color: #4285f4; }
89
+ .lc-close { width: 26px; height: 26px; border-radius: 8px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); background: transparent; color: inherit; cursor: pointer; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; }
90
+ .lc-close:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
91
+ .lc-dot.on { animation: lc-pulse 2s ease-in-out infinite; }
92
+ @keyframes lc-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(76,175,80,.55); } 50% { box-shadow: 0 0 0 5px rgba(76,175,80,0); } }
93
+
94
+ /* chips → soft cards */
95
+ .lc-stats { padding: 7px 14px; gap: 7px; }
96
+ .lc-chip {
97
+ padding: 3px 9px; border-radius: 9px;
98
+ background: light-dark(rgba(255,255,255,.9), rgba(255,255,255,.055));
99
+ border: 1px solid light-dark(rgba(0,0,0,.07), rgba(255,255,255,.08));
100
+ box-shadow: 0 1px 2px rgba(0,0,0,.05);
101
+ }
102
+ .lc-chip:hover { border-color: light-dark(rgba(0,0,0,.14), rgba(255,255,255,.16)); }
103
+
104
+ /* segmented tabs */
105
+ .lc-tabs { padding: 8px 12px 0; gap: 4px; border-bottom: 1px solid light-dark(rgba(0,0,0,.07), rgba(255,255,255,.08)); }
106
+ .lc-tab { border-radius: 999px; padding: 5px 13px; font-size: 12.5px; font-weight: 600; opacity: .68; transition: background .14s, opacity .14s, color .14s; }
107
+ .lc-tab[data-on] {
108
+ opacity: 1; color: light-dark(#fff, #fff);
109
+ background: linear-gradient(135deg, #4285f4, #5b8def);
110
+ box-shadow: 0 2px 8px rgba(66,133,244,.35);
111
+ }
112
+ .lc-tab[data-on]:hover { background: linear-gradient(135deg, #3b78e0, #4f83e4); }
113
+
114
+ /* toolbar grouping */
115
+ .lc-toolgroup { display: inline-flex; align-items: center; gap: 4px; }
116
+ .lc-toolsep { width: 1px; height: 18px; margin: 0 3px; background: light-dark(rgba(0,0,0,.12), rgba(255,255,255,.15)); }
117
+ .lc-icobtn { transition: background .12s, border-color .12s, transform .08s; }
118
+ .lc-icobtn:active { transform: scale(.93); }
119
+ .lc-btn:active, .lc-key:active { transform: scale(.96); }
120
+
121
+ /* live screen: device-like framing, badge, tap ripple */
122
+ .lc-screen-img {
123
+ background: radial-gradient(120% 90% at 50% 0%, light-dark(#eef1f6, #1b2027) 0%, light-dark(#e4e8ef, #0d1014) 100%);
124
+ padding: 10px;
125
+ }
126
+ .lc-screen-img canvas {
127
+ border-radius: 12px;
128
+ box-shadow: 0 10px 30px rgba(0,0,0,.42), 0 0 0 1px rgba(255,255,255,.06);
129
+ background: #000;
130
+ }
131
+ .lc-screen-badge {
132
+ position: absolute; top: 14px; left: 14px; z-index: 3;
133
+ font-size: 10px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
134
+ background: rgba(8,10,14,.62); color: #dfe6f2; backdrop-filter: blur(6px);
135
+ border: 1px solid rgba(255,255,255,.12); pointer-events: none;
136
+ }
137
+ .lc-screen-badge.warn { color: #ffd479; border-color: rgba(251,192,45,.4); }
138
+ .lc-tap-ripple {
139
+ position: fixed; z-index: 4; width: 30px; height: 30px; margin: -15px 0 0 -15px;
140
+ border-radius: 50%; background: rgba(66,133,244,.5); pointer-events: none;
141
+ animation: lc-ripple .42s ease-out forwards;
142
+ }
143
+ @keyframes lc-ripple { from { transform: scale(.35); opacity: .95; } to { transform: scale(1.7); opacity: 0; } }
144
+
145
+ /* friendlier empty states */
146
+ .lc-empty { flex-direction: column; gap: 10px; text-align: center; padding: 0 24px; }
147
+ .lc-empty-icon { font-size: 30px; opacity: .45; }
148
+
149
+ /* log line polish */
150
+ .lc-line { transition: background .1s; }
151
+ .lc-line .tag { cursor: pointer; }
152
+ .lc-line .tag:hover { text-decoration: underline; }
153
+ .lc-hit { background: linear-gradient(180deg, rgba(66,133,244,.42), rgba(66,133,244,.28)); border-radius: 3px; padding: 0 1px; }
154
+
155
+ /* RE workbench headers */
156
+ .lc-re-head { letter-spacing: .2px; background: light-dark(rgba(0,0,0,.025), rgba(255,255,255,.03)); }
76
157
  .lc-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; flex-wrap: wrap; }
77
158
  .lc-select, .lc-search { 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: 5px 9px; }
78
159
  .lc-select:focus, .lc-search:focus { outline: 2px solid rgba(66,133,244,.35); border-color: rgba(66,133,244,.5); }
@@ -157,6 +238,53 @@ window.__ModuleLoader__.load({
157
238
  .lc-pausedbar .lc-btn { padding: 2px 10px; }
158
239
  .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); }
159
240
  .lc-fab:hover { background: light-dark(rgba(66,133,244,.22), rgba(66,133,244,.34)); }
241
+ /* ---- design pass: token layer + refinements ------------------------------
242
+ Colours resolve from the DSH design tokens when the host exposes them and
243
+ fall back to a hand-tuned pair otherwise, so the panel reads native on 0.2
244
+ and still looks right on older clients. One accent (blue); severity hues are
245
+ data encoding, not decoration. */
246
+ .lc-panel {
247
+ --lc-accent: var(--dsw-alias-brand-primary, #3b82f6);
248
+ --lc-accent-soft: light-dark(rgba(59,130,246,.10), rgba(59,130,246,.20));
249
+ --lc-raised: var(--dsw-alias-bg-layer-2, light-dark(#f7f8fa, #1c1f26));
250
+ --lc-fg: var(--dsw-alias-label-primary, light-dark(#16181d, #e6e9ef));
251
+ --lc-fg-2: var(--dsw-alias-label-secondary, light-dark(#4a4f57, #a8afba));
252
+ --lc-dim: var(--dsw-alias-label-caption, light-dark(#6b7078, #8b93a0));
253
+ --lc-border: var(--dsw-alias-border-l2, light-dark(rgba(0,0,0,.10), rgba(255,255,255,.12)));
254
+ --lc-border-strong: var(--dsw-alias-border-l4, light-dark(rgba(0,0,0,.20), rgba(255,255,255,.22)));
255
+ --lc-hover: var(--dsw-alias-interactive-bg-hover, light-dark(rgba(0,0,0,.045), rgba(255,255,255,.07)));
256
+ background: var(--dsw-alias-bg-layer-1, light-dark(#ffffff, #16181d));
257
+ color: var(--lc-fg);
258
+ font-family: system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
259
+ }
260
+ .lc-header { padding: 11px 14px; gap: 9px; border-bottom-color: var(--lc-border); }
261
+ .lc-title { font-size: 14px; font-weight: 650; letter-spacing: -.01em; }
262
+ .lc-tabs { gap: 2px; padding: 0 10px; border-bottom-color: var(--lc-border); }
263
+ .lc-tab { font-size: 13px; font-weight: 500; padding: 8px 12px; border-radius: 0; box-shadow: none; opacity: .62; transition: opacity .12s ease, color .12s ease; }
264
+ .lc-tab:hover { opacity: .95; background: transparent; }
265
+ .lc-tab[data-on] { opacity: 1; background: transparent; color: var(--lc-accent); box-shadow: inset 0 -2px 0 var(--lc-accent); }
266
+ .lc-chip { background: light-dark(rgba(0,0,0,.035), rgba(255,255,255,.05)); font-variant-numeric: tabular-nums; }
267
+ .lc-icobtn { width: 28px; height: 26px; border-radius: 8px; border-color: transparent; color: var(--lc-fg-2); transition: background .12s ease, color .12s ease, border-color .12s ease; }
268
+ .lc-icobtn:hover { background: var(--lc-hover); color: var(--lc-fg); }
269
+ .lc-icobtn[data-on] { background: var(--lc-accent-soft); border-color: var(--lc-accent); color: var(--lc-accent); }
270
+ .lc-btn { border-color: transparent; border-radius: 8px; color: var(--lc-fg-2); transition: background .12s ease, color .12s ease; }
271
+ .lc-btn:hover { background: var(--lc-hover); color: var(--lc-fg); }
272
+ .lc-btn[data-on] { background: var(--lc-accent-soft); color: var(--lc-accent); }
273
+ .lc-icobtn:focus-visible, .lc-btn:focus-visible, .lc-key:focus-visible, .lc-tab:focus-visible { outline: 2px solid var(--lc-accent); outline-offset: 1px; }
274
+ .lc-key { display: inline-flex; align-items: center; gap: 4px; border-radius: 7px; border-color: transparent; background: light-dark(rgba(0,0,0,.035), rgba(255,255,255,.05)); }
275
+ .lc-key:hover { background: var(--lc-hover); }
276
+ .lc-search, .lc-select { border-radius: 8px; border-color: var(--lc-border); background: var(--lc-raised); transition: border-color .12s ease, background .12s ease; }
277
+ .lc-search:focus, .lc-select:focus { border-color: var(--lc-accent); background: light-dark(#ffffff, #16181d); outline: none; }
278
+ .lc-line { font-family: ui-monospace, "Cascadia Mono", "JetBrains Mono", Consolas, monospace; font-size: 12px; font-variant-numeric: tabular-nums; }
279
+ .lc-line:hover { background: var(--lc-hover); }
280
+ .lc-log.compact .lc-line { font-size: 11px; }
281
+ .lc-status { background: light-dark(rgba(0,0,0,.015), rgba(255,255,255,.022)); font-variant-numeric: tabular-nums; border-top-color: var(--lc-border); }
282
+ .lc-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--lc-dim); font-size: 12.5px; text-align: center; }
283
+ .lc-empty-icon { color: light-dark(rgba(0,0,0,.20), rgba(255,255,255,.22)); }
284
+ .lc-overlay { background: light-dark(rgba(15,23,42,.28), rgba(0,0,0,.48)); backdrop-filter: blur(2px); }
285
+ .lc-modal { border-radius: 14px; border-color: var(--lc-border-strong); box-shadow: 0 24px 64px light-dark(rgba(15,23,42,.20), rgba(0,0,0,.55)); }
286
+ .lc-hit { background: var(--lc-accent-soft); }
287
+ .lc-re-head { text-transform: none; letter-spacing: 0; }
160
288
  `;
161
289
  //#endregion
162
290
 
@@ -190,9 +318,11 @@ window.__ModuleLoader__.load({
190
318
  const API_BASE = "/api/dsh-logcat";
191
319
  const LEVELS = ["V", "D", "I", "W", "E", "F"];
192
320
  const LEVEL_TITLES = { V: "详细", D: "调试", I: "信息", W: "警告", E: "错误", F: "致命" };
321
+ /** H.264 screen-stream tiers: encoded size → bitrate (bps). */
322
+ const VIDEO_TIERS = { "360x800": 1200000, "540x1200": 2500000, "720x1600": 4000000, "1080x2400": 6000000 };
193
323
 
194
324
  /** The Logcat panel view. */
195
- function LogcatPanel({ controller }) {
325
+ function LogcatPanel({ controller, embedded }) {
196
326
  const [adbPath, setAdbPath] = useState("");
197
327
  const [adbReady, setAdbReady] = useState(false);
198
328
  const [devices, setDevices] = useState([]);
@@ -253,6 +383,16 @@ window.__ModuleLoader__.load({
253
383
  const [screenFps, setScreenFps] = useState(0);
254
384
  const [screenHasFrame, setScreenHasFrame] = useState(false);
255
385
  const [hostScreenCapable, setHostScreenCapable] = useState(false);
386
+ // null = unknown (status not fetched yet); false = the running GUI host predates the
387
+ // H.264 engine, so the video stream cannot work until that process is restarted.
388
+ const [hostVideoCapable, setHostVideoCapable] = useState(null);
389
+ // serial -> { width, height, rotation }: the device's current oriented display
390
+ // geometry, used to translate touch positions out of frame space.
391
+ const [displays, setDisplays] = useState({});
392
+ const [screenMode, setScreenMode] = useState(() => { try { return localStorage.getItem("dsh-logcat-screenmode-v2") === "png" ? "png" : "video"; } catch { return "video"; } });
393
+ const [videoTier, setVideoTier] = useState(() => { try { const t = localStorage.getItem("dsh-logcat-videotier"); return t !== null && VIDEO_TIERS[t] !== undefined ? t : "540x1200"; } catch { return "540x1200"; } });
394
+ const [videoInfo, setVideoInfo] = useState(null);
395
+ const [ripples, setRipples] = useState([]);
256
396
  const panelOpen = useSyncExternalStore(controller.subscribe, controller.getSnapshot).panelOpen;
257
397
 
258
398
  const wsRef = useRef(null);
@@ -276,6 +416,12 @@ window.__ModuleLoader__.load({
276
416
  const screenDecodingRef = useRef(false);
277
417
  const screenPendingRef = useRef(null);
278
418
  const screenFpsTsRef = useRef([]);
419
+ const decoderRef = useRef(null);
420
+ const screenModeRef = useRef(screenMode);
421
+ const videoTierRef = useRef(videoTier);
422
+ const videoInfoRef = useRef(null);
423
+ const videoFallbackTimerRef = useRef(null);
424
+ videoInfoRef.current = videoInfo;
279
425
 
280
426
  useEffect(() => { serialRef.current = serial; }, [serial]);
281
427
  useEffect(() => { pausedRef.current = paused; }, [paused]);
@@ -339,6 +485,22 @@ window.__ModuleLoader__.load({
339
485
  }
340
486
  };
341
487
 
488
+ /**
489
+ * Re-assert the live-screen intent. `ready` also arrives after a reconnect, and the host
490
+ * may have been restarted in the meantime — without this the panel would sit on a dead
491
+ * stream until the user happens to toggle something.
492
+ */
493
+ const reassertStream = () => {
494
+ const ws = wsRef.current;
495
+ const target = serialRef.current;
496
+ if (ws === null || ws.readyState !== WebSocket.OPEN || target === "" || !screenWantRef.current) return;
497
+ const useVideo = screenModeRef.current === "video" && typeof VideoDecoder !== "undefined";
498
+ const tier = videoTierRef.current;
499
+ ws.send(JSON.stringify({ type: "video", serial: target, on: useVideo, size: tier, bitRate: VIDEO_TIERS[tier] ?? 2500000 }));
500
+ ws.send(JSON.stringify({ type: "screen", serial: target, on: !useVideo }));
501
+ setScreenOn(true);
502
+ };
503
+
342
504
  const applyFrame = (frame) => {
343
505
  if (frame.type === "ready") {
344
506
  setAdbPath(frame.adbPath ?? "");
@@ -351,6 +513,7 @@ window.__ModuleLoader__.load({
351
513
  const first = (frame.devices ?? []).find((d) => d.state === "device");
352
514
  if (first !== undefined) pickSerial(first.serial);
353
515
  }
516
+ reassertStream();
354
517
  } else if (frame.type === "devices") {
355
518
  setDevices(frame.devices ?? []);
356
519
  devicesRef.current = frame.devices ?? [];
@@ -390,6 +553,19 @@ window.__ModuleLoader__.load({
390
553
  }
391
554
  } else if (frame.type === "crash") {
392
555
  setLastCrash(frame.crash ?? null);
556
+ } else if (frame.type === "svideo-init") {
557
+ if (frame.serial === serialRef.current) setupVideoDecoder(frame);
558
+ } else if (frame.type === "video-state") {
559
+ if (frame.serial !== serialRef.current) return;
560
+ if (frame.display !== undefined && frame.display !== null) setDisplays((prev) => ({ ...prev, [frame.serial]: frame.display }));
561
+ setVideoInfo((prev) => ({
562
+ ...(prev ?? {}),
563
+ running: frame.running === true,
564
+ idle: frame.idle === true,
565
+ size: frame.size ?? prev?.size,
566
+ bitRate: frame.bitRate ?? prev?.bitRate,
567
+ error: frame.running === true ? (frame.error ?? prev?.error) : (frame.error ?? prev?.error ?? "视频流已停止"),
568
+ }));
393
569
  }
394
570
  };
395
571
  applyFrameRef.current = applyFrame;
@@ -447,6 +623,8 @@ window.__ModuleLoader__.load({
447
623
  setStreaming(body.streaming ?? []);
448
624
  setEventsStreaming(body.eventsStreaming ?? []);
449
625
  setHostScreenCapable(body.screenCapable === true);
626
+ setHostVideoCapable(body.videoCapable === true || Array.isArray(body.videoStreaming));
627
+ setDisplays(body.displays ?? {});
450
628
  setCurrentPackage(body.currentPackage ?? "");
451
629
  setCurrentVersion(body.currentVersion ?? "");
452
630
  setLatestVersion(body.latestVersion ?? "");
@@ -825,20 +1003,109 @@ window.__ModuleLoader__.load({
825
1003
  reDumpAt("0x" + addr.toLowerCase(), reDumpLen);
826
1004
  };
827
1005
 
1006
+ // Physical keyboard → device (while the Screen tab is focused and no text field is active).
1007
+ useEffect(() => {
1008
+ if (tab !== "screen" || !panelOpen || serial === "") return undefined;
1009
+ const KEYS = { Enter: 66, Backspace: 67, Escape: 4, Tab: 61, Delete: 112, ArrowUp: 19, ArrowDown: 20, ArrowLeft: 21, ArrowRight: 22, Home: 3 };
1010
+ const onKeyDown = (ev) => {
1011
+ const el = document.activeElement;
1012
+ if (el !== null && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable === true)) return;
1013
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
1014
+ if (KEYS[ev.key] !== undefined) {
1015
+ ev.preventDefault();
1016
+ fetch(API_BASE + "/keyevent", {
1017
+ method: "POST",
1018
+ headers: { "content-type": "application/json" },
1019
+ body: JSON.stringify({ serial, code: KEYS[ev.key] }),
1020
+ }).catch(() => { /* offline */ });
1021
+ return;
1022
+ }
1023
+ if (ev.key.length === 1) {
1024
+ ev.preventDefault();
1025
+ // Single printable char: ASCII only (use the text box for IME/Chinese input).
1026
+ const ch = ev.key === " " ? "%s" : ev.key.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1027
+ fetch(API_BASE + "/exec", {
1028
+ method: "POST",
1029
+ headers: { "content-type": "application/json" },
1030
+ body: JSON.stringify({ serial, command: `input text "${ch}"` }),
1031
+ }).catch(() => { /* offline */ });
1032
+ }
1033
+ };
1034
+ window.addEventListener("keydown", onKeyDown);
1035
+ return () => window.removeEventListener("keydown", onKeyDown);
1036
+ }, [tab, panelOpen, serial]);
1037
+
1038
+ // Esc closes the panel — except on the Screen tab, where Esc is the device BACK key.
1039
+ useEffect(() => {
1040
+ if (!panelOpen || tab === "screen") return undefined;
1041
+ const onKey = (ev) => {
1042
+ if (ev.key !== "Escape") return;
1043
+ const el = document.activeElement;
1044
+ if (el !== null && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) {
1045
+ if (typeof el.blur === "function") el.blur();
1046
+ return;
1047
+ }
1048
+ controller.close();
1049
+ };
1050
+ window.addEventListener("keydown", onKey);
1051
+ return () => window.removeEventListener("keydown", onKey);
1052
+ }, [panelOpen, tab]);
1053
+
828
1054
  // Live screen stream: on while the 屏幕 tab is open, the panel is open, and a device is selected.
1055
+ // Preferred path is the H.264 stream (30fps+); the PNG screencap path is the fallback.
829
1056
  useEffect(() => {
830
1057
  const prev = prevSerialRef.current;
831
1058
  prevSerialRef.current = serial;
1059
+ screenModeRef.current = screenMode;
1060
+ videoTierRef.current = videoTier;
832
1061
  const want = tab === "screen" && serial !== "" && panelOpen;
833
1062
  screenWantRef.current = want;
834
1063
  const ws = wsRef.current;
1064
+ const useVideo = want && screenMode === "video" && typeof VideoDecoder !== "undefined" && hostVideoCapable !== false;
835
1065
  if (ws !== null && ws.readyState === WebSocket.OPEN) {
836
- if (prev !== "" && prev !== serial) ws.send(JSON.stringify({ type: "screen", serial: prev, on: false }));
837
- ws.send(JSON.stringify({ type: "screen", serial, on: want }));
1066
+ if (prev !== "" && prev !== serial) {
1067
+ ws.send(JSON.stringify({ type: "screen", serial: prev, on: false }));
1068
+ ws.send(JSON.stringify({ type: "video", serial: prev, on: false }));
1069
+ }
1070
+ ws.send(JSON.stringify({ type: "video", serial, on: useVideo, size: videoTier, bitRate: VIDEO_TIERS[videoTier] ?? 2500000 }));
1071
+ ws.send(JSON.stringify({ type: "screen", serial, on: want && !useVideo }));
838
1072
  }
839
1073
  setScreenOn(want);
840
- if (!want) setScreenImg(null);
841
- }, [tab, serial, panelOpen]);
1074
+ if (!want) {
1075
+ setScreenHasFrame(false);
1076
+ setVideoInfo(null);
1077
+ closeVideoDecoder();
1078
+ }
1079
+ if (videoFallbackTimerRef.current !== null) { clearTimeout(videoFallbackTimerRef.current); videoFallbackTimerRef.current = null; }
1080
+ if (useVideo) {
1081
+ // If no avcC init arrives in 6s, the device/browser cannot do H.264 → PNG mode.
1082
+ videoFallbackTimerRef.current = setTimeout(() => {
1083
+ videoFallbackTimerRef.current = null;
1084
+ if (videoInfoRef.current?.codec) return;
1085
+ // A stale GUI host simply ignores the `video` request. Do NOT fall back to
1086
+ // PNG mode here and do NOT persist anything: this is a host-version problem,
1087
+ // and it must heal by itself once the host is restarted.
1088
+ if (hostVideoCapable === false) {
1089
+ setVideoInfo({ error: "宿主进程仍是旧版(v" + currentVersion + "),H.264 视频流不可用 —— 重启 GUI 后刷新即恢复" });
1090
+ return;
1091
+ }
1092
+ setVideoInfo({ error: "视频流启动超时,已回退到截图模式" });
1093
+ setScreenMode("png");
1094
+ // Deliberately not persisted: an automatic fallback must never become sticky,
1095
+ // otherwise a transient failure would disable the video path forever.
1096
+ }, 6000);
1097
+ }
1098
+ // Keep the oriented display geometry fresh while the screen tab is open: touch
1099
+ // mapping depends on it and the device can rotate at any moment.
1100
+ if (!want) return;
1101
+ const displayTimer = setInterval(() => {
1102
+ fetch(API_BASE + "/status")
1103
+ .then((res) => res.json())
1104
+ .then((body) => { if (body.displays !== undefined) setDisplays(body.displays); })
1105
+ .catch(() => { /* offline */ });
1106
+ }, 4000);
1107
+ return () => clearInterval(displayTimer);
1108
+ }, [tab, serial, panelOpen, screenMode, videoTier, hostVideoCapable]);
842
1109
 
843
1110
  // Binary WS frames: 4-byte JSON header length + header + PNG payload.
844
1111
  // Decoding goes through createImageBitmap into a canvas (no base64
@@ -860,6 +1127,11 @@ window.__ModuleLoader__.load({
860
1127
  c2d.drawImage(bmp, 0, 0);
861
1128
  bmp.close();
862
1129
  setScreenHasFrame(true);
1130
+ const now = performance.now();
1131
+ const ts = screenFpsTsRef.current;
1132
+ ts.push(now);
1133
+ while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
1134
+ setScreenFps(Math.min(Math.round(ts.length / 1.5), 120));
863
1135
  } catch { /* undecodable frame — skip */ }
864
1136
  }
865
1137
  } finally {
@@ -872,14 +1144,14 @@ window.__ModuleLoader__.load({
872
1144
  const view = new DataView(buffer);
873
1145
  const hlen = view.getUint32(0);
874
1146
  const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, hlen)));
875
- if (header.type !== "simage" || header.serial !== serialRef.current) return;
1147
+ if (header.serial !== serialRef.current) return;
876
1148
  const bytes = new Uint8Array(buffer, 4 + hlen);
877
- // Sliding 1.5s window → rough fps readout.
878
- const now = performance.now();
879
- const ts = screenFpsTsRef.current;
880
- ts.push(now);
881
- while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
882
- setScreenFps(Math.min(ts.length, 10));
1149
+ if (header.type === "svideo") {
1150
+ decodeVideoFrame(header, bytes);
1151
+ return;
1152
+ }
1153
+ if (header.type !== "simage") return;
1154
+ // PNG fallback path: keep only the newest pending frame.
883
1155
  screenPendingRef.current = bytes;
884
1156
  void pumpScreenFrame();
885
1157
  } catch { /* malformed frame */ }
@@ -887,19 +1159,55 @@ window.__ModuleLoader__.load({
887
1159
  handleBinaryRef.current = handleBinary;
888
1160
 
889
1161
  const screenToDevice = (e) => {
890
- const img = screenImgRef.current;
891
- if (img === null || img.naturalWidth === 0) return null;
892
- const imgRect = img.getBoundingClientRect();
893
- const x = Math.round(((e.clientX - imgRect.left) / imgRect.width) * img.naturalWidth);
894
- const y = Math.round(((e.clientY - imgRect.top) / imgRect.height) * img.naturalHeight);
895
- if (x < 0 || y < 0 || x >= img.naturalWidth || y >= img.naturalHeight) return null;
896
- return { x, y };
1162
+ const canvas = screenCanvasRef.current;
1163
+ if (canvas === null || canvas.width <= 1) return null;
1164
+ const rect = canvas.getBoundingClientRect();
1165
+ if (rect.width <= 0 || rect.height <= 0) return null;
1166
+ const u = (e.clientX - rect.left) / rect.width;
1167
+ const v = (e.clientY - rect.top) / rect.height;
1168
+ if (u < 0 || u > 1 || v < 0 || v > 1) return null;
1169
+ // Touch coordinates live in the device's CURRENT oriented display space, which is
1170
+ // not the encoded frame space (a landscape device encodes a portrait frame unless
1171
+ // the stream size follows the rotation).
1172
+ const disp = displays[serial];
1173
+ const w = disp !== undefined && disp.width > 0 ? disp.width : canvas.width;
1174
+ const h = disp !== undefined && disp.height > 0 ? disp.height : canvas.height;
1175
+ return { x: Math.round(u * w), y: Math.round(v * h) };
897
1176
  };
898
1177
 
899
1178
  const screenMouseDown = (e) => {
1179
+ if (e.button === 2) return; // right-click handled by contextmenu (back key)
900
1180
  const pt = screenToDevice(e);
901
1181
  if (pt === null) return;
902
- screenDragRef.current = { ...pt, clientX: e.clientX, clientY: e.clientY };
1182
+ const now = Date.now();
1183
+ const last = screenDragRef.current;
1184
+ // double-click → two taps
1185
+ if (last !== null && last.tapOnly === true && now - last.endedAt < 320 && Math.abs(last.x - pt.x) < 48 && Math.abs(last.y - pt.y) < 48) {
1186
+ screenDragRef.current = null;
1187
+ tapAt(pt.x, pt.y);
1188
+ tapAt(pt.x, pt.y);
1189
+ return;
1190
+ }
1191
+ showRipple(e);
1192
+ screenDragRef.current = { ...pt, clientX: e.clientX, clientY: e.clientY, startedAt: now, held: false };
1193
+ };
1194
+
1195
+ /** Send a tap at device coordinates. */
1196
+ const tapAt = (x, y) => {
1197
+ fetch(API_BASE + "/exec", {
1198
+ method: "POST",
1199
+ headers: { "content-type": "application/json" },
1200
+ body: JSON.stringify({ serial, command: `input tap ${x} ${y}` }),
1201
+ }).catch(() => { /* offline */ });
1202
+ };
1203
+
1204
+ /** Send a swipe (used by drag, wheel and long-press). */
1205
+ const swipeAt = (x1, y1, x2, y2, ms) => {
1206
+ fetch(API_BASE + "/exec", {
1207
+ method: "POST",
1208
+ headers: { "content-type": "application/json" },
1209
+ body: JSON.stringify({ serial, command: `input swipe ${x1} ${y1} ${x2} ${y2} ${ms}` }),
1210
+ }).catch(() => { /* offline */ });
903
1211
  };
904
1212
 
905
1213
  const screenMouseUp = (e) => {
@@ -911,13 +1219,43 @@ window.__ModuleLoader__.load({
911
1219
  const dx = pt.x - drag.x;
912
1220
  const dy = pt.y - drag.y;
913
1221
  const dist = Math.sqrt(dx * dx + dy * dy);
914
- const command = dist < 12
915
- ? `input tap ${drag.x} ${drag.y}`
916
- : `input swipe ${drag.x} ${drag.y} ${pt.x} ${pt.y} 120`;
917
- fetch(API_BASE + "/exec", {
1222
+ const heldMs = Date.now() - (drag.startedAt ?? Date.now());
1223
+ if (dist < 24 && heldMs > 620) {
1224
+ // long press: swipe in place
1225
+ swipeAt(drag.x, drag.y, drag.x, drag.y, 800);
1226
+ screenDragRef.current = { x: pt.x, y: pt.y, tapOnly: true, endedAt: Date.now() };
1227
+ return;
1228
+ }
1229
+ if (dist < 24) {
1230
+ tapAt(drag.x, drag.y);
1231
+ screenDragRef.current = { x: pt.x, y: pt.y, tapOnly: true, endedAt: Date.now() };
1232
+ return;
1233
+ }
1234
+ swipeAt(drag.x, drag.y, pt.x, pt.y, 120);
1235
+ };
1236
+
1237
+ /** Mouse wheel over the live screen → vertical swipe (natural scrolling). */
1238
+ const lastWheelRef = { current: 0 };
1239
+ const screenWheel = (e) => {
1240
+ const pt = screenToDevice(e);
1241
+ if (pt === null) return;
1242
+ e.preventDefault();
1243
+ const now = Date.now();
1244
+ if (now - lastWheelRef.current < 220) return;
1245
+ lastWheelRef.current = now;
1246
+ const step = Math.round((e.deltaY > 0 ? 1 : -1) * Math.min(Math.abs(e.deltaY), 420));
1247
+ const disp = displays[serial];
1248
+ const dh = disp !== undefined && disp.height > 0 ? disp.height : 2408;
1249
+ swipeAt(pt.x, pt.y, pt.x, Math.max(4, Math.min(dh - 12, pt.y - step)), 140);
1250
+ };
1251
+
1252
+ /** Right click → Android BACK key. */
1253
+ const screenContextMenu = (e) => {
1254
+ e.preventDefault();
1255
+ fetch(API_BASE + "/keyevent", {
918
1256
  method: "POST",
919
1257
  headers: { "content-type": "application/json" },
920
- body: JSON.stringify({ serial, command }),
1258
+ body: JSON.stringify({ serial, key: "back" }),
921
1259
  }).catch(() => { /* offline */ });
922
1260
  };
923
1261
 
@@ -933,6 +1271,18 @@ window.__ModuleLoader__.load({
933
1271
  }).catch(() => { /* offline */ });
934
1272
  };
935
1273
 
1274
+ /** Show a short ripple where the user tapped the live screen. */
1275
+ const showRipple = (e) => {
1276
+ const wrap = screenWrapRef.current;
1277
+ if (wrap === null) return;
1278
+ const id = Date.now() + Math.random();
1279
+ // Viewport-fixed: the ripple must sit under the cursor no matter how the canvas is
1280
+ // scaled, letterboxed or rotated inside the wrapper.
1281
+ const ripple = { id, x: e.clientX, y: e.clientY };
1282
+ setRipples((prev) => [...prev.slice(-3), ripple]);
1283
+ setTimeout(() => setRipples((prev) => prev.filter((r) => r.id !== id)), 430);
1284
+ };
1285
+
936
1286
  const downloadScreen = () => {
937
1287
  const canvas = screenCanvasRef.current;
938
1288
  if (canvas === null || canvas.width <= 1) return;
@@ -953,27 +1303,92 @@ window.__ModuleLoader__.load({
953
1303
  try { el.requestFullscreen?.(); } catch { /* denied */ }
954
1304
  };
955
1305
 
1306
+ // ---- H.264 path (WebCodecs): screenrecord stream → VideoDecoder → canvas ----
1307
+
1308
+ const closeVideoDecoder = () => {
1309
+ const decoder = decoderRef.current;
1310
+ decoderRef.current = null;
1311
+ if (decoder !== null) { try { decoder.close(); } catch { /* already closed */ } }
1312
+ };
1313
+
1314
+ /** Paint one decoded VideoFrame and update the fps readout. */
1315
+ const drawVideoFrame = (frame) => {
1316
+ try {
1317
+ const canvas = screenCanvasRef.current;
1318
+ if (canvas !== null) {
1319
+ if (canvas.width !== frame.displayWidth) canvas.width = frame.displayWidth;
1320
+ if (canvas.height !== frame.displayHeight) canvas.height = frame.displayHeight;
1321
+ const c2d = canvas.getContext("2d");
1322
+ c2d.drawImage(frame, 0, 0, canvas.width, canvas.height);
1323
+ setScreenHasFrame(true);
1324
+ const now = performance.now();
1325
+ const ts = screenFpsTsRef.current;
1326
+ ts.push(now);
1327
+ while (ts.length > 0 && now - ts[0] > 1500) ts.shift();
1328
+ setScreenFps(Math.min(Math.round(ts.length / 1.5), 120));
1329
+ }
1330
+ } finally {
1331
+ try { frame.close(); } catch { /* already closed */ }
1332
+ }
1333
+ };
1334
+
1335
+ /** Configure a fresh VideoDecoder from the host's avcC description. */
1336
+ const setupVideoDecoder = (init) => {
1337
+ closeVideoDecoder();
1338
+ if (typeof VideoDecoder === "undefined") {
1339
+ setVideoInfo((prev) => ({ ...(prev ?? {}), error: "此浏览器不支持 WebCodecs,已回退截图模式" }));
1340
+ return;
1341
+ }
1342
+ try {
1343
+ const raw = atob(init.avcC);
1344
+ const description = new Uint8Array(raw.length);
1345
+ for (let i = 0; i < raw.length; i++) description[i] = raw.charCodeAt(i);
1346
+ const decoder = new VideoDecoder({
1347
+ output: drawVideoFrame,
1348
+ error: (err) => setVideoInfo((prev) => ({ ...(prev ?? {}), error: String(err?.message ?? err) })),
1349
+ });
1350
+ decoder.configure({ codec: init.codec, description, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" });
1351
+ decoderRef.current = decoder;
1352
+ setVideoInfo((prev) => ({ ...(prev ?? {}), codec: init.codec, size: init.size ?? prev?.size, running: true, error: undefined }));
1353
+ } catch (error) {
1354
+ setVideoInfo((prev) => ({ ...(prev ?? {}), error: String(error?.message ?? error) }));
1355
+ closeVideoDecoder();
1356
+ }
1357
+ };
1358
+
1359
+ /** Feed one AVCC access unit from the host into the decoder. */
1360
+ const decodeVideoFrame = (header, bytes) => {
1361
+ const decoder = decoderRef.current;
1362
+ if (decoder === null || decoder.state !== "configured") return;
1363
+ try {
1364
+ decoder.decode(new EncodedVideoChunk({
1365
+ type: header.key === true ? "key" : "delta",
1366
+ timestamp: (Number(header.ts) || Date.now()) * 1000,
1367
+ data: bytes,
1368
+ }));
1369
+ } catch { /* dropped frame */ }
1370
+ };
1371
+
956
1372
  return h("div", { className: "lc-panel" },
957
1373
  h("div", { className: "lc-header" },
958
- h("button", { type: "button", className: "lc-back", onClick: () => controller.close() },
959
- h("span", { "aria-hidden": true }, "‹"),
960
- h("span", null, "关闭"),
961
- ),
962
1374
  h("h2", { className: "lc-title" }, "Logcat"),
1375
+ currentVersion !== "" ? h("span", { className: "lc-version" }, "v" + currentVersion) : null,
963
1376
  h("span", { className: "lc-dot " + (live ? "on" : connected ? "warn" : "off") }),
964
- h("span", { style: { fontSize: 12, color: "var(--lc-dim, #9e9e9e)" } },
965
- live ? "实机日志流中" : devices.length === 0 ? "无设备" : connected ? "未选设备" : "未连接"),
1377
+ h("span", { style: { fontSize: 12, color: "var(--lc-dim, #9e9e9e)", flex: "none" } },
1378
+ live ? "流中" : devices.length === 0 ? "无设备" : connected ? "未选设备" : "未连接"),
966
1379
  h("select", {
967
1380
  className: "lc-select",
968
1381
  value: serial,
969
1382
  onChange: (e) => pickSerial(e.target.value),
970
1383
  title: "选择设备",
1384
+ style: { flex: 1, minWidth: 0 },
971
1385
  },
972
1386
  devices.length === 0
973
1387
  ? h("option", { value: "" }, "无设备 — 请连接并开启 USB 调试")
974
1388
  : devices.map((d) =>
975
1389
  h("option", { key: d.serial, value: d.serial },
976
1390
  (d.model !== "" ? d.model + " · " : "") + d.serial + " [" + d.state + "]"))),
1391
+ embedded === true ? null : h("button", { type: "button", className: "lc-close", onClick: () => controller.close(), title: "关闭面板" }, h(Icon, { name: "close", size: 14 })),
977
1392
  ),
978
1393
  h("div", { className: "lc-stats" },
979
1394
  stats?.model
@@ -1052,11 +1467,11 @@ window.__ModuleLoader__.load({
1052
1467
  style: { maxWidth: 180 },
1053
1468
  }),
1054
1469
  h("button", { type: "button", className: "lc-btn", onClick: () => setPackageFilter(pkgInput), title: "按包名过滤日志(agent 侧 logcat_recent 同步生效)" }, "包名"),
1055
- h("button", { type: "button", className: "lc-icobtn", onClick: takeScreenshot, title: "截取真机屏幕并下载 PNG" }, "📷"),
1056
- h("button", { type: "button", className: "lc-icobtn", onClick: loadHistory, title: "从磁盘加载历史日志(重启不丢)" }, "🕘"),
1057
- h("button", { type: "button", className: "lc-icobtn", "data-on": eventsOn ? "" : undefined, onClick: toggleEvents, title: "logcat -b events 事件缓冲(应用启动/崩溃/生命周期)" }, "📑"),
1058
- h("button", { type: "button", className: "lc-icobtn", onClick: openCrashModal, title: "崩溃自动快照历史(截图+上下文)" }, "💥"),
1059
- h("button", { type: "button", className: "lc-icobtn", onClick: () => setWifiOpen(true), title: "WiFi 无线调试(adb pair + connect)" }, "📶"),
1470
+ h("button", { type: "button", className: "lc-icobtn", onClick: takeScreenshot, title: "截取真机屏幕并下载 PNG" }, h(Icon, { name: "camera" })),
1471
+ h("button", { type: "button", className: "lc-icobtn", onClick: loadHistory, title: "从磁盘加载历史日志(重启不丢)" }, h(Icon, { name: "history" })),
1472
+ h("button", { type: "button", className: "lc-icobtn", "data-on": eventsOn ? "" : undefined, onClick: toggleEvents, title: "logcat -b events 事件缓冲(应用启动/崩溃/生命周期)" }, h(Icon, { name: "events" })),
1473
+ h("button", { type: "button", className: "lc-icobtn", onClick: openCrashModal, title: "崩溃自动快照历史(截图+上下文)" }, h(Icon, { name: "crash" })),
1474
+ h("button", { type: "button", className: "lc-icobtn", onClick: () => setWifiOpen(true), title: "WiFi 无线调试(adb pair + connect)" }, h(Icon, { name: "wifi" })),
1060
1475
  h("input", {
1061
1476
  ref: fileInputRef,
1062
1477
  type: "file",
@@ -1064,25 +1479,25 @@ window.__ModuleLoader__.load({
1064
1479
  style: { display: "none" },
1065
1480
  onChange: (e) => { installApkFile(e.target.files?.[0] ?? null); e.target.value = ""; },
1066
1481
  }),
1067
- h("button", { type: "button", className: "lc-icobtn", onClick: () => fileInputRef.current?.click(), title: "选择本地 APK 安装到当前设备", disabled: installingApk ? true : undefined }, "📦"),
1482
+ h("button", { type: "button", className: "lc-icobtn", onClick: () => fileInputRef.current?.click(), title: "选择本地 APK 安装到当前设备", disabled: installingApk ? true : undefined }, h(Icon, { name: "package" })),
1068
1483
  h("button", { type: "button", className: "lc-icobtn", "data-on": paused ? "" : undefined, onClick: togglePause, title: "暂停冻结视图(新日志缓冲) / 继续回放" },
1069
- paused ? "▶" : "⏸"),
1070
- h("button", { type: "button", className: "lc-icobtn", onClick: clearLog, title: "清空当前视图" }, "🗑"),
1071
- h("button", { type: "button", className: "lc-icobtn", onClick: copyLog, title: "复制过滤后日志" }, "📋"),
1072
- h("button", { type: "button", className: "lc-icobtn", onClick: exportLog, title: "导出过滤后日志 .txt" }, "⬇"),
1484
+ paused ? h(Icon, { name: "play" }) : h(Icon, { name: "pause" })),
1485
+ h("button", { type: "button", className: "lc-icobtn", onClick: clearLog, title: "清空当前视图" }, h(Icon, { name: "trash" })),
1486
+ h("button", { type: "button", className: "lc-icobtn", onClick: copyLog, title: "复制过滤后日志" }, h(Icon, { name: "copy" })),
1487
+ h("button", { type: "button", className: "lc-icobtn", onClick: exportLog, title: "导出过滤后日志 .txt" }, h(Icon, { name: "download" })),
1073
1488
  h("button", {
1074
1489
  type: "button",
1075
1490
  className: "lc-icobtn",
1076
1491
  "data-on": autoScroll ? "" : undefined,
1077
1492
  title: "新日志自动滚到底部",
1078
1493
  onClick: () => setAutoScroll(!autoScroll),
1079
- }, "📌"),
1494
+ }, h(Icon, { name: "pin" })),
1080
1495
  )
1081
1496
  : null,
1082
1497
  tab === "log" || tab === "screen"
1083
1498
  ? h("div", { className: "lc-keys" },
1084
- [["home", "⌂ Home"], ["back", "← 返回"], ["recents", "▦ 最近"], ["wakeup", "☀ 唤醒"], ["power", "⏻ 电源"], ["volume_up", "🔊+"], ["volume_down", "🔉−"]].map(([key, label]) =>
1085
- h("button", { key, type: "button", className: "lc-key", title: "发送按键 " + key, onClick: () => sendKey(key) }, label)))
1499
+ [["home", "home", "Home"], ["back", "back", "返回"], ["recents", "grid", "最近"], ["wakeup", "sun", "唤醒"], ["power", "power", "电源"], ["volume_up", "volumeUp", ""], ["volume_down", "volumeDown", ""]].map(([key, icon, label]) =>
1500
+ h("button", { key, type: "button", className: "lc-key", title: "发送按键 " + key, onClick: () => sendKey(key) }, h(Icon, { name: icon, size: 14 }), label)))
1086
1501
  : null,
1087
1502
  lastCrash !== null
1088
1503
  ? h("div", { className: "lc-crashbar" },
@@ -1101,7 +1516,7 @@ window.__ModuleLoader__.load({
1101
1516
  : null,
1102
1517
  paused && tab === "log" && !eventsOn
1103
1518
  ? h("div", { className: "lc-pausedbar" },
1104
- h("span", { style: { flex: 1 } }, "⏸ 已暂停 — 新日志缓冲中(" + pendingCount + " 条),列表已冻结"),
1519
+ h("span", { style: { flex: 1 } }, h(Icon, { name: "pause", size: 14 }), "已暂停 — 新日志缓冲中(" + pendingCount + " 条),列表已冻结"),
1105
1520
  h("button", { type: "button", className: "lc-btn", onClick: togglePause, title: "恢复实时,回放缓冲日志" }, "继续"),
1106
1521
  )
1107
1522
  : null,
@@ -1155,18 +1570,61 @@ window.__ModuleLoader__.load({
1155
1570
  : tab === "screen"
1156
1571
  ? h("div", { className: "lc-screen" },
1157
1572
  h("div", { className: "lc-screen-bar" },
1158
- h("span", { className: "lc-dot " + (screenOn && hostScreenCapable ? "on" : "off") }),
1159
- h("span", null, !hostScreenCapable ? "宿主版本过旧" : (screenOn ? "投屏中 (~" + screenFps + "fps)" : "投屏未开启")),
1160
- h("input", { placeholder: "输入文字后回车发送…", value: screenText, onChange: (e) => setScreenText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") sendScreenText(); }, style: { flex: 1, minWidth: 140 } }),
1573
+ h("span", { className: "lc-dot " + (screenOn ? (videoInfo?.error === undefined && videoInfo?.idle !== true ? "on" : "warn") : "off") }),
1574
+ h("span", { title: videoInfo?.error ?? undefined },
1575
+ hostVideoCapable === false ? "宿主版本过旧"
1576
+ : screenOn
1577
+ ? (screenMode === "video"
1578
+ ? "流畅 " + screenFps + "fps · " + (videoInfo?.size ?? videoTier)
1579
+ : "高清 " + screenFps + "fps · " + (videoInfo?.size ?? "PNG"))
1580
+ : "投屏未开启"),
1581
+ h("select", {
1582
+ className: "lc-select",
1583
+ value: screenMode,
1584
+ onChange: (e) => { const m = e.target.value; setScreenMode(m); try { localStorage.setItem("dsh-logcat-screenmode-v2", m); } catch { /* private */ } },
1585
+ title: "流畅=H.264 视频流(30fps+);高清=PNG 截图(静帧清晰)",
1586
+ style: { padding: "2px 6px" },
1587
+ },
1588
+ h("option", { value: "video" }, "流畅"),
1589
+ h("option", { value: "png" }, "高清")),
1590
+ h("select", {
1591
+ className: "lc-select",
1592
+ value: videoTier,
1593
+ onChange: (e) => { const t = e.target.value; setVideoTier(t); try { localStorage.setItem("dsh-logcat-videotier", t); } catch { /* private */ } },
1594
+ title: "投屏分辨率档位(越低越省带宽/CPU)",
1595
+ style: { padding: "2px 6px" },
1596
+ },
1597
+ Object.keys(VIDEO_TIERS).map((t) => h("option", { key: t, value: t }, t))),
1598
+ h("input", { placeholder: "输入文字后回车发送…", value: screenText, onChange: (e) => setScreenText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") sendScreenText(); }, style: { flex: 1, minWidth: 120 } }),
1599
+ h("button", { type: "button", className: "lc-btn", onClick: () => sendKey("wakeup"), title: "唤醒设备屏幕(等效按电源键点亮)" }, "唤醒"),
1161
1600
  h("button", { type: "button", className: "lc-btn", onClick: downloadScreen, title: "下载当前帧 PNG" }, "下载"),
1162
- h("button", { type: "button", className: "lc-btn", onClick: toggleScreenFullscreen, title: "全屏查看(Esc 退出)" }, "⛶"),
1163
- h("span", { className: "lc-screen-hint" }, "点击=点按 · 拖动=滑动"),
1601
+ h("button", { type: "button", className: "lc-btn", onClick: toggleScreenFullscreen, title: "全屏查看(Esc 退出)" }, h(Icon, { name: "expand", size: 14 })),
1164
1602
  ),
1165
- !hostScreenCapable
1166
- ? h("div", { className: "lc-empty" }, "⚠ 运行中的 GUI 宿主版本过旧(0.6.0-preview.1)——请重启 GUI(dsh web)后刷新,投屏功能才会生效")
1167
- : h("div", { className: "lc-screen-img", ref: screenWrapRef, onMouseDown: screenMouseDown, onMouseUp: screenMouseUp, onMouseLeave: screenMouseUp },
1168
- h("canvas", { ref: screenCanvasRef, alt: "设备屏幕", style: { width: "1080px", height: "2408px" } }),
1169
- !screenHasFrame ? h("div", { className: "lc-empty" }, "等待画面…(设备连接后自动开始)") : null,
1603
+ hostVideoCapable === false
1604
+ ? h("div", { className: "lc-empty" },
1605
+ h("span", { className: "lc-empty-icon" }, h(Icon, { name: "crash", size: 26 })),
1606
+ h("span", null, "运行中的 GUI 宿主版本过旧 —— 重启 GUI(dsh web)后刷新,投屏才会生效"))
1607
+ : h("div", {
1608
+ className: "lc-screen-img",
1609
+ ref: screenWrapRef,
1610
+ onMouseDown: screenMouseDown,
1611
+ onMouseUp: screenMouseUp,
1612
+ onMouseLeave: screenMouseUp,
1613
+ onWheel: screenWheel,
1614
+ onContextMenu: screenContextMenu,
1615
+ },
1616
+ h("canvas", { ref: screenCanvasRef, width: 540, height: 1200 }),
1617
+ screenOn
1618
+ ? h("div", { className: "lc-screen-badge" + (videoInfo?.error !== undefined && videoInfo?.error !== null ? " warn" : videoInfo?.idle === true ? " warn" : "") },
1619
+ (videoInfo?.idle === true ? "设备休眠 · 点「唤醒」" : (screenMode === "video" ? "H.264" : "PNG") + " · " + screenFps + "fps · " + (videoInfo?.size ?? videoTier))
1620
+ + (videoInfo?.error ? " · " + videoInfo.error : ""))
1621
+ : null,
1622
+ ripples.map((r) => h("span", { key: r.id, className: "lc-tap-ripple", style: { left: r.x, top: r.y } })),
1623
+ !screenHasFrame
1624
+ ? h("div", { className: "lc-empty" },
1625
+ h("span", { className: "lc-empty-icon" }, h(Icon, { name: "plug", size: 26 })),
1626
+ h("span", null, devices.length === 0 ? "连接设备并开启 USB 调试后自动开始投屏" : "正在建立投屏流…"))
1627
+ : null,
1170
1628
  ),
1171
1629
  )
1172
1630
  : eventsOn
@@ -1174,10 +1632,12 @@ window.__ModuleLoader__.load({
1174
1632
  ? h("div", { className: "lc-empty" }, "events 事件缓冲(已开启流,等待事件…)")
1175
1633
  : h(VirtualLog, { entries: filteredEvents, scrollTop, onScrollTop: setScrollTop, bodyRef, keyword, onTagClick: addKeyword, onLineDblClick: copyLine, rowDensity }))
1176
1634
  : (filtered.length === 0
1177
- ? h("div", { className: "lc-empty" }, paused ? "已暂停(" + entries.length + " 条已缓冲)" : (devices.length === 0 ? "连接设备并开启 USB 调试后自动开始…" : "暂无日志"))
1635
+ ? h("div", { className: "lc-empty" },
1636
+ h("span", { className: "lc-empty-icon" }, h(Icon, { name: paused ? "pause" : devices.length === 0 ? "plug" : "file", size: 26 })),
1637
+ h("span", null, paused ? "已暂停(" + entries.length + " 条已缓冲)" : (devices.length === 0 ? "连接设备并开启 USB 调试后自动开始…" : "暂无日志")))
1178
1638
  : h(VirtualLog, { entries: filtered, scrollTop, onScrollTop: setScrollTop, bodyRef, keyword, onTagClick: addKeyword, onLineDblClick: copyLine, rowDensity })),
1179
1639
  tab === "log" && !eventsOn && live && !paused
1180
- ? h("button", { type: "button", className: "lc-fab", onClick: togglePause, title: "暂停:冻结当前视图(新日志进入缓冲)" }, "⏸ 暂停")
1640
+ ? h("button", { type: "button", className: "lc-fab", onClick: togglePause, title: "暂停:冻结当前视图(新日志进入缓冲)" }, h(Icon, { name: "pause", size: 14 }), "暂停")
1181
1641
  : null,
1182
1642
  ),
1183
1643
  crashModal
@@ -1193,7 +1653,7 @@ window.__ModuleLoader__.load({
1193
1653
  ? h("span", null, "暂无崩溃快照")
1194
1654
  : crashList.map((c) =>
1195
1655
  h("div", { key: c.id, className: "lc-crash-item", onClick: () => toggleCrashDetail(c.id) },
1196
- h("div", { className: "t" }, c.ts + " [" + c.kind + "]" + (c.hasScreenshot ? " 📷" : "")),
1656
+ h("div", { className: "t" }, c.ts + " [" + c.kind + "]", c.hasScreenshot ? h(Icon, { name: "camera", size: 12 }) : null),
1197
1657
  h("div", { className: "s" }, c.summary),
1198
1658
  crashOpenId === c.id && crashDetail !== null
1199
1659
  ? h("div", { className: "lc-crash-detail" },
@@ -1570,6 +2030,91 @@ window.__ModuleLoader__.load({
1570
2030
  //#endregion
1571
2031
 
1572
2032
  //#region entry
2033
+ //#region icons
2034
+ /**
2035
+ * Minimal geometric glyphs on one 24px grid, stroked with currentColor —
2036
+ * deliberately dependency-free (the client bundle may only require shell
2037
+ * seeds), so the panel ships its own small, consistent icon set instead of
2038
+ * emoji or an external icon package.
2039
+ */
2040
+ const ICONS = {
2041
+ camera: '<path d="M4 8.8h3.2L8.7 6.4h6.6l1.5 2.4H20v9.2H4z"/><circle cx="12" cy="13" r="3.1"/>',
2042
+ history: '<path d="M4.4 12a7.6 7.6 0 1 1 2.2 5.4"/><path d="M4 4.6v3.2h3.2"/><path d="M12 8.4V12l2.6 1.8"/>',
2043
+ events: '<path d="M4 6.5h16M4 12h16M4 17.5h10"/>',
2044
+ crash: '<path d="M12 4 20.5 19H3.5z"/><path d="M12 9.5v4.2"/><path d="M12 16.6h.01"/>',
2045
+ wifi: '<path d="M4 9.2a12 12 0 0 1 16 0"/><path d="M7 12.6a8 8 0 0 1 10 0"/><path d="M10 16a4 4 0 0 1 4 0"/><path d="M12 19h.01"/>',
2046
+ package: '<path d="M12 3.4 20 7.8v8.4L12 20.6 4 16.2V7.8z"/><path d="M4 7.8 12 12l8-4.2"/><path d="M12 12v8.6"/>',
2047
+ pause: '<path d="M9.2 6v12M14.8 6v12"/>',
2048
+ play: '<path d="M8.4 5.4 18.6 12 8.4 18.6z"/>',
2049
+ trash: '<path d="M5 7h14"/><path d="M10 7V5h4v2"/><path d="M6.6 7l1 13h8.8l1-13"/><path d="M10.5 10.5v6M13.5 10.5v6"/>',
2050
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 5.5H5.5V15"/>',
2051
+ download: '<path d="M12 4v11"/><path d="M7.6 10.8 12 15.2l4.4-4.4"/><path d="M5 19h14"/>',
2052
+ pin: '<path d="M12 3.6c2.9 0 4.8 2 4.8 4.4 0 2.9-2.4 4.8-3.4 7.6h-2.8C9.6 12.8 7.2 10.9 7.2 8 7.2 5.6 9.1 3.6 12 3.6z"/><path d="M12 15.6V20.4"/>',
2053
+ expand: '<path d="M9.5 4.5H4.5v5"/><path d="M14.5 4.5h5v5"/><path d="M14.5 19.5h5v-5"/><path d="M9.5 19.5h-5v-5"/>',
2054
+ home: '<path d="M4.5 11 12 4.6 19.5 11v8.4h-15z"/><path d="M10 19.4v-4.6h4v4.6"/>',
2055
+ back: '<path d="M14.6 5.6 8.2 12l6.4 6.4"/>',
2056
+ grid: '<rect x="4.5" y="4.5" width="6" height="6" rx="1.4"/><rect x="13.5" y="4.5" width="6" height="6" rx="1.4"/><rect x="4.5" y="13.5" width="6" height="6" rx="1.4"/><rect x="13.5" y="13.5" width="6" height="6" rx="1.4"/>',
2057
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3.4v2.2M12 18.4v2.2M4.6 12H6.8M17.2 12h2.2M6.8 6.8l1.6 1.6M15.6 15.6l1.6 1.6M17.2 6.8l-1.6 1.6M8.4 15.6l-1.6 1.6"/>',
2058
+ power: '<path d="M12 4v8"/><path d="M7.6 7.2a7 7 0 1 0 8.8 0"/>',
2059
+ volumeUp: '<path d="M4.5 9.8h3l4-3v10.4l-4-3h-3z"/><path d="M15 9.6a3.6 3.6 0 0 1 0 4.8"/><path d="M17.6 7.6a6.8 6.8 0 0 1 0 8.8"/>',
2060
+ volumeDown: '<path d="M4.5 9.8h3l4-3v10.4l-4-3h-3z"/><path d="M15 10.4a3 3 0 0 1 0 3.2"/>',
2061
+ plug: '<path d="M9.5 3.6v4.8M14.5 3.6v4.8"/><path d="M6.4 8.4h11.2v2.6a5.6 5.6 0 0 1-11.2 0z"/><path d="M12 16.6v3.8"/>',
2062
+ file: '<path d="M6.6 3.6h7.2l4 4v12.8H6.6z"/><path d="M13.8 3.6v4h4"/><path d="M9.4 12.4h5.2M9.4 15.6h5.2"/>',
2063
+ close: '<path d="M6.4 6.4 17.6 17.6M17.6 6.4 6.4 17.6"/>',
2064
+ search: '<circle cx="11" cy="11" r="5.6"/><path d="M15.2 15.2 19.4 19.4"/>',
2065
+ };
2066
+
2067
+ /** One glyph from the set above, at the requested square edge. */
2068
+ function Icon({ name, size = 16 }) {
2069
+ const body = ICONS[name];
2070
+ if (body === undefined) return null;
2071
+ return h("svg", {
2072
+ width: size,
2073
+ height: size,
2074
+ viewBox: "0 0 24 24",
2075
+ fill: "none",
2076
+ stroke: "currentColor",
2077
+ strokeWidth: 1.7,
2078
+ strokeLinecap: "round",
2079
+ strokeLinejoin: "round",
2080
+ "aria-hidden": true,
2081
+ dangerouslySetInnerHTML: { __html: body },
2082
+ });
2083
+ }
2084
+ //#endregion
2085
+
2086
+ //#region dsh 0.2 native integration
2087
+ /** The glyph the left sidebar shows for this panel. */
2088
+ function SidebarLogcatIcon({ size = 16 }) {
2089
+ return h(Icon, { name: "file", size: size ?? 16 });
2090
+ }
2091
+
2092
+ /**
2093
+ * Register the panel as a first-class 0.2 surface: the central `main` panel
2094
+ * (selected by the sidebar entry id) plus its `sidebar.panellist` row.
2095
+ * @returns whether the host accepted the native seats synchronously — when
2096
+ * it did, the legacy drawer is not mounted at all.
2097
+ */
2098
+ function registerNative(ctx, makePanel) {
2099
+ if (ctx?.slots === undefined || typeof ctx.slots.inject !== "function") return false;
2100
+ let active = false;
2101
+ try {
2102
+ ctx.slots.inject("main", () => {
2103
+ active = true;
2104
+ return ctx.slots.register({ name: "main", key: "logcat" }, makePanel);
2105
+ });
2106
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register(
2107
+ { name: "sidebar.panellist", id: "logcat", order: 100, label: "Logcat" },
2108
+ SidebarLogcatIcon,
2109
+ ));
2110
+ } catch (error) {
2111
+ console.warn("[dsh-logcat] native slot registration unavailable:", error);
2112
+ return false;
2113
+ }
2114
+ return active;
2115
+ }
2116
+ //#endregion
2117
+
1573
2118
  /** Required services (fiber inject waiting — the runtime must be up first). */
1574
2119
  const inject = ["slots"];
1575
2120
 
@@ -1590,16 +2135,23 @@ window.__ModuleLoader__.load({
1590
2135
  styleGuard.observe(document.head, { childList: true });
1591
2136
 
1592
2137
  const controller = new PanelController();
1593
- const disposers = [];
1594
- try {
1595
- disposers.push(mountSidebarEntry(controller));
1596
- disposers.push(mountPanel(controller));
1597
- } catch (error) {
1598
- console.warn("[dsh-logcat] mount failed:", error);
1599
- }
2138
+ const legacy = [];
2139
+ const mountLegacy = () => {
2140
+ try {
2141
+ legacy.push(mountSidebarEntry(controller));
2142
+ legacy.push(mountPanel(controller));
2143
+ } catch (error) {
2144
+ console.warn("[dsh-logcat] legacy mount failed:", error);
2145
+ }
2146
+ };
2147
+ // dsh 0.2 exposes `main` + `sidebar.panellist`: the panel becomes a native
2148
+ // central surface with its own sidebar row. Clients that do not offer those
2149
+ // seats keep the self-healing sidebar entry and the right-hand drawer.
2150
+ const native = registerNative(ctx, () => h(LogcatPanel, { controller, embedded: true }));
2151
+ if (!native) mountLegacy();
1600
2152
  ctx.effect(() => () => {
1601
2153
  styleGuard.disconnect();
1602
- for (const dispose of disposers.splice(0)) dispose();
2154
+ for (const dispose of legacy.splice(0)) dispose();
1603
2155
  style.remove();
1604
2156
  }, "dsh-logcat: ui mounts");
1605
2157
  }