dsh-mobilecode 0.11.2 → 0.11.3

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.
Files changed (3) hide show
  1. package/README.md +11 -4
  2. package/lib/client.js +70 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -142,17 +142,24 @@ card is **Device 1**; co-op docks a second, **Device 2**. It is produced
142
142
  its online row, never a duplicate boot entry. A **Live** badge shows the
143
143
  streamed serial. Quick sizes (Fit / 100% / S·240 / M·320 presets + a select)
144
144
  and frame styles (none / bezel / device) reshape the stage — each stream pane
145
- carries its own copy of these controls (0.11.2).
145
+ carries its own copy of these controls (0.11.2). In **Fit** the stage locks
146
+ to a shared height, so the two panes sit at identical heights even when the
147
+ devices have different screen aspect ratios (0.11.3).
146
148
  - **Screenshot** captures a still via `POST /stream/still` (a real `screencap`,
147
149
  embedded as a data URL up to 4 MB) and flips the stage to a still view with a
148
150
  "back to Live" link.
149
151
  - **Co-op split view (⧉, v0.9.0)**: with two or more online devices the card
150
152
  header grows a ⧉ toggle that docks a second **Device 2** pane beside it —
151
153
  its own device select, live `<img>`, tap/drag control, and (since 0.11.2) its
152
- own Size/Frame row mirroring Device 1's; both panes default to Fit and share
153
- one CSS height cap, so the pair starts identical and can be styled
154
+ own Size/Frame row mirroring Device 1's; both panes default to Fit, which
155
+ locks to one stage height, so the pair starts identical and can be styled
154
156
  independently. Each pane grants its own HMAC capability; closing one pane
155
- never stalls or disturbs the other stream.
157
+ never stalls or disturbs the other stream. A presence watcher polls device
158
+ liveness while streaming: if a device is powered off (⏻ off, crash, unplug)
159
+ its pane drops the frozen frame and the Live badge within ~5 s, and the card
160
+ stays idle instead of silently grabbing another device (0.11.3). Both stream
161
+ captions carry the running client version (`· v0.11.x`) so a page refresh is
162
+ visibly proven.
156
163
  - **Security**: every stream route sits behind a loopback + trusted-browser
157
164
  transport fence (peer address, loopback `Host`, `Sec-Fetch-Site` / `Origin` —
158
165
  so a LAN client cannot spoof localhost and a DNS-rebinding `Host` is rejected),
package/lib/client.js CHANGED
@@ -171,6 +171,8 @@ window.__ModuleLoader__.load({
171
171
  .mc-seg { display: inline-flex; border: 1px solid light-dark(rgba(0,0,0,.18), rgba(255,255,255,.2)); border-radius: 8px; overflow: hidden; }
172
172
  .mc-seg button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 11px; padding: 4px 9px; cursor: pointer; opacity: .6; }
173
173
  .mc-seg button[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); color: #4285f4; opacity: 1; }
174
+ .mc-live-stage.fit { height: 60vh; display: flex; align-items: center; justify-content: center; }
175
+ .mc-live-stage.fit img { max-height: 100%; }
174
176
  .mc-live-stage.bezel { padding: 8px; background: #111; border-radius: 16px; }
175
177
  .mc-live-stage.device { padding: 12px; background: linear-gradient(145deg, #2c2f36, #17191d); border-radius: 26px; box-shadow: 0 10px 30px rgba(0,0,0,.45); }
176
178
  /* ── 0.7.0: compact conversation cards ── */
@@ -185,6 +187,10 @@ window.__ModuleLoader__.load({
185
187
  //#endregion
186
188
 
187
189
  const API_BASE = "/api/dsh-mobilecode";
190
+ // Shown on both stream captions so a refresh visibly proves which client
191
+ // bundle the browser is actually running. Kept in lockstep with
192
+ // package.json by test/client.mjs.
193
+ const MC_VERSION = "0.11.3";
188
194
  // The panel shows cards for platforms the host can actually drive; the
189
195
  // server reports its OS in info.os (iOS tooling is darwin-only).
190
196
  const fallbackPlatforms = (os) => (os === "darwin" ? ["ios", "android"] : ["android"]);
@@ -195,7 +201,10 @@ window.__ModuleLoader__.load({
195
201
  : m.mode === "px"
196
202
  ? { width: m.value + "px", maxWidth: "100%", maxHeight: "60vh" }
197
203
  : {});
198
- const stageClassFor = (f) => "mc-live-stage" + (f !== "none" ? " " + f : "");
204
+ // Fit panes lock to a shared stage height so devices with different
205
+ // aspect ratios still sit at the same height (0.11.3); presets stay
206
+ // width-driven (S·240 / M·320 = device width px).
207
+ const stageClassFor = (f, fit = false) => "mc-live-stage" + (fit ? " fit" : "") + (f !== "none" ? " " + f : "");
199
208
  const SIZE_PRESETS = [
200
209
  { label: "Fit", mode: { mode: "fit" } },
201
210
  { label: "100%", mode: { mode: "pct", value: 100 } },
@@ -585,6 +594,24 @@ window.__ModuleLoader__.load({
585
594
  };
586
595
  const reset = () => { clearTimeout(grantTimer.current); setStreamUrl(""); };
587
596
  useEffect(() => () => clearTimeout(grantTimer.current), []);
597
+ // Presence watcher (0.11.3): an MJPEG <img> does NOT fire error when
598
+ // the device dies — it just freezes on the last frame with the Live
599
+ // badge stuck. While a stream is up, poll device presence and drop
600
+ // the stream if the serial vanished (⏻ off, crash, unplug).
601
+ useEffect(() => {
602
+ if (streamUrl === "") return;
603
+ const watch = setInterval(async () => {
604
+ try {
605
+ const r = await streamPost("/stream/devices", {});
606
+ if (!r.devices.some((d) => d.serial === serialRef.current)) {
607
+ clearTimeout(grantTimer.current);
608
+ setStreamUrl("");
609
+ onErrorRef.current("device offline — stream stopped");
610
+ }
611
+ } catch { /* the next tick decides */ }
612
+ }, 5000);
613
+ return () => clearInterval(watch);
614
+ }, [streamUrl]);
588
615
 
589
616
  const control = async (action) => {
590
617
  if (serialRef.current === "") return;
@@ -632,13 +659,16 @@ window.__ModuleLoader__.load({
632
659
  const [menuOpen, setMenuOpen] = useState(false);
633
660
  const [still, setStill] = useState(null);
634
661
  const [view, setView] = useState("live"); // 'live' | 'still'
635
- // sizeMode/frame live in LiveStreamSection (0.11.0) so both co-op
636
- // panes scale identically Device 2 must never be smaller.
662
+ // sizeMode/frame are per-pane (0.11.2); StageControls renders the
663
+ // identical row in both cards, and Fit locks both to one height.
637
664
  const [booting, setBooting] = useState(""); // AVD name while POST /boot is in flight
638
665
  const [powering, setPowering] = useState(""); // serial while POST /off is in flight
639
666
  const live = useLiveStream({ serial, onError: setError });
640
667
  const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
641
668
  const control = live.control;
669
+ const serialRef = useRef(serial);
670
+ serialRef.current = serial;
671
+ const autoPicked = useRef(false);
642
672
  useEffect(() => { if (onSerial) onSerial(serial); }, [serial]);
643
673
 
644
674
  const refreshDevices = async () => {
@@ -646,7 +676,17 @@ window.__ModuleLoader__.load({
646
676
  const r = await streamPost("/stream/devices", {});
647
677
  setDevices(r.devices);
648
678
  setAvds(r.avds ?? []);
649
- setSerial((cur) => cur || (r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? ""));
679
+ const first = r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? "";
680
+ const cur = serialRef.current;
681
+ if (cur === "") {
682
+ // Auto-stream once per page load (the welcome flow). After
683
+ // an explicit ⏻ off, the card stays idle — it must NOT
684
+ // silently grab the partner device (0.11.3).
685
+ if (!autoPicked.current && first !== "") { autoPicked.current = true; setSerial(first); }
686
+ } else if (!r.devices.some((d) => d.serial === cur)) {
687
+ autoPicked.current = true;
688
+ setSerial(""); live.reset();
689
+ }
650
690
  } catch (err) {
651
691
  setError(err instanceof Error ? err.message : String(err));
652
692
  }
@@ -685,7 +725,7 @@ window.__ModuleLoader__.load({
685
725
  catch (err) { setError(err instanceof Error ? err.message : String(err)); }
686
726
  };
687
727
 
688
- const pick = (device) => { setSerial(device); live.reset(); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
728
+ const pick = (device) => { autoPicked.current = true; setSerial(device); live.reset(); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
689
729
  // 0.10.0 merged "Start server": boot a configured AVD from the picker.
690
730
  // The route waits for boot completion; then we re-list and stream it.
691
731
  const boot = async (avd) => {
@@ -719,7 +759,7 @@ window.__ModuleLoader__.load({
719
759
  const showLive = () => { setView("live"); if (serial !== "" && live.streamUrl === "") grant(serial); };
720
760
 
721
761
  const sizeStyle = stageSizeStyle(sizeMode);
722
- const stageClass = stageClassFor(frame);
762
+ const stageClass = stageClassFor(frame, sizeMode.mode === "fit");
723
763
  const runningAvds = new Set(devices.map((d) => d.avd).filter(Boolean));
724
764
  const toolbar = [
725
765
  { title: "Back", icon: "M15 18l-6-6 6-6", run: () => control({ kind: "button", name: "back" }) },
@@ -804,14 +844,16 @@ window.__ModuleLoader__.load({
804
844
  ? h("img", { src: still.dataUrl, alt: "still capture", draggable: false })
805
845
  : live.streamUrl !== ""
806
846
  ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "device screen", style: sizeStyle, onError: () => grant(serial) })
807
- : h("div", { className: "mc-live-off" }, devices.length === 0
808
- ? "No device attached — boot an AVD from the ▾ picker, or press Run app."
809
- : "Connecting to " + serial + "..."),
847
+ : h("div", { className: "mc-live-off" }, serial === ""
848
+ ? (devices.length === 0
849
+ ? "No device attached boot an AVD from the ▾ picker, or press Run app."
850
+ : "Device was shut down — pick one from ▾, or ⏻ boot an AVD.")
851
+ : "Connecting to " + serial + "..."),
810
852
  ),
811
853
  view === "still"
812
854
  ? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
813
855
  h("a", { className: "mc-log-toggle", onClick: showLive }, "back to Live"))
814
- : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
856
+ : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device · v" + MC_VERSION),
815
857
  h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
816
858
  );
817
859
  }
@@ -828,17 +870,22 @@ window.__ModuleLoader__.load({
828
870
  const [error, setError] = useState("");
829
871
  const live = useLiveStream({ serial, onError: setError });
830
872
  const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
873
+ // Serial mirror for the interval (avoids stale-closure on setInterval).
874
+ const serialRef = useRef(serial);
875
+ serialRef.current = serial;
831
876
 
832
877
  const refresh = async () => {
833
878
  try {
834
879
  const r = await streamPost("/stream/devices", {});
835
880
  setDevices(r.devices);
836
- // Keep my pick unless it vanished or became the other pane's device.
837
- setSerial((cur) => {
838
- if (cur !== "" && cur !== excludeSerial && r.devices.some((d) => d.serial === cur)) return cur;
839
- const others = r.devices.filter((d) => d.serial !== excludeSerial);
840
- return (others.find((d) => d.streaming) ?? others[0])?.serial ?? "";
841
- });
881
+ const others = r.devices.filter((d) => d.serial !== excludeSerial);
882
+ const next = (others.find((d) => d.streaming) ?? others[0])?.serial ?? "";
883
+ const cur = serialRef.current;
884
+ // If the current device is still valid (online and not excluded), keep it.
885
+ if (cur !== "" && cur !== excludeSerial && others.some((d) => d.serial === cur)) return;
886
+ // On any change (death, exclusion, or manual), drop the stream so we
887
+ // don't show a frozen frame under the new serial.
888
+ if (next !== cur) { setSerial(next); live.reset(); }
842
889
  } catch {
843
890
  /* the header keeps the last device list */
844
891
  }
@@ -863,12 +910,16 @@ window.__ModuleLoader__.load({
863
910
  ),
864
911
  ),
865
912
  error !== "" && h("div", { className: "mc-error" }, error),
866
- h("div", { className: stageClassFor(frame) },
913
+ h("div", { className: stageClassFor(frame, sizeMode.mode === "fit") },
867
914
  live.streamUrl !== ""
868
915
  ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "co-op device screen", style: stageSizeStyle(sizeMode), onError: () => grant(serial) })
869
- : h("div", { className: "mc-live-off" }, devices.length === 0 ? "No second device — boot another emulator." : "Connecting to " + serial + "..."),
916
+ : h("div", { className: "mc-live-off" }, devices.filter((d) => d.serial !== excludeSerial).length === 0
917
+ ? "No second device — boot another emulator."
918
+ : serial === "" || !devices.some((d) => d.serial === serial)
919
+ ? "Second device offline — pick one above."
920
+ : "Connecting to " + serial + "..."),
870
921
  ),
871
- h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive this device"),
922
+ h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive this device · v" + MC_VERSION),
872
923
  h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
873
924
  );
874
925
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mobilecode",
3
3
  "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 37 agent tools (device_run, device_screen, device_ui_tree, device_ui_rows, device_tap_row, device_tap_element, device_wait_for, device_scroll_to, device_input, device_batch, device_intent, device_connect, device_pair_qr, device_perf, device_meminfo, device_backtrace, device_display, device_avd_create, device_pair_capture, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) plus a host-mediated co-op mesh hub (random callsigns, JSON pub/sub, tunable latency/jitter/drop/dup/throttle) so two virtual devices — and the AI watching them — can talk in real time. One classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
- "version": "0.11.2",
4
+ "version": "0.11.3",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {