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.
- package/README.md +21 -10
- package/lib/client.js +125 -65
- 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)
|
|
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
|
|
140
|
-
|
|
141
|
-
sizes (Fit / 100% / S·240 / M·320 presets + a select)
|
|
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
|
|
148
|
-
its own device select,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
598
|
-
//
|
|
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
|
-
|
|
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" },
|
|
777
|
-
|
|
778
|
-
|
|
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(
|
|
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.
|
|
813
|
-
*
|
|
814
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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.
|
|
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
|
|
875
|
-
coop && h(CoopPane, { excludeSerial: serialA
|
|
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.
|
|
4
|
+
"version": "0.11.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|