mobile-automator 0.23.8

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.
Files changed (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // Normalizes config values into the types config_schema.json declares.
4
+ //
5
+ // Two entry points, one contract:
6
+ // coerceValue — a raw CLI string on the way IN to `config set`.
7
+ // normalizeConfig — a config object on the way OUT of `manager.load()`,
8
+ // healing values written before the schema existed (#136).
9
+ //
10
+ // The comma-splitting rule is what makes the setup guide's long-standing
11
+ // `mauto config set environments <env1,env2,...>` prose correct instead of
12
+ // silently producing a string.
13
+
14
+ const { declaredTypesAt, LIST_KEY_PATHS } = require('./schema');
15
+
16
+ function splitList(raw) {
17
+ return String(raw)
18
+ .split(',')
19
+ .map((s) => s.trim())
20
+ .filter((s) => s.length > 0);
21
+ }
22
+
23
+ function tryJson(raw) {
24
+ try {
25
+ return { ok: true, value: JSON.parse(raw) };
26
+ } catch (_) {
27
+ return { ok: false };
28
+ }
29
+ }
30
+
31
+ function coerceValue(dottedKey, rawValue) {
32
+ // A bare `null` argument always means JSON null, regardless of the key's
33
+ // declared type. It is `validateAt` — not this function — that decides
34
+ // whether the key is allowed to hold it: keys declared nullable accept it,
35
+ // everything else gets a loud invalid_input instead of silently storing the
36
+ // string "null" (see #136 fix-wave, Important 2).
37
+ if (String(rawValue).trim() === 'null') return null;
38
+
39
+ const types = declaredTypesAt(dottedKey);
40
+
41
+ // Undeclared key: preserve the historical lenient behavior exactly.
42
+ if (types.length === 0) {
43
+ const parsed = tryJson(rawValue);
44
+ return parsed.ok ? parsed.value : rawValue;
45
+ }
46
+
47
+ if (types.includes('array')) {
48
+ const parsed = tryJson(rawValue);
49
+ if (parsed.ok && Array.isArray(parsed.value)) return parsed.value;
50
+ return splitList(rawValue);
51
+ }
52
+
53
+ if (types.includes('string')) {
54
+ // Never let JSON.parse retype a string key (a project literally named
55
+ // "12345" stays a string).
56
+ return String(rawValue);
57
+ }
58
+
59
+ const parsed = tryJson(rawValue);
60
+ return parsed.ok ? parsed.value : rawValue;
61
+ }
62
+
63
+ function getPath(obj, parts) {
64
+ let cur = obj;
65
+ for (const part of parts) {
66
+ if (cur == null || typeof cur !== 'object') return undefined;
67
+ cur = cur[part];
68
+ }
69
+ return cur;
70
+ }
71
+
72
+ function setPath(obj, parts, value) {
73
+ let cur = obj;
74
+ for (let i = 0; i < parts.length - 1; i++) {
75
+ if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') return;
76
+ cur = cur[parts[i]];
77
+ }
78
+ cur[parts[parts.length - 1]] = value;
79
+ }
80
+
81
+ // Heals every declared list path that holds a string. Values that are already
82
+ // arrays — or are neither string nor array — are left exactly as found; this
83
+ // repairs the known drift, it does not guess at anything else.
84
+ function normalizeConfig(cfg) {
85
+ if (cfg == null || typeof cfg !== 'object') return cfg;
86
+ const out = JSON.parse(JSON.stringify(cfg));
87
+ for (const listPath of LIST_KEY_PATHS) {
88
+ const parts = listPath.split('.');
89
+ const current = getPath(out, parts);
90
+ if (typeof current === 'string') {
91
+ setPath(out, parts, splitList(current));
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ module.exports = { coerceValue, normalizeConfig, splitList };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // Workspace config manager for the `mauto` CLI.
4
+ //
5
+ // Reads/writes mobile-automator/config.json under a project root.
6
+ // A config without an explicit `mode` is treated as `platform-aware`.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { normalizeConfig } = require('./coerce');
11
+
12
+ function configDir(projectRoot) {
13
+ return path.join(projectRoot, 'mobile-automator');
14
+ }
15
+
16
+ function configPath(projectRoot) {
17
+ return path.join(configDir(projectRoot), 'config.json');
18
+ }
19
+
20
+ // Parsed config object, or null when the file is absent. Values written before
21
+ // the config schema existed are healed on the way out (a comma-joined list key
22
+ // becomes a real array) — see #136. Since set() is load-mutate-write, the next
23
+ // write persists the healed shape.
24
+ function load(projectRoot) {
25
+ const p = configPath(projectRoot);
26
+ if (!fs.existsSync(p)) return null;
27
+ return normalizeConfig(JSON.parse(fs.readFileSync(p, 'utf8')));
28
+ }
29
+
30
+ // Default to platform-aware for null/empty/legacy configs.
31
+ function resolveMode(cfg) {
32
+ return cfg && cfg.mode ? cfg.mode : 'platform-aware';
33
+ }
34
+
35
+ // Dotted-path lookup. Missing path -> undefined.
36
+ function get(projectRoot, key) {
37
+ const cfg = load(projectRoot);
38
+ if (cfg == null) return undefined;
39
+ return getPath(cfg, key);
40
+ }
41
+
42
+ // Load-or-{}, set the dotted path, write back preserving every other field as
43
+ // pretty 2-space JSON. Creates mobile-automator/ if needed.
44
+ function set(projectRoot, key, value) {
45
+ const cfg = load(projectRoot) || {};
46
+ setPath(cfg, key, value);
47
+ fs.mkdirSync(configDir(projectRoot), { recursive: true });
48
+ fs.writeFileSync(configPath(projectRoot), JSON.stringify(cfg, null, 2) + '\n');
49
+ return cfg;
50
+ }
51
+
52
+ function getPath(obj, key) {
53
+ const parts = String(key).split('.');
54
+ let cur = obj;
55
+ for (const part of parts) {
56
+ if (cur == null || typeof cur !== 'object') return undefined;
57
+ cur = cur[part];
58
+ }
59
+ return cur;
60
+ }
61
+
62
+ function setPath(obj, key, value) {
63
+ const parts = String(key).split('.');
64
+ let cur = obj;
65
+ for (let i = 0; i < parts.length - 1; i++) {
66
+ const part = parts[i];
67
+ if (cur[part] == null || typeof cur[part] !== 'object') {
68
+ cur[part] = {};
69
+ }
70
+ cur = cur[part];
71
+ }
72
+ cur[parts[parts.length - 1]] = value;
73
+ }
74
+
75
+ module.exports = { load, resolveMode, get, set, configPath, configDir };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ // Type lookup + per-key validation for the workspace config.
4
+ //
5
+ // config_schema.json is the single source of truth for what shape each config
6
+ // key holds. This module is the only reader of that fact: the coercion layer
7
+ // asks it "what type is this key?" and the CLI asks it "is this value valid?".
8
+ // Nothing else hard-codes a config key's type.
9
+ //
10
+ // Validation is deliberately PER-KEY, not whole-document: a config carrying
11
+ // unrelated pre-existing drift must not block an unrelated `config set`.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const Ajv = require('ajv');
16
+
17
+ const { formatError } = require('../schemas/format-error');
18
+
19
+ const CONFIG_SCHEMA_PATH = path.resolve(__dirname, '../schemas/config_schema.json');
20
+
21
+ const schema = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_PATH, 'utf8'));
22
+ const ajv = new Ajv({ allErrors: true, strict: false });
23
+
24
+ // Resolve a local `$ref` ("#/definitions/stringList") to a fixpoint: a
25
+ // resolved node may itself be another `$ref` (e.g. a `$ref` to a `$ref`), so
26
+ // one hop is not enough to agree with Ajv, which resolves refs fully. A
27
+ // visited-set cycle guard makes a self-referential or circular `$ref` a no-op
28
+ // (return the last-seen node) instead of an infinite loop.
29
+ function deref(node) {
30
+ const seen = new Set();
31
+ let cur = node;
32
+ while (cur && typeof cur === 'object' && typeof cur.$ref === 'string') {
33
+ if (seen.has(cur.$ref)) return cur; // circular $ref — bail with what we have
34
+ seen.add(cur.$ref);
35
+ const parts = cur.$ref.replace(/^#\//, '').split('/');
36
+ let target = schema;
37
+ for (const part of parts) {
38
+ if (target == null) return cur; // unresolvable pointer — leave as-is
39
+ target = target[part];
40
+ }
41
+ if (!target) return cur;
42
+ cur = target;
43
+ }
44
+ return cur;
45
+ }
46
+
47
+ // Walk `properties` down a dotted path. Undeclared path -> null.
48
+ function subschemaAt(dottedKey) {
49
+ const parts = String(dottedKey).split('.');
50
+ let node = schema;
51
+ for (const part of parts) {
52
+ node = deref(node);
53
+ if (!node || !node.properties || !node.properties[part]) return null;
54
+ node = node.properties[part];
55
+ }
56
+ const resolved = deref(node);
57
+ return resolved || null;
58
+ }
59
+
60
+ function declaredTypesAt(dottedKey) {
61
+ const sub = subschemaAt(dottedKey);
62
+ if (!sub || sub.type === undefined) return [];
63
+ return Array.isArray(sub.type) ? [...sub.type] : [sub.type];
64
+ }
65
+
66
+ // Every array-typed dotted path in the schema, flat and nested. Drives both
67
+ // read-side healing and the structural lint guard.
68
+ function collectListPaths(node, prefix, out) {
69
+ const resolved = deref(node);
70
+ if (!resolved || !resolved.properties) return out;
71
+ for (const [name, child] of Object.entries(resolved.properties)) {
72
+ const childPath = prefix ? `${prefix}.${name}` : name;
73
+ const rc = deref(child);
74
+ if (!rc) continue;
75
+ if (rc.type === 'array') out.push(childPath);
76
+ else if (rc.type === 'object' || rc.properties) collectListPaths(rc, childPath, out);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ const LIST_KEY_PATHS = collectListPaths(schema, '', []);
82
+
83
+ // Compiled per-path validators, built lazily and cached.
84
+ const compiled = new Map();
85
+
86
+ function validateAt(dottedKey, value) {
87
+ const sub = subschemaAt(dottedKey);
88
+ if (!sub) return { valid: true, errors: [] }; // undeclared key -> always allowed
89
+ if (!compiled.has(dottedKey)) {
90
+ // `sub` may be an object-typed node (e.g. "app", "knowledge") whose OWN
91
+ // top-level $ref was resolved by subschemaAt's final deref, but whose
92
+ // nested properties (e.g. app.ios_bundle_id) still carry an unresolved
93
+ // local `#/definitions/...` $ref. Compiling `sub` standalone would make
94
+ // that pointer resolve against `sub`'s own (definitions-less) root and
95
+ // throw "can't resolve reference". Carrying the root `definitions`
96
+ // alongside makes every local ref in this schema resolve correctly no
97
+ // matter how deep the compiled subschema sits.
98
+ compiled.set(dottedKey, ajv.compile({ ...sub, definitions: schema.definitions }));
99
+ }
100
+ const fn = compiled.get(dottedKey);
101
+ const valid = fn(value);
102
+ if (valid) return { valid: true, errors: [] };
103
+ // formatError prefixes a root-level error with "(root)" — meaningful for
104
+ // the scenario validator, whose messages otherwise carry no subject. Here
105
+ // the CLI message already names the key ("invalid value for config key
106
+ // ..."), so a bare "(root)" only duplicates it; strip that one case. A
107
+ // non-root instancePath (e.g. "/android_package" on an object-typed key
108
+ // like "app") still adds information the key name alone doesn't, so it is
109
+ // left as formatError produced it. Fixed here, not in format-error.js,
110
+ // because the scenario validator's messages depend on that prefix.
111
+ const errors = (fn.errors || []).map((err) => {
112
+ const msg = formatError(err);
113
+ return err.instancePath ? msg : msg.replace(/^\(root\)\s*/, '');
114
+ });
115
+ return { valid: false, errors };
116
+ }
117
+
118
+ module.exports = {
119
+ CONFIG_SCHEMA_PATH,
120
+ subschemaAt,
121
+ declaredTypesAt,
122
+ LIST_KEY_PATHS,
123
+ validateAt,
124
+ };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth binding every scenario-schema action to HOW it is
4
+ // executed. Three things must agree and never drift: the schema's action
5
+ // enums (`step.action` + `preconditions.device_actions`), the `mauto` verb
6
+ // surface (cli.js + DeviceBridge), and the execute guide's mapping table.
7
+ // `tests/lint/action-coverage.test.js` derives its assertions from this map,
8
+ // so an action that loses (or never had) a faithful execution path fails the
9
+ // build instead of silently degrading at replay time.
10
+ //
11
+ // `resolution` declares the execution contract for each action:
12
+ // 'verb' — a dedicated one-shot `mauto` verb backed by a DeviceBridge
13
+ // method that calls a single mobile-mcp primitive.
14
+ // 'semantic' — invoked via `mauto press <action>`; resolved to per-platform
15
+ // mechanics by src/device/semantic-press (see ACTION_METHOD).
16
+ // 'composed' — no dedicated verb; the agent composes it from existing verbs
17
+ // (e.g. poll `mauto elements`, repeat `mauto swipe`). The
18
+ // execute guide documents the composition.
19
+ // 'unsupported' — mobile-mcp 0.0.55 exposes no primitive for it. Not
20
+ // mechanically executable; the guide must NOT promise a verb,
21
+ // and the agent reports it honestly / handles it manually.
22
+ //
23
+ // For 'verb' entries, `args` documents the mobile-mcp argument keys the bridge
24
+ // method must pass — deliberately explicit because the keys are inconsistent
25
+ // across the engine (launch=packageName, uninstall=bundle_id, install=path).
26
+
27
+ const ACTION_CATALOG = {
28
+ // --- direct verbs (engine-backed) --------------------------------------
29
+ tap: {
30
+ resolution: 'verb', verb: 'tap', bridge: 'tap',
31
+ primitive: 'mobile_click_on_screen_at_coordinates', args: ['x', 'y'],
32
+ },
33
+ long_press: {
34
+ resolution: 'verb', verb: 'long-press', bridge: 'longPress',
35
+ primitive: 'mobile_long_press_on_screen_at_coordinates', args: ['x', 'y', 'duration?'],
36
+ },
37
+ double_tap: {
38
+ resolution: 'verb', verb: 'double-tap', bridge: 'doubleTap',
39
+ primitive: 'mobile_double_tap_on_screen', args: ['x', 'y'],
40
+ },
41
+ type: {
42
+ resolution: 'verb', verb: 'type', bridge: 'type',
43
+ primitive: 'mobile_type_keys', args: ['text', 'submit'],
44
+ },
45
+ swipe: {
46
+ resolution: 'verb', verb: 'swipe', bridge: 'swipe',
47
+ primitive: 'mobile_swipe_on_screen', args: ['direction'],
48
+ },
49
+ press_button: {
50
+ resolution: 'verb', verb: 'press', bridge: 'pressButton',
51
+ primitive: 'mobile_press_button', args: ['button'],
52
+ },
53
+ launch_app: {
54
+ resolution: 'verb', verb: 'launch', bridge: 'launchApp',
55
+ primitive: 'mobile_launch_app', args: ['packageName'],
56
+ },
57
+ install_app: {
58
+ resolution: 'verb', verb: 'install', bridge: 'installApp',
59
+ primitive: 'mobile_install_app', args: ['path'],
60
+ },
61
+ uninstall_app: {
62
+ resolution: 'verb', verb: 'uninstall', bridge: 'uninstallApp',
63
+ primitive: 'mobile_uninstall_app', args: ['bundle_id'],
64
+ },
65
+ open_url: {
66
+ resolution: 'verb', verb: 'open-url', bridge: 'openUrl',
67
+ primitive: 'mobile_open_url', args: ['url'],
68
+ },
69
+ set_orientation: {
70
+ resolution: 'verb', verb: 'orientation', bridge: 'setOrientation',
71
+ primitive: 'mobile_set_orientation', args: ['orientation'],
72
+ },
73
+
74
+ // --- semantic (mauto press <action>, platform-resolved) ----------------
75
+ press_back: { resolution: 'semantic', verb: 'press' },
76
+ dismiss_keyboard: { resolution: 'semantic', verb: 'press' },
77
+ grant_permission: { resolution: 'semantic', verb: 'press' },
78
+ deny_permission: { resolution: 'semantic', verb: 'press' },
79
+
80
+ // --- composed (agent builds from existing verbs; no dedicated verb) -----
81
+ scroll_to_element: { resolution: 'composed' },
82
+ wait_for_element: { resolution: 'composed' },
83
+ wait_for_element_gone: { resolution: 'composed' },
84
+ wait_for_loading_complete: { resolution: 'composed' },
85
+ capture_value: { resolution: 'composed' },
86
+
87
+ // --- unsupported (no mobile-mcp 0.0.55 primitive) ----------------------
88
+ clear_app_data: { resolution: 'unsupported' },
89
+ enable_wifi: { resolution: 'unsupported' },
90
+ disable_wifi: { resolution: 'unsupported' },
91
+ };
92
+
93
+ const RESOLUTIONS = ['verb', 'semantic', 'composed', 'unsupported'];
94
+
95
+ function actionsByResolution(resolution) {
96
+ return Object.keys(ACTION_CATALOG).filter((a) => ACTION_CATALOG[a].resolution === resolution);
97
+ }
98
+
99
+ module.exports = { ACTION_CATALOG, RESOLUTIONS, actionsByResolution };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const { normalize, parseElements } = require('./element-model');
4
+ const { normalizeDevices } = require('./device-model');
5
+ const { resolveSingleDevice } = require('./device-resolver');
6
+
7
+ // Thin wrapper over an injected mobile-mcp `call(toolName, args)` function.
8
+ // Returns the agnostic element model and exposes only the primitives the CLI needs.
9
+ class DeviceBridge {
10
+ constructor({ call, device = null }) {
11
+ if (typeof call !== 'function') {
12
+ throw new TypeError('DeviceBridge requires a `call` function (toolName, args) => Promise');
13
+ }
14
+ this._call = call;
15
+ this._device = device;
16
+ }
17
+
18
+ // Enumerate connected devices/simulators via mobile-mcp and return the
19
+ // agnostic device model (id/name/platform/state only). Tolerates both a bare
20
+ // array and the { devices: [...] } envelope mobile-mcp may return.
21
+ async listDevices() {
22
+ const result = await this._call('mobile_list_available_devices', {});
23
+ return normalizeDevices(result);
24
+ }
25
+
26
+ async listElements() {
27
+ const result = await this._call('mobile_list_elements_on_screen', {});
28
+ return normalize(parseElements(result));
29
+ }
30
+
31
+ async screenshot(destPath) {
32
+ // mobile-mcp's save-screenshot tool names the destination `saveTo` (not
33
+ // `path`); passing the wrong key makes it a silent no-op that still reports
34
+ // success. It returns a confirmation string, so we resolve to destPath.
35
+ const result = await this._call('mobile_save_screenshot', { saveTo: destPath });
36
+ return (result && result.path) || destPath;
37
+ }
38
+
39
+ // Tap at absolute screen coordinates. The agent derives x,y from listElements
40
+ // centers, so the bridge stays free of any element targeting logic.
41
+ async tap({ x, y } = {}) {
42
+ return this._call('mobile_click_on_screen_at_coordinates', { x, y });
43
+ }
44
+
45
+ // Long-press at absolute screen coordinates. Optional duration (ms) overrides
46
+ // mobile-mcp's 500ms default; absent it is not sent so the default applies.
47
+ async longPress({ x, y, duration } = {}) {
48
+ const args = { x, y };
49
+ if (duration !== undefined) args.duration = duration;
50
+ return this._call('mobile_long_press_on_screen_at_coordinates', args);
51
+ }
52
+
53
+ // Double-tap at absolute screen coordinates.
54
+ async doubleTap({ x, y } = {}) {
55
+ return this._call('mobile_double_tap_on_screen', { x, y });
56
+ }
57
+
58
+ // Type into the focused element. We never auto-submit; pressing ENTER is a
59
+ // distinct, explicit action via pressButton.
60
+ async type(text) {
61
+ return this._call('mobile_type_keys', { text, submit: false });
62
+ }
63
+
64
+ // Swipe in a cardinal direction. Optional x/y set the start point and
65
+ // distance the travel — used by the iOS edge-swipe back gesture. Absent keys
66
+ // are not sent so the cardinal-from-center default is preserved.
67
+ async swipe({ direction, x, y, distance } = {}) {
68
+ const args = { direction };
69
+ if (x !== undefined) args.x = x;
70
+ if (y !== undefined) args.y = y;
71
+ if (distance !== undefined) args.distance = distance;
72
+ return this._call('mobile_swipe_on_screen', args);
73
+ }
74
+
75
+ // Platform ('android'/'ios') of the connected device. Uses the pinned id when
76
+ // one was provided, else the single auto-resolved device.
77
+ async getPlatform() {
78
+ const devices = normalizeDevices(await this._call('mobile_list_available_devices', {}));
79
+ const id = this._device || resolveSingleDevice(devices); // throws DeviceResolutionError on 0/many
80
+ const match = devices.find((d) => d.id === id);
81
+ if (!match || !match.platform) {
82
+ const err = new Error(`Cannot determine the platform of device "${id}".`);
83
+ err.hint = 'Ensure the device is listed by `mauto devices`.';
84
+ throw err;
85
+ }
86
+ return String(match.platform).toLowerCase();
87
+ }
88
+
89
+ // Physical screen size in pixels, for geometry-based gestures. mobile-mcp
90
+ // returns this as the human string "Screen size is <w>x<h> pixels" (not an
91
+ // object), so we parse the WxH out of it; a structured {width,height} shape is
92
+ // also accepted in case a future engine returns one. An unreadable size is a
93
+ // hard error — never silently 0/NaN, which would make geometry gestures no-ops.
94
+ async getScreenSize() {
95
+ const r = await this._call('mobile_get_screen_size', {});
96
+ if (r && typeof r === 'object' && r.width != null && r.height != null) {
97
+ return { width: Number(r.width), height: Number(r.height) };
98
+ }
99
+ const m = /(\d+)\s*x\s*(\d+)/i.exec(String(r));
100
+ if (!m) {
101
+ const err = new Error(`Could not read the device screen size from "${r}".`);
102
+ err.hint = 'Ensure a device or simulator is connected.';
103
+ throw err;
104
+ }
105
+ return { width: Number(m[1]), height: Number(m[2]) };
106
+ }
107
+
108
+ // Press a hardware/system button (BACK, HOME, ENTER, ...).
109
+ async pressButton(button) {
110
+ return this._call('mobile_press_button', { button });
111
+ }
112
+
113
+ // Launch an installed app by its package/bundle identifier.
114
+ async launchApp(packageName) {
115
+ return this._call('mobile_launch_app', { packageName });
116
+ }
117
+
118
+ // Install an app from a local artifact (apk/ipa) path.
119
+ async installApp(appPath) {
120
+ return this._call('mobile_install_app', { path: appPath });
121
+ }
122
+
123
+ // Uninstall an app by its bundle/package identifier.
124
+ async uninstallApp(appId) {
125
+ return this._call('mobile_uninstall_app', { bundle_id: appId });
126
+ }
127
+
128
+ // Open a URL (deep link or web) on the device.
129
+ async openUrl(url) {
130
+ return this._call('mobile_open_url', { url });
131
+ }
132
+
133
+ // Set the device orientation ('portrait'/'landscape').
134
+ async setOrientation(orientation) {
135
+ return this._call('mobile_set_orientation', { orientation });
136
+ }
137
+ }
138
+
139
+ module.exports = { DeviceBridge };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // The single verb-facing seam for talking to a device.
4
+ //
5
+ // acquireConnection() hides the daemon-vs-oneshot decision entirely: callers
6
+ // get back a uniform { bridge, close } and never learn (or care) whether the
7
+ // bridge is daemon-backed or a one-shot spawn. The six-concern decision matrix
8
+ // lives privately in resolve-connection.js; this module is the ONE place the
9
+ // rest of the app reaches for a connection.
10
+ //
11
+ // It also owns the session lifecycle ops (is a daemon alive / spawn one / shut
12
+ // one down) so "should I reuse, spawn, or stop the daemon" has a single owner
13
+ // instead of being re-wired independently by the verb path and the lifecycle
14
+ // handlers.
15
+
16
+ const { resolveDeviceConnection } = require('./resolve-connection');
17
+ const sessionClient = require('./session-client');
18
+ const sessionSpawn = require('./session-spawn');
19
+
20
+ // Acquire a device connection, transparently reusing a live daemon when one
21
+ // fits and falling back to a one-shot mobile-mcp spawn otherwise. Returns
22
+ // { bridge, close }; close() releases this verb's hold — a no-op for the shared
23
+ // daemon, a real transport teardown for a one-shot.
24
+ //
25
+ // `resolve` is an internal test seam only; verbs call acquireConnection with
26
+ // just { device, projectRoot }.
27
+ async function acquireConnection({
28
+ device = null,
29
+ projectRoot = process.cwd(),
30
+ resolve = resolveDeviceConnection,
31
+ } = {}) {
32
+ const { bridge, close } = await resolve({ device, projectRoot });
33
+ return { bridge, close };
34
+ }
35
+
36
+ // --- Session lifecycle (one owner for daemon liveness/spawn/shutdown) -------
37
+
38
+ // Is a device session daemon currently reachable for this workspace?
39
+ function isSessionAlive(projectRoot, { client = sessionClient } = {}) {
40
+ return client.isAlive(projectRoot);
41
+ }
42
+
43
+ // Rich status: running + in-flight call count + pinned device. `null`s when not
44
+ // running. The in-flight count exposes the double-execution window.
45
+ function sessionStatus(projectRoot, { client = sessionClient } = {}) {
46
+ return client.getSessionStatus(projectRoot);
47
+ }
48
+
49
+ // Spawn a daemon and wait until it answers. Resolves true on success.
50
+ function startSession({ projectRoot, device = null, idleMs, spawn = sessionSpawn } = {}) {
51
+ return spawn.spawnDaemon({ projectRoot, device, idleMs });
52
+ }
53
+
54
+ // Ask a live daemon to shut down. Resolves true when one acknowledged.
55
+ function endSession(projectRoot, { client = sessionClient } = {}) {
56
+ return client.requestShutdown(projectRoot);
57
+ }
58
+
59
+ module.exports = {
60
+ acquireConnection,
61
+ isSessionAlive,
62
+ sessionStatus,
63
+ startSession,
64
+ endSession,
65
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // Agnostic device shape for `mauto devices`. We deliberately surface ONLY the
4
+ // four stable keys an agent needs to pick a target — id, name, platform, state
5
+ // — and drop every other raw mobile-mcp field (no resource-id, no OS-specific
6
+ // internals). `os` is mapped onto `platform` so the output reads the same way
7
+ // across the Android/iOS/simulator variants mobile-mcp returns.
8
+
9
+ function nullable(v) {
10
+ return v === undefined || v === null || v === '' ? null : v;
11
+ }
12
+
13
+ function normalizeOne(raw) {
14
+ if (!raw || typeof raw !== 'object') return null;
15
+
16
+ const id = nullable(
17
+ raw.id !== undefined
18
+ ? raw.id
19
+ : raw.udid !== undefined
20
+ ? raw.udid
21
+ : raw.deviceId !== undefined
22
+ ? raw.deviceId
23
+ : raw.serial
24
+ );
25
+ if (id == null) return null; // an unidentifiable device is not selectable
26
+
27
+ const name = nullable(
28
+ raw.name !== undefined ? raw.name : raw.deviceName
29
+ );
30
+
31
+ // mobile-mcp reports the OS under `os`; expose it agnostically as `platform`.
32
+ const platform = nullable(
33
+ raw.platform !== undefined ? raw.platform : raw.os
34
+ );
35
+
36
+ const state = nullable(raw.state !== undefined ? raw.state : raw.status);
37
+
38
+ return { id: String(id), name, platform, state };
39
+ }
40
+
41
+ function normalizeDevices(raw) {
42
+ let list;
43
+ if (Array.isArray(raw)) {
44
+ list = raw;
45
+ } else if (raw && Array.isArray(raw.devices)) {
46
+ list = raw.devices;
47
+ } else {
48
+ return [];
49
+ }
50
+
51
+ const out = [];
52
+ for (const item of list) {
53
+ const d = normalizeOne(item);
54
+ if (d) out.push(d);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ module.exports = { normalizeDevices };