dsh-mobilecode 0.4.0 → 0.5.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 CHANGED
@@ -87,6 +87,11 @@ drawer:
87
87
  - `device_apps` / `device_launch_app` — list installed packages (third-party by
88
88
  default) so a package name is never guessed / launch one by package or a
89
89
  unique substring, with `relaunch` for a cold start.
90
+ - `device_intent` — open anything by Android intent: an `action` (e.g.
91
+ `android.settings.WIFI_SETTINGS`), a deep-link `uri` (`geo:`, `https://`,
92
+ `file://`), or an explicit `component` (`pkg/.Activity`). Reaches screens no
93
+ tap can address. Values are single-quoted for the device shell (apostrophes
94
+ escaped, control characters refused), so metacharacters stay inert.
90
95
  - `device_stream` — drive the live screen stream the panel shows: `start` an
91
96
  online device (returns a signed `streamUrl`), `status`, or `stop`. Agents that
92
97
  just need to see the screen should prefer `device_screen` / `device_ui_tree`.
@@ -148,6 +153,9 @@ the plain JSON summary (path + UI tree + OCR) with no new error.
148
153
  Fix button for auto-fixable checks (PaddleOCR install).
149
154
  - **PaddleOCR** — install status, progress log, and the install button.
150
155
  - **AI Prompt** — the copyable agent prompt.
156
+ - **Connection** — the resolved adb binary path and every device `adb` sees
157
+ (serial, state dot, model, USB / Wi-Fi badge), straight from
158
+ `GET /connection`. Explains the Wi-Fi reconnect policy.
151
159
  - The same settings appear as a **`MobileCode` page in the DSH Settings**
152
160
  (registered as a `settings.section` slot, like the other installed plugins),
153
161
  so they are reachable from Settings even when the Devices pane is closed.
@@ -159,7 +167,22 @@ the plain JSON summary (path + UI tree + OCR) with no new error.
159
167
  `directory` + `platform` body, mirroring mobilecode's `server.devicePreview`
160
168
  group — plus setup endpoints: `GET /welcome`, `POST /welcome/dismiss`,
161
169
  `GET /doctor`, `POST /doctor/fix {id}`, `GET /ocr`, `POST /ocr/install`,
162
- `GET/POST /settings`.
170
+ `GET/POST /settings`, `GET /connection`.
171
+
172
+ **One classified adb boundary** — every serial-targeted adb command runs through
173
+ `adbRun()` in `lib/device-build.js`:
174
+
175
+ - A transport failure (`device not found` / `offline` / `unauthorized` /
176
+ `closed` / `no devices`) on a **Wi-Fi serial** (`ip:port`) gets exactly ONE
177
+ `adb connect` retry. Read-only commands (the `replaySafeAdb` allowlist:
178
+ `exec-out`/`screencap`/`uiautomator`/`dumpsys`/`getprop`/`logcat`/`cat`/…)
179
+ then replay automatically; **side-effectful ones never do** — a replayed tap
180
+ could double-tap — they raise "reconnected — call again" instead.
181
+ - USB serials and non-transport failures (a real `am start` error, a
182
+ `SecurityException`) surface as classified, actionable errors — the old
183
+ silent `capture()` → `""` swallowing is gone on agent-facing paths.
184
+ - Tolerant internal callers (boot polling, IME checks) opt back into
185
+ best-effort with an explicit `.catch(() => "")`.
163
186
 
164
187
  ## How it works
165
188
 
@@ -247,7 +247,7 @@ export class AndroidStreamHost {
247
247
 
248
248
  async getRotation(serial) {
249
249
  try {
250
- const value = Number((await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation'])).trim())
250
+ const value = Number((await DeviceBuild.adbRun(serial, ['shell', 'settings', 'get', 'system', 'user_rotation'])).trim())
251
251
  return ROTATION_CYCLE.includes(value) ? value : 0
252
252
  } catch {
253
253
  return 0
@@ -263,8 +263,7 @@ export class AndroidStreamHost {
263
263
  }
264
264
 
265
265
  async #shell(serial, shell) {
266
- const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell], { timeoutMs: CONTROL_TIMEOUT_MS }).exit
267
- if (code !== 0) throw new Error(`adb shell ${shell.join(' ')} failed (exit ${code})`)
266
+ await DeviceBuild.adbRun(serial, ['shell', ...shell], { timeoutMs: CONTROL_TIMEOUT_MS })
268
267
  }
269
268
 
270
269
  async #keepAliveTick() {
@@ -371,7 +370,7 @@ export class AndroidStreamHost {
371
370
 
372
371
  /** Override-aware physical screen size for the non-streaming control fallback. */
373
372
  async function screenSize(serial) {
374
- const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
373
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size'])
375
374
  const match = /Override size:\s*(\d+)x(\d+)/.exec(output) ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
376
375
  if (!match) throw new Error(`cannot read the screen size of ${serial}`)
377
376
  return { width: Number(match[1]), height: Number(match[2]) }
package/lib/client.js CHANGED
@@ -121,6 +121,13 @@ window.__ModuleLoader__.load({
121
121
  .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; }
122
122
  .mc-live-nav button:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
123
123
  .mc-live-cap { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); text-align: center; }
124
+ .mc-device-list { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
125
+ .mc-device-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); border-radius: 8px; }
126
+ .mc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; background: #9e9e9e; }
127
+ .mc-dot[data-on="true"] { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,.6); }
128
+ .mc-serial { font-weight: 600; font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
129
+ .mc-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: light-dark(rgba(0,0,0,.07), rgba(255,255,255,.12)); }
130
+ .mc-warn { color: #b3261e; font-size: 12px; margin: 6px 0; }
124
131
  `;
125
132
  //#endregion
126
133
 
@@ -140,13 +147,13 @@ window.__ModuleLoader__.load({
140
147
  }
141
148
 
142
149
  const getInfo = async (directory) => {
143
- const url = API_BASE + (directory ? "?directory=" + encodeURIComponent(directory) : "");
150
+ const url = location.origin + API_BASE + (directory ? "?directory=" + encodeURIComponent(directory) : "");
144
151
  const response = await fetch(url);
145
152
  if (!response.ok) throw new Error("HTTP " + response.status);
146
153
  return response.json();
147
154
  };
148
155
  const post = async (path, body) => {
149
- const response = await fetch(API_BASE + path, {
156
+ const response = await fetch(location.origin + API_BASE + path, {
150
157
  method: "POST",
151
158
  headers: { "content-type": "application/json" },
152
159
  body: JSON.stringify(body ?? {}),
@@ -155,14 +162,14 @@ window.__ModuleLoader__.load({
155
162
  return response.json();
156
163
  };
157
164
  const apiGet = async (path) => {
158
- const response = await fetch(API_BASE + path);
165
+ const response = await fetch(location.origin + API_BASE + path);
159
166
  if (!response.ok) throw new Error("HTTP " + response.status);
160
167
  return response.json();
161
168
  };
162
169
  // POST that surfaces the server's JSON error copy (stream routes return
163
170
  // human-readable 4xx bodies the generic post() would flatten to "HTTP 409").
164
171
  const streamPost = async (path, body) => {
165
- const response = await fetch(API_BASE + path, {
172
+ const response = await fetch(location.origin + API_BASE + path, {
166
173
  method: "POST",
167
174
  headers: { "content-type": "application/json" },
168
175
  body: JSON.stringify(body ?? {}),
@@ -374,7 +381,9 @@ window.__ModuleLoader__.load({
374
381
  if (!device) return;
375
382
  try {
376
383
  const r = await streamPost("/stream/grant", { device });
377
- setStreamUrl(r.streamUrl);
384
+ // Absolute URL: the plugin card can be hosted under a different
385
+ // origin/path than the API, and <img> src ignores fetch()'s base.
386
+ setStreamUrl(location.origin + r.streamUrl);
378
387
  setSerial(r.device);
379
388
  setError("");
380
389
  clearTimeout(grantTimer.current);
@@ -575,14 +584,21 @@ window.__ModuleLoader__.load({
575
584
  }
576
585
 
577
586
  /** Settings dialog: tabs for Doctor / PaddleOCR / AI prompt. */
578
- /** Shared settings tabs (Doctor / PaddleOCR / AI Prompt) — used by both the ⚙ dialog and the DSH Settings page. */
587
+ /** Shared settings tabs (Doctor / PaddleOCR / AI Prompt / Connection) — used by both the ⚙ dialog and the DSH Settings page. */
579
588
  function SettingsTabs() {
580
589
  const [tab, setTab] = useState("doctor");
581
590
  const [prompt, setPrompt] = useState("");
582
591
  const [copied, setCopied] = useState(false);
592
+ const [conn, setConn] = useState(null);
593
+ const [connError, setConnError] = useState("");
583
594
  useEffect(() => {
584
595
  apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
585
596
  }, []);
597
+ useEffect(() => {
598
+ if (tab !== "connection") return;
599
+ setConn(null); setConnError("");
600
+ apiGet("/connection").then(setConn).catch((e) => setConnError(e instanceof Error ? e.message : String(e)));
601
+ }, [tab]);
586
602
 
587
603
  const copy = async () => {
588
604
  const done = await copyText(prompt);
@@ -598,6 +614,7 @@ window.__ModuleLoader__.load({
598
614
  tabButton("doctor", "Doctor"),
599
615
  tabButton("ocr", "PaddleOCR"),
600
616
  tabButton("prompt", "AI Prompt"),
617
+ tabButton("connection", "Connection"),
601
618
  ),
602
619
  tab === "doctor" && h("div", { className: "mc-modal-body" }, h(DoctorView, { onFixed: () => {} })),
603
620
  tab === "ocr" && h("div", { className: "mc-modal-body" }, h(OcrCard, {})),
@@ -611,6 +628,27 @@ window.__ModuleLoader__.load({
611
628
  h("button", { className: "mc-btn mc-copy", onClick: copy }, copied ? "Copied ✓" : "Copy"),
612
629
  ),
613
630
  ),
631
+ tab === "connection" && h("div", { className: "mc-modal-body" },
632
+ h("div", null,
633
+ h("h3", null, "adb connection"),
634
+ connError !== "" && h("div", { className: "mc-warn" }, connError),
635
+ !conn && connError === "" && h("div", { className: "mc-hint" }, "Loading…"),
636
+ conn && h("div", null,
637
+ h("div", { className: "mc-hint" }, `adb: ${conn.adb}`),
638
+ h("div", { className: "mc-device-list" },
639
+ conn.devices.length === 0 && h("div", { className: "mc-hint" }, "No devices attached. Plug a phone (USB debug) or boot an emulator."),
640
+ conn.devices.map((d) => h("div", { className: "mc-device-row", key: d.serial },
641
+ h("span", { className: "mc-dot", "data-on": d.state === "device" ? "true" : undefined }),
642
+ h("span", { className: "mc-serial" }, d.serial),
643
+ d.model && h("span", { className: "mc-hint" }, d.model),
644
+ h("span", { className: "mc-badge" }, d.wifi ? "Wi-Fi" : "USB"),
645
+ h("span", { className: "mc-hint" }, d.state),
646
+ )),
647
+ ),
648
+ h("div", { className: "mc-hint" }, "Wi-Fi serials look like 192.168.1.23:5555 — attach with `adb connect <ip>:5555`. A dropped Wi-Fi link is reconnected once automatically; read-only probes then replay, taps never do."),
649
+ ),
650
+ ),
651
+ ),
614
652
  ];
615
653
  }
616
654
 
@@ -97,17 +97,20 @@ export function exec(command, args, options, onLine) {
97
97
  return { child, exit }
98
98
  }
99
99
 
100
- /** Run a command purely for its stdout, e.g. a `-json` query. Empty string on failure. */
101
- export async function capture(command, args, options = {}) {
100
+ /** Run a command, capturing stdout AND stderr. Resolves {code, out, err}. */
101
+ export async function captureFull(command, args, options = {}) {
102
102
  const out = []
103
+ const err = []
103
104
  const running = launch(command, args, {
104
105
  cwd: options.cwd,
105
106
  env: options.env ? { ...process.env, ...options.env } : undefined,
106
- stdio: ["ignore", "pipe", "ignore"],
107
+ stdio: ["ignore", "pipe", "pipe"],
107
108
  windowsHide: true,
108
109
  })
109
110
  running.stdout?.setEncoding("utf8")
111
+ running.stderr?.setEncoding("utf8")
110
112
  running.stdout?.on("data", (chunk) => out.push(chunk))
113
+ running.stderr?.on("data", (chunk) => err.push(chunk))
111
114
  let timer
112
115
  if (options.timeoutMs) {
113
116
  timer = setTimeout(() => running.kill(), options.timeoutMs)
@@ -118,9 +121,18 @@ export async function capture(command, args, options = {}) {
118
121
  running.once("close", (value) => resolve(value ?? -1))
119
122
  })
120
123
  if (timer) clearTimeout(timer)
121
- if (code !== 0) return ""
122
124
  const text = out.join("")
123
- return options.maxBytes && Buffer.byteLength(text) > options.maxBytes ? text.slice(0, options.maxBytes) : text
125
+ return {
126
+ code,
127
+ out: options.maxBytes && Buffer.byteLength(text) > options.maxBytes ? text.slice(0, options.maxBytes) : text,
128
+ err: err.join(""),
129
+ }
130
+ }
131
+
132
+ /** Run a command purely for its stdout, e.g. a `-json` query. Empty string on failure. */
133
+ export async function capture(command, args, options = {}) {
134
+ const result = await captureFull(command, args, options)
135
+ return result.code === 0 ? result.out : ""
124
136
  }
125
137
 
126
138
  /** Direct child pids of `pid`. POSIX only; returns nothing when pgrep is unavailable. */
@@ -715,6 +727,87 @@ export function adb() {
715
727
  return exe ?? "adb"
716
728
  }
717
729
 
730
+ // ── classified adb boundary (Wi-Fi resilience; pattern credited to boheastill/phone-eye) ──
731
+
732
+ /** Wi-Fi adb serials end in `ip:port` (`192.168.1.23:5555`); USB/emulator serials never do. */
733
+ export function isWifiSerial(serial) {
734
+ return /:\d+$/.test(serial)
735
+ }
736
+
737
+ const ADB_TRANSPORT_RE = /no devices|device (?:'.*?' )?not found|device offline|device unauthorized|device still connecting|error: closed|cannot connect to daemon|failed to start daemon/i
738
+
739
+ /** Why an adb command failed: 'multi-device', 'transport' (transient — a reconnect may fix it), or undefined (real command error). */
740
+ export function classifyAdbFailure(result) {
741
+ const text = `${result.err}\n${result.out}`
742
+ if (/more than one device/i.test(text)) return "multi-device"
743
+ if (ADB_TRANSPORT_RE.test(text)) return "transport"
744
+ return undefined
745
+ }
746
+
747
+ /**
748
+ * Commands that may be auto-replayed after a reconnect: read-only probes only.
749
+ * Anything else (input/am/pm-mutate/install/screencap-to-file) must never
750
+ * replay — a replayed `input tap` could double-tap a payment button.
751
+ */
752
+ export function replaySafeAdb(args) {
753
+ return /^(exec-out (screencap|cat|uiautomator|logcat|getprop|dumpsys)|shell (screencap|uiautomator|dumpsys|getprop|wm|settings get|pm list|ime list|cat|df|dmesg|logcat|true)|logcat( |$)|emu avd name)/.test(args.join(" "))
754
+ }
755
+
756
+ function adbFailTail(result) {
757
+ const text = `${result.err}\n${result.out}`.trim().split(/\r?\n/).filter(Boolean).slice(-3).join(" | ")
758
+ return text.slice(-300)
759
+ }
760
+
761
+ async function adbConnect(serial) {
762
+ const result = await captureFull(adb(), ["connect", serial], { timeoutMs: 15_000 })
763
+ return /connected|already connected/i.test(`${result.out}\n${result.err}`)
764
+ }
765
+
766
+ /**
767
+ * The single classified boundary for every serial-targeted adb command.
768
+ * On a transient transport failure with a Wi-Fi serial (ip:port) it attempts
769
+ * exactly one `adb connect`; only read-only commands are then replayed, while
770
+ * side-effectful ones raise "reconnected — call again". USB serials get a
771
+ * classified, actionable message instead of an empty result.
772
+ */
773
+ export async function adbRun(serial, args, options = {}) {
774
+ const argv = ["-s", serial, ...args]
775
+ let result = await captureFull(adb(), argv, options)
776
+ if (result.code === 0) return result.out
777
+ const kind = classifyAdbFailure(result)
778
+ if (kind === "multi-device") throw new Error("adb: more than one device/emulator attached — pass an explicit serial (see adb devices)")
779
+ if (kind !== "transport" || !isWifiSerial(serial)) {
780
+ throw new Error(kind === "transport"
781
+ ? `device ${serial} unreachable (${adbFailTail(result)}). For Wi-Fi adb run: adb connect <ip>:5555`
782
+ : `adb ${String(args[0])} failed on ${serial} (exit ${result.code}): ${adbFailTail(result)}`)
783
+ }
784
+ if (!(await adbConnect(serial))) {
785
+ throw new Error(`device ${serial} unreachable — adb connect failed; check the phone's Wi-Fi IP or replug USB once`)
786
+ }
787
+ if (!replaySafeAdb(args)) {
788
+ throw new Error(`device ${serial} reconnected over Wi-Fi; command not auto-replayed (side effects) — call again`)
789
+ }
790
+ result = await captureFull(adb(), argv, options)
791
+ if (result.code !== 0) throw new Error(`device ${serial} still failing after reconnect (exit ${result.code}): ${adbFailTail(result)}`)
792
+ return result.out
793
+ }
794
+
795
+ /** Quote one value for the device shell (`adb shell` rejoins argv through sh -c): '…' with ' → '\''; control chars refused. */
796
+ export function shQuoteDevice(text) {
797
+ if (/[\x00-\x1f]/.test(text)) throw new Error(`refusing control characters in adb shell argument: ${JSON.stringify(text.slice(0, 40))}`)
798
+ return `'${text.replace(/'/g, `'\\''`)}'`
799
+ }
800
+
801
+ /** Build the `am start` argv from intent parts; values are device-shell quoted so metacharacters stay inert. */
802
+ export function adbIntentArgs({ action, uri, component } = {}) {
803
+ const parts = ["shell", "am", "start"]
804
+ for (const [flag, value] of [["-a", action], ["-d", uri], ["-n", component]]) {
805
+ if (value === undefined || String(value) === "") continue
806
+ parts.push(flag, shQuoteDevice(String(value)))
807
+ }
808
+ return parts
809
+ }
810
+
718
811
  /** SDK emulator binary (emulator.exe on Windows), or undefined. */
719
812
  export function emulatorBinary() {
720
813
  const sdk = androidSdk()
@@ -736,13 +829,13 @@ export async function androidAvds() {
736
829
 
737
830
  /** True once the serial has finished booting (sys.boot_completed == 1). */
738
831
  export async function androidBooted(serial) {
739
- const output = await capture(adb(), ["-s", serial, "shell", "getprop", "sys.boot_completed"])
832
+ const output = await adbRun(serial, ["shell", "getprop", "sys.boot_completed"]).catch(() => "")
740
833
  return output.trim() === "1"
741
834
  }
742
835
 
743
836
  /** AVD name of an emulator serial (`adb emu avd name`); undefined for physical/offline. */
744
837
  export async function avdName(serial) {
745
- const output = await capture(adb(), ["-s", serial, "emu", "avd", "name"])
838
+ const output = await adbRun(serial, ["emu", "avd", "name"]).catch(() => "")
746
839
  const line = output
747
840
  .split(/\r?\n/)
748
841
  .map((item) => item.trim())
@@ -771,14 +864,14 @@ export function bootEmulator(avd) {
771
864
 
772
865
  /** True when the ADBKeyboard IME is installed (the only way to type non-ASCII over adb). */
773
866
  export async function adbKeyboardReady(serial) {
774
- const output = await capture(adb(), ["-s", serial, "shell", "ime", "list", "-s"])
867
+ const output = await adbRun(serial, ["shell", "ime", "list", "-s"]).catch(() => "")
775
868
  return /com\.android\.adbkeyboard/i.test(output)
776
869
  }
777
870
 
778
871
  /** Type one string via the ADBKeyboard broadcast (base64, so any codepoint survives the shell). */
779
872
  export async function typeViaAdbKeyboard(serial, text) {
780
873
  const msg = Buffer.from(text, "utf8").toString("base64")
781
- await exec(adb(), ["-s", serial, "shell", "am", "broadcast", "-a", "ADB_INPUT_B64", "--es", "msg", msg]).exit
874
+ await adbRun(serial, ["shell", "am", "broadcast", "-a", "ADB_INPUT_B64", "--es", "msg", msg])
782
875
  }
783
876
 
784
877
  /** True when every codepoint is safe for `adb shell input text` (printable ASCII, no shell metachars). */
@@ -819,14 +912,14 @@ export async function androidDevice() {
819
912
 
820
913
  /** Primary CPU ABI of a device, e.g. `arm64-v8a`. */
821
914
  export async function androidAbi(serial) {
822
- const output = await capture(adb(), ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"])
915
+ const output = await adbRun(serial, ["shell", "getprop", "ro.product.cpu.abi"]).catch(() => "")
823
916
  const abi = output.trim()
824
917
  return /^[a-z0-9_-]+$/i.test(abi) ? abi : undefined
825
918
  }
826
919
 
827
920
  /** Free space on the device's data partition in megabytes, when `df` reports it. */
828
921
  export async function androidFreeMb(serial) {
829
- return parseFreeMb(await capture(adb(), ["-s", serial, "shell", "df", "-k", "/data"]))
922
+ return parseFreeMb(await adbRun(serial, ["shell", "df", "-k", "/data"]).catch(() => ""))
830
923
  }
831
924
 
832
925
  /** Second line of `df -k`: Filesystem 1K-blocks Used Available Use% Mounted. */
@@ -931,7 +1024,7 @@ export async function devices(serial) {
931
1024
  /** Local path of a fresh screenshot of the serial. undefined on failure. */
932
1025
  export async function screenCapture(serial, outDir) {
933
1026
  const remote = "/sdcard/dsh-mobilecode-shot.png"
934
- await exec(adb(), ["-s", serial, "shell", "screencap", "-p", remote]).exit
1027
+ await adbRun(serial, ["shell", "screencap", "-p", remote])
935
1028
  const name = `screen-${serial}-${Date.now()}.png`
936
1029
  const local = path.join(outDir ?? os.tmpdir(), name)
937
1030
  const pulled = await new Promise((resolve) => {
@@ -952,8 +1045,8 @@ export async function screenCapture(serial, outDir) {
952
1045
  */
953
1046
  export async function uiDump(serial) {
954
1047
  const remote = "/sdcard/dsh-mobilecode-ui.xml"
955
- await exec(adb(), ["-s", serial, "shell", "uiautomator", "dump", remote]).exit
956
- const xml = await capture(adb(), ["-s", serial, "shell", "cat", remote])
1048
+ await adbRun(serial, ["shell", "uiautomator", "dump", remote])
1049
+ const xml = await adbRun(serial, ["shell", "cat", remote])
957
1050
  const items = []
958
1051
  const node = /<node[^>]*>/g
959
1052
  for (const match of xml.match(node) ?? []) {
@@ -975,7 +1068,7 @@ export async function uiDump(serial) {
975
1068
 
976
1069
  /** Foreground activity, e.g. "com.foo/.MainActivity", or undefined. */
977
1070
  export async function foregroundActivity(serial) {
978
- const output = await capture(adb(), ["-s", serial, "shell", "dumpsys", "activity", "activities"])
1071
+ const output = await adbRun(serial, ["shell", "dumpsys", "activity", "activities"]).catch(() => "")
979
1072
  const line = output.split("\n").find((item) => /topResumedActivity|mResumedActivity/.test(item))
980
1073
  const match = /ActivityRecord\{[^}]*\s([^\s}]+)\}/.exec(line ?? "")
981
1074
  return match?.[1] ?? undefined
@@ -983,14 +1076,14 @@ export async function foregroundActivity(serial) {
983
1076
 
984
1077
  /** Kernel log (dmesg). Requires adb root — works on emulators, usually not on real devices. */
985
1078
  export async function dmesg(serial) {
986
- return capture(adb(), ["-s", serial, "shell", "dmesg"])
1079
+ return adbRun(serial, ["shell", "dmesg"])
987
1080
  }
988
1081
 
989
1082
  /** logcat snapshot, filtered by buffer/level/package-like substring. */
990
1083
  export async function logcat(serial, { buffer = "main", lines = 200, filter } = {}) {
991
- const args = ["-s", serial, "logcat", "-d", "-t", String(lines)]
1084
+ const args = ["logcat", "-d", "-t", String(lines)]
992
1085
  if (buffer && buffer !== "all") args.push("-b", buffer)
993
- let output = await capture(adb(), args)
1086
+ let output = await adbRun(serial, args)
994
1087
  if (filter) output = output.split("\n").filter((line) => line.toLowerCase().includes(filter.toLowerCase())).join("\n")
995
1088
  return output
996
1089
  }
package/lib/index.js CHANGED
@@ -220,6 +220,20 @@ function makeRoutes(engine, config, stream) {
220
220
  writeJson(res, 200, { ok: true })
221
221
  },
222
222
  },
223
+ // GET /api/dsh-mobilecode/connection → {adb, devices:[{serial, state, model?, wifi}]} — connection card data.
224
+ {
225
+ kind: 'exact',
226
+ path: API_BASE + '/connection',
227
+ handler: async (req, res) => {
228
+ if (!guard(req, res)) return
229
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
230
+ try {
231
+ writeJson(res, 200, { adb: adbHostInfo(), devices: await connectionDevices() })
232
+ } catch (error) {
233
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
234
+ }
235
+ },
236
+ },
223
237
  // GET /api/dsh-mobilecode/doctor → [{name, ok, detail, fix?}] — plugin health check.
224
238
  {
225
239
  kind: 'exact',
@@ -728,11 +742,10 @@ function deviceInputTool() {
728
742
  async execute(args) {
729
743
  const serial = await requireAndroidDevice(args.serial)
730
744
  const action = args.action ?? 'tap'
731
- const adbArgs = (shell) => ['-s', serial, 'shell', ...shell]
732
745
  switch (action) {
733
746
  case 'tap': {
734
747
  if (typeof args.x !== 'number' || typeof args.y !== 'number') throw new Error('action=tap requires x and y (integer pixels).')
735
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'tap', String(args.x), String(args.y)])).exit
748
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(args.x), String(args.y)])
736
749
  return { serial, action, sent: `tap ${args.x},${args.y}` }
737
750
  }
738
751
  case 'swipe': {
@@ -740,13 +753,13 @@ function deviceInputTool() {
740
753
  throw new Error('action=swipe requires x, y, x2, y2.')
741
754
  }
742
755
  const duration = args.duration ?? 200
743
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'swipe', String(args.x), String(args.y), String(args.x2), String(args.y2), String(duration)])).exit
756
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'swipe', String(args.x), String(args.y), String(args.x2), String(args.y2), String(duration)])
744
757
  return { serial, action, sent: `swipe ${args.x},${args.y}→${args.x2},${args.y2} (${duration}ms)` }
745
758
  }
746
759
  case 'text': {
747
760
  if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
748
761
  if (DeviceBuild.isAsciiInput(args.text)) {
749
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', DeviceBuild.escapeInputText(args.text)])).exit
762
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'text', DeviceBuild.escapeInputText(args.text)])
750
763
  return { serial, action, sent: `text "${args.text}"` }
751
764
  }
752
765
  // Non-ASCII (CJK, emoji, accented) cannot go through `input text`; the
@@ -765,7 +778,7 @@ function deviceInputTool() {
765
778
  const raw = String(args.key ?? '')
766
779
  const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
767
780
  if (!code) throw new Error(`unknown key "${raw}" — use a name from ${Object.keys(KEYCODES).join(', ')} or a raw keycode integer.`)
768
- await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'keyevent', String(code)])).exit
781
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'keyevent', String(code)])
769
782
  return { serial, action, sent: `key ${raw} (${code})` }
770
783
  }
771
784
  default:
@@ -775,7 +788,71 @@ function deviceInputTool() {
775
788
  })
776
789
  }
777
790
 
778
- /** OCR the current screen and report whether `wantedLower` appears; undefined when OCR is unavailable. */
791
+ /** Plain-device adb diagnostics (no serial) for the settings Connection card. */
792
+ export function adbHostInfo() {
793
+ return DeviceBuild.adb()
794
+ }
795
+
796
+ /** Lines of `adb devices -l` after the header rows, parsed for the settings Connection card. */
797
+ export async function connectionDevices() {
798
+ const output = await DeviceBuild.capture(DeviceBuild.adb(), ["devices", "-l"])
799
+ return parseDeviceLongList(output)
800
+ }
801
+
802
+ /** Parse `adb devices -l` into [{serial, state, model, wifi}]. */
803
+ export function parseDeviceLongList(output) {
804
+ return String(output)
805
+ .split(/\r?\n/)
806
+ .map((line) => line.trim())
807
+ .filter((line) => line && !/^List of devices/i.test(line) && !/^\* daemon/i.test(line))
808
+ .map((line) => {
809
+ const [serial, state, ...rest] = line.split(/\s+/)
810
+ const model = /model:(\S+)/.exec(rest.join(" "))?.[1]
811
+ const row = { serial, state: state || "unknown", wifi: DeviceBuild.isWifiSerial(serial) }
812
+ if (model) row.model = model
813
+ return row
814
+ })
815
+ .filter((row) => row.serial)
816
+ }
817
+
818
+ function deviceIntentTool() {
819
+ return defineTool({
820
+ name: 'device_intent',
821
+ description: 'Open anything on the device by Android intent: an action (e.g. android.settings.WIFI_SETTINGS), ' +
822
+ 'a deep-link URI, or an explicit component (package/.Activity). Reaches screens no tap can address — deep ' +
823
+ 'settings pages, app deep links, files. Values are device-shell quoted so metacharacters stay inert.',
824
+ parameters: {
825
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
826
+ action: { type: 'string', description: 'Intent action, e.g. android.settings.WIFI_SETTINGS or android.intent.action.VIEW.' },
827
+ uri: { type: 'string', description: 'Data URI the intent carries, e.g. geo:0,0?q=Berlin or a https:// deep link.' },
828
+ component: { type: 'string', description: 'Explicit component, e.g. com.android.settings/.Settings.' },
829
+ },
830
+ output: {
831
+ schema: {
832
+ type: 'object',
833
+ additionalProperties: false,
834
+ properties: {
835
+ serial: { type: 'string', required: true },
836
+ intent: { type: 'string', required: true },
837
+ started: { type: 'boolean', required: true },
838
+ },
839
+ },
840
+ render: (_args, value) => [{ type: 'text', text: `Started ${value?.intent} on ${value?.serial}` }],
841
+ },
842
+ async execute(args) {
843
+ const serial = await requireAndroidDevice(args.serial)
844
+ const argv = DeviceBuild.adbIntentArgs({ action: args.action, uri: args.uri, component: args.component })
845
+ if (argv.length === 3) throw new Error('device_intent needs at least one of action, uri or component.')
846
+ const output = await DeviceBuild.adbRun(serial, argv)
847
+ if (/^Error/i.test(output.trim())) throw new Error(`am start refused the intent on ${serial}: ${output.trim().split(/\r?\n/)[0]}`)
848
+ const label = [["-a", args.action], ["-d", args.uri], ["-n", args.component]]
849
+ .filter(([, value]) => value !== undefined && value !== '')
850
+ .map(([flag, value]) => `${flag} ${value}`)
851
+ .join(' ')
852
+ return { serial, intent: label, started: true }
853
+ },
854
+ })
855
+ }
779
856
  async function ocrHasText(serial, wantedLower) {
780
857
  if (!DeviceBuild.ocrPython()) return undefined
781
858
  const png = await DeviceBuild.screenCapture(serial)
@@ -931,7 +1008,7 @@ function deviceShutdownTool() {
931
1008
  if (!isEmulator) {
932
1009
  throw new Error(`device_shutdown refuses ${serial}: it is a physical device and adb has no power-off verb for phones — use its own power button.`)
933
1010
  }
934
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'emu', 'kill']).exit
1011
+ await DeviceBuild.adbRun(serial, ['emu', 'kill'])
935
1012
  return { serial, shutdown: true }
936
1013
  },
937
1014
  })
@@ -971,15 +1048,15 @@ function deviceActionTool() {
971
1048
  const serial = await requireAndroidDevice(args.serial)
972
1049
  const action = String(args.action ?? '')
973
1050
  if (action === 'rotate') {
974
- const current = Number(await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation']))
1051
+ const current = Number(await DeviceBuild.adbRun(serial, ['shell', 'settings', 'get', 'system', 'user_rotation']))
975
1052
  const next = ((Number.isFinite(current) ? current : 0) + 1) % 4
976
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).exit
977
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)]).exit
1053
+ await DeviceBuild.adbRun(serial, ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'])
1054
+ await DeviceBuild.adbRun(serial, ['shell', 'settings', 'put', 'system', 'user_rotation', String(next)])
978
1055
  return { serial, action, rotation: next }
979
1056
  }
980
1057
  const shell = DEVICE_ACTIONS[action]
981
1058
  if (!shell) throw new Error(`unknown action "${action}" — use ${[...Object.keys(DEVICE_ACTIONS), 'rotate'].join(', ')}.`)
982
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell]).exit
1059
+ await DeviceBuild.adbRun(serial, ['shell', ...shell])
983
1060
  return { serial, action }
984
1061
  },
985
1062
  })
@@ -1012,7 +1089,7 @@ function deviceAppsTool() {
1012
1089
  },
1013
1090
  async execute(args) {
1014
1091
  const serial = await requireAndroidDevice(args.serial)
1015
- const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
1092
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
1016
1093
  const packages = output
1017
1094
  .split(/\r?\n/)
1018
1095
  .map((line) => line.trim())
@@ -1050,7 +1127,7 @@ function deviceLaunchAppTool() {
1050
1127
  const serial = await requireAndroidDevice(args.serial)
1051
1128
  let pkg = String(args.package ?? '').trim()
1052
1129
  if (pkg === '') throw new Error('device_launch_app requires a package name (or a unique substring).')
1053
- const listOut = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages'])
1130
+ const listOut = await DeviceBuild.adbRun(serial, ['shell', 'pm', 'list', 'packages'])
1054
1131
  const all = listOut
1055
1132
  .split(/\r?\n/)
1056
1133
  .map((line) => line.trim())
@@ -1062,9 +1139,9 @@ function deviceLaunchAppTool() {
1062
1139
  if (matches.length > 1) throw new Error(`"${pkg}" matches ${matches.length} packages (${matches.slice(0, 8).join(', ')}${matches.length > 8 ? ', …' : ''}) — be more specific.`)
1063
1140
  pkg = matches[0]
1064
1141
  }
1065
- if (args.relaunch) await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'am', 'force-stop', pkg]).exit
1066
- const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).exit
1067
- if (code !== 0) throw new Error(`Could not launch ${pkg} (no launcher activity, or monkey failed with exit ${code}).`)
1142
+ if (args.relaunch) await DeviceBuild.adbRun(serial, ['shell', 'am', 'force-stop', pkg])
1143
+ const launched = await DeviceBuild.adbRun(serial, ['shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).catch((error) => { throw new Error(`Could not launch ${pkg}: ${error instanceof Error ? error.message : error}`) })
1144
+ if (/no activities found|no events to send/i.test(launched)) throw new Error(`Could not launch ${pkg} (no launcher activity).`)
1068
1145
  return { serial, package: pkg, launched: true }
1069
1146
  },
1070
1147
  })
@@ -1240,7 +1317,7 @@ function deviceScreenTool(engine, vision) {
1240
1317
  }
1241
1318
 
1242
1319
  async function captureScreenSize(serial) {
1243
- const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
1320
+ const output = await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size'])
1244
1321
  // An `wm size` override wins over the physical panel — the input space is the override.
1245
1322
  const override = /Override size:\s*(\d+)x(\d+)/.exec(output)
1246
1323
  const match = override ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
@@ -1362,7 +1439,7 @@ function deviceTapElementTool() {
1362
1439
  const selector = { identifier: args.resource_id, label: args.text }
1363
1440
  const { node, matchedBy } = UiTree.resolveTapTarget(parsed.roots, selector, { tool: 'device_tap_element', allowOffscreen: args.allow_offscreen === true })
1364
1441
  const center = UiTree.boundsCenter(node.bounds)
1365
- await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'input', 'tap', String(center.x), String(center.y)]).exit
1442
+ await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(center.x), String(center.y)])
1366
1443
  const describe = () => {
1367
1444
  const parts = []
1368
1445
  if (node.resourceId) parts.push(`resource_id ${node.resourceId}`)
@@ -1579,6 +1656,10 @@ function guidance() {
1579
1656
  '- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
1580
1657
  '- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
1581
1658
  '- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
1659
+ '- device_intent: open anything by Android intent (action, deep-link URI, or package/.Activity). Reaches screens no tap can address.',
1660
+ ' Every adb command runs through one classified boundary: a dropped Wi-Fi connection (ip:port serial) gets exactly one',
1661
+ ' `adb connect` attempt, read-only commands then replay automatically, and side-effectful ones refuse the replay —',
1662
+ ' a replayed tap could double-tap — and raise "reconnected — call again" instead.',
1582
1663
  '- device_stream: drive the live screen stream the Devices panel shows (status / start an online device / stop). Agents',
1583
1664
  ' that just need to see the screen should prefer device_screen or device_ui_tree.',
1584
1665
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
@@ -1661,6 +1742,7 @@ export function apply(ctx, config) {
1661
1742
  deviceActionTool(),
1662
1743
  deviceAppsTool(),
1663
1744
  deviceLaunchAppTool(),
1745
+ deviceIntentTool(),
1664
1746
  deviceStreamTool(streamHost, streamAccess),
1665
1747
  deviceLogTool(engine),
1666
1748
  deviceStatusTool(engine),
package/lib/uitree.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * Ported design credit: ZSeven-W/dsh-android (MIT) src/uitree.ts.
14
14
  */
15
15
 
16
- import { adb, capture, exec } from "./device-build.js"
16
+ import { adb, adbRun, exec } from "./device-build.js"
17
17
 
18
18
  const DUMP_TIMEOUT_MS = 60_000
19
19
  const DUMP_MAX_BYTES = 8 * 1024 * 1024
@@ -260,7 +260,7 @@ export async function dumpUiTreeXml(serial) {
260
260
  let primaryFailure
261
261
  for (let attempt = 0; attempt < 2; attempt += 1) {
262
262
  try {
263
- const buffer = await capture(adb(), ["-s", serial, "exec-out", "uiautomator", "dump", "/dev/tty"], options)
263
+ const buffer = await adbRun(serial, ["exec-out", "uiautomator", "dump", "/dev/tty"], options)
264
264
  if (buffer === "") throw new Error("the device produced no output")
265
265
  return extractHierarchyXml(buffer)
266
266
  } catch (error) {
@@ -284,8 +284,8 @@ export async function dumpUiTreeXml(serial) {
284
284
  }
285
285
  const remotePath = "/sdcard/window_dump.xml"
286
286
  try {
287
- const notice = await capture(adb(), ["-s", serial, "shell", "uiautomator", "dump", remotePath], options)
288
- const buffer = await capture(adb(), ["-s", serial, "exec-out", "cat", remotePath], options)
287
+ const notice = await adbRun(serial, ["shell", "uiautomator", "dump", remotePath], options)
288
+ const buffer = await adbRun(serial, ["exec-out", "cat", remotePath], options)
289
289
  const xml = extractHierarchyXml(buffer)
290
290
  await exec(adb(), ["-s", serial, "shell", "rm", "-f", remotePath]).exit.catch(() => {})
291
291
  if (xml.trim() === "") throw new Error(notice.trim() || "empty dump")
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mobilecode",
3
- "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
- "version": "0.4.0",
3
+ "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 15 agent tools (device_run, device_screen, device_ui_tree, device_tap_element, device_input, device_intent, device_log, live screen stream, multimodal screenshots) with one classified adb boundary. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {