dsh-mobilecode 0.11.1 → 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 +21 -10
  2. package/lib/client.js +125 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -133,22 +133,33 @@ card is **Device 1**; co-op docks a second, **Device 2**. It is produced
133
133
  stage, and a ☰ **device menu** adds notifications / quick settings / collapse /
134
134
  lock / wake / assistant.
135
135
  - The header **device picker** groups online devices (🖥 emulator / 📱 physical,
136
- streaming badge) and lists configured AVDs with a one-click **⏻ boot** button
136
+ streaming badge) each online emulator row carries a **⏻ off** power button
137
+ (`POST /off` → `adb emu kill`; physical devices are refused) — and lists
138
+ *stopped* configured AVDs with a one-click **⏻ boot** button
137
139
  (`POST /boot`, the merged device-start action from the retired Start-server
138
140
  flow: validate → spawn detached → adopt if already running → wait boot
139
- complete → the card streams it immediately); a **Live** badge shows the
140
- streamed serial. Quick
141
- sizes (Fit / 100% / S·240 / M·320 presets + a select) and frame styles
142
- (none / bezel / device) reshape the stage.
141
+ complete → the card streams it immediately); a running AVD appears only as
142
+ its online row, never a duplicate boot entry. A **Live** badge shows the
143
+ streamed serial. Quick sizes (Fit / 100% / S·240 / M·320 presets + a select)
144
+ and frame styles (none / bezel / device) reshape the stage — each stream pane
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).
143
148
  - **Screenshot** captures a still via `POST /stream/still` (a real `screencap`,
144
149
  embedded as a data URL up to 4 MB) and flips the stage to a still view with a
145
150
  "back to Live" link.
146
151
  - **Co-op split view (⧉, v0.9.0)**: with two or more online devices the card
147
- header grows a ⧉ toggle that docks a compact **Player B** pane beside it —
148
- its own device select, its own live `<img>`, and its own tap/drag control, so
149
- a human watches both players fight at the same instant. Each pane grants its
150
- own HMAC capability; closing one pane never stalls or disturbs the other
151
- stream.
152
+ header grows a ⧉ toggle that docks a second **Device 2** pane beside it —
153
+ its own device select, live `<img>`, tap/drag control, and (since 0.11.2) its
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
156
+ independently. Each pane grants its own HMAC capability; closing one pane
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.
152
163
  - **Security**: every stream route sits behind a loopback + trusted-browser
153
164
  transport fence (peer address, loopback `Host`, `Sec-Fetch-Site` / `Origin` —
154
165
  so a LAN client cannot spoof localhost and a DNS-rebinding `Host` is rejected),
package/lib/client.js CHANGED
@@ -118,14 +118,14 @@ window.__ModuleLoader__.load({
118
118
  .mc-live-bar select { background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.18), rgba(255,255,255,.18)); border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; max-width: 200px; }
119
119
  .mc-live-stage { position: relative; align-self: center; background: #000; border-radius: 10px; overflow: hidden; line-height: 0; box-shadow: inset 0 0 0 1px light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); }
120
120
  .mc-live-stage img { display: block; max-width: 100%; max-height: 60vh; width: auto; height: auto; cursor: crosshair; touch-action: none; user-select: none; }
121
- .mc-live-stage .mc-live-off { display: flex; align-items: center; justify-content: center; width: 260px; height: 460px; color: #9aa0a8; font-size: 13px; line-height: 1.5; text-align: center; }
121
+ .mc-live-stage .mc-live-off { display: flex; align-items: center; justify-content: center; width: 260px; max-width: 100%; height: 460px; color: #9aa0a8; font-size: 13px; line-height: 1.5; text-align: center; }
122
122
  /* 0.9.1 co-op: two forced columns — side-by-side MUST survive a narrow drawer. */
123
123
  .mc-live-section { display: flex; flex-direction: column; gap: 10px; }
124
124
  .mc-live-section.coop { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: start; }
125
125
  .mc-live-section.coop > .mc-card { min-width: 0; }
126
126
  .mc-live-section.coop .mc-card-head { flex-wrap: wrap; }
127
- .mc-live-section.coop .mc-live-stage img { max-height: 48vh; }
128
- .mc-live-section.coop .mc-live-off { width: 180px; height: 340px; }
127
+ /* No coop-specific height cap: both panes must share Device 1's 60vh rule so
128
+ they match at every size, Fit included (0.11.2). */
129
129
  .mc-coop-select { width: auto; padding: 3px 6px; font-size: 12px; }
130
130
  .mc-live-nav { display: flex; align-items: center; justify-content: center; gap: 8px; }
131
131
  .mc-live-nav button { border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)); background: light-dark(#fff, #20242b); color: inherit; border-radius: 999px; width: 40px; height: 32px; cursor: pointer; font-size: 14px; }
@@ -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,46 @@ 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 : "");
208
+ const SIZE_PRESETS = [
209
+ { label: "Fit", mode: { mode: "fit" } },
210
+ { label: "100%", mode: { mode: "pct", value: 100 } },
211
+ { label: "S · 240", mode: { mode: "px", value: 240 } },
212
+ { label: "M · 320", mode: { mode: "px", value: 320 } },
213
+ ];
214
+ /** Size presets + px/% select + frame seg — shared by both stream panes
215
+ * (0.11.2) so Device 2 gets the same controls as Device 1. */
216
+ function StageControls({ sizeMode, setSizeMode, frame, setFrame }) {
217
+ const isCurrent = (m) => m.mode === sizeMode.mode && (m.value === undefined || m.value === sizeMode.value);
218
+ return h("div", { className: "mc-live-size" },
219
+ h("span", { className: "mc-hint" }, "Size"),
220
+ h("div", { className: "mc-seg" }, SIZE_PRESETS.map((p) => h("button", { key: p.label, "data-on": isCurrent(p.mode) || undefined, onClick: () => setSizeMode(p.mode) }, p.label))),
221
+ h("select", { className: "mc-input mc-live-sizesel", value: sizeMode.mode === "pct" ? sizeMode.value + "%" : sizeMode.mode === "px" ? String(sizeMode.value) : "fit", onChange: (e) => {
222
+ const v = e.target.value;
223
+ if (v === "fit") setSizeMode({ mode: "fit" });
224
+ else if (v.endsWith("%")) setSizeMode({ mode: "pct", value: Number(v.slice(0, -1)) });
225
+ else setSizeMode({ mode: "px", value: Number(v) });
226
+ } },
227
+ h("option", { value: "fit" }, "fit"),
228
+ h("option", { value: "50%" }, "50%"),
229
+ h("option", { value: "75%" }, "75%"),
230
+ h("option", { value: "100%" }, "100%"),
231
+ h("option", { value: "125%" }, "125%"),
232
+ h("option", { value: "240" }, "S · 240px"),
233
+ h("option", { value: "320" }, "M · 320px"),
234
+ h("option", { value: "420" }, "L · 420px"),
235
+ ),
236
+ h("span", { className: "mc-hint" }, "Frame"),
237
+ h("div", { className: "mc-seg" },
238
+ h("button", { "data-on": frame === "none" || undefined, onClick: () => setFrame("none") }, "none"),
239
+ h("button", { "data-on": frame === "bezel" || undefined, onClick: () => setFrame("bezel") }, "bezel"),
240
+ h("button", { "data-on": frame === "device" || undefined, onClick: () => setFrame("device") }, "device"),
241
+ ),
242
+ );
243
+ }
199
244
 
200
245
  function PanelController() {
201
246
  let panelOpen = false;
@@ -549,6 +594,24 @@ window.__ModuleLoader__.load({
549
594
  };
550
595
  const reset = () => { clearTimeout(grantTimer.current); setStreamUrl(""); };
551
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]);
552
615
 
553
616
  const control = async (action) => {
554
617
  if (serialRef.current === "") return;
@@ -585,7 +648,9 @@ window.__ModuleLoader__.load({
585
648
 
586
649
  /** Device 1 stream: picker-popover header, SVG toolbar pill, device
587
650
  * menu, quick sizes + frame styles, and tap/drag with duration. */
588
- function LiveDeviceCard({ coopOn, onToggleCoop, onSerial, sizeMode, setSizeMode, frame, setFrame }) {
651
+ function LiveDeviceCard({ coopOn, onToggleCoop, onSerial }) {
652
+ const [sizeMode, setSizeMode] = useState({ mode: "fit" }); // {mode:'fit'|'pct'|'px', value?}
653
+ const [frame, setFrame] = useState("none"); // 'none' | 'bezel' | 'device'
589
654
  const [devices, setDevices] = useState([]);
590
655
  const [avds, setAvds] = useState([]);
591
656
  const [serial, setSerial] = useState("");
@@ -594,13 +659,16 @@ window.__ModuleLoader__.load({
594
659
  const [menuOpen, setMenuOpen] = useState(false);
595
660
  const [still, setStill] = useState(null);
596
661
  const [view, setView] = useState("live"); // 'live' | 'still'
597
- // sizeMode/frame live in LiveStreamSection (0.11.0) so both co-op
598
- // 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.
599
664
  const [booting, setBooting] = useState(""); // AVD name while POST /boot is in flight
600
665
  const [powering, setPowering] = useState(""); // serial while POST /off is in flight
601
666
  const live = useLiveStream({ serial, onError: setError });
602
667
  const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
603
668
  const control = live.control;
669
+ const serialRef = useRef(serial);
670
+ serialRef.current = serial;
671
+ const autoPicked = useRef(false);
604
672
  useEffect(() => { if (onSerial) onSerial(serial); }, [serial]);
605
673
 
606
674
  const refreshDevices = async () => {
@@ -608,7 +676,17 @@ window.__ModuleLoader__.load({
608
676
  const r = await streamPost("/stream/devices", {});
609
677
  setDevices(r.devices);
610
678
  setAvds(r.avds ?? []);
611
- 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
+ }
612
690
  } catch (err) {
613
691
  setError(err instanceof Error ? err.message : String(err));
614
692
  }
@@ -647,7 +725,7 @@ window.__ModuleLoader__.load({
647
725
  catch (err) { setError(err instanceof Error ? err.message : String(err)); }
648
726
  };
649
727
 
650
- 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); };
651
729
  // 0.10.0 merged "Start server": boot a configured AVD from the picker.
652
730
  // The route waits for boot completion; then we re-list and stream it.
653
731
  const boot = async (avd) => {
@@ -681,7 +759,7 @@ window.__ModuleLoader__.load({
681
759
  const showLive = () => { setView("live"); if (serial !== "" && live.streamUrl === "") grant(serial); };
682
760
 
683
761
  const sizeStyle = stageSizeStyle(sizeMode);
684
- const stageClass = stageClassFor(frame);
762
+ const stageClass = stageClassFor(frame, sizeMode.mode === "fit");
685
763
  const runningAvds = new Set(devices.map((d) => d.avd).filter(Boolean));
686
764
  const toolbar = [
687
765
  { title: "Back", icon: "M15 18l-6-6 6-6", run: () => control({ kind: "button", name: "back" }) },
@@ -699,13 +777,6 @@ window.__ModuleLoader__.load({
699
777
  { action: "wake", label: "Wake" },
700
778
  { action: "assistant", label: "Assistant" },
701
779
  ];
702
- const SIZE_PRESETS = [
703
- { label: "Fit", mode: { mode: "fit" } },
704
- { label: "100%", mode: { mode: "pct", value: 100 } },
705
- { label: "S · 240", mode: { mode: "px", value: 240 } },
706
- { label: "M · 320", mode: { mode: "px", value: 320 } },
707
- ];
708
- const isCurrent = (m) => m.mode === sizeMode.mode && (m.value === undefined || m.value === sizeMode.value);
709
780
 
710
781
  return h("div", { className: "mc-card mc-live" },
711
782
  h("div", { className: "mc-card-head" },
@@ -773,61 +844,48 @@ window.__ModuleLoader__.load({
773
844
  ? h("img", { src: still.dataUrl, alt: "still capture", draggable: false })
774
845
  : live.streamUrl !== ""
775
846
  ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "device screen", style: sizeStyle, onError: () => grant(serial) })
776
- : h("div", { className: "mc-live-off" }, devices.length === 0
777
- ? "No device attached — boot an AVD from the ▾ picker, or press Run app."
778
- : "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 + "..."),
779
852
  ),
780
853
  view === "still"
781
854
  ? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
782
855
  h("a", { className: "mc-log-toggle", onClick: showLive }, "back to Live"))
783
- : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
784
- h("div", { className: "mc-live-size" },
785
- h("span", { className: "mc-hint" }, "Size"),
786
- h("div", { className: "mc-seg" }, SIZE_PRESETS.map((p) => h("button", { key: p.label, "data-on": isCurrent(p.mode) || undefined, onClick: () => setSizeMode(p.mode) }, p.label))),
787
- h("select", { className: "mc-input mc-live-sizesel", value: sizeMode.mode === "pct" ? sizeMode.value + "%" : sizeMode.mode === "px" ? String(sizeMode.value) : "fit", onChange: (e) => {
788
- const v = e.target.value;
789
- if (v === "fit") setSizeMode({ mode: "fit" });
790
- else if (v.endsWith("%")) setSizeMode({ mode: "pct", value: Number(v.slice(0, -1)) });
791
- else setSizeMode({ mode: "px", value: Number(v) });
792
- } },
793
- h("option", { value: "fit" }, "fit"),
794
- h("option", { value: "50%" }, "50%"),
795
- h("option", { value: "75%" }, "75%"),
796
- h("option", { value: "100%" }, "100%"),
797
- h("option", { value: "125%" }, "125%"),
798
- h("option", { value: "240" }, "S · 240px"),
799
- h("option", { value: "320" }, "M · 320px"),
800
- h("option", { value: "420" }, "L · 420px"),
801
- ),
802
- h("span", { className: "mc-hint" }, "Frame"),
803
- h("div", { className: "mc-seg" },
804
- h("button", { "data-on": frame === "none" || undefined, onClick: () => setFrame("none") }, "none"),
805
- h("button", { "data-on": frame === "bezel" || undefined, onClick: () => setFrame("bezel") }, "bezel"),
806
- h("button", { "data-on": frame === "device" || undefined, onClick: () => setFrame("device") }, "device"),
807
- ),
808
- ),
856
+ : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device · v" + MC_VERSION),
857
+ h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
809
858
  );
810
859
  }
811
860
  /** Co-op second pane (0.9.0): a compact stream view for a second device —
812
- * device select, live image, tap/drag. No toolbar/menu/size knobs; the
813
- * full card next to it carries those for whichever device it shows. */
814
- function CoopPane({ excludeSerial, sizeMode, frame }) {
861
+ * device select, live image, tap/drag — and (0.11.2) its own Size/Frame
862
+ * row, mirroring Device 1's controls. No toolbar/menu: those belong to
863
+ * the full card. Defaults are identical, so the panes match out of the
864
+ * box and can be adjusted independently. */
865
+ function CoopPane({ excludeSerial }) {
815
866
  const [devices, setDevices] = useState([]);
816
867
  const [serial, setSerial] = useState("");
868
+ const [sizeMode, setSizeMode] = useState({ mode: "fit" });
869
+ const [frame, setFrame] = useState("none");
817
870
  const [error, setError] = useState("");
818
871
  const live = useLiveStream({ serial, onError: setError });
819
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;
820
876
 
821
877
  const refresh = async () => {
822
878
  try {
823
879
  const r = await streamPost("/stream/devices", {});
824
880
  setDevices(r.devices);
825
- // Keep my pick unless it vanished or became the other pane's device.
826
- setSerial((cur) => {
827
- if (cur !== "" && cur !== excludeSerial && r.devices.some((d) => d.serial === cur)) return cur;
828
- const others = r.devices.filter((d) => d.serial !== excludeSerial);
829
- return (others.find((d) => d.streaming) ?? others[0])?.serial ?? "";
830
- });
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(); }
831
889
  } catch {
832
890
  /* the header keeps the last device list */
833
891
  }
@@ -852,27 +910,29 @@ window.__ModuleLoader__.load({
852
910
  ),
853
911
  ),
854
912
  error !== "" && h("div", { className: "mc-error" }, error),
855
- h("div", { className: stageClassFor(frame) },
913
+ h("div", { className: stageClassFor(frame, sizeMode.mode === "fit") },
856
914
  live.streamUrl !== ""
857
915
  ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "co-op device screen", style: stageSizeStyle(sizeMode), onError: () => grant(serial) })
858
- : 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 + "..."),
859
921
  ),
860
- 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),
923
+ h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
861
924
  );
862
925
  }
863
926
 
864
927
  /** Stream section: the classic single card, plus a co-op mode that docks
865
- * a second pane next to it for two-player testing. */
928
+ * a second pane next to it for two-player testing. Each pane owns its
929
+ * Size/Frame knobs (0.11.2); the shared CSS base makes them match. */
866
930
  function LiveStreamSection() {
867
931
  const [coop, setCoop] = useState(false);
868
932
  const [serialA, setSerialA] = useState("");
869
- // Shared knobs (0.11.0): Device 1's Size/Frame controls drive both
870
- // panes, so the pair always renders at the same height.
871
- const [sizeMode, setSizeMode] = useState({ mode: "fit" }); // {mode:'fit'|'pct'|'px', value?}
872
- const [frame, setFrame] = useState("none"); // 'none' | 'bezel' | 'device'
873
933
  return h("div", { className: "mc-live-section" + (coop ? " coop" : "") },
874
- h(LiveDeviceCard, { coopOn: coop, onToggleCoop: () => setCoop((v) => !v), onSerial: setSerialA, sizeMode, setSizeMode, frame, setFrame }),
875
- coop && h(CoopPane, { excludeSerial: serialA, sizeMode, frame }),
934
+ h(LiveDeviceCard, { coopOn: coop, onToggleCoop: () => setCoop((v) => !v), onSerial: setSerialA }),
935
+ coop && h(CoopPane, { excludeSerial: serialA }),
876
936
  );
877
937
  }
878
938
 
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.1",
4
+ "version": "0.11.3",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {