@phone-use/sdk 0.4.1 → 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/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as registerBackend, c as DeviceInUseError, d as SessionNotFoundError, f as TimeoutError, i as listBackends, l as DeviceNotFoundError, m as toPhoneUseError, n as BaseDeviceBackend, o as AbortedError, p as UnsupportedCapabilityError, r as getBackendFactory, s as ActionFailedError, t as ALL_CAPABILITIES, u as PhoneUseError } from "./device-Xsy_LPUF.mjs";
2
2
  import { createAgentDeviceClient } from "agent-device";
3
+ import { execFile, spawn } from "node:child_process";
3
4
  import { writeFile } from "node:fs/promises";
4
- import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  //#region src/observe.ts
7
7
  const NOISE_LABELS = /* @__PURE__ */ new Set([
@@ -1471,6 +1471,917 @@ function createAgentDeviceBackend(config) {
1471
1471
  return new AgentDeviceBackend(config);
1472
1472
  }
1473
1473
  //#endregion
1474
+ //#region src/exec.ts
1475
+ const pExecFile$1 = promisify(execFile);
1476
+ const defaultExecRunner = async (file, args, opts) => {
1477
+ const { stdout, stderr } = await pExecFile$1(file, args, {
1478
+ encoding: "utf8",
1479
+ maxBuffer: 16 * 1024 * 1024,
1480
+ ...opts?.timeoutMs === void 0 ? {} : { timeout: opts.timeoutMs },
1481
+ ...opts?.env === void 0 ? {} : { env: opts.env }
1482
+ });
1483
+ return {
1484
+ stdout,
1485
+ stderr
1486
+ };
1487
+ };
1488
+ function isExecError(err) {
1489
+ return err instanceof Error && ("code" in err || "killed" in err || "stderr" in err);
1490
+ }
1491
+ //#endregion
1492
+ //#region src/lifecycle.ts
1493
+ var IdleLease = class {
1494
+ timer = null;
1495
+ windowMs;
1496
+ onExpire;
1497
+ constructor(windowMs, onExpire) {
1498
+ this.windowMs = windowMs;
1499
+ this.onExpire = onExpire;
1500
+ this.touch();
1501
+ }
1502
+ /** Re-arm with the configured window (no-op when disabled). */
1503
+ touch() {
1504
+ this.arm(this.windowMs);
1505
+ }
1506
+ /** Re-arm with a one-shot override window. */
1507
+ extend(ms) {
1508
+ this.arm(ms ?? this.windowMs);
1509
+ }
1510
+ arm(ms) {
1511
+ if (this.timer) clearTimeout(this.timer);
1512
+ this.timer = null;
1513
+ if (ms === false) return;
1514
+ const t = setTimeout(this.onExpire, ms);
1515
+ t.unref?.();
1516
+ this.timer = t;
1517
+ }
1518
+ dispose() {
1519
+ if (this.timer) clearTimeout(this.timer);
1520
+ this.timer = null;
1521
+ }
1522
+ };
1523
+ /**
1524
+ * Assemble a Device handle over a backend: lease/reaper, verb surface,
1525
+ * close/dispose semantics. Engine authors (ios and android here,
1526
+ * phone-backend-* third parties) build on this; tests fabricate devices with
1527
+ * it over a FakeBackend.
1528
+ */
1529
+ function createDeviceHandle(opts) {
1530
+ let status = "running";
1531
+ let closePromise = null;
1532
+ const close = () => {
1533
+ closePromise ??= (async () => {
1534
+ status = "closed";
1535
+ lease.dispose();
1536
+ await opts.backend.closeSession().catch(() => void 0);
1537
+ await opts.doClose();
1538
+ })();
1539
+ return closePromise;
1540
+ };
1541
+ const lease = new IdleLease(opts.idleTimeoutMs ?? 18e4, () => {
1542
+ close().catch(() => void 0).then(() => opts.onIdleClose?.(device));
1543
+ });
1544
+ const touchingBackend = new Proxy(opts.backend, { get(target, prop, receiver) {
1545
+ const value = Reflect.get(target, prop, receiver);
1546
+ if (typeof value !== "function") return value;
1547
+ return (...args) => {
1548
+ if (status === "running") lease.touch();
1549
+ return value.apply(target, args);
1550
+ };
1551
+ } });
1552
+ const core = opts.coreFactory?.(touchingBackend) ?? new DeviceCore(touchingBackend);
1553
+ const secrets = new SecretStore(opts.secrets);
1554
+ const assertOpen = () => {
1555
+ if (status === "closed") throw new SessionNotFoundError(`device ${opts.id} is closed`);
1556
+ };
1557
+ const toQuery = (target) => typeof target === "string" ? { label: target } : target;
1558
+ const device = {
1559
+ id: opts.id,
1560
+ platform: opts.platform,
1561
+ name: opts.name,
1562
+ backendName: opts.backend.backendName,
1563
+ capabilities: opts.backend.capabilities,
1564
+ backend: touchingBackend,
1565
+ createdByUs: opts.createdByUs,
1566
+ get status() {
1567
+ return status;
1568
+ },
1569
+ get isClosed() {
1570
+ return status === "closed";
1571
+ },
1572
+ extendLease(ms) {
1573
+ if (status === "running") lease.extend(ms);
1574
+ },
1575
+ close,
1576
+ [Symbol.asyncDispose]: close,
1577
+ observe() {
1578
+ assertOpen();
1579
+ return buildObserveResult(core, secrets);
1580
+ },
1581
+ tap(target, actOpts = {}) {
1582
+ assertOpen();
1583
+ return executeAction(core, {
1584
+ formatVersion: 0,
1585
+ verb: "tap",
1586
+ target: toQuery(target)
1587
+ }, actOpts, secrets);
1588
+ },
1589
+ type(text, actOpts = {}) {
1590
+ assertOpen();
1591
+ const { field, submit, ...rest } = actOpts;
1592
+ const action = field === void 0 ? {
1593
+ formatVersion: 0,
1594
+ verb: "type",
1595
+ params: {
1596
+ text,
1597
+ submit
1598
+ }
1599
+ } : {
1600
+ formatVersion: 0,
1601
+ verb: "fill",
1602
+ target: toQuery(field),
1603
+ params: {
1604
+ text,
1605
+ submit
1606
+ }
1607
+ };
1608
+ return executeAction(core, action, rest, secrets);
1609
+ },
1610
+ act(action, actOpts = {}) {
1611
+ assertOpen();
1612
+ return executeAction(core, action, actOpts, secrets);
1613
+ },
1614
+ apps: {
1615
+ open(app, o = {}) {
1616
+ assertOpen();
1617
+ const action = o.url === void 0 ? {
1618
+ formatVersion: 0,
1619
+ verb: "openApp",
1620
+ params: {
1621
+ app,
1622
+ relaunch: o.relaunch
1623
+ }
1624
+ } : {
1625
+ formatVersion: 0,
1626
+ verb: "openUrl",
1627
+ params: {
1628
+ app,
1629
+ url: o.url
1630
+ }
1631
+ };
1632
+ return executeAction(core, action, { signal: o.signal }, secrets);
1633
+ },
1634
+ list(o = {}) {
1635
+ assertOpen();
1636
+ return core.listApps();
1637
+ },
1638
+ current() {
1639
+ return core.currentApp();
1640
+ }
1641
+ },
1642
+ screen: {
1643
+ scroll(direction, o = {}) {
1644
+ assertOpen();
1645
+ return executeAction(core, {
1646
+ formatVersion: 0,
1647
+ verb: "scroll",
1648
+ params: { direction }
1649
+ }, { signal: o.signal }, secrets);
1650
+ },
1651
+ async screenshot(o) {
1652
+ assertOpen();
1653
+ return {
1654
+ success: true,
1655
+ message: `screenshot saved`,
1656
+ path: await core.screenshot(o.path)
1657
+ };
1658
+ },
1659
+ waitForText(text, o = {}) {
1660
+ assertOpen();
1661
+ return executeAction(core, {
1662
+ formatVersion: 0,
1663
+ verb: "waitForText",
1664
+ params: { text }
1665
+ }, {
1666
+ signal: o.signal,
1667
+ timeoutMs: o.timeoutMs
1668
+ }, secrets);
1669
+ },
1670
+ alert(action, o = {}) {
1671
+ assertOpen();
1672
+ if (action === "get") return core.handleAlert("get").then((r) => ({
1673
+ success: r.present,
1674
+ message: r.present ? `alert: ${r.description ?? ""}` : "no system alert is showing"
1675
+ }));
1676
+ return executeAction(core, {
1677
+ formatVersion: 0,
1678
+ verb: "alert",
1679
+ params: { alertAction: action }
1680
+ }, { signal: o.signal }, secrets);
1681
+ },
1682
+ back(o = {}) {
1683
+ assertOpen();
1684
+ return executeAction(core, {
1685
+ formatVersion: 0,
1686
+ verb: "back"
1687
+ }, { signal: o.signal }, secrets);
1688
+ },
1689
+ home(o = {}) {
1690
+ assertOpen();
1691
+ return executeAction(core, {
1692
+ formatVersion: 0,
1693
+ verb: "home"
1694
+ }, { signal: o.signal }, secrets);
1695
+ }
1696
+ },
1697
+ secrets
1698
+ };
1699
+ return device;
1700
+ }
1701
+ //#endregion
1702
+ //#region src/backends/android-hierarchy.ts
1703
+ /** `[x1,y1][x2,y2]` → a top-left Rect. Returns undefined if unparseable. */
1704
+ function parseBounds(bounds) {
1705
+ const m = bounds?.match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
1706
+ if (!m) return void 0;
1707
+ const x1 = Number(m[1]);
1708
+ const y1 = Number(m[2]);
1709
+ const x2 = Number(m[3]);
1710
+ const y2 = Number(m[4]);
1711
+ return {
1712
+ x: x1,
1713
+ y: y1,
1714
+ width: x2 - x1,
1715
+ height: y2 - y1
1716
+ };
1717
+ }
1718
+ /** `android.widget.Button` → `Button`; keeps a bare token as-is. */
1719
+ function shortType(className) {
1720
+ if (!className) return void 0;
1721
+ const tail = className.split(".").pop();
1722
+ return tail && tail.length > 0 ? tail : className;
1723
+ }
1724
+ /**
1725
+ * Normalise Android widget classes to the harness's canonical (iOS-named)
1726
+ * element types, so every layer above — inputFields(), setField(), the
1727
+ * interactive/editable sets in the SDK — works unchanged on Android.
1728
+ *
1729
+ * This is load-bearing: without it `type --field` reports "no editable text
1730
+ * field" on a real Android because `AutoCompleteTextView` never matches the
1731
+ * SDK's EDITABLE set ({SearchField, TextField, SecureTextField}). Measured on
1732
+ * a OnePlus Nord: the Settings search box is an AutoCompleteTextView.
1733
+ */
1734
+ function canonicalType(shortName, attrs) {
1735
+ if (!shortName) return void 0;
1736
+ const isPassword = attrs.password === "true";
1737
+ switch (shortName) {
1738
+ case "EditText":
1739
+ case "AutoCompleteTextView":
1740
+ case "MultiAutoCompleteTextView":
1741
+ case "SearchView":
1742
+ case "TextInputEditText": return isPassword ? "SecureTextField" : "TextField";
1743
+ case "Switch":
1744
+ case "SwitchCompat":
1745
+ case "CheckBox":
1746
+ case "ToggleButton":
1747
+ case "RadioButton": return "Switch";
1748
+ case "SeekBar": return "Slider";
1749
+ case "ImageButton": return "Button";
1750
+ default:
1751
+ if (attrs.clickable === "true") return "Button";
1752
+ return shortName;
1753
+ }
1754
+ }
1755
+ const ATTR = /([\w:-]+)="([^"]*)"/g;
1756
+ function attrs(fragment) {
1757
+ const out = {};
1758
+ for (const m of fragment.matchAll(ATTR)) {
1759
+ const key = m[1];
1760
+ const val = m[2];
1761
+ if (key !== void 0 && val !== void 0) out[key] = decodeEntities(val);
1762
+ }
1763
+ return out;
1764
+ }
1765
+ function decodeEntities(s) {
1766
+ return s.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'");
1767
+ }
1768
+ const isTrue = (v) => v === "true";
1769
+ /**
1770
+ * Parse a uiautomator XML dump. Each <node> becomes a SnapshotNode with a
1771
+ * fresh `@aN` ref. When `interactiveOnly`, keep only nodes that are actually
1772
+ * actionable (clickable / long-clickable / a focusable field) or that carry a
1773
+ * label — the rest is layout scaffolding an agent should not try to tap.
1774
+ */
1775
+ function parseHierarchy(xml, opts = {}) {
1776
+ const nodes = [];
1777
+ const rects = /* @__PURE__ */ new Map();
1778
+ let packageName;
1779
+ let index = 0;
1780
+ for (const m of xml.matchAll(/<node\b([^>]*?)\/?>/g)) {
1781
+ const fragment = m[1];
1782
+ if (fragment === void 0) continue;
1783
+ const a = attrs(fragment);
1784
+ if (!packageName && a.package) packageName = a.package;
1785
+ const label = a.text || a["content-desc"] || void 0;
1786
+ const clickable = isTrue(a.clickable) || isTrue(a["long-clickable"]);
1787
+ const focusableField = isTrue(a.focusable) && (label !== void 0 || isTrue(a.editable));
1788
+ if (opts.interactiveOnly && !clickable && !focusableField && !label) continue;
1789
+ index += 1;
1790
+ const ref = `@a${index}`;
1791
+ const rect = parseBounds(a.bounds);
1792
+ if (rect) rects.set(ref, rect);
1793
+ const node = {
1794
+ ref,
1795
+ type: canonicalType(shortType(a.class), a),
1796
+ role: canonicalType(shortType(a.class), a),
1797
+ ...label !== void 0 ? { label } : {},
1798
+ ...a.text && a.text !== label ? { value: a.text } : {},
1799
+ ...a["resource-id"] ? { identifier: a["resource-id"] } : {},
1800
+ enabled: a.enabled ? isTrue(a.enabled) : true,
1801
+ selected: isTrue(a.selected),
1802
+ focused: isTrue(a.focused),
1803
+ ...rect ? { rect } : {},
1804
+ ...a.enabled && !isTrue(a.enabled) ? { interactionBlocked: "disabled" } : {}
1805
+ };
1806
+ nodes.push(node);
1807
+ }
1808
+ return {
1809
+ nodes,
1810
+ rects,
1811
+ ...packageName ? { packageName } : {}
1812
+ };
1813
+ }
1814
+ //#endregion
1815
+ //#region src/backends/android.ts
1816
+ /** Android keycodes used below (KeyEvent constants). */
1817
+ const KEYCODE = {
1818
+ home: 3,
1819
+ back: 4,
1820
+ enter: 66,
1821
+ del: 67,
1822
+ moveEnd: 123
1823
+ };
1824
+ /** Where uiautomator writes its dump: /data/local/tmp round-trips on scoped-storage builds, /sdcard does not. */
1825
+ const DUMP_PATH = "/data/local/tmp/phone-use-dump.xml";
1826
+ /** A package id as Android accepts it: dotted Java-style segments. */
1827
+ const PACKAGE_RE = /^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/;
1828
+ /** `com.pkg/.Activity` as `cmd package resolve-activity --brief` prints it. */
1829
+ const ACTIVITY_RE = /^[\w.]+\/[\w.$]+$/;
1830
+ /** Characters that need no quoting for the device's /bin/sh. */
1831
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
1832
+ /**
1833
+ * Quote one argument for the device shell. `adb shell` does not escape its
1834
+ * arguments, so this is the injection boundary for every device command.
1835
+ */
1836
+ function shellQuote(arg) {
1837
+ if (arg.length > 0 && SHELL_SAFE.test(arg)) return arg;
1838
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
1839
+ }
1840
+ /** True when every char is printable ASCII — the range `input text` can deliver. */
1841
+ function isPrintableAscii(text) {
1842
+ for (let i = 0; i < text.length; i += 1) {
1843
+ const c = text.charCodeAt(i);
1844
+ if (c < 32 || c > 126) return false;
1845
+ }
1846
+ return true;
1847
+ }
1848
+ const pExecFile = promisify(execFile);
1849
+ const defaultBinaryExecRunner = async (file, args, opts) => {
1850
+ const { stdout } = await pExecFile(file, args, {
1851
+ encoding: "buffer",
1852
+ maxBuffer: 64 * 1024 * 1024,
1853
+ ...opts?.timeoutMs === void 0 ? {} : { timeout: opts.timeoutMs }
1854
+ });
1855
+ return new Uint8Array(stdout);
1856
+ };
1857
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1858
+ /**
1859
+ * Drive an Android device or emulator over adb. Implements the
1860
+ * {@link DeviceBackend} contract with uiautomator for the tree, screencap for
1861
+ * pixels, and `input` for gestures. Stateless across calls except the ref → rect
1862
+ * map from the last snapshot (what makes press-by-ref work).
1863
+ */
1864
+ var AndroidBackend = class AndroidBackend extends BaseDeviceBackend {
1865
+ serial;
1866
+ adbBin;
1867
+ timeoutMs;
1868
+ exec;
1869
+ execBinary;
1870
+ sleep;
1871
+ rects = /* @__PURE__ */ new Map();
1872
+ sizeCache = null;
1873
+ launchablesCache = null;
1874
+ constructor(opts = {}) {
1875
+ super("android-adb", [
1876
+ "snapshot",
1877
+ "screenshot",
1878
+ "press",
1879
+ "longPress",
1880
+ "fill",
1881
+ "type",
1882
+ "key",
1883
+ "scroll",
1884
+ "pan",
1885
+ "waitForText",
1886
+ "home",
1887
+ "back",
1888
+ "openApp",
1889
+ "openUrl",
1890
+ "listApps",
1891
+ "closeSession"
1892
+ ]);
1893
+ this.serial = opts.serial;
1894
+ this.adbBin = opts.adbBin ?? "adb";
1895
+ this.timeoutMs = opts.commandTimeoutMs ?? 3e4;
1896
+ this.exec = opts.exec ?? defaultExecRunner;
1897
+ this.execBinary = opts.execBinary ?? defaultBinaryExecRunner;
1898
+ this.sleep = opts.sleep ?? defaultSleep;
1899
+ }
1900
+ withSerial(args) {
1901
+ return this.serial ? [
1902
+ "-s",
1903
+ this.serial,
1904
+ ...args
1905
+ ] : args;
1906
+ }
1907
+ /** Normalize an exec failure into the PhoneUseError the contract promises. */
1908
+ fail(what, err) {
1909
+ if (err instanceof PhoneUseError) return err;
1910
+ if (isExecError(err)) {
1911
+ const stderr = (err.stderr ?? "").trim();
1912
+ if (err.killed || err.signal) return new TimeoutError(`adb ${what} timed out after ${this.timeoutMs}ms`, { cause: err });
1913
+ if (err.code === "ENOENT") return new ActionFailedError(`adb not found at "${this.adbBin}" — install the Android platform-tools or pass adbBin`, { cause: err });
1914
+ if (/not found|no devices\/emulators|device offline|more than one device|unauthorized/i.test(stderr)) return new DeviceNotFoundError(`adb ${what}: ${stderr}`, { cause: err });
1915
+ return new ActionFailedError(`adb ${what} failed (exit ${String(err.code ?? "?")}): ${stderr || err.message}`, {
1916
+ details: { backendCode: String(err.code ?? "EXEC") },
1917
+ cause: err
1918
+ });
1919
+ }
1920
+ return new ActionFailedError(`adb ${what} failed: ${String(err)}`, { cause: err });
1921
+ }
1922
+ /** Run an adb command, returning stdout text. */
1923
+ async adb(args) {
1924
+ try {
1925
+ return (await this.exec(this.adbBin, this.withSerial(args), { timeoutMs: this.timeoutMs })).stdout;
1926
+ } catch (err) {
1927
+ throw this.fail(args.slice(0, 2).join(" "), err);
1928
+ }
1929
+ }
1930
+ /** `adb shell <argv>` — each argument quoted for the device shell. */
1931
+ shell(...argv) {
1932
+ return this.adb(["shell", argv.map(shellQuote).join(" ")]);
1933
+ }
1934
+ /** `adb shell <literal>` for the few CONSTANT commands that need a pipe. Never pass input. */
1935
+ shellRaw(literal) {
1936
+ return this.adb(["shell", literal]);
1937
+ }
1938
+ center(ref) {
1939
+ const rect = this.rects.get(ref);
1940
+ if (!rect) throw new ActionFailedError(`unknown element ${ref} — take a fresh snapshot first`);
1941
+ return {
1942
+ x: Math.round(rect.x + rect.width / 2),
1943
+ y: Math.round(rect.y + rect.height / 2)
1944
+ };
1945
+ }
1946
+ /** The screen size input coordinates map to (an `Override size` wins over `Physical size`). */
1947
+ async screenSize() {
1948
+ if (this.sizeCache) return this.sizeCache;
1949
+ const out = await this.shell("wm", "size");
1950
+ const m = out.match(/Override size:\s*(\d+)x(\d+)/) ?? out.match(/Physical size:\s*(\d+)x(\d+)/);
1951
+ if (!m) throw new ActionFailedError(`could not read screen size from: ${out.trim()}`);
1952
+ this.sizeCache = {
1953
+ width: Number(m[1]),
1954
+ height: Number(m[2])
1955
+ };
1956
+ return this.sizeCache;
1957
+ }
1958
+ async snapshot(opts) {
1959
+ const size = await this.screenSize();
1960
+ let xml = "";
1961
+ let lastError;
1962
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1963
+ try {
1964
+ await this.shell("uiautomator", "dump", DUMP_PATH);
1965
+ xml = await this.adb([
1966
+ "exec-out",
1967
+ "cat",
1968
+ DUMP_PATH
1969
+ ]);
1970
+ } catch (err) {
1971
+ lastError = err;
1972
+ }
1973
+ if (xml.includes("<node")) break;
1974
+ await this.sleep(400);
1975
+ }
1976
+ if (!xml.includes("<node")) throw new ActionFailedError("uiautomator dump produced no UI nodes after 3 attempts — the UI may be mid-transition or uiautomator may be wedged (try `adb shell uiautomator dump` by hand)", { cause: lastError });
1977
+ const parsed = parseHierarchy(xml, { interactiveOnly: opts?.interactiveOnly ?? false });
1978
+ this.rects = parsed.rects;
1979
+ const appName = await this.foregroundPackage().catch(() => void 0) ?? parsed.packageName;
1980
+ return {
1981
+ nodes: [{
1982
+ ref: "@a0",
1983
+ type: "Application",
1984
+ role: "Application",
1985
+ label: appName ?? "android",
1986
+ rect: {
1987
+ x: 0,
1988
+ y: 0,
1989
+ width: size.width,
1990
+ height: size.height
1991
+ }
1992
+ }, ...parsed.nodes],
1993
+ ...appName ? {
1994
+ appName,
1995
+ appBundleId: appName
1996
+ } : {}
1997
+ };
1998
+ }
1999
+ /** The package of the resumed (frontmost) activity, for the snapshot header. */
2000
+ async foregroundPackage() {
2001
+ return (await this.shellRaw("dumpsys activity activities | grep -m1 ResumedActivity")).match(/\su0\s+([\w.]+)\//)?.[1];
2002
+ }
2003
+ async screenshot(opts) {
2004
+ let png;
2005
+ try {
2006
+ png = await this.execBinary(this.adbBin, this.withSerial([
2007
+ "exec-out",
2008
+ "screencap",
2009
+ "-p"
2010
+ ]), { timeoutMs: this.timeoutMs });
2011
+ } catch (err) {
2012
+ throw this.fail("screencap", err);
2013
+ }
2014
+ await writeFile(opts.path, png);
2015
+ return { path: opts.path };
2016
+ }
2017
+ async press(target) {
2018
+ const { x, y } = "ref" in target ? this.center(target.ref) : target;
2019
+ await this.shell("input", "tap", String(Math.round(x)), String(Math.round(y)));
2020
+ }
2021
+ async longPress(ref, durationMs = 700) {
2022
+ const { x, y } = this.center(ref);
2023
+ await this.shell("input", "swipe", String(x), String(y), String(x), String(y), String(Math.max(400, durationMs)));
2024
+ }
2025
+ async fill(ref, text) {
2026
+ const { x, y } = this.center(ref);
2027
+ await this.shell("input", "tap", String(x), String(y));
2028
+ await this.sleep(120);
2029
+ await this.clearFocusedField();
2030
+ await this.typeText(text);
2031
+ }
2032
+ /**
2033
+ * Empty the currently-focused text field. Ctrl+A then DEL (`input
2034
+ * keycombination`, Android 12+); a short MOVE_END + backspace sweep stays as
2035
+ * the fallback for the rare field that ignores the select combo. Replaces a
2036
+ * 60-backspace sweep that left residue on long fields and made repeated
2037
+ * `type`s ACCUMULATE, sending the agent into retry loops.
2038
+ */
2039
+ async clearFocusedField() {
2040
+ await this.shell("input", "keycombination", "113", "29").catch(() => void 0);
2041
+ await this.shell("input", "keyevent", String(KEYCODE.del));
2042
+ await this.shell("input", "keyevent", String(KEYCODE.moveEnd));
2043
+ for (let i = 0; i < 8; i += 1) await this.shell("input", "keyevent", String(KEYCODE.del));
2044
+ }
2045
+ async typeText(text) {
2046
+ if (!text) return;
2047
+ for (const [i, line] of text.split("\n").entries()) {
2048
+ if (i > 0) await this.shell("input", "keyevent", String(KEYCODE.enter));
2049
+ for (const [j, chunk] of line.split("\b").entries()) {
2050
+ if (j > 0) await this.shell("input", "keyevent", String(KEYCODE.del));
2051
+ if (!chunk) continue;
2052
+ if (isPrintableAscii(chunk)) for (let at = 0; at < chunk.length; at += 400) await this.shell("input", "text", chunk.slice(at, at + 400));
2053
+ else await this.typeUnicode(chunk);
2054
+ }
2055
+ }
2056
+ }
2057
+ /** The IME id of senzhk/ADBKeyBoard, the de-facto Unicode input path for adb. */
2058
+ static ADB_IME = "com.android.adbkeyboard/.AdbIME";
2059
+ /**
2060
+ * Type non-ASCII via the ADB Keyboard broadcast. The IME must be the ACTIVE
2061
+ * keyboard; some OEM builds deny the shell WRITE_SECURE_SETTINGS so `ime set`
2062
+ * cannot switch it. We try (works on stock builds) and restore the previous
2063
+ * keyboard afterwards when we did the switching. Never a silent drop.
2064
+ */
2065
+ async typeUnicode(text) {
2066
+ const ime = AndroidBackend.ADB_IME;
2067
+ if (!(await this.shell("pm", "list", "packages", "com.android.adbkeyboard").catch(() => "")).includes("com.android.adbkeyboard")) throw new ActionFailedError(`Android cannot type non-ASCII text ("${text.slice(0, 20)}…") over adb: the platform's own \`input text\` rejects it and no built-in alternative exists. ASCII works. For accented or non-Latin text, tap the field and use the on-screen keyboard (tap the keys), or paste text the user already has on the clipboard. (Dev/test devices only: the ADB Keyboard IME — github.com/senzhk/ADBKeyBoard — enables Unicode over adb.)`);
2068
+ const previous = (await this.shell("settings", "get", "secure", "default_input_method").catch(() => "")).trim();
2069
+ let switched = false;
2070
+ if (previous !== ime) {
2071
+ await this.shell("ime", "enable", ime).catch(() => void 0);
2072
+ await this.shell("ime", "set", ime).catch(() => void 0);
2073
+ switched = (await this.shell("settings", "get", "secure", "default_input_method").catch(() => "")).trim() === ime;
2074
+ if (!switched) throw new ActionFailedError("Android cannot type non-ASCII text over adb on this device: the ADB Keyboard IME is installed but the OS blocks adb from activating it (WRITE_SECURE_SETTINGS denied). ASCII works. For accented or non-Latin text, tap the field and use the on-screen keyboard, or paste from the clipboard. (Dev/test: enable ADB Keyboard once in Settings → Languages & input → Manage keyboards.)");
2075
+ await this.sleep(300);
2076
+ }
2077
+ try {
2078
+ const b64 = Buffer.from(text, "utf8").toString("base64");
2079
+ await this.shell("am", "broadcast", "-a", "ADB_INPUT_B64", "--es", "msg", b64);
2080
+ await this.sleep(150);
2081
+ } finally {
2082
+ if (switched && previous) await this.shell("ime", "set", previous).catch(() => void 0);
2083
+ }
2084
+ }
2085
+ async pressKey(_key) {
2086
+ await this.shell("input", "keyevent", String(KEYCODE.enter));
2087
+ }
2088
+ async scroll(direction) {
2089
+ const { width, height } = await this.screenSize();
2090
+ const imeShown = (await this.shell("dumpsys", "input_method").catch(() => "")).includes("mInputShown=true");
2091
+ const cx = Math.round(width / 2);
2092
+ const cy = imeShown ? Math.round(height * .3) : Math.round(height / 2);
2093
+ const dx = Math.round(width * .35);
2094
+ const dy = imeShown ? Math.round(height * .15) : Math.round(height * .35);
2095
+ const from = {
2096
+ up: [cx, cy - dy],
2097
+ down: [cx, cy + dy],
2098
+ left: [cx - dx, cy],
2099
+ right: [cx + dx, cy]
2100
+ };
2101
+ const to = {
2102
+ up: [cx, cy + dy],
2103
+ down: [cx, cy - dy],
2104
+ left: [cx + dx, cy],
2105
+ right: [cx - dx, cy]
2106
+ };
2107
+ const [fx, fy] = from[direction];
2108
+ const [tx, ty] = to[direction];
2109
+ await this.shell("input", "swipe", String(fx), String(fy), String(tx), String(ty), "300");
2110
+ }
2111
+ async pan(x, y, dx, dy, durationMs = 300) {
2112
+ await this.shell("input", "swipe", String(Math.round(x)), String(Math.round(y)), String(Math.round(x + dx)), String(Math.round(y + dy)), String(Math.max(50, durationMs)));
2113
+ }
2114
+ async waitForText(text, timeoutMs = 8e3) {
2115
+ const deadline = Date.now() + timeoutMs;
2116
+ const needle = text.toLowerCase();
2117
+ for (;;) {
2118
+ if ((await this.snapshot()).nodes.some((n) => (n.label ?? "").toLowerCase().includes(needle))) return;
2119
+ if (Date.now() >= deadline) throw new TimeoutError(`"${text}" did not appear within ${timeoutMs}ms`);
2120
+ await this.sleep(500);
2121
+ }
2122
+ }
2123
+ async home() {
2124
+ await this.shell("input", "keyevent", String(KEYCODE.home));
2125
+ }
2126
+ async back() {
2127
+ await this.shell("input", "keyevent", String(KEYCODE.back));
2128
+ }
2129
+ /** Packages that expose a launcher icon — the set `open <name>` can resolve to. */
2130
+ async launchables() {
2131
+ if (this.launchablesCache) return this.launchablesCache;
2132
+ const out = await this.shell("cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER").catch(() => "");
2133
+ const pkgs = [...new Set([...out.matchAll(/packageName=([\w.]+)/g)].map((m) => m[1]))];
2134
+ if (pkgs.length) this.launchablesCache = pkgs;
2135
+ return pkgs;
2136
+ }
2137
+ /**
2138
+ * Resolve a human app name to an installed package: agents say "Markor" or
2139
+ * "Simple Calendar", `am` needs `net.gsantner.markor`. Score each launchable
2140
+ * package by how many of the query's words appear in its id; best (shortest
2141
+ * on a tie) wins. An exact package id passes straight through. Returns
2142
+ * undefined when nothing matches and the query is not a package id at all.
2143
+ */
2144
+ async resolvePackage(query) {
2145
+ const launch = await this.launchables();
2146
+ if (launch.includes(query)) return query;
2147
+ if (PACKAGE_RE.test(query)) return query;
2148
+ const norm = (v) => v.toLowerCase().replace(/[^a-z0-9]/g, "");
2149
+ const words = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
2150
+ let best = null;
2151
+ for (const pkg of launch) {
2152
+ const np = norm(pkg);
2153
+ const score = words.filter((w) => np.includes(w)).length;
2154
+ if (score === 0) continue;
2155
+ if (!best || score > best.score || score === best.score && pkg.length < best.pkg.length) best = {
2156
+ pkg,
2157
+ score
2158
+ };
2159
+ }
2160
+ return best?.pkg;
2161
+ }
2162
+ async openApp(opts) {
2163
+ if (opts.url) {
2164
+ await this.shell("am", "start", "-a", "android.intent.action.VIEW", "-d", opts.url);
2165
+ return {};
2166
+ }
2167
+ if (!opts.app) throw new ActionFailedError("openApp needs an app name/package id or a url");
2168
+ const pkg = await this.resolvePackage(opts.app);
2169
+ if (!pkg) throw new DeviceNotFoundError(`no installed app matches "${opts.app}" — pass a launcher name or a package id (see listApps)`);
2170
+ if (opts.relaunch) await this.shell("am", "force-stop", pkg).catch(() => void 0);
2171
+ const activity = (await this.shell("cmd", "package", "resolve-activity", "--brief", "-c", "android.intent.category.LAUNCHER", pkg).catch(() => "")).trim().split("\n").pop()?.trim();
2172
+ if (activity && ACTIVITY_RE.test(activity)) await this.shell("am", "start", "-n", activity);
2173
+ else await this.shell("monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1").catch(() => void 0);
2174
+ return { appBundleId: pkg };
2175
+ }
2176
+ async listApps() {
2177
+ return (await this.shell("pm", "list", "packages")).split("\n").map((l) => l.replace(/^package:/, "").trim()).filter(Boolean).sort();
2178
+ }
2179
+ closeSession() {
2180
+ return Promise.resolve();
2181
+ }
2182
+ };
2183
+ /**
2184
+ * Backend factory for the `android-adb` registry entry: an
2185
+ * {@link AndroidDeviceConfig} pins the serial; anything else targets the single
2186
+ * attached device.
2187
+ */
2188
+ function createAndroidBackend(config) {
2189
+ if (config && "platform" in config) return new AndroidBackend(config.platform === "android" ? { serial: config.serial } : {});
2190
+ return new AndroidBackend(config);
2191
+ }
2192
+ /** Parse `adb devices -l` (header dropped). */
2193
+ function parseDevices(stdout) {
2194
+ return stdout.split("\n").slice(1).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("*")).map((line) => {
2195
+ const [serial = "", state = "unknown", ...rest] = line.split(/\s+/);
2196
+ const model = rest.find((f) => f.startsWith("model:"))?.slice(6);
2197
+ return {
2198
+ serial,
2199
+ state,
2200
+ ...model ? { model } : {}
2201
+ };
2202
+ }).filter((d) => d.serial.length > 0);
2203
+ }
2204
+ async function runAdb(exec, adbBin, args, timeoutMs) {
2205
+ try {
2206
+ return (await exec(adbBin, args, { timeoutMs })).stdout;
2207
+ } catch (err) {
2208
+ if (isExecError(err)) {
2209
+ if (err.killed || err.signal) throw new TimeoutError(`adb ${args[0]} timed out after ${timeoutMs}ms`, { cause: err });
2210
+ if (err.code === "ENOENT") throw new ActionFailedError(`adb not found at "${adbBin}" — install the Android platform-tools`, { cause: err });
2211
+ throw new ActionFailedError(`adb ${args[0]} failed: ${(err.stderr ?? err.message).trim()}`, { cause: err });
2212
+ }
2213
+ throw new ActionFailedError(`adb ${args[0]} failed: ${String(err)}`, { cause: err });
2214
+ }
2215
+ }
2216
+ /** Devices adb currently sees, with their state. */
2217
+ async function list(options = {}) {
2218
+ return parseDevices(await runAdb(options.exec ?? defaultExecRunner, options.adbBin ?? "adb", ["devices", "-l"], 15e3));
2219
+ }
2220
+ function finishHandle$1(row, createdByUs, opts, doClose) {
2221
+ const backend = new AndroidBackend({
2222
+ serial: row.serial,
2223
+ adbBin: opts.adbBin,
2224
+ exec: opts.exec,
2225
+ execBinary: opts.execBinary,
2226
+ sleep: opts.sleep
2227
+ });
2228
+ return createDeviceHandle({
2229
+ id: row.serial,
2230
+ platform: "android",
2231
+ name: row.model,
2232
+ backend,
2233
+ createdByUs,
2234
+ idleTimeoutMs: opts.idleTimeoutMs,
2235
+ onIdleClose: opts.onIdleClose,
2236
+ secrets: opts.secrets,
2237
+ coreFactory: opts.coreFactory,
2238
+ doClose
2239
+ });
2240
+ }
2241
+ const FIX_USB_DEBUGGING = "enable Developer options → USB debugging on the phone, connect over USB (or `adb connect <ip:port>`), and confirm with `adb devices`";
2242
+ /**
2243
+ * Attach to a device adb already sees. With a serial, that device; without,
2244
+ * the single ready device (several attached → an error naming them, so verbs
2245
+ * never land on the wrong phone). `close()` releases the handle and never
2246
+ * shuts the device down — it was yours before we connected.
2247
+ */
2248
+ async function connect$1(serial, options = {}) {
2249
+ const exec = options.exec ?? defaultExecRunner;
2250
+ const devices = await list({
2251
+ adbBin: options.adbBin,
2252
+ exec
2253
+ });
2254
+ let row;
2255
+ if (serial !== void 0) {
2256
+ row = devices.find((d) => d.serial === serial);
2257
+ if (!row) throw new DeviceNotFoundError(`no adb device with serial ${serial} — attached: ${devices.map((d) => `${d.serial} (${d.state})`).join(", ") || "none"}`);
2258
+ } else {
2259
+ const ready = devices.filter((d) => d.state === "device");
2260
+ if (ready.length === 0) {
2261
+ const unauthorized = devices.find((d) => d.state === "unauthorized");
2262
+ throw new DeviceNotFoundError(unauthorized ? `device ${unauthorized.serial} is attached but unauthorized — unlock it and accept the "Allow USB debugging" prompt` : `no Android device attached — ${FIX_USB_DEBUGGING}`);
2263
+ }
2264
+ if (ready.length > 1) throw new DeviceNotFoundError(`several Android devices attached (${ready.map((d) => d.serial).join(", ")}) — pass a serial`);
2265
+ row = ready[0];
2266
+ }
2267
+ if (row.state !== "device") throw new DeviceNotFoundError(row.state === "unauthorized" ? `device ${row.serial} is unauthorized — unlock it and accept the "Allow USB debugging" prompt` : `device ${row.serial} is ${row.state} — ${FIX_USB_DEBUGGING}`);
2268
+ return finishHandle$1(row, false, options, () => Promise.resolve());
2269
+ }
2270
+ function defaultEmulatorBin() {
2271
+ const root = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
2272
+ return root ? `${root}/emulator/emulator` : "emulator";
2273
+ }
2274
+ function defaultSpawn(file, args) {
2275
+ const child = spawn(file, args, { stdio: "ignore" });
2276
+ let exited = false;
2277
+ child.once("exit", () => {
2278
+ exited = true;
2279
+ });
2280
+ child.once("error", () => {
2281
+ exited = true;
2282
+ child.emit("exit");
2283
+ });
2284
+ return {
2285
+ kill: (signal) => {
2286
+ child.kill(signal);
2287
+ },
2288
+ once: (event, listener) => child.once(event, listener),
2289
+ get exited() {
2290
+ return exited;
2291
+ }
2292
+ };
2293
+ }
2294
+ /**
2295
+ * Boot a DEDICATED emulator instance from an AVD and return a {@link Device}
2296
+ * pinned to its serial (`emulator-<port>`). `close()` kills the instance.
2297
+ * `readOnly: true` lets several instances of one AVD run side by side, which
2298
+ * is how a cloud worker turns one golden image into N phones.
2299
+ */
2300
+ async function launch$1(options) {
2301
+ const exec = options.exec ?? defaultExecRunner;
2302
+ const sleep = options.sleep ?? defaultSleep;
2303
+ const adbBin = options.adbBin ?? "adb";
2304
+ const port = options.port ?? 5554;
2305
+ if (port % 2 !== 0 || port < 5554 || port > 5682) throw new ActionFailedError(`emulator port must be an even number in 5554..5682, got ${port}`);
2306
+ const serial = `emulator-${port}`;
2307
+ const bootTimeoutMs = options.bootTimeoutMs ?? 18e4;
2308
+ const pollMs = options.pollIntervalMs ?? 1e3;
2309
+ const spawnEmulator = options.spawn ?? defaultSpawn;
2310
+ const args = [
2311
+ "-avd",
2312
+ options.avd,
2313
+ "-port",
2314
+ String(port),
2315
+ "-no-boot-anim",
2316
+ "-no-audio",
2317
+ ...options.headless === false ? [] : ["-no-window"],
2318
+ ...options.readOnly ? ["-read-only"] : [],
2319
+ ...options.extraArgs ?? []
2320
+ ];
2321
+ const child = spawnEmulator(options.emulatorBin ?? defaultEmulatorBin(), args);
2322
+ const stop = async () => {
2323
+ await runAdb(exec, adbBin, [
2324
+ "-s",
2325
+ serial,
2326
+ "emu",
2327
+ "kill"
2328
+ ], 1e4).catch(() => void 0);
2329
+ if (child.exited) return;
2330
+ await new Promise((resolve) => {
2331
+ const timer = setTimeout(() => {
2332
+ child.kill("SIGKILL");
2333
+ resolve();
2334
+ }, 1e4);
2335
+ child.once("exit", () => {
2336
+ clearTimeout(timer);
2337
+ resolve();
2338
+ });
2339
+ });
2340
+ };
2341
+ try {
2342
+ const deadline = Date.now() + bootTimeoutMs;
2343
+ await runAdb(exec, adbBin, [
2344
+ "-s",
2345
+ serial,
2346
+ "wait-for-device"
2347
+ ], bootTimeoutMs);
2348
+ for (;;) {
2349
+ if (child.exited) throw new ActionFailedError(`emulator ${serial} exited during boot`);
2350
+ if ((await runAdb(exec, adbBin, [
2351
+ "-s",
2352
+ serial,
2353
+ "shell",
2354
+ "getprop",
2355
+ "sys.boot_completed"
2356
+ ], 15e3).catch(() => "")).trim() === "1") break;
2357
+ if (Date.now() >= deadline) throw new TimeoutError(`emulator ${serial} did not finish booting within ${bootTimeoutMs}ms`);
2358
+ await sleep(pollMs);
2359
+ }
2360
+ } catch (err) {
2361
+ await stop().catch(() => void 0);
2362
+ throw err;
2363
+ }
2364
+ return finishHandle$1({
2365
+ serial,
2366
+ state: "device",
2367
+ model: options.avd
2368
+ }, true, options, stop);
2369
+ }
2370
+ /**
2371
+ * The Android engine object (the `ios` twin): `android.connect()` for a device
2372
+ * adb already sees, `android.launch()` for a dedicated emulator instance,
2373
+ * `android.list()` to see what is attached. All return/describe the same
2374
+ * Device type the iOS engine does.
2375
+ */
2376
+ const android = {
2377
+ /** Attach to an attached device or running emulator by serial (no-arg: the single ready device). */
2378
+ connect: connect$1,
2379
+ /** Boot a dedicated emulator instance from an AVD and return a Device pinned to it. */
2380
+ launch: launch$1,
2381
+ /** Devices adb currently sees, with their state. */
2382
+ list
2383
+ };
2384
+ //#endregion
1474
2385
  //#region src/backends/cloud-sandbox.ts
1475
2386
  var CloudSandboxBackend = class extends BaseDeviceBackend {
1476
2387
  endpoint;
@@ -1555,6 +2466,24 @@ var CloudSandboxBackend = class extends BaseDeviceBackend {
1555
2466
  closeSession() {
1556
2467
  return this.rpc("closeSession");
1557
2468
  }
2469
+ /** Save the device's current state. Returns the checkpoint's id. */
2470
+ checkpoint(opts) {
2471
+ return this.rpc("checkpoint", opts);
2472
+ }
2473
+ /** Restore a checkpoint by id or label. The checkpoint survives — restore as
2474
+ * often as needed. Device identity may change server-side; the sandbox
2475
+ * endpoint/token stay valid. */
2476
+ restoreCheckpoint(ref) {
2477
+ return this.rpc("restoreCheckpoint", ref);
2478
+ }
2479
+ /** List this sandbox's checkpoints. */
2480
+ listCheckpoints() {
2481
+ return this.rpc("listCheckpoints");
2482
+ }
2483
+ /** Delete a checkpoint by id or label. */
2484
+ deleteCheckpoint(ref) {
2485
+ return this.rpc("deleteCheckpoint", ref);
2486
+ }
1558
2487
  /** Upload a zipped .app bundle (base64) and install it on the sandbox device. */
1559
2488
  /**
1560
2489
  * Upload a zipped .app as a raw stream. Prefer this over {@link installApp}:
@@ -1790,234 +2719,6 @@ var DeviceRunnerBackend = class extends BaseDeviceBackend {
1790
2719
  * `PHONE_USE_RUNNER_URL` / `PHONE_USE_RUNNER_TOKEN` when `config` is omitted). */
1791
2720
  const createDeviceRunnerBackend = (config) => new DeviceRunnerBackend(config);
1792
2721
  //#endregion
1793
- //#region src/exec.ts
1794
- const pExecFile = promisify(execFile);
1795
- const defaultExecRunner = async (file, args, opts) => {
1796
- const { stdout, stderr } = await pExecFile(file, args, {
1797
- encoding: "utf8",
1798
- maxBuffer: 16 * 1024 * 1024,
1799
- ...opts?.timeoutMs === void 0 ? {} : { timeout: opts.timeoutMs },
1800
- ...opts?.env === void 0 ? {} : { env: opts.env }
1801
- });
1802
- return {
1803
- stdout,
1804
- stderr
1805
- };
1806
- };
1807
- function isExecError(err) {
1808
- return err instanceof Error && ("code" in err || "killed" in err || "stderr" in err);
1809
- }
1810
- //#endregion
1811
- //#region src/lifecycle.ts
1812
- var IdleLease = class {
1813
- timer = null;
1814
- windowMs;
1815
- onExpire;
1816
- constructor(windowMs, onExpire) {
1817
- this.windowMs = windowMs;
1818
- this.onExpire = onExpire;
1819
- this.touch();
1820
- }
1821
- /** Re-arm with the configured window (no-op when disabled). */
1822
- touch() {
1823
- this.arm(this.windowMs);
1824
- }
1825
- /** Re-arm with a one-shot override window. */
1826
- extend(ms) {
1827
- this.arm(ms ?? this.windowMs);
1828
- }
1829
- arm(ms) {
1830
- if (this.timer) clearTimeout(this.timer);
1831
- this.timer = null;
1832
- if (ms === false) return;
1833
- const t = setTimeout(this.onExpire, ms);
1834
- t.unref?.();
1835
- this.timer = t;
1836
- }
1837
- dispose() {
1838
- if (this.timer) clearTimeout(this.timer);
1839
- this.timer = null;
1840
- }
1841
- };
1842
- /**
1843
- * Assemble a Device handle over a backend: lease/reaper, verb surface,
1844
- * close/dispose semantics. Engine authors (ios here, android in item 7b,
1845
- * phone-backend-* third parties) build on this; tests fabricate devices with
1846
- * it over a FakeBackend.
1847
- */
1848
- function createDeviceHandle(opts) {
1849
- let status = "running";
1850
- let closePromise = null;
1851
- const close = () => {
1852
- closePromise ??= (async () => {
1853
- status = "closed";
1854
- lease.dispose();
1855
- await opts.backend.closeSession().catch(() => void 0);
1856
- await opts.doClose();
1857
- })();
1858
- return closePromise;
1859
- };
1860
- const lease = new IdleLease(opts.idleTimeoutMs ?? 18e4, () => {
1861
- close().catch(() => void 0).then(() => opts.onIdleClose?.(device));
1862
- });
1863
- const touchingBackend = new Proxy(opts.backend, { get(target, prop, receiver) {
1864
- const value = Reflect.get(target, prop, receiver);
1865
- if (typeof value !== "function") return value;
1866
- return (...args) => {
1867
- if (status === "running") lease.touch();
1868
- return value.apply(target, args);
1869
- };
1870
- } });
1871
- const core = opts.coreFactory?.(touchingBackend) ?? new DeviceCore(touchingBackend);
1872
- const secrets = new SecretStore(opts.secrets);
1873
- const assertOpen = () => {
1874
- if (status === "closed") throw new SessionNotFoundError(`device ${opts.id} is closed`);
1875
- };
1876
- const toQuery = (target) => typeof target === "string" ? { label: target } : target;
1877
- const device = {
1878
- id: opts.id,
1879
- platform: opts.platform,
1880
- name: opts.name,
1881
- backendName: opts.backend.backendName,
1882
- capabilities: opts.backend.capabilities,
1883
- backend: touchingBackend,
1884
- createdByUs: opts.createdByUs,
1885
- get status() {
1886
- return status;
1887
- },
1888
- get isClosed() {
1889
- return status === "closed";
1890
- },
1891
- extendLease(ms) {
1892
- if (status === "running") lease.extend(ms);
1893
- },
1894
- close,
1895
- [Symbol.asyncDispose]: close,
1896
- observe() {
1897
- assertOpen();
1898
- return buildObserveResult(core, secrets);
1899
- },
1900
- tap(target, actOpts = {}) {
1901
- assertOpen();
1902
- return executeAction(core, {
1903
- formatVersion: 0,
1904
- verb: "tap",
1905
- target: toQuery(target)
1906
- }, actOpts, secrets);
1907
- },
1908
- type(text, actOpts = {}) {
1909
- assertOpen();
1910
- const { field, submit, ...rest } = actOpts;
1911
- const action = field === void 0 ? {
1912
- formatVersion: 0,
1913
- verb: "type",
1914
- params: {
1915
- text,
1916
- submit
1917
- }
1918
- } : {
1919
- formatVersion: 0,
1920
- verb: "fill",
1921
- target: toQuery(field),
1922
- params: {
1923
- text,
1924
- submit
1925
- }
1926
- };
1927
- return executeAction(core, action, rest, secrets);
1928
- },
1929
- act(action, actOpts = {}) {
1930
- assertOpen();
1931
- return executeAction(core, action, actOpts, secrets);
1932
- },
1933
- apps: {
1934
- open(app, o = {}) {
1935
- assertOpen();
1936
- const action = o.url === void 0 ? {
1937
- formatVersion: 0,
1938
- verb: "openApp",
1939
- params: {
1940
- app,
1941
- relaunch: o.relaunch
1942
- }
1943
- } : {
1944
- formatVersion: 0,
1945
- verb: "openUrl",
1946
- params: {
1947
- app,
1948
- url: o.url
1949
- }
1950
- };
1951
- return executeAction(core, action, { signal: o.signal }, secrets);
1952
- },
1953
- list(o = {}) {
1954
- assertOpen();
1955
- return core.listApps();
1956
- },
1957
- current() {
1958
- return core.currentApp();
1959
- }
1960
- },
1961
- screen: {
1962
- scroll(direction, o = {}) {
1963
- assertOpen();
1964
- return executeAction(core, {
1965
- formatVersion: 0,
1966
- verb: "scroll",
1967
- params: { direction }
1968
- }, { signal: o.signal }, secrets);
1969
- },
1970
- async screenshot(o) {
1971
- assertOpen();
1972
- return {
1973
- success: true,
1974
- message: `screenshot saved`,
1975
- path: await core.screenshot(o.path)
1976
- };
1977
- },
1978
- waitForText(text, o = {}) {
1979
- assertOpen();
1980
- return executeAction(core, {
1981
- formatVersion: 0,
1982
- verb: "waitForText",
1983
- params: { text }
1984
- }, {
1985
- signal: o.signal,
1986
- timeoutMs: o.timeoutMs
1987
- }, secrets);
1988
- },
1989
- alert(action, o = {}) {
1990
- assertOpen();
1991
- if (action === "get") return core.handleAlert("get").then((r) => ({
1992
- success: r.present,
1993
- message: r.present ? `alert: ${r.description ?? ""}` : "no system alert is showing"
1994
- }));
1995
- return executeAction(core, {
1996
- formatVersion: 0,
1997
- verb: "alert",
1998
- params: { alertAction: action }
1999
- }, { signal: o.signal }, secrets);
2000
- },
2001
- back(o = {}) {
2002
- assertOpen();
2003
- return executeAction(core, {
2004
- formatVersion: 0,
2005
- verb: "back"
2006
- }, { signal: o.signal }, secrets);
2007
- },
2008
- home(o = {}) {
2009
- assertOpen();
2010
- return executeAction(core, {
2011
- formatVersion: 0,
2012
- verb: "home"
2013
- }, { signal: o.signal }, secrets);
2014
- }
2015
- },
2016
- secrets
2017
- };
2018
- return device;
2019
- }
2020
- //#endregion
2021
2722
  //#region src/backends/ios.ts
2022
2723
  const UDID_RE = /^[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}$/i;
2023
2724
  function simctlArgs(setPath, args) {
@@ -2170,16 +2871,17 @@ const ios = {
2170
2871
  //#region src/index.ts
2171
2872
  /**
2172
2873
  * @phone-use/sdk — the device runtime SDK: engine-as-object lifecycle
2173
- * (ios.launch/connect → Device), Device backends, config, errors, capabilities,
2874
+ * (ios.launch/connect and android.launch/connect → Device), Device backends, config, errors, capabilities,
2174
2875
  * and the action verb surface.
2175
2876
  *
2176
2877
  * The test double (FakeBackend) lives on the "@phone-use/sdk/testing" subpath,
2177
2878
  * deliberately not re-exported here.
2178
2879
  */
2179
2880
  /** The published package version (kept in sync with package.json by the release flow). */
2180
- const VERSION = "0.4.1";
2881
+ const VERSION = "0.5.0";
2181
2882
  registerBackend("agent-device", createAgentDeviceBackend);
2883
+ registerBackend("android-adb", createAndroidBackend);
2182
2884
  //#endregion
2183
- export { ALL_CAPABILITIES, AbortedError, ActionFailedError, BaseDeviceBackend, CloudSandboxBackend, DeviceCore, DeviceInUseError, DeviceNotFoundError, DeviceRunnerBackend, PhoneUseError, SecretStore, SessionNotFoundError, TimeoutError, UnsupportedCapabilityError, VERSION, buildObserveResult, createAgentDeviceBackend, createCloudSandboxBackend, createDeviceHandle, createDeviceRunnerBackend, describeError, executeAction, getBackendFactory, ios, labelMatches, listBackends, matchInElements, registerBackend, toActions, toPhoneUseError };
2885
+ export { ALL_CAPABILITIES, AbortedError, ActionFailedError, AndroidBackend, BaseDeviceBackend, CloudSandboxBackend, DeviceCore, DeviceInUseError, DeviceNotFoundError, DeviceRunnerBackend, PhoneUseError, SecretStore, SessionNotFoundError, TimeoutError, UnsupportedCapabilityError, VERSION, android, buildObserveResult, createAgentDeviceBackend, createAndroidBackend, createCloudSandboxBackend, createDeviceHandle, createDeviceRunnerBackend, describeError, executeAction, getBackendFactory, ios, labelMatches, listBackends, matchInElements, registerBackend, toActions, toPhoneUseError };
2184
2886
 
2185
2887
  //# sourceMappingURL=index.mjs.map