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.
- package/LICENSE +17 -0
- package/README.md +167 -0
- package/bin/mauto-session-daemon.js +101 -0
- package/bin/mauto.js +13 -0
- package/package.json +49 -0
- package/src/assertion/evaluator.js +285 -0
- package/src/cli.js +1722 -0
- package/src/config/coerce.js +97 -0
- package/src/config/manager.js +75 -0
- package/src/config/schema.js +124 -0
- package/src/device/action-catalog.js +99 -0
- package/src/device/bridge.js +139 -0
- package/src/device/connection.js +65 -0
- package/src/device/device-model.js +59 -0
- package/src/device/device-resolver.js +34 -0
- package/src/device/element-model.js +82 -0
- package/src/device/mobile-mcp-client.js +163 -0
- package/src/device/resolve-connection.js +156 -0
- package/src/device/selection.js +80 -0
- package/src/device/semantic-press/index.js +38 -0
- package/src/device/semantic-press/labels.js +39 -0
- package/src/device/semantic-press/resolver.js +100 -0
- package/src/device/session-client.js +173 -0
- package/src/device/session-daemon.js +526 -0
- package/src/device/session-paths.js +80 -0
- package/src/device/session-protocol.js +60 -0
- package/src/device/session-spawn.js +67 -0
- package/src/device/tool-args.js +14 -0
- package/src/guide/content/execute.agnostic.md +207 -0
- package/src/guide/content/execute.aware.md +281 -0
- package/src/guide/content/execute.invariants.md +4 -0
- package/src/guide/content/generate.agnostic.md +236 -0
- package/src/guide/content/generate.aware.md +327 -0
- package/src/guide/content/generate.invariants.md +5 -0
- package/src/guide/content/setup.agnostic.md +63 -0
- package/src/guide/content/setup.aware.md +91 -0
- package/src/guide/content/setup.invariants.md +3 -0
- package/src/guide/emitter.js +137 -0
- package/src/guide/placeholders.js +132 -0
- package/src/init/adapters.js +162 -0
- package/src/init/skill-meta.js +31 -0
- package/src/init/skill-renderer.js +60 -0
- package/src/mcp/prompts.js +55 -0
- package/src/mcp/server.js +55 -0
- package/src/memory/entries.js +61 -0
- package/src/memory/history.js +86 -0
- package/src/memory/lock.js +5 -0
- package/src/memory/paths.js +47 -0
- package/src/memory/store.js +230 -0
- package/src/output/envelope.js +106 -0
- package/src/result/capability-catalog.js +144 -0
- package/src/result/flags.js +52 -0
- package/src/result/store.js +273 -0
- package/src/scenario/validator.js +30 -0
- package/src/schemas/config_schema.json +68 -0
- package/src/schemas/format-error.js +19 -0
- package/src/schemas/result_schema.json +211 -0
- package/src/schemas/scenario_schema.json +587 -0
- package/src/setup/scaffold.js +53 -0
- package/src/util/atomic.js +41 -0
- 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 };
|