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,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 };
|