dsh-mobilecode 0.4.0 → 0.6.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.
@@ -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,231 @@ 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
+ /** Attach a Wi-Fi device: `adb connect <ip>:<port>`; true on success. Never throws (returns false with no side effect). */
767
+ export async function adbConnectSerial(serial) {
768
+ return adbConnect(serial)
769
+ }
770
+
771
+ /**
772
+ * Pair a Wi-Fi device (Android 11+ requires pairing before the first connect):
773
+ * `adb pair <ip>:<pair-port> <code>`. Resolves when pairing succeeded.
774
+ */
775
+ export async function adbPair(serial, code) {
776
+ const result = await captureFull(adb(), ["pair", serial, String(code)], { timeoutMs: 20_000 })
777
+ if (/successfully paired/i.test(`${result.out}\n${result.err}`)) return true
778
+ throw new Error(`adb pair ${serial} failed: ${adbFailTail(result) || "no output"}`)
779
+ }
780
+
781
+ /**
782
+ * Parse `adb mdns services` output for the pairing service the device advertises
783
+ * while its "pair using QR code" dialog is open. Returns the `ip:port` the
784
+ * pairing broadcast listens on, or undefined.
785
+ */
786
+ export function parseMdnsPairing(output) {
787
+ const line = String(output).split(/\r?\n/).find((row) => row.includes("_adb-tls-pairing._tcp"))
788
+ if (line === undefined) return undefined
789
+ const address = /(\d{1,3}(?:\.\d{1,3}){3}:\d+)/.exec(line)?.[1] ?? /(\[[0-9a-f:]+\]:\d+)/.exec(line)?.[1]
790
+ return address ?? undefined
791
+ }
792
+
793
+ /** A Wi-Fi QR code the device scanner accepts: `WIFI:T:ADB;S:<name>;P:<password>;;`. */
794
+ export function generateQrAdbWifi(name, password) {
795
+ return `WIFI:T:ADB;S:${name};P:${password};;`
796
+ }
797
+
798
+ /**
799
+ * Poll `adb mdns services` until the phone's pairing dialog advertises
800
+ * `_adb-tls-pairing._tcp` (resolves to its `ip:port`), or undefined on timeout.
801
+ * Requires adb with mdns support (>= 31). Non-serial, read-only.
802
+ */
803
+ export async function waitForMdnsPairing(timeoutMs = 60_000, pollMs = 1_500) {
804
+ const deadline = Date.now() + timeoutMs
805
+ for (;;) {
806
+ const result = await captureFull(adb(), ["mdns", "services"], { timeoutMs: 5_000 }).catch(() => null)
807
+ if (result && result.code === 0) {
808
+ const address = parseMdnsPairing(result.out)
809
+ if (address !== undefined) return address
810
+ }
811
+ if (Date.now() >= deadline) return undefined
812
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
813
+ }
814
+ }
815
+
816
+ const QR_NAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
817
+ /** Random ADB QR name (ADB_WIFI_xxxxxxxxxxxxxx-yyyyyy) + 21-char password, matching Android's own structure. */
818
+ export function randomQrCredentials(prefix = "ADB_WIFI") {
819
+ const pick = (n) => Array.from({ length: n }, () => QR_NAME_CHARS[Math.floor(Math.random() * QR_NAME_CHARS.length)]).join("")
820
+ return { name: `${prefix}_${pick(14)}-${pick(6)}`, password: pick(21) }
821
+ }
822
+
823
+ /**
824
+ * The single classified boundary for every serial-targeted adb command.
825
+ * On a transient transport failure with a Wi-Fi serial (ip:port) it attempts
826
+ * exactly one `adb connect`; only read-only commands are then replayed, while
827
+ * side-effectful ones raise "reconnected — call again". USB serials get a
828
+ * classified, actionable message instead of an empty result.
829
+ */
830
+ export async function adbRun(serial, args, options = {}) {
831
+ const argv = ["-s", serial, ...args]
832
+ let result = await captureFull(adb(), argv, options)
833
+ if (result.code === 0) return result.out
834
+ const kind = classifyAdbFailure(result)
835
+ if (kind === "multi-device") throw new Error("adb: more than one device/emulator attached — pass an explicit serial (see adb devices)")
836
+ if (kind !== "transport" || !isWifiSerial(serial)) {
837
+ throw new Error(kind === "transport"
838
+ ? `device ${serial} unreachable (${adbFailTail(result)}). For Wi-Fi adb run: adb connect <ip>:5555`
839
+ : `adb ${String(args[0])} failed on ${serial} (exit ${result.code}): ${adbFailTail(result)}`)
840
+ }
841
+ if (!(await adbConnect(serial))) {
842
+ throw new Error(`device ${serial} unreachable — adb connect failed; check the phone's Wi-Fi IP or replug USB once`)
843
+ }
844
+ if (!replaySafeAdb(args)) {
845
+ throw new Error(`device ${serial} reconnected over Wi-Fi; command not auto-replayed (side effects) — call again`)
846
+ }
847
+ result = await captureFull(adb(), argv, options)
848
+ if (result.code !== 0) throw new Error(`device ${serial} still failing after reconnect (exit ${result.code}): ${adbFailTail(result)}`)
849
+ return result.out
850
+ }
851
+
852
+ /** Quote one value for the device shell (`adb shell` rejoins argv through sh -c): '…' with ' → '\''; control chars refused. */
853
+ export function shQuoteDevice(text) {
854
+ if (/[\x00-\x1f]/.test(text)) throw new Error(`refusing control characters in adb shell argument: ${JSON.stringify(text.slice(0, 40))}`)
855
+ return `'${text.replace(/'/g, `'\\''`)}'`
856
+ }
857
+
858
+ /** Build the `am start` argv from intent parts; values are device-shell quoted so metacharacters stay inert. */
859
+ export function adbIntentArgs({ action, uri, component } = {}) {
860
+ const parts = ["shell", "am", "start"]
861
+ for (const [flag, value] of [["-a", action], ["-d", uri], ["-n", component]]) {
862
+ if (value === undefined || String(value) === "") continue
863
+ parts.push(flag, shQuoteDevice(String(value)))
864
+ }
865
+ return parts
866
+ }
867
+
868
+ // ── perf / app-info parsers (pattern credited to newborne/dsh-adb-ultimate) ──
869
+
870
+ /** Parse `cat /proc/meminfo` into {totalKb, availableKb, usedKb, usedPercent}. */
871
+ export function parseMeminfo(output) {
872
+ const grab = (name) => {
873
+ const match = new RegExp(`^${name}:\\s*(\\d+)`, "m").exec(String(output))
874
+ return match ? Number(match[1]) : undefined
875
+ }
876
+ const totalKb = grab("MemTotal")
877
+ const availableKb = grab("MemAvailable")
878
+ if (totalKb === undefined) return undefined
879
+ const usedKb = availableKb === undefined ? undefined : totalKb - availableKb
880
+ return {
881
+ totalKb,
882
+ availableKb,
883
+ usedKb,
884
+ usedPercent: usedKb !== undefined && totalKb > 0 ? Math.round((usedKb / totalKb) * 1000) / 10 : undefined,
885
+ }
886
+ }
887
+
888
+ /** Parse `dumpsys battery` into {level, temperatureC, status, health, powered}. */
889
+ export function parseBattery(output) {
890
+ const grab = (name) => {
891
+ const match = new RegExp(`^\\s*${name}:\\s*(.+)`, "m").exec(String(output))
892
+ return match ? match[1].trim() : undefined
893
+ }
894
+ const levelRaw = grab("level")
895
+ const tempRaw = grab("temperature")
896
+ const level = levelRaw ? Number(levelRaw) : undefined
897
+ const tempRawNum = tempRaw ? Number(tempRaw) : undefined
898
+ return {
899
+ level,
900
+ temperatureC: tempRawNum !== undefined && Number.isFinite(tempRawNum) ? Math.round(tempRawNum) / 10 : undefined,
901
+ status: grab("status"),
902
+ health: grab("health"),
903
+ powered: grab("AC powered") === "true" || grab("USB powered") === "true" || grab("Wireless powered") === "true",
904
+ }
905
+ }
906
+
907
+ /** Parse `cat /proc/cpuinfo` into {cores, hardware}. */
908
+ export function parseCpuinfo(output) {
909
+ const text = String(output)
910
+ const cores = (text.match(/^processor\s*:/gm) ?? []).length
911
+ const hardware = /^Hardware\s*:\s*(.+)$/m.exec(text)?.[1]?.trim()
912
+ return { cores: cores > 0 ? cores : undefined, hardware }
913
+ }
914
+
915
+ /** Parse `dumpsys package <pkg>` into {versionName, versionCode, minSdk, targetSdk, permissions, activities}. */
916
+ export function parseAppInfo(output, packageName) {
917
+ const text = String(output)
918
+ const grab = (name) => {
919
+ const match = new RegExp(`${name}=(\\S+)`, "m").exec(text)
920
+ return match ? match[1] : undefined
921
+ }
922
+ const permissions = []
923
+ const textLines = text.split(/\r?\n/)
924
+ const headerIndex = textLines.findIndex((line) => line.trim() === "requested permissions:")
925
+ if (headerIndex >= 0) {
926
+ for (let i = headerIndex + 1; i < textLines.length; i += 1) {
927
+ const trimmed = textLines[i].trim()
928
+ if (trimmed === "" || !/^(android\.permission\.|com\.[^\s]+\.permission\.)[A-Z0-9_.]+$/.test(trimmed)) break
929
+ permissions.push(trimmed)
930
+ }
931
+ }
932
+ const activities = []
933
+ const resolverIndex = textLines.findIndex((line) => line.trim() === "Activity Resolver Table:")
934
+ if (resolverIndex >= 0) {
935
+ // Real resolver-table rows are `<hex> pkg/.Activity filter <hex>` (or `pkg/.Activity (hex)`):
936
+ // 5f60459 com.android.settings/.Settings$ApnEditorActivity filter 4e9281e
937
+ // MIME-type headers (vnd.android.cursor.item/telephony-carrier:) contain a `-` so the
938
+ // component class [\w.$]+ cannot span it, keeping them out of the match.
939
+ for (let i = resolverIndex + 1; i < textLines.length && activities.length < 20; i += 1) {
940
+ const match = /([A-Za-z_][\w.$]*\/[\w.$]+)\s+(?:filter\s+[0-9a-f]+|\([0-9a-f]+\))/i.exec(textLines[i].trim())
941
+ if (match && !activities.includes(match[1])) activities.push(match[1])
942
+ }
943
+ }
944
+ return {
945
+ package: packageName,
946
+ versionName: grab("versionName"),
947
+ versionCode: grab("versionCode"),
948
+ minSdk: grab("minSdk"),
949
+ targetSdk: grab("targetSdk"),
950
+ permissions: [...new Set(permissions)].slice(0, 60),
951
+ activities: activities.slice(0, 20),
952
+ }
953
+ }
954
+
718
955
  /** SDK emulator binary (emulator.exe on Windows), or undefined. */
719
956
  export function emulatorBinary() {
720
957
  const sdk = androidSdk()
@@ -736,13 +973,13 @@ export async function androidAvds() {
736
973
 
737
974
  /** True once the serial has finished booting (sys.boot_completed == 1). */
738
975
  export async function androidBooted(serial) {
739
- const output = await capture(adb(), ["-s", serial, "shell", "getprop", "sys.boot_completed"])
976
+ const output = await adbRun(serial, ["shell", "getprop", "sys.boot_completed"]).catch(() => "")
740
977
  return output.trim() === "1"
741
978
  }
742
979
 
743
980
  /** AVD name of an emulator serial (`adb emu avd name`); undefined for physical/offline. */
744
981
  export async function avdName(serial) {
745
- const output = await capture(adb(), ["-s", serial, "emu", "avd", "name"])
982
+ const output = await adbRun(serial, ["emu", "avd", "name"]).catch(() => "")
746
983
  const line = output
747
984
  .split(/\r?\n/)
748
985
  .map((item) => item.trim())
@@ -771,14 +1008,14 @@ export function bootEmulator(avd) {
771
1008
 
772
1009
  /** True when the ADBKeyboard IME is installed (the only way to type non-ASCII over adb). */
773
1010
  export async function adbKeyboardReady(serial) {
774
- const output = await capture(adb(), ["-s", serial, "shell", "ime", "list", "-s"])
1011
+ const output = await adbRun(serial, ["shell", "ime", "list", "-s"]).catch(() => "")
775
1012
  return /com\.android\.adbkeyboard/i.test(output)
776
1013
  }
777
1014
 
778
1015
  /** Type one string via the ADBKeyboard broadcast (base64, so any codepoint survives the shell). */
779
1016
  export async function typeViaAdbKeyboard(serial, text) {
780
1017
  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
1018
+ await adbRun(serial, ["shell", "am", "broadcast", "-a", "ADB_INPUT_B64", "--es", "msg", msg])
782
1019
  }
783
1020
 
784
1021
  /** True when every codepoint is safe for `adb shell input text` (printable ASCII, no shell metachars). */
@@ -819,14 +1056,14 @@ export async function androidDevice() {
819
1056
 
820
1057
  /** Primary CPU ABI of a device, e.g. `arm64-v8a`. */
821
1058
  export async function androidAbi(serial) {
822
- const output = await capture(adb(), ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"])
1059
+ const output = await adbRun(serial, ["shell", "getprop", "ro.product.cpu.abi"]).catch(() => "")
823
1060
  const abi = output.trim()
824
1061
  return /^[a-z0-9_-]+$/i.test(abi) ? abi : undefined
825
1062
  }
826
1063
 
827
1064
  /** Free space on the device's data partition in megabytes, when `df` reports it. */
828
1065
  export async function androidFreeMb(serial) {
829
- return parseFreeMb(await capture(adb(), ["-s", serial, "shell", "df", "-k", "/data"]))
1066
+ return parseFreeMb(await adbRun(serial, ["shell", "df", "-k", "/data"]).catch(() => ""))
830
1067
  }
831
1068
 
832
1069
  /** Second line of `df -k`: Filesystem 1K-blocks Used Available Use% Mounted. */
@@ -931,7 +1168,7 @@ export async function devices(serial) {
931
1168
  /** Local path of a fresh screenshot of the serial. undefined on failure. */
932
1169
  export async function screenCapture(serial, outDir) {
933
1170
  const remote = "/sdcard/dsh-mobilecode-shot.png"
934
- await exec(adb(), ["-s", serial, "shell", "screencap", "-p", remote]).exit
1171
+ await adbRun(serial, ["shell", "screencap", "-p", remote])
935
1172
  const name = `screen-${serial}-${Date.now()}.png`
936
1173
  const local = path.join(outDir ?? os.tmpdir(), name)
937
1174
  const pulled = await new Promise((resolve) => {
@@ -952,8 +1189,8 @@ export async function screenCapture(serial, outDir) {
952
1189
  */
953
1190
  export async function uiDump(serial) {
954
1191
  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])
1192
+ await adbRun(serial, ["shell", "uiautomator", "dump", remote])
1193
+ const xml = await adbRun(serial, ["shell", "cat", remote])
957
1194
  const items = []
958
1195
  const node = /<node[^>]*>/g
959
1196
  for (const match of xml.match(node) ?? []) {
@@ -975,7 +1212,7 @@ export async function uiDump(serial) {
975
1212
 
976
1213
  /** Foreground activity, e.g. "com.foo/.MainActivity", or undefined. */
977
1214
  export async function foregroundActivity(serial) {
978
- const output = await capture(adb(), ["-s", serial, "shell", "dumpsys", "activity", "activities"])
1215
+ const output = await adbRun(serial, ["shell", "dumpsys", "activity", "activities"]).catch(() => "")
979
1216
  const line = output.split("\n").find((item) => /topResumedActivity|mResumedActivity/.test(item))
980
1217
  const match = /ActivityRecord\{[^}]*\s([^\s}]+)\}/.exec(line ?? "")
981
1218
  return match?.[1] ?? undefined
@@ -983,14 +1220,14 @@ export async function foregroundActivity(serial) {
983
1220
 
984
1221
  /** Kernel log (dmesg). Requires adb root — works on emulators, usually not on real devices. */
985
1222
  export async function dmesg(serial) {
986
- return capture(adb(), ["-s", serial, "shell", "dmesg"])
1223
+ return adbRun(serial, ["shell", "dmesg"])
987
1224
  }
988
1225
 
989
1226
  /** logcat snapshot, filtered by buffer/level/package-like substring. */
990
1227
  export async function logcat(serial, { buffer = "main", lines = 200, filter } = {}) {
991
- const args = ["-s", serial, "logcat", "-d", "-t", String(lines)]
1228
+ const args = ["logcat", "-d", "-t", String(lines)]
992
1229
  if (buffer && buffer !== "all") args.push("-b", buffer)
993
- let output = await capture(adb(), args)
1230
+ let output = await adbRun(serial, args)
994
1231
  if (filter) output = output.split("\n").filter((line) => line.toLowerCase().includes(filter.toLowerCase())).join("\n")
995
1232
  return output
996
1233
  }