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,34 @@
1
+ 'use strict';
2
+
3
+ // Typed error so callers (CLI verb handlers) can map it onto the `device`
4
+ // envelope with the carried, actionable hint.
5
+ class DeviceResolutionError extends Error {
6
+ constructor(message, hint) {
7
+ super(message);
8
+ this.name = 'DeviceResolutionError';
9
+ this.kind = 'device';
10
+ this.hint = hint;
11
+ }
12
+ }
13
+
14
+ // mobile-mcp 0.0.55 requires a concrete device id on every action/read tool.
15
+ // When the caller pinned nothing (no --device, no persisted selection), we
16
+ // auto-discover: exactly one active device is used; zero or many is a clear,
17
+ // actionable failure rather than a silent empty result.
18
+ function resolveSingleDevice(devices) {
19
+ const list = Array.isArray(devices) ? devices : [];
20
+ if (list.length === 1) return list[0].id;
21
+ if (list.length === 0) {
22
+ throw new DeviceResolutionError(
23
+ 'No active device or emulator found.',
24
+ 'Start an emulator/simulator (or connect a device), or pass --device <id>.'
25
+ );
26
+ }
27
+ const ids = list.map((d) => (d && d.name ? `${d.id} (${d.name})` : d.id)).join(', ');
28
+ throw new DeviceResolutionError(
29
+ `Multiple active devices: ${ids}.`,
30
+ 'Pick one with --device <id> or persist it via `mauto devices use <id>`.'
31
+ );
32
+ }
33
+
34
+ module.exports = { resolveSingleDevice, DeviceResolutionError };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ // Agnostic element shape. We deliberately drop every OS-specific identifier
4
+ // (resource_id / identifier) so downstream consumers can only target elements
5
+ // by visible/semantic attributes (text, accessibility_label, geometry).
6
+
7
+ function resolveBounds(raw) {
8
+ if (Array.isArray(raw.bounds) && raw.bounds.length === 4) {
9
+ const b = raw.bounds.map(Number);
10
+ if (b.every(Number.isFinite)) return b;
11
+ }
12
+ if (Array.isArray(raw.coordinates) && raw.coordinates.length === 4) {
13
+ const c = raw.coordinates.map(Number);
14
+ if (c.every(Number.isFinite)) return c;
15
+ }
16
+ if (raw.coordinates && typeof raw.coordinates === 'object' && !Array.isArray(raw.coordinates)) {
17
+ const { x, y, width, height } = raw.coordinates;
18
+ if ([x, y, width, height].every((v) => Number.isFinite(v))) {
19
+ return [x, y, x + width, y + height];
20
+ }
21
+ }
22
+ if (raw.rect && typeof raw.rect === 'object') {
23
+ const { x, y, width, height } = raw.rect;
24
+ if ([x, y, width, height].every((v) => Number.isFinite(v))) {
25
+ return [x, y, x + width, y + height];
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function centerOf([x1, y1, x2, y2]) {
32
+ return [Math.round((x1 + x2) / 2), Math.round((y1 + y2) / 2)];
33
+ }
34
+
35
+ function nullable(v) {
36
+ return v === undefined || v === null || v === '' ? null : v;
37
+ }
38
+
39
+ function normalize(rawElements) {
40
+ if (!Array.isArray(rawElements)) return [];
41
+
42
+ const out = [];
43
+ for (const raw of rawElements) {
44
+ if (!raw || typeof raw !== 'object') continue;
45
+ const bounds = resolveBounds(raw);
46
+ if (!bounds) continue; // can't position it -> not useful, skip
47
+
48
+ out.push({
49
+ text: nullable(raw.text),
50
+ accessibility_label: nullable(
51
+ raw.accessibility_label !== undefined ? raw.accessibility_label : raw.label
52
+ ),
53
+ bounds,
54
+ center: centerOf(bounds),
55
+ type: nullable(raw.type),
56
+ });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // mobile-mcp 0.0.55 returns elements as the string
62
+ // `Found these elements on screen: <JSON array>`. Earlier/other shapes may be
63
+ // a bare array or `{elements:[...]}`. Parse all three into the raw element
64
+ // array. Parse the WHOLE JSON array (never per-element regex) so labels
65
+ // containing brackets/quotes/newlines survive.
66
+ function parseElements(raw) {
67
+ if (Array.isArray(raw)) return raw;
68
+ if (raw && Array.isArray(raw.elements)) return raw.elements;
69
+ if (typeof raw === 'string') {
70
+ const start = raw.indexOf('[');
71
+ const end = raw.lastIndexOf(']');
72
+ if (start !== -1 && end > start) {
73
+ try {
74
+ const parsed = JSON.parse(raw.slice(start, end + 1));
75
+ if (Array.isArray(parsed)) return parsed;
76
+ } catch (_e) { /* fall through */ }
77
+ }
78
+ }
79
+ return [];
80
+ }
81
+
82
+ module.exports = { normalize, parseElements };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
5
+ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
6
+ const { injectDeviceArg } = require('./tool-args');
7
+ const { resolveSingleDevice } = require('./device-resolver');
8
+ const { normalizeDevices } = require('./device-model');
9
+
10
+ // Resolve the mobile-mcp server entry point from the bundled, pinned
11
+ // dependency rather than fetching `@latest` at runtime. We read the package's
12
+ // own `bin` map (mcp-server-mobile -> lib/index.js) so the path stays correct
13
+ // across mobile-mcp versions, then spawn it with the current Node binary.
14
+ //
15
+ // Resolution is done lazily inside createCall (not at module load) so that the
16
+ // module can be required without mobile-mcp installed — the smoke test only
17
+ // asserts the module loads and exports createCall.
18
+ function resolveServerEntry() {
19
+ const pkgPath = require.resolve('@mobilenext/mobile-mcp/package.json');
20
+ const pkg = require('@mobilenext/mobile-mcp/package.json');
21
+ const pkgDir = path.dirname(pkgPath);
22
+ const binRel =
23
+ typeof pkg.bin === 'string'
24
+ ? pkg.bin
25
+ : (pkg.bin && pkg.bin['mcp-server-mobile']) || pkg.main || 'lib/index.js';
26
+ return path.join(pkgDir, binRel);
27
+ }
28
+
29
+ // Integration glue: spawns mobile-mcp over stdio and returns a `call`
30
+ // function shaped exactly like the one DeviceBridge expects.
31
+ // NOT unit-tested (would require spawning mobile-mcp); see the smoke test.
32
+ async function createCall({ device } = {}) {
33
+ const serverEntry = resolveServerEntry();
34
+ const transport = new StdioClientTransport({
35
+ command: process.execPath,
36
+ args: [serverEntry],
37
+ // mobile-mcp reads the target device from the environment / its own
38
+ // selection; we pass it through when provided so callers can pin a device.
39
+ env: device ? { ...process.env, MOBILE_MCP_DEVICE: device } : process.env,
40
+ });
41
+
42
+ const client = new Client(
43
+ { name: 'mauto', version: '0.1.0' },
44
+ { capabilities: {} }
45
+ );
46
+
47
+ await client.connect(transport);
48
+
49
+ async function rawCall(toolName, args = {}) {
50
+ const res = await client.callTool({ name: toolName, arguments: args });
51
+ return parseToolResult(res);
52
+ }
53
+ const { call } = makeCall({ rawCall, device });
54
+
55
+ async function close() {
56
+ try {
57
+ await client.close();
58
+ } catch (_e) {
59
+ // best-effort teardown
60
+ }
61
+ }
62
+
63
+ return { call, close };
64
+ }
65
+
66
+ // Pure orchestration over a low-level rawCall(toolName, args) => Promise<parsed>.
67
+ // Threads a concrete device id into every tool call except the discovery verb,
68
+ // lazily auto-discovering a single device when none was pinned (cached after
69
+ // the first resolution). Extracted from createCall so it is unit-testable
70
+ // without spawning mobile-mcp.
71
+ function makeCall({ rawCall, device = null }) {
72
+ let resolvedPromise = device != null ? Promise.resolve(device) : null;
73
+ function ensureDevice() {
74
+ if (!resolvedPromise) {
75
+ resolvedPromise = rawCall('mobile_list_available_devices', {})
76
+ .then((listed) => resolveSingleDevice(normalizeDevices(listed))) // throws DeviceResolutionError on 0/many
77
+ .catch((err) => {
78
+ // A failed discovery must not wedge the cache: the next call re-lists.
79
+ resolvedPromise = null;
80
+ throw err;
81
+ });
82
+ }
83
+ return resolvedPromise;
84
+ }
85
+ async function call(toolName, args = {}) {
86
+ if (toolName === 'mobile_list_available_devices') {
87
+ return rawCall(toolName, args);
88
+ }
89
+ // The auto-resolved device can go stale (disconnected, a new one appears);
90
+ // the cached id then fails every call with `Device "X" not found` until the
91
+ // daemon restarts. Retry ONCE by busting the cache and re-resolving — but
92
+ // only for an auto-resolved device (device === null): a user-pinned daemon
93
+ // must never silently switch devices.
94
+ let retried = false;
95
+ for (;;) {
96
+ const deviceId = await ensureDevice();
97
+ try {
98
+ return await rawCall(toolName, injectDeviceArg(toolName, args, deviceId));
99
+ } catch (err) {
100
+ const staleAutoResolved =
101
+ device == null && !retried && /Device .+ not found/i.test(err && err.message);
102
+ if (!staleAutoResolved) throw err;
103
+ retried = true;
104
+ resolvedPromise = null;
105
+ }
106
+ }
107
+ }
108
+ return { call };
109
+ }
110
+
111
+ // Collect the text content blocks from an MCP tool result into one string.
112
+ function textContent(res) {
113
+ const content = res && res.content;
114
+ if (!Array.isArray(content)) return '';
115
+ return content
116
+ .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
117
+ .map((c) => c.text)
118
+ .join('\n');
119
+ }
120
+
121
+ // mobile-mcp returns *recoverable* failures (its `ActionableError`) as ordinary
122
+ // text content WITHOUT the `isError` flag, appending this sentinel so an agent
123
+ // reads the hint and retries. We must treat those as failures too, otherwise
124
+ // the error text gets laundered into a successful `{ok:true}` envelope.
125
+ const MOBILE_MCP_ACTIONABLE_SUFFIX = '. Please fix the issue and try again.';
126
+
127
+ // mobile-mcp returns its payload as text content that is itself JSON.
128
+ // Parse defensively: prefer JSON text content, fall back to raw text/struct.
129
+ function parseToolResult(res) {
130
+ if (!res) return null;
131
+
132
+ // `isError` covers "real exceptions"; the sentinel covers ActionableError.
133
+ // Surface both so device verbs fail honestly instead of reporting ok:true.
134
+ if (res.isError) {
135
+ throw new Error(textContent(res) || 'mobile-mcp tool call failed');
136
+ }
137
+
138
+ if (res.structuredContent !== undefined) return res.structuredContent;
139
+
140
+ const content = res.content;
141
+ if (Array.isArray(content)) {
142
+ const texts = content
143
+ .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
144
+ .map((c) => c.text);
145
+ for (const t of texts) {
146
+ try {
147
+ return JSON.parse(t);
148
+ } catch (_e) {
149
+ // not JSON; keep looking, fall through to raw join
150
+ }
151
+ }
152
+ if (texts.length) {
153
+ const joined = texts.join('\n');
154
+ if (joined.endsWith(MOBILE_MCP_ACTIONABLE_SUFFIX)) {
155
+ throw new Error(joined);
156
+ }
157
+ return joined;
158
+ }
159
+ }
160
+ return res;
161
+ }
162
+
163
+ module.exports = { createCall, makeCall, parseToolResult };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ // Decides HOW a one-shot verb gets its device connection, transparently
4
+ // reusing a persistent daemon when one fits.
5
+ //
6
+ // Returns { bridge, close, source } where source is 'daemon' | 'oneshot'.
7
+ //
8
+ // Branches (in order):
9
+ // (a) daemon live AND its device pin matches the requested device
10
+ // -> daemon-backed bridge; close() is a NO-OP (must NOT tear the
11
+ // shared daemon down — other verbs depend on it).
12
+ // (b) device-pin MISMATCH (daemon bound to A, verb asks for B)
13
+ // -> one-shot connection for this call (do not silently reuse A).
14
+ // (c) autostart && no reachable daemon
15
+ // -> spawn a daemon, then connect daemon-backed (no-op close).
16
+ // (d) spawn failed, or autostart:false
17
+ // -> one-shot fallback via the real createCall (real close).
18
+ //
19
+ // Everything device-touching is injected so the whole matrix is unit-testable
20
+ // with a fake daemon + fake createCall + fake spawn.
21
+
22
+ const fs = require('fs');
23
+
24
+ const { DeviceBridge } = require('./bridge');
25
+ const paths = require('./session-paths');
26
+ const sessionClient = require('./session-client');
27
+ const sessionSpawn = require('./session-spawn');
28
+
29
+ function defaultCreateCall(opts) {
30
+ return require('./mobile-mcp-client').createCall(opts);
31
+ }
32
+
33
+ const NOOP_CLOSE = () => Promise.resolve();
34
+
35
+ // Read the live daemon's pinned device from its handle, or null. A null pin
36
+ // means the daemon serves whatever device mobile-mcp selected (matches any
37
+ // request that doesn't pin a specific device).
38
+ function readHandleDevice(projectRoot) {
39
+ try {
40
+ const raw = fs.readFileSync(paths.handlePath(projectRoot), 'utf8');
41
+ const handle = JSON.parse(raw);
42
+ return handle && handle.device ? handle.device : null;
43
+ } catch (_) {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // Does a daemon pinned to `handleDevice` satisfy a request for `requested`?
49
+ // - request without --device -> reuse any live daemon.
50
+ // - request with --device -> only reuse when the pins are equal.
51
+ function deviceMatches(requested, handleDevice) {
52
+ if (!requested) return true;
53
+ return requested === handleDevice;
54
+ }
55
+
56
+ // PURE decision: given the observed facts, which strategy should we run?
57
+ // 'daemon' — a live daemon fits; connect daemon-backed (no-op close)
58
+ // 'oneshot' — connect once for this call (real close)
59
+ // 'spawn-then-daemon'— no daemon fits but autostart is on; spawn then connect
60
+ //
61
+ // Zero collaborators, no I/O — value-in/value-out so the branching can be tested
62
+ // without faking a client/spawn/createCall or writing a handle to disk. The
63
+ // effectful resolver (below) gathers the facts, calls this, and runs exactly one
64
+ // of the three thin effect paths.
65
+ function chooseConnectionStrategy({ alive, handleDevice, requestedDevice, autostart }) {
66
+ if (alive && deviceMatches(requestedDevice, handleDevice)) return 'daemon';
67
+ if (alive) return 'oneshot'; // live daemon, but its device pin mismatches
68
+ if (autostart) return 'spawn-then-daemon';
69
+ return 'oneshot';
70
+ }
71
+
72
+ async function resolveDeviceConnection({
73
+ device = null,
74
+ projectRoot = process.cwd(),
75
+ autostart = true,
76
+ // Injectable seams (tests override these).
77
+ client = sessionClient,
78
+ spawn = sessionSpawn,
79
+ createCall = defaultCreateCall,
80
+ idleMs = undefined,
81
+ } = {}) {
82
+ const oneShot = async () => {
83
+ const { call, close } = await createCall({ device });
84
+ return { bridge: new DeviceBridge({ call, device }), close, source: 'oneshot' };
85
+ };
86
+
87
+ const daemonBacked = async () => {
88
+ const conn = await client.tryConnect(projectRoot);
89
+ if (!conn) return null;
90
+ // The verb's `device` may be null while the daemon is pinned to a device;
91
+ // fall back to the live handle's pin so getPlatform() resolves correctly.
92
+ const pinned = device || readHandleDevice(projectRoot);
93
+ // close() is a no-op AND we must release our socket so the daemon's idle
94
+ // timer can fire — wrap close to end the underlying socket but never stop
95
+ // the shared daemon.
96
+ return {
97
+ bridge: new DeviceBridge({ call: conn.call, device: pinned }),
98
+ close: async () => {
99
+ try {
100
+ await conn.close();
101
+ } catch (_) {
102
+ /* ignore */
103
+ }
104
+ },
105
+ source: 'daemon',
106
+ };
107
+ };
108
+
109
+ const alive = await client.isAlive(projectRoot);
110
+
111
+ if (alive) {
112
+ const handleDevice = readHandleDevice(projectRoot);
113
+ const strategy = chooseConnectionStrategy({
114
+ alive: true,
115
+ handleDevice,
116
+ requestedDevice: device,
117
+ autostart,
118
+ });
119
+ if (strategy === 'daemon') {
120
+ // (a) reuse the live daemon.
121
+ const conn = await daemonBacked();
122
+ if (conn) return conn;
123
+ // Race: daemon vanished between isAlive and connect — fall through and
124
+ // re-decide as if no daemon were alive.
125
+ } else {
126
+ // (b) device-pin mismatch: never silently reuse the wrong device.
127
+ return oneShot();
128
+ }
129
+ }
130
+
131
+ // No live daemon (or it vanished mid-flight): spawn-then-daemon vs one-shot.
132
+ const strategy = chooseConnectionStrategy({
133
+ alive: false,
134
+ handleDevice: null,
135
+ requestedDevice: device,
136
+ autostart,
137
+ });
138
+ if (strategy === 'spawn-then-daemon') {
139
+ // (c) spawn a daemon, then connect daemon-backed.
140
+ const started = await spawn.spawnDaemon({ projectRoot, device, idleMs });
141
+ if (started) {
142
+ const conn = await daemonBacked();
143
+ if (conn) return conn;
144
+ }
145
+ }
146
+
147
+ // (d) explicit one-shot, or spawn failed / unreachable.
148
+ return oneShot();
149
+ }
150
+
151
+ module.exports = {
152
+ resolveDeviceConnection,
153
+ chooseConnectionStrategy,
154
+ deviceMatches,
155
+ readHandleDevice,
156
+ };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // Persisted device selection for the `mauto` CLI.
4
+ //
5
+ // A chosen device id is remembered across separate one-shot verb invocations so
6
+ // the agent does not have to re-pass --device every call. The selection lives in
7
+ // #91's session layout (mobile-automator/.session/) as a small sidecar file,
8
+ // SEPARATE from config.json (which is user-authored project config) and from the
9
+ // live session handle (session.json, owned by the daemon lifecycle).
10
+ //
11
+ // `read` is graceful: a missing OR corrupt file returns null so a verb never
12
+ // crashes on a half-written/garbage selection — it just behaves as "no
13
+ // selection". The directory is created lazily on write.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const paths = require('./session-paths');
19
+ const { atomicWrite } = require('../util/atomic');
20
+
21
+ const SELECTION_NAME = 'selection.json';
22
+
23
+ function selectionPath(projectRoot) {
24
+ return path.join(paths.sessionDir(projectRoot), SELECTION_NAME);
25
+ }
26
+
27
+ // The persisted device id, or null when absent/corrupt.
28
+ function read(projectRoot) {
29
+ try {
30
+ const raw = fs.readFileSync(selectionPath(projectRoot), 'utf8');
31
+ const parsed = JSON.parse(raw);
32
+ const id = parsed && parsed.device;
33
+ return id ? String(id) : null;
34
+ } catch (_) {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // Persist a device id. Creates mobile-automator/.session/ if needed. Written
40
+ // via atomicWrite (tmp + fsync + rename) so a concurrent reader or a crash
41
+ // sees the old-complete or new-complete selection, never a torn one.
42
+ function write(projectRoot, id) {
43
+ atomicWrite(
44
+ selectionPath(projectRoot),
45
+ JSON.stringify({ device: String(id) }, null, 2) + '\n'
46
+ );
47
+ return String(id);
48
+ }
49
+
50
+ // Remove the persisted selection. Idempotent — clearing when none exists is a
51
+ // no-op (never throws).
52
+ function clear(projectRoot) {
53
+ try {
54
+ fs.rmSync(selectionPath(projectRoot), { force: true });
55
+ } catch (_) {
56
+ /* ignore */
57
+ }
58
+ }
59
+
60
+ // Pure precedence resolver: an explicit --device flag ALWAYS wins and is never
61
+ // written to the store (it is a per-call override only); otherwise fall back to
62
+ // the persisted selection; otherwise none. `store` is injectable so this stays
63
+ // pure and unit-testable without touching the filesystem.
64
+ //
65
+ // Returns { device, source } where source is:
66
+ // 'flag' — explicit --device supplied
67
+ // 'session' — resolved from the persisted selection
68
+ // 'none' — no device chosen (mobile-mcp will auto-select a single device)
69
+ function resolveDevice({ explicit, projectRoot, store = module.exports } = {}) {
70
+ if (explicit) {
71
+ return { device: String(explicit), source: 'flag' };
72
+ }
73
+ const persisted = store.read(projectRoot);
74
+ if (persisted) {
75
+ return { device: persisted, source: 'session' };
76
+ }
77
+ return { device: null, source: 'none' };
78
+ }
79
+
80
+ module.exports = { selectionPath, read, write, clear, resolveDevice, SELECTION_NAME };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { AndroidResolver, IOSResolver, SemanticResolutionError } = require('./resolver');
4
+
5
+ const ACTION_METHOD = {
6
+ press_back: 'pressBack',
7
+ dismiss_keyboard: 'dismissKeyboard',
8
+ grant_permission: 'grantPermission',
9
+ deny_permission: 'denyPermission',
10
+ };
11
+
12
+ const SEMANTIC_ACTIONS = Object.keys(ACTION_METHOD);
13
+
14
+ function isSemanticAction(token) {
15
+ return Object.prototype.hasOwnProperty.call(ACTION_METHOD, token);
16
+ }
17
+
18
+ const STRATEGIES = { android: AndroidResolver, ios: IOSResolver };
19
+
20
+ // Pick the per-platform resolver, or hard-fail (never silently forward).
21
+ function selectResolver(platform, bridge) {
22
+ const Strategy = STRATEGIES[platform];
23
+ if (!Strategy) {
24
+ throw new SemanticResolutionError(
25
+ `No semantic-action resolver for platform "${platform}".`,
26
+ 'Connect a supported Android or iOS device/simulator.'
27
+ );
28
+ }
29
+ return new Strategy(bridge);
30
+ }
31
+
32
+ module.exports = {
33
+ SEMANTIC_ACTIONS,
34
+ ACTION_METHOD,
35
+ isSemanticAction,
36
+ selectResolver,
37
+ SemanticResolutionError,
38
+ };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // System permission-dialog button labels, per platform. Lowercase and
4
+ // apostrophe-normalized so matching is locale-tolerant within English.
5
+ const ALLOW_LABELS = {
6
+ android: ['allow', 'while using the app', 'allow only this time', 'ok', 'continue'],
7
+ ios: ['allow', 'allow while using app', 'allow once', 'ok'],
8
+ };
9
+
10
+ const DENY_LABELS = {
11
+ android: ['deny', "don't allow"],
12
+ ios: ["don't allow"],
13
+ };
14
+
15
+ function norm(s) {
16
+ // Map the curly apostrophe (U+2019, the ’ glyph in the regex below) onto a
17
+ // straight quote, so "Don’t Allow" matches the straight-quoted entries in the
18
+ // label sets above. The U+2019 byte must be preserved literally here; the
19
+ // guard assertion in labels.test.js fails loudly if it is ever flattened to a
20
+ // straight quote by editor/transport tooling.
21
+ return String(s == null ? '' : s).replace(/’/g, "'").trim().toLowerCase();
22
+ }
23
+
24
+ // First element whose visible text or accessibility label exactly equals one of
25
+ // `labels` (after normalization). Returns the element plus the original label
26
+ // that matched, or null.
27
+ function findByLabels(elements, labels) {
28
+ const wanted = new Set(labels.map(norm));
29
+ for (const el of Array.isArray(elements) ? elements : []) {
30
+ for (const raw of [el.text, el.accessibility_label]) {
31
+ if (raw != null && wanted.has(norm(raw))) {
32
+ return { element: el, label: raw };
33
+ }
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ module.exports = { ALLOW_LABELS, DENY_LABELS, findByLabels };