dsh-mobilecode 0.6.0 → 0.7.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/README.md +68 -6
- package/lib/client.js +286 -27
- package/lib/device-build.js +77 -0
- package/lib/index.js +514 -23
- package/lib/list-rows.js +295 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,8 +93,10 @@ drawer:
|
|
|
93
93
|
tap can address. Values are single-quoted for the device shell (apostrophes
|
|
94
94
|
escaped, control characters refused), so metacharacters stay inert.
|
|
95
95
|
- `device_stream` — drive the live screen stream the panel shows: `start` an
|
|
96
|
-
online device (returns a signed `streamUrl`
|
|
97
|
-
|
|
96
|
+
online device (returns a signed `streamUrl` **and** an `android-stream`
|
|
97
|
+
`presentationMeta` that renders a compact conversation card and auto-opens the
|
|
98
|
+
panel), `status`, or `stop`. Agents that just need to see the screen should
|
|
99
|
+
prefer `device_screen` / `device_ui_tree`.
|
|
98
100
|
|
|
99
101
|
**Live device stream (the panel)**
|
|
100
102
|
|
|
@@ -110,9 +112,20 @@ The Devices pane shows a real-time mirror of the attached device. It is produced
|
|
|
110
112
|
frames instead of building an unbounded queue or watching a growing delay.
|
|
111
113
|
- A consumer refcount + idle timeout stops the loop when nobody is watching; a
|
|
112
114
|
keep-alive restarts a crashed loop; switching devices retires the old child.
|
|
113
|
-
- **Tap or drag directly on the screen** to drive the device
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
- **Tap or drag directly on the screen** to drive the device: a short press is a
|
|
116
|
+
tap, a long drag becomes a swipe carrying the real press duration (clamped
|
|
117
|
+
0–5000 ms), coalesced into one control call. A floating **pill toolbar** of SVG
|
|
118
|
+
icons (Back / Home / Recents / Screenshot / Rotate / Refresh) sits over the
|
|
119
|
+
stage, and a ☰ **device menu** adds notifications / quick settings / collapse /
|
|
120
|
+
lock / wake / assistant.
|
|
121
|
+
- The header **device picker** groups online devices (🖥 emulator / 📱 physical,
|
|
122
|
+
streaming badge) and lists configured AVDs as boot hints (`device_boot` starts
|
|
123
|
+
one — the picker never does); a **Live** badge shows the streamed serial. Quick
|
|
124
|
+
sizes (Fit / 100% / S·240 / M·320 presets + a select) and frame styles
|
|
125
|
+
(none / bezel / device) reshape the stage.
|
|
126
|
+
- **Screenshot** captures a still via `POST /stream/still` (a real `screencap`,
|
|
127
|
+
embedded as a data URL up to 4 MB) and flips the stage to a still view with a
|
|
128
|
+
"back to Live" link.
|
|
116
129
|
- **Security**: every stream route sits behind a loopback + trusted-browser
|
|
117
130
|
transport fence (peer address, loopback `Host`, `Sec-Fetch-Site` / `Origin` —
|
|
118
131
|
so a LAN client cannot spoof localhost and a DNS-rebinding `Host` is rejected),
|
|
@@ -166,6 +179,44 @@ classified adb boundary (argv-based, quoted, replay-safe):
|
|
|
166
179
|
- `device_reboot` — reboot into `normal` / `recovery` / `bootloader`
|
|
167
180
|
(the device drops offline and comes back in a minute or two).
|
|
168
181
|
|
|
182
|
+
**Row & memory tools (v0.7.0)** — ported from
|
|
183
|
+
[ZSeven-W/dsh-android](https://github.com/ZSeven-W/dsh-android):
|
|
184
|
+
|
|
185
|
+
- `device_ui_rows` — read a list as **rows, not a tree**: clusters repeated
|
|
186
|
+
sibling nodes of similar height (≥3 rows; page-sized containers and off-screen
|
|
187
|
+
nodes excluded), aggregates each row's label, and parses counters (`3万`,
|
|
188
|
+
`1.2k`, `42 items`) into `{key, value, raw}`. Returns
|
|
189
|
+
`{rows: [{index, group, frame, label, counters}], omittedOffscreen}` — the
|
|
190
|
+
compact handle for "tap row 7 of this list" without reading a 40 KB UI tree.
|
|
191
|
+
Optional `filter` keeps rows whose label contains a substring.
|
|
192
|
+
- `device_tap_row` — tap row `index` (from `device_ui_rows`) at row-relative
|
|
193
|
+
fractions `x`/`y` (default 0.5,0.5 = center). `expect_count {key, delta}` turns
|
|
194
|
+
the tap into **one verified round trip**: the counter must already be visible
|
|
195
|
+
in the row before the tap (refused otherwise — never probe an unknown control),
|
|
196
|
+
and after an 800 ms settle the same row must show the count moved by exactly
|
|
197
|
+
`delta` (default +1) with the row not having scrolled.
|
|
198
|
+
- `device_meminfo` — a running app's memory profile from `dumpsys meminfo`:
|
|
199
|
+
TOTAL PSS/RSS/Swap-PSS, the App Summary heap breakdown
|
|
200
|
+
(Java/Native/Code/Stack/Graphics) and the top PSS categories. Throws when the
|
|
201
|
+
package has no running process.
|
|
202
|
+
- `device_backtrace` — a thread/crash dump without a debugger: sends SIGQUIT
|
|
203
|
+
(`kill -3`), waits for ART to write the trace, reads the newest `/data/anr`
|
|
204
|
+
entry, and falls back to the logcat crash buffer when `/data/anr` is unreadable
|
|
205
|
+
(`engine: "anr-trace" | "logcat-crash"`). Pass `package_name` or `pid`.
|
|
206
|
+
|
|
207
|
+
**Conversation surface (v0.7.0)** — the transcript integration ported from
|
|
208
|
+
dsh-android's UI/UX:
|
|
209
|
+
|
|
210
|
+
- A settled `device_stream` / `device_boot` call renders a **compact card** in
|
|
211
|
+
the conversation (kind, serial, streaming/failed badge, "⤢ open in panel")
|
|
212
|
+
instead of a raw JSON blob, and **auto-opens the Devices panel once** when a
|
|
213
|
+
stream first settles. The card hydrates from the host-projected
|
|
214
|
+
`presentationMeta` on top-level calls and falls back to parsing the serial out
|
|
215
|
+
of the durable result text for nested calls; it never crashes on a shape it
|
|
216
|
+
does not recognize.
|
|
217
|
+
- A **composer capsule** — a green `● <serial>` pill in the input dock — shows
|
|
218
|
+
while a device streams and the panel is closed; clicking it opens the panel.
|
|
219
|
+
|
|
169
220
|
**First-run experience & settings**
|
|
170
221
|
|
|
171
222
|
- On first start after installation, a **welcome window** explains how to use the
|
|
@@ -200,7 +251,18 @@ classified adb boundary (argv-based, quoted, replay-safe):
|
|
|
200
251
|
group — plus setup endpoints: `GET /welcome`, `POST /welcome/dismiss`,
|
|
201
252
|
`GET /doctor`, `POST /doctor/fix {id}`, `GET /ocr`, `POST /ocr/install`,
|
|
202
253
|
`GET/POST /settings`, `GET /connection` — plus the Wi-Fi actions
|
|
203
|
-
`POST /connect` (host/port/pairing code) and `POST /pair-qr` (mDNS QR flow)
|
|
254
|
+
`POST /connect` (host/port/pairing code) and `POST /pair-qr` (mDNS QR flow) —
|
|
255
|
+
plus the live-stream routes `GET /stream/status`, `POST /stream/grant`,
|
|
256
|
+
`POST /stream/control` (tap / swipe / key / long-press, coalesced),
|
|
257
|
+
`POST /stream/devices` (online devices **and** configured AVDs),
|
|
258
|
+
`POST /stream/still` (one `screencap` as a data URL up to 4 MB),
|
|
259
|
+
`POST /stream/device-action` (the `device_action` verbs over the panel fence)
|
|
260
|
+
and `GET /stream/{token}` (the multipart frame body). Every stream error carries
|
|
261
|
+
a machine-readable `code` next to the HTTP status — `token_invalid`,
|
|
262
|
+
`stream_not_running`, `stream_start_failed`, `devices_unavailable`,
|
|
263
|
+
`device_not_found`, `device_offline`, `unknown_action`, `bad_request`,
|
|
264
|
+
`control_failed`, `capture_failed`, `device_action_failed` — so the panel can
|
|
265
|
+
say *why* instead of "something failed".
|
|
204
266
|
|
|
205
267
|
**One classified adb boundary** — every serial-targeted adb command runs through
|
|
206
268
|
`adbRun()` in `lib/device-build.js`:
|
package/lib/client.js
CHANGED
|
@@ -134,6 +134,41 @@ window.__ModuleLoader__.load({
|
|
|
134
134
|
.mc-qr { margin: 8px 0; padding: 8px; border: 1px dashed light-dark(rgba(0,0,0,.25), rgba(255,255,255,.3)); border-radius: 8px; }
|
|
135
135
|
.mc-qr-text { user-select: all; font-family: ui-monospace, Consolas, monospace; font-size: 12px; word-break: break-all; margin: 4px 0 0; }
|
|
136
136
|
.mc-warn { color: #b3261e; font-size: 12px; margin: 6px 0; }
|
|
137
|
+
/* ── 0.7.0: toolbar pill, picker popover, device menu, sizes, frames ── */
|
|
138
|
+
.mc-toolbar { display: flex; align-items: center; justify-content: center; gap: 2px; padding: 3px; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)); border-radius: 999px; background: light-dark(rgba(0,0,0,.03), rgba(255,255,255,.05)); align-self: center; }
|
|
139
|
+
.mc-toolbar button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 28px; border: 0; background: transparent; color: inherit; border-radius: 999px; cursor: pointer; opacity: .8; }
|
|
140
|
+
.mc-toolbar button:hover:not(:disabled) { background: light-dark(rgba(0,0,0,.08), rgba(255,255,255,.12)); opacity: 1; }
|
|
141
|
+
.mc-toolbar button:disabled { opacity: .3; cursor: default; }
|
|
142
|
+
.mc-toolbar svg { display: block; }
|
|
143
|
+
.mc-live-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: #4caf50; }
|
|
144
|
+
.mc-picker { position: relative; }
|
|
145
|
+
.mc-picker .mc-btn { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
146
|
+
.mc-picker-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 50; min-width: 240px; max-width: 320px; max-height: 320px; overflow: auto; background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.35); padding: 6px; display: flex; flex-direction: column; gap: 6px; }
|
|
147
|
+
.mc-picker-group { display: flex; flex-direction: column; gap: 2px; }
|
|
148
|
+
.mc-picker-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: light-dark(#6b7078, #9aa0a8); padding: 2px 6px; }
|
|
149
|
+
.mc-picker-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; text-align: left; }
|
|
150
|
+
.mc-picker-row:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
|
|
151
|
+
.mc-picker-row.on { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); }
|
|
152
|
+
.mc-picker-hint { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); padding: 4px 8px; }
|
|
153
|
+
.mc-menu-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 50; min-width: 180px; background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.35); padding: 4px; display: flex; flex-direction: column; }
|
|
154
|
+
.mc-menu-pop button { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; text-align: left; }
|
|
155
|
+
.mc-menu-pop button:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
|
|
156
|
+
.mc-menu { position: relative; }
|
|
157
|
+
.mc-live-sizesel { flex: 0 1 auto; width: 92px; }
|
|
158
|
+
.mc-live-size { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
159
|
+
.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; }
|
|
160
|
+
.mc-seg button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 11px; padding: 4px 9px; cursor: pointer; opacity: .6; }
|
|
161
|
+
.mc-seg button[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); color: #4285f4; opacity: 1; }
|
|
162
|
+
.mc-live-stage.bezel { padding: 8px; background: #111; border-radius: 16px; }
|
|
163
|
+
.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); }
|
|
164
|
+
/* ── 0.7.0: compact conversation cards ── */
|
|
165
|
+
.mc-card-compact { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)); border-radius: 10px; font-size: 12px; background: light-dark(rgba(0,0,0,.02), rgba(255,255,255,.03)); }
|
|
166
|
+
.mc-card-compact .mc-card-kind { font-weight: 700; }
|
|
167
|
+
.mc-badge.ok { background: rgba(76,175,80,.16); color: #4caf50; }
|
|
168
|
+
.mc-card-open { font-size: 11px; color: #4285f4; cursor: pointer; border: 0; background: transparent; padding: 2px 4px; font: inherit; }
|
|
169
|
+
/* ── 0.7.0: composer status capsule ── */
|
|
170
|
+
.mc-capsule { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid rgba(76,175,80,.4); border-radius: 999px; background: rgba(76,175,80,.1); color: #4caf50; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
171
|
+
.mc-capsule:hover { background: rgba(76,175,80,.18); }
|
|
137
172
|
`;
|
|
138
173
|
//#endregion
|
|
139
174
|
|
|
@@ -372,13 +407,99 @@ window.__ModuleLoader__.load({
|
|
|
372
407
|
);
|
|
373
408
|
}
|
|
374
409
|
|
|
375
|
-
|
|
376
|
-
|
|
410
|
+
// 0.7.0: the conversation cards (tool.call.toolview) and the composer
|
|
411
|
+
// capsule (conversation.input.dock) live outside our panel tree, so the
|
|
412
|
+
// controller they need to open the panel is a module-level reference set
|
|
413
|
+
// by apply() before the slots register. Auto-open fires exactly once per
|
|
414
|
+
// page load (a settled boot/stream card opens the panel).
|
|
415
|
+
let activeController = null;
|
|
416
|
+
let autoOpenedOnce = false;
|
|
417
|
+
/** Stable stand-in store so the capsule hooks are unconditional even before apply() runs. */
|
|
418
|
+
const DUMMY_STORE = { subscribe: () => () => {}, getSnapshot: () => ({ panelOpen: true }) };
|
|
419
|
+
|
|
420
|
+
/** Extract text from a tool-result block (string content or pages of {type,text}). */
|
|
421
|
+
function blockText(block) {
|
|
422
|
+
if (typeof block?.content === "string") return block.content;
|
|
423
|
+
if (Array.isArray(block?.content)) return block.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
|
|
424
|
+
return "";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Compact conversation card for tools that emit android-stream meta.
|
|
428
|
+
* ToolCallBlock model (matches the rc.6 runtime): a settled result has
|
|
429
|
+
* `kind: "tool-result"` + `isError`; a running block has neither, so it
|
|
430
|
+
* resolves here to null and the runtime's own running UI shows. */
|
|
431
|
+
function MobileCodeToolCard(props) {
|
|
432
|
+
const block = props?.block ?? null;
|
|
433
|
+
const toolName = props?.toolName ?? block?.toolName ?? "";
|
|
434
|
+
const settled = block !== null && typeof block === "object" && "kind" in block;
|
|
435
|
+
const error = settled && block.isError === true;
|
|
436
|
+
// block.meta IS the host-projected presentationMeta envelope (see
|
|
437
|
+
// dsh-android parseAndroidMeta). Nested calls that carry no meta fall
|
|
438
|
+
// back to hydrating the serial from the durable text.
|
|
439
|
+
const meta = settled && !error ? block?.meta ?? null : null;
|
|
440
|
+
const kind = meta?.kind ?? null;
|
|
441
|
+
const serial = meta?.device?.serial
|
|
442
|
+
?? (/(emulator-\d+|(?:RFC[0-9A-F]{7,}|[A-Za-z0-9]{6,}))/i.exec(blockText(block))?.[1] ?? "");
|
|
443
|
+
// Auto-open the panel once when a stream settles (boot/stream start).
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (settled && kind === "android-stream" && !autoOpenedOnce && activeController) {
|
|
446
|
+
autoOpenedOnce = true;
|
|
447
|
+
activeController.setOpen(true);
|
|
448
|
+
}
|
|
449
|
+
}, [settled, kind]);
|
|
450
|
+
if (!settled || kind !== "android-stream") return null;
|
|
451
|
+
const badge = error ? "failed" : "streaming";
|
|
452
|
+
return h("div", { className: "mc-card-compact" },
|
|
453
|
+
h("span", { className: "mc-card-kind" }, "Android stream"),
|
|
454
|
+
serial !== "" && h("span", { className: "mc-serial" }, serial),
|
|
455
|
+
h("span", { className: "mc-badge" + (error ? " err" : " ok") }, badge),
|
|
456
|
+
h("span", { className: "sp" }),
|
|
457
|
+
activeController && h("button", { className: "mc-card-open", onClick: () => activeController.setOpen(true) }, "⤢ open in panel"),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Composer capsule: green "● <serial>" pill while a device streams and the panel is closed. */
|
|
462
|
+
function MobileCodeStatusCapsule() {
|
|
463
|
+
const [running, setRunning] = useState(false);
|
|
464
|
+
const [status, setStatus] = useState(null);
|
|
465
|
+
const store = activeController ?? DUMMY_STORE;
|
|
466
|
+
const { panelOpen } = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
467
|
+
const poll = async () => {
|
|
468
|
+
try {
|
|
469
|
+
const r = await streamPost("/stream/status", {});
|
|
470
|
+
setRunning(r.running === true);
|
|
471
|
+
setStatus(r);
|
|
472
|
+
} catch {
|
|
473
|
+
setRunning(false);
|
|
474
|
+
setStatus(null);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
poll();
|
|
479
|
+
const timer = setInterval(() => { if (!panelOpen) poll(); }, 5000);
|
|
480
|
+
return () => clearInterval(timer);
|
|
481
|
+
}, [panelOpen]);
|
|
482
|
+
if (!running || panelOpen) return null;
|
|
483
|
+
return h("button", { className: "mc-capsule", onClick: () => activeController?.setOpen(true) },
|
|
484
|
+
h("span", { className: "mc-dot on" }),
|
|
485
|
+
h("span", null, status?.serial ?? "device stream"),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Live device stream: picker-popover header, SVG toolbar pill, device
|
|
490
|
+
* menu, quick sizes + frame styles, and tap/drag with duration. */
|
|
377
491
|
function LiveDeviceCard() {
|
|
378
492
|
const [devices, setDevices] = useState([]);
|
|
493
|
+
const [avds, setAvds] = useState([]);
|
|
379
494
|
const [serial, setSerial] = useState("");
|
|
380
495
|
const [streamUrl, setStreamUrl] = useState("");
|
|
381
496
|
const [error, setError] = useState("");
|
|
497
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
498
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
499
|
+
const [still, setStill] = useState(null);
|
|
500
|
+
const [view, setView] = useState("live"); // 'live' | 'still'
|
|
501
|
+
const [sizeMode, setSizeMode] = useState({ mode: "fit" }); // {mode:'fit'|'pct'|'px', value?}
|
|
502
|
+
const [frame, setFrame] = useState("none"); // 'none' | 'bezel' | 'device'
|
|
382
503
|
const imgRef = useRef(null);
|
|
383
504
|
const dragRef = useRef(null);
|
|
384
505
|
const grantTimer = useRef(null);
|
|
@@ -406,6 +527,7 @@ window.__ModuleLoader__.load({
|
|
|
406
527
|
try {
|
|
407
528
|
const r = await streamPost("/stream/devices", {});
|
|
408
529
|
setDevices(r.devices);
|
|
530
|
+
setAvds(r.avds ?? []);
|
|
409
531
|
setSerial((cur) => cur || (r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? ""));
|
|
410
532
|
} catch (err) {
|
|
411
533
|
setError(err instanceof Error ? err.message : String(err));
|
|
@@ -418,8 +540,8 @@ window.__ModuleLoader__.load({
|
|
|
418
540
|
return () => { clearInterval(timer); clearTimeout(grantTimer.current); };
|
|
419
541
|
}, []);
|
|
420
542
|
|
|
421
|
-
// Auto-grant once a serial is known
|
|
422
|
-
useEffect(() => { if (serial !== "" && streamUrl === "") grant(serial).catch(() => {}); }, [serial, streamUrl]);
|
|
543
|
+
// Auto-grant once a serial is known, no stream, and we're in live view.
|
|
544
|
+
useEffect(() => { if (serial !== "" && streamUrl === "" && view === "live") grant(serial).catch(() => {}); }, [serial, streamUrl, view]);
|
|
423
545
|
|
|
424
546
|
const control = async (action) => {
|
|
425
547
|
if (serial === "") return;
|
|
@@ -437,50 +559,158 @@ window.__ModuleLoader__.load({
|
|
|
437
559
|
y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
|
|
438
560
|
};
|
|
439
561
|
};
|
|
440
|
-
|
|
562
|
+
// Gesture coalescing: remember the down point + timestamp; on up,
|
|
563
|
+
// taps (<2% travel) vs drags keep the real duration (clamped 0..5s).
|
|
564
|
+
const onDown = (e) => { const p = norm(e.clientX, e.clientY); if (p) dragRef.current = { ...p, t: Date.now() }; };
|
|
441
565
|
const onUp = (e) => {
|
|
442
566
|
const start = dragRef.current;
|
|
443
567
|
dragRef.current = null;
|
|
444
568
|
const end = norm(e.clientX, e.clientY);
|
|
445
569
|
if (!start || !end) return;
|
|
570
|
+
const durationMs = Math.max(0, Math.min(5000, Date.now() - start.t));
|
|
446
571
|
if (Math.hypot(end.x - start.x, end.y - start.y) > 0.02) {
|
|
447
|
-
control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y });
|
|
572
|
+
control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y, durationMs });
|
|
448
573
|
} else {
|
|
449
574
|
control({ kind: "tap", x: end.x, y: end.y });
|
|
450
575
|
}
|
|
451
576
|
};
|
|
452
577
|
|
|
578
|
+
const capture = async () => {
|
|
579
|
+
if (serial === "") return;
|
|
580
|
+
try {
|
|
581
|
+
const r = await streamPost("/stream/still", { device: serial });
|
|
582
|
+
if (r.dataUrl) {
|
|
583
|
+
setStill(r);
|
|
584
|
+
setView("still");
|
|
585
|
+
setError("");
|
|
586
|
+
} else {
|
|
587
|
+
setStill(null);
|
|
588
|
+
setView("live");
|
|
589
|
+
setError("still saved to " + (r.path ?? "a temp file") + " but too large to embed here");
|
|
590
|
+
}
|
|
591
|
+
} catch (err) {
|
|
592
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const menuAction = async (action) => {
|
|
597
|
+
if (serial === "") return;
|
|
598
|
+
try { await streamPost("/stream/device-action", { device: serial, action }); setMenuOpen(false); }
|
|
599
|
+
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const pick = (device) => { setSerial(device); setStreamUrl(""); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
|
|
603
|
+
const showLive = () => { setView("live"); if (serial !== "" && streamUrl === "") grant(serial).catch(() => {}); };
|
|
604
|
+
|
|
605
|
+
const sizeStyle = sizeMode.mode === "pct"
|
|
606
|
+
? { width: sizeMode.value + "%", maxWidth: "100%", maxHeight: "60vh" }
|
|
607
|
+
: sizeMode.mode === "px"
|
|
608
|
+
? { width: sizeMode.value + "px", maxWidth: "100%", maxHeight: "60vh" }
|
|
609
|
+
: {};
|
|
610
|
+
const stageClass = "mc-live-stage" + (frame !== "none" ? " " + frame : "");
|
|
611
|
+
const toolbar = [
|
|
612
|
+
{ title: "Back", icon: "M15 18l-6-6 6-6", run: () => control({ kind: "button", name: "back" }) },
|
|
613
|
+
{ title: "Home", icon: "M3 10.5L12 3l9 7.5M5 9.5V21h14V9.5", run: () => control({ kind: "button", name: "home" }) },
|
|
614
|
+
{ title: "Recents", icon: "M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5", run: () => control({ kind: "button", name: "recents" }) },
|
|
615
|
+
{ title: "Screenshot", icon: "M4 7h4l2-3h4l2 3h4v13H4zM12 17a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", run: () => capture() },
|
|
616
|
+
{ title: "Rotate", icon: "M21 12a9 9 0 1 1-2.6-6.3M21 3v6h-6", run: () => control({ kind: "rotate" }) },
|
|
617
|
+
{ title: "Refresh", icon: "M21 12a9 9 0 1 1-2.6-6.3M21 3v6h-6", run: () => { if (serial !== "") grant(serial).catch(() => {}); } },
|
|
618
|
+
];
|
|
619
|
+
const menuItems = [
|
|
620
|
+
{ action: "notifications", label: "Notifications" },
|
|
621
|
+
{ action: "quick_settings", label: "Quick settings" },
|
|
622
|
+
{ action: "collapse", label: "Collapse" },
|
|
623
|
+
{ action: "lock", label: "Lock" },
|
|
624
|
+
{ action: "wake", label: "Wake" },
|
|
625
|
+
{ action: "assistant", label: "Assistant" },
|
|
626
|
+
];
|
|
627
|
+
const SIZE_PRESETS = [
|
|
628
|
+
{ label: "Fit", mode: { mode: "fit" } },
|
|
629
|
+
{ label: "100%", mode: { mode: "pct", value: 100 } },
|
|
630
|
+
{ label: "S · 240", mode: { mode: "px", value: 240 } },
|
|
631
|
+
{ label: "M · 320", mode: { mode: "px", value: 320 } },
|
|
632
|
+
];
|
|
633
|
+
const isCurrent = (m) => m.mode === sizeMode.mode && (m.value === undefined || m.value === sizeMode.value);
|
|
634
|
+
|
|
453
635
|
return h("div", { className: "mc-card mc-live" },
|
|
454
636
|
h("div", { className: "mc-card-head" },
|
|
455
637
|
h("span", null, "Live device"),
|
|
638
|
+
streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
|
|
456
639
|
h("span", { className: "sp" }),
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
640
|
+
h("div", { className: "mc-picker" },
|
|
641
|
+
h("button", { className: "mc-btn", onClick: () => setPickerOpen((v) => !v) }, serial || "no device", " ▾"),
|
|
642
|
+
pickerOpen && h("div", { className: "mc-picker-pop" },
|
|
643
|
+
devices.length > 0
|
|
644
|
+
? h("div", { className: "mc-picker-group" },
|
|
645
|
+
h("div", { className: "mc-picker-label" }, "Online devices"),
|
|
646
|
+
devices.map((d) => h("button", { key: d.serial, className: "mc-picker-row" + (d.serial === serial ? " on" : ""), onClick: () => pick(d.serial) },
|
|
647
|
+
h("span", null, d.kind === "emulator" ? "🖥" : "📱"),
|
|
648
|
+
h("span", { className: "mc-serial" }, d.serial),
|
|
649
|
+
d.streaming && h("span", { className: "mc-badge" }, "streaming"),
|
|
650
|
+
)),
|
|
651
|
+
)
|
|
652
|
+
: h("div", { className: "mc-picker-hint" }, "No device attached"),
|
|
653
|
+
avds.length > 0 && h("div", { className: "mc-picker-group" },
|
|
654
|
+
h("div", { className: "mc-picker-label" }, "AVDs — start with device_boot"),
|
|
655
|
+
avds.map((avd) => h("div", { key: avd, className: "mc-picker-hint" }, "▫ " + avd)),
|
|
656
|
+
),
|
|
657
|
+
),
|
|
658
|
+
),
|
|
659
|
+
h("div", { className: "mc-menu" },
|
|
660
|
+
h("button", { className: "mc-btn", disabled: serial === "", title: "Device actions", onClick: () => setMenuOpen((v) => !v) }, "☰"),
|
|
661
|
+
menuOpen && h("div", { className: "mc-menu-pop" },
|
|
662
|
+
menuItems.map((item) => h("button", { key: item.action, onClick: () => menuAction(item.action) }, item.label)),
|
|
663
|
+
),
|
|
664
|
+
),
|
|
464
665
|
),
|
|
465
666
|
error !== "" && h("div", { className: "mc-error" }, error),
|
|
466
|
-
h("div", { className: "mc-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
667
|
+
h("div", { className: "mc-toolbar" },
|
|
668
|
+
toolbar.map((item) => h("button", { key: item.title, title: item.title, disabled: serial === "", onClick: item.run },
|
|
669
|
+
h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
670
|
+
h("path", { d: item.icon }),
|
|
671
|
+
),
|
|
672
|
+
)),
|
|
673
|
+
),
|
|
674
|
+
h("div", { className: stageClass },
|
|
675
|
+
view === "still" && still?.dataUrl
|
|
676
|
+
? h("img", { src: still.dataUrl, alt: "still capture", draggable: false })
|
|
677
|
+
: streamUrl !== ""
|
|
678
|
+
? h("img", { ref: imgRef, src: streamUrl, alt: "device screen", style: sizeStyle, draggable: false, onPointerDown: onDown, onPointerUp: onUp, onError: () => grant(serial) })
|
|
679
|
+
: h("div", { className: "mc-live-off" }, devices.length === 0
|
|
680
|
+
? "No device attached. Boot one with device_boot, or press Run app."
|
|
681
|
+
: "Connecting to " + serial + "..."),
|
|
472
682
|
),
|
|
473
|
-
|
|
474
|
-
h("
|
|
475
|
-
|
|
476
|
-
h("
|
|
477
|
-
|
|
478
|
-
h("
|
|
683
|
+
view === "still"
|
|
684
|
+
? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
|
|
685
|
+
h("a", { className: "mc-log-toggle", onClick: showLive }, "back to Live"))
|
|
686
|
+
: h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
|
|
687
|
+
h("div", { className: "mc-live-size" },
|
|
688
|
+
h("span", { className: "mc-hint" }, "Size"),
|
|
689
|
+
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))),
|
|
690
|
+
h("select", { className: "mc-input mc-live-sizesel", value: sizeMode.mode === "pct" ? sizeMode.value + "%" : sizeMode.mode === "px" ? String(sizeMode.value) : "fit", onChange: (e) => {
|
|
691
|
+
const v = e.target.value;
|
|
692
|
+
if (v === "fit") setSizeMode({ mode: "fit" });
|
|
693
|
+
else if (v.endsWith("%")) setSizeMode({ mode: "pct", value: Number(v.slice(0, -1)) });
|
|
694
|
+
else setSizeMode({ mode: "px", value: Number(v) });
|
|
695
|
+
} },
|
|
696
|
+
h("option", { value: "fit" }, "fit"),
|
|
697
|
+
h("option", { value: "50%" }, "50%"),
|
|
698
|
+
h("option", { value: "75%" }, "75%"),
|
|
699
|
+
h("option", { value: "100%" }, "100%"),
|
|
700
|
+
h("option", { value: "125%" }, "125%"),
|
|
701
|
+
h("option", { value: "240" }, "S · 240px"),
|
|
702
|
+
h("option", { value: "320" }, "M · 320px"),
|
|
703
|
+
h("option", { value: "420" }, "L · 420px"),
|
|
704
|
+
),
|
|
705
|
+
h("span", { className: "mc-hint" }, "Frame"),
|
|
706
|
+
h("div", { className: "mc-seg" },
|
|
707
|
+
h("button", { "data-on": frame === "none" || undefined, onClick: () => setFrame("none") }, "none"),
|
|
708
|
+
h("button", { "data-on": frame === "bezel" || undefined, onClick: () => setFrame("bezel") }, "bezel"),
|
|
709
|
+
h("button", { "data-on": frame === "device" || undefined, onClick: () => setFrame("device") }, "device"),
|
|
710
|
+
),
|
|
479
711
|
),
|
|
480
|
-
h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
|
|
481
712
|
);
|
|
482
713
|
}
|
|
483
|
-
|
|
484
714
|
//#region setup UI (welcome + settings)
|
|
485
715
|
|
|
486
716
|
/** PaddleOCR status card: fetch /ocr, Install button, log tail. */
|
|
@@ -1018,6 +1248,7 @@ window.__ModuleLoader__.load({
|
|
|
1018
1248
|
styleGuard.observe(document.head, { childList: true });
|
|
1019
1249
|
|
|
1020
1250
|
const controller = new PanelController();
|
|
1251
|
+
activeController = controller;
|
|
1021
1252
|
const disposers = [];
|
|
1022
1253
|
try {
|
|
1023
1254
|
disposers.push(mountSidebarEntry(controller));
|
|
@@ -1035,6 +1266,32 @@ window.__ModuleLoader__.load({
|
|
|
1035
1266
|
}, SettingsSection),
|
|
1036
1267
|
);
|
|
1037
1268
|
disposers.push(disposeSlot);
|
|
1269
|
+
// 0.7.0: compact conversation card under a settled device_stream /
|
|
1270
|
+
// device_boot result (opens the panel on "⤢ open in panel", and
|
|
1271
|
+
// auto-opens it once when a stream first settles). One slot per
|
|
1272
|
+
// tool name keyed like the reference port (openpencil shape):
|
|
1273
|
+
// `key: toolName` scopes the card to that tool's calls, so other
|
|
1274
|
+
// tools keep their default card.
|
|
1275
|
+
for (const toolName of ["device_stream", "device_boot"]) {
|
|
1276
|
+
const disposeCard = ctx.slots.inject("tool.call.toolview", () =>
|
|
1277
|
+
ctx.slots.register({
|
|
1278
|
+
name: "tool.call.toolview",
|
|
1279
|
+
key: toolName,
|
|
1280
|
+
}, MobileCodeToolCard),
|
|
1281
|
+
);
|
|
1282
|
+
disposers.push(disposeCard);
|
|
1283
|
+
}
|
|
1284
|
+
// 0.7.0: green "● <serial>" composer capsule while a device
|
|
1285
|
+
// streams; clicking it opens the panel. Registered unconditionally
|
|
1286
|
+
// so the capsule can appear even before any card auto-opens.
|
|
1287
|
+
const disposeCapsule = ctx.slots.inject("conversation.input.dock", () =>
|
|
1288
|
+
ctx.slots.register({
|
|
1289
|
+
name: "conversation.input.dock",
|
|
1290
|
+
id: "dsh-mobilecode-status",
|
|
1291
|
+
order: 40,
|
|
1292
|
+
}, MobileCodeStatusCapsule),
|
|
1293
|
+
);
|
|
1294
|
+
disposers.push(disposeCapsule);
|
|
1038
1295
|
}
|
|
1039
1296
|
} catch (error) {
|
|
1040
1297
|
console.warn("[dsh-mobilecode] mount failed:", error);
|
|
@@ -1042,6 +1299,8 @@ window.__ModuleLoader__.load({
|
|
|
1042
1299
|
ctx.effect(() => () => {
|
|
1043
1300
|
styleGuard.disconnect();
|
|
1044
1301
|
for (const dispose of disposers.splice(0)) dispose();
|
|
1302
|
+
activeController = null;
|
|
1303
|
+
autoOpenedOnce = false;
|
|
1045
1304
|
style.remove();
|
|
1046
1305
|
}, "dsh-mobilecode: ui mounts");
|
|
1047
1306
|
}
|
package/lib/device-build.js
CHANGED
|
@@ -912,6 +912,83 @@ export function parseCpuinfo(output) {
|
|
|
912
912
|
return { cores: cores > 0 ? cores : undefined, hardware }
|
|
913
913
|
}
|
|
914
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Deep-clean a parsed value for a DSH tool output: drop `undefined` keys (and
|
|
917
|
+
* undefined array holes) and turn non-finite numbers into `null`, so the value
|
|
918
|
+
* round-trips JSON losslessly (DSH rejects outputs that lose keys/values on
|
|
919
|
+
* serialize). Leaves everything else untouched; primitives pass through.
|
|
920
|
+
*/
|
|
921
|
+
export function jsonSafe(value) {
|
|
922
|
+
if (Array.isArray(value)) {
|
|
923
|
+
const out = []
|
|
924
|
+
for (const item of value) {
|
|
925
|
+
if (item !== undefined) out.push(jsonSafe(item))
|
|
926
|
+
}
|
|
927
|
+
return out
|
|
928
|
+
}
|
|
929
|
+
if (value !== null && typeof value === "object") {
|
|
930
|
+
const out = {}
|
|
931
|
+
for (const [key, item] of Object.entries(value)) {
|
|
932
|
+
if (item !== undefined) out[key] = jsonSafe(item)
|
|
933
|
+
}
|
|
934
|
+
return out
|
|
935
|
+
}
|
|
936
|
+
if (typeof value === "number" && !Number.isFinite(value)) return null
|
|
937
|
+
return value
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Parse `dumpsys meminfo <pkg>` into {totalPssKb, totalRssKb, totalSwapPssKb,
|
|
942
|
+
* appSummary, topCategories}. The dumpsys layout (Android 10+):
|
|
943
|
+
* - "TOTAL PSS:" / "TOTAL RSS:" / "TOTAL SWAP PSS:" summary line;
|
|
944
|
+
* - "App Summary" section with one line per heap bucket (Java/Native/Code/
|
|
945
|
+
* Stack/Graphics), first column = PSS total;
|
|
946
|
+
* - a category breakdown whose rows are ` Name pss privDirty privClean swapDirty`
|
|
947
|
+
* (heap rows add Heap Size/Alloc/Free columns). Rows are only EVIDENCE:
|
|
948
|
+
* the parser keeps the top PSS rows and drops header/dashed/TOTAL lines.
|
|
949
|
+
* Returns undefined when the requested process is absent ("No process found").
|
|
950
|
+
*/
|
|
951
|
+
export function parsePackageMeminfo(output) {
|
|
952
|
+
const text = String(output)
|
|
953
|
+
const blockIndex = text.indexOf("** MEMINFO in pid")
|
|
954
|
+
if (blockIndex < 0) return undefined
|
|
955
|
+
const grab = (name) => {
|
|
956
|
+
const match = new RegExp(`${name}:\\s*([\\d,]+)`, "m").exec(text)
|
|
957
|
+
return match ? Number(match[1].replace(/,/g, "")) : undefined
|
|
958
|
+
}
|
|
959
|
+
const summary = {
|
|
960
|
+
totalPssKb: grab("TOTAL PSS"),
|
|
961
|
+
totalRssKb: grab("TOTAL RSS"),
|
|
962
|
+
totalSwapPssKb: grab("TOTAL SWAP PSS"),
|
|
963
|
+
}
|
|
964
|
+
// App Summary: capture the PSS column of the five heap buckets.
|
|
965
|
+
const appSummary = {}
|
|
966
|
+
for (const [key, rowName] of [["javaHeapKb", "Java Heap"], ["nativeHeapKb", "Native Heap"], ["codeKb", "Code"], ["stackKb", "Stack"], ["graphicsKb", "Graphics"]]) {
|
|
967
|
+
const line = text.split(/\r?\n/).find((item) => new RegExp(`^\\s+${rowName}:`).test(item) && /\d/.test(item))
|
|
968
|
+
const match = line ? /:\s+([\d,]+)/.exec(line) : undefined
|
|
969
|
+
if (match) appSummary[key] = Number(match[1].replace(/,/g, ""))
|
|
970
|
+
}
|
|
971
|
+
// Category breakdown: ` Name pss privDirty privClean swapDirty [+3 heap cols]`.
|
|
972
|
+
const categories = []
|
|
973
|
+
const seen = new Set()
|
|
974
|
+
for (const line of text.split(/\r?\n/)) {
|
|
975
|
+
const match = /^\s{2,}([A-Za-z.][A-Za-z0-9. _()\/-]*?)\s+([\d,]+)\s+([\d,]+)\s+([\d,]+)\s+([\d,]+)/.exec(line)
|
|
976
|
+
if (!match) continue
|
|
977
|
+
const name = match[1].trim()
|
|
978
|
+
if (!name || name === "TOTAL" || /^[-\s]+$/.test(name) || /^(Pss|Private|Swap|Size|Alloc|Free)$/.test(name)) continue
|
|
979
|
+
const pss = Number(match[2].replace(/,/g, ""))
|
|
980
|
+
if (seen.has(name)) continue
|
|
981
|
+
seen.add(name)
|
|
982
|
+
categories.push({ name, pssKb: pss })
|
|
983
|
+
}
|
|
984
|
+
categories.sort((l, r) => r.pssKb - l.pssKb)
|
|
985
|
+
return {
|
|
986
|
+
...summary,
|
|
987
|
+
appSummary: Object.keys(appSummary).length > 0 ? appSummary : undefined,
|
|
988
|
+
topCategories: categories.slice(0, 10),
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
915
992
|
/** Parse `dumpsys package <pkg>` into {versionName, versionCode, minSdk, targetSdk, permissions, activities}. */
|
|
916
993
|
export function parseAppInfo(output, packageName) {
|
|
917
994
|
const text = String(output)
|