dsh-mobilecode 0.11.2 → 0.11.4

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 +12 -4
  2. package/lib/client.js +92 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -142,17 +142,25 @@ 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 stays
160
+ idle instead of silently grabbing another device — both panes guard against
161
+ the re-grab (0.11.3–4). Both stream
162
+ captions carry the running client version (`· v0.11.x`) so a page refresh is
163
+ visibly proven.
156
164
  - **Security**: every stream route sits behind a loopback + trusted-browser
157
165
  transport fence (peer address, loopback `Host`, `Sec-Fetch-Site` / `Origin` —
158
166
  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.4";
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,22 @@ 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
+ // Prefer emulators for the welcome auto-pick: a physical
680
+ // serial sorts first in adb order but has no ⏻ off control.
681
+ // (Serial prefix, not the avd field — avdName() can fail.)
682
+ const isEmu = (d) => d.serial.startsWith("emulator-");
683
+ const first = r.devices.find((d) => d.streaming && isEmu(d))?.serial ?? r.devices.find(isEmu)?.serial
684
+ ?? r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? "";
685
+ const cur = serialRef.current;
686
+ if (cur === "") {
687
+ // Auto-stream once per page load (the welcome flow). After
688
+ // an explicit ⏻ off, the card stays idle — it must NOT
689
+ // silently grab the partner device (0.11.3).
690
+ if (!autoPicked.current && first !== "") { autoPicked.current = true; setSerial(first); }
691
+ } else if (!r.devices.some((d) => d.serial === cur)) {
692
+ autoPicked.current = true;
693
+ setSerial(""); live.reset();
694
+ }
650
695
  } catch (err) {
651
696
  setError(err instanceof Error ? err.message : String(err));
652
697
  }
@@ -685,7 +730,7 @@ window.__ModuleLoader__.load({
685
730
  catch (err) { setError(err instanceof Error ? err.message : String(err)); }
686
731
  };
687
732
 
688
- const pick = (device) => { setSerial(device); live.reset(); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
733
+ const pick = (device) => { autoPicked.current = true; setSerial(device); live.reset(); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
689
734
  // 0.10.0 merged "Start server": boot a configured AVD from the picker.
690
735
  // The route waits for boot completion; then we re-list and stream it.
691
736
  const boot = async (avd) => {
@@ -719,7 +764,7 @@ window.__ModuleLoader__.load({
719
764
  const showLive = () => { setView("live"); if (serial !== "" && live.streamUrl === "") grant(serial); };
720
765
 
721
766
  const sizeStyle = stageSizeStyle(sizeMode);
722
- const stageClass = stageClassFor(frame);
767
+ const stageClass = stageClassFor(frame, sizeMode.mode === "fit");
723
768
  const runningAvds = new Set(devices.map((d) => d.avd).filter(Boolean));
724
769
  const toolbar = [
725
770
  { title: "Back", icon: "M15 18l-6-6 6-6", run: () => control({ kind: "button", name: "back" }) },
@@ -804,14 +849,16 @@ window.__ModuleLoader__.load({
804
849
  ? h("img", { src: still.dataUrl, alt: "still capture", draggable: false })
805
850
  : live.streamUrl !== ""
806
851
  ? 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 + "..."),
852
+ : h("div", { className: "mc-live-off" }, serial === ""
853
+ ? (devices.length === 0
854
+ ? "No device attached boot an AVD from the ▾ picker, or press Run app."
855
+ : "Device was shut down — pick one from ▾, or ⏻ boot an AVD.")
856
+ : "Connecting to " + serial + "..."),
810
857
  ),
811
858
  view === "still"
812
859
  ? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
813
860
  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"),
861
+ : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device · v" + MC_VERSION),
815
862
  h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
816
863
  );
817
864
  }
@@ -828,17 +875,35 @@ window.__ModuleLoader__.load({
828
875
  const [error, setError] = useState("");
829
876
  const live = useLiveStream({ serial, onError: setError });
830
877
  const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
878
+ // Serial mirror for the 5s poller + one-shot auto-pick guard: this
879
+ // pane may steal a partner while nothing is picked, but it must NOT
880
+ // silently switch to a third device after its own goes offline.
881
+ const serialRef = useRef(serial);
882
+ serialRef.current = serial;
883
+ const autoPicked = useRef(false);
831
884
 
832
885
  const refresh = async () => {
833
886
  try {
834
887
  const r = await streamPost("/stream/devices", {});
835
888
  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
- });
889
+ // Partner candidates: emulators only (serial prefix, not the
890
+ // flaky avd field) the co-op partner must be killable from
891
+ // the picker, and never Device 1's own serial.
892
+ const others = r.devices.filter((d) => d.serial !== excludeSerial && d.serial.startsWith("emulator-"));
893
+ const cur = serialRef.current;
894
+ if (cur !== "") {
895
+ // Keep any valid pick — including a manually chosen physical
896
+ // device; only emulators are eligible for auto-pick. A dead
897
+ // or stolen pick clears to the placeholder (never re-grabs).
898
+ if (cur !== excludeSerial && r.devices.some((d) => d.serial === cur)) return;
899
+ autoPicked.current = true;
900
+ setSerial(""); live.reset();
901
+ return;
902
+ }
903
+ if (!autoPicked.current) {
904
+ const next = (others.find((d) => d.streaming) ?? others[0])?.serial ?? "";
905
+ if (next !== "") { autoPicked.current = true; setSerial(next); }
906
+ }
842
907
  } catch {
843
908
  /* the header keeps the last device list */
844
909
  }
@@ -858,17 +923,24 @@ window.__ModuleLoader__.load({
858
923
  live.streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
859
924
  h("span", { className: "sp" }),
860
925
  h("select", { className: "mc-input mc-coop-select", value: serial, onChange: (e) => { setSerial(e.target.value); live.reset(); } },
861
- devices.length === 0 && h("option", { value: "" }, "no device"),
926
+ // An honest empty option: a select with no matching option
927
+ // silently DISPLAYS devices[0] while state is still "" —
928
+ // that made the probe read a phantom pick (0.11.4).
929
+ !devices.some((d) => d.serial === serial) && h("option", { value: "" }, devices.length === 0 ? "no device" : "— pick device —"),
862
930
  devices.map((d) => h("option", { key: d.serial, value: d.serial }, d.serial + (d.serial === excludeSerial ? " (Device 1)" : d.streaming ? " ●" : ""))),
863
931
  ),
864
932
  ),
865
933
  error !== "" && h("div", { className: "mc-error" }, error),
866
- h("div", { className: stageClassFor(frame) },
934
+ h("div", { className: stageClassFor(frame, sizeMode.mode === "fit") },
867
935
  live.streamUrl !== ""
868
936
  ? 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 + "..."),
937
+ : h("div", { className: "mc-live-off" }, devices.filter((d) => d.serial !== excludeSerial).length === 0
938
+ ? "No second device — boot another emulator."
939
+ : serial === "" || !devices.some((d) => d.serial === serial)
940
+ ? "Second device offline — pick one above."
941
+ : "Connecting to " + serial + "..."),
870
942
  ),
871
- h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive this device"),
943
+ h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive this device · v" + MC_VERSION),
872
944
  h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
873
945
  );
874
946
  }
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.4",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {