mobile-automator 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/cli.js +75 -14
- package/src/device/action-catalog.js +2 -2
- package/src/device/bridge.js +36 -9
- package/src/device/connection.js +72 -3
- package/src/device/device-resolver.js +2 -1
- package/src/device/element-model.js +37 -15
- package/src/device/engine-hints.js +83 -0
- package/src/device/mobile-mcp-client.js +107 -12
- package/src/device/process-identity.js +124 -0
- package/src/device/process-reaper.js +123 -0
- package/src/device/session-client.js +11 -5
- package/src/device/session-daemon.js +75 -76
- package/src/device/session-paths.js +23 -0
- package/src/device/session-spawn.js +22 -2
- package/src/device/swipe-geometry.js +39 -0
- package/src/device/tool-args.js +3 -1
- package/src/observe/event.js +2 -2
- package/src/observe/vocab.js +15 -5
|
@@ -74,6 +74,26 @@ function lockPath(projectRoot) {
|
|
|
74
74
|
return path.join(sessionDir(projectRoot), LOCK_NAME);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// The workspace a daemon serves, as it appears on its command line (#156).
|
|
78
|
+
// The daemon itself reads its root from env (MAUTO_SESSION_PROJECT_ROOT), which
|
|
79
|
+
// `ps` cannot see, so every daemon's command line used to be identical. This
|
|
80
|
+
// marker is what lets `session end` tell THIS workspace's daemon from another
|
|
81
|
+
// workspace's before it signals a process group: a copied workspace (`cp -R`
|
|
82
|
+
// carries daemon.pid) or a recycled pid can name a different live daemon.
|
|
83
|
+
// The root is the LAST argument so a match can be anchored at the end.
|
|
84
|
+
const DAEMON_ROOT_FLAG = '--project-root';
|
|
85
|
+
|
|
86
|
+
function daemonArgv(projectRoot) {
|
|
87
|
+
return [DAEMON_ROOT_FLAG, path.resolve(projectRoot)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Does a `ps -o command=` line name exactly this workspace? Anchored at the end
|
|
91
|
+
// of the line so `/a/proj` never matches a daemon serving `/a/proj2`.
|
|
92
|
+
function commandNamesWorkspace(command, projectRoot) {
|
|
93
|
+
const line = String(command || '').replace(/\s+$/, '');
|
|
94
|
+
return line.endsWith(` ${DAEMON_ROOT_FLAG} ${path.resolve(projectRoot)}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
module.exports = {
|
|
78
98
|
SESSION_DIRNAME,
|
|
79
99
|
SOCKET_NAME,
|
|
@@ -89,4 +109,7 @@ module.exports = {
|
|
|
89
109
|
pidFilePath,
|
|
90
110
|
handlePath,
|
|
91
111
|
lockPath,
|
|
112
|
+
DAEMON_ROOT_FLAG,
|
|
113
|
+
daemonArgv,
|
|
114
|
+
commandNamesWorkspace,
|
|
92
115
|
};
|
|
@@ -12,6 +12,8 @@ const childProcess = require('child_process');
|
|
|
12
12
|
|
|
13
13
|
const sessionClient = require('./session-client');
|
|
14
14
|
const sessionLog = require('./session-log');
|
|
15
|
+
const paths = require('./session-paths');
|
|
16
|
+
const reaper = require('./process-reaper');
|
|
15
17
|
|
|
16
18
|
const DAEMON_BIN = path.join(__dirname, '..', '..', 'bin', 'mauto-session-daemon.js');
|
|
17
19
|
const DEFAULT_READY_TIMEOUT_MS = 15000;
|
|
@@ -31,6 +33,10 @@ async function spawnDaemon({
|
|
|
31
33
|
pollMs = DEFAULT_POLL_MS,
|
|
32
34
|
isAlive = sessionClient.isAlive,
|
|
33
35
|
openLog = sessionLog.openDaemonLog,
|
|
36
|
+
// Kills a daemon that never became ready (#156). group:true reaches the
|
|
37
|
+
// daemon AND its mobile-mcp engine: `detached: true` below makes the daemon
|
|
38
|
+
// a process-group leader, and the engine is spawned into its group.
|
|
39
|
+
terminate = (pid) => reaper.terminate(pid, { group: true }),
|
|
34
40
|
} = {}) {
|
|
35
41
|
if (!projectRoot) throw new TypeError('spawnDaemon requires projectRoot');
|
|
36
42
|
|
|
@@ -46,7 +52,9 @@ async function spawnDaemon({
|
|
|
46
52
|
const log = openLog(projectRoot);
|
|
47
53
|
|
|
48
54
|
try {
|
|
49
|
-
|
|
55
|
+
// The root in argv is an identity marker only (see paths.daemonArgv): env
|
|
56
|
+
// stays the daemon's source of truth, but `ps` can only see argv.
|
|
57
|
+
const child = spawn(process.execPath, [DAEMON_BIN, ...paths.daemonArgv(projectRoot)], {
|
|
50
58
|
detached: true,
|
|
51
59
|
stdio: log.stdio,
|
|
52
60
|
env,
|
|
@@ -80,6 +88,18 @@ async function spawnDaemon({
|
|
|
80
88
|
if (await isAlive(projectRoot)) return true;
|
|
81
89
|
await sleep(pollMs);
|
|
82
90
|
}
|
|
91
|
+
|
|
92
|
+
// Not ready in time. Without this the child lives on — typically still
|
|
93
|
+
// inside createCall, holding the O_EXCL lock — and every later verb burns
|
|
94
|
+
// its own readiness window against it (#156). Only OUR child, and only if
|
|
95
|
+
// it is still running: an ELOCKED spawn-race loser has already exited and
|
|
96
|
+
// must never signal anything. Node tracked this exact child, so its state
|
|
97
|
+
// needs no pidIdentity check and has no pid-reuse window.
|
|
98
|
+
if (child && child.pid && child.exitCode === null && child.signalCode === null) {
|
|
99
|
+
const outcome = await terminate(child.pid);
|
|
100
|
+
// The parent is the only witness to this kill — write it down (#163).
|
|
101
|
+
log.write(`mauto: daemon not ready after ${readyTimeoutMs}ms; terminated pid=${child.pid} (${outcome})\n`);
|
|
102
|
+
}
|
|
83
103
|
return false;
|
|
84
104
|
} finally {
|
|
85
105
|
// The child holds its own dup, so the parent releases its descriptor rather
|
|
@@ -89,4 +109,4 @@ async function spawnDaemon({
|
|
|
89
109
|
}
|
|
90
110
|
}
|
|
91
111
|
|
|
92
|
-
module.exports = { spawnDaemon, DAEMON_BIN };
|
|
112
|
+
module.exports = { spawnDaemon, DAEMON_BIN, DEFAULT_READY_TIMEOUT_MS };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Geometry for a direction-only swipe: start at centre + 30% of the axis
|
|
4
|
+
// dimension and travel 60% of it, i.e. centre ± 30% (#199).
|
|
5
|
+
//
|
|
6
|
+
// Why mauto computes this instead of letting the engine choose: mobile-mcp
|
|
7
|
+
// 0.0.55's adb and WDA robots used exactly this proportional travel, but 1.0.5
|
|
8
|
+
// routes every device through mobilecli, whose direction-only swipe is a fixed
|
|
9
|
+
// 400px centred stroke — about a third of the old travel on a phone, so
|
|
10
|
+
// scroll-to-element needs ~3x the swipes. Every robot in both versions reads an
|
|
11
|
+
// explicit {x, y, distance} the same way (start at x,y; move `distance` px in
|
|
12
|
+
// `direction`), so sending it pins identical behaviour on either pin.
|
|
13
|
+
//
|
|
14
|
+
// Kept pure and in its own module (not inside DeviceBridge) so the arithmetic
|
|
15
|
+
// is testable without an engine fake and the bridge stays a thin wrapper.
|
|
16
|
+
// There is deliberately no platform branch: proportional geometry is what makes
|
|
17
|
+
// it platform-agnostic.
|
|
18
|
+
//
|
|
19
|
+
// The integer formulas mirror 0.0.55's AndroidRobot (`Math.floor(n * 0.8)`,
|
|
20
|
+
// `Math.floor(n * 0.2)`, centre `floor(n / 2)`) so the endpoints match its old
|
|
21
|
+
// ones pixel-for-pixel, floating-point rounding included.
|
|
22
|
+
function centredSwipe({ width, height }, direction) {
|
|
23
|
+
const hi = (n) => Math.floor(n * 0.8);
|
|
24
|
+
const lo = (n) => Math.floor(n * 0.2);
|
|
25
|
+
const cx = Math.floor(width / 2);
|
|
26
|
+
const cy = Math.floor(height / 2);
|
|
27
|
+
switch (direction) {
|
|
28
|
+
case 'up': return { x: cx, y: hi(height), distance: hi(height) - lo(height) };
|
|
29
|
+
case 'down': return { x: cx, y: lo(height), distance: hi(height) - lo(height) };
|
|
30
|
+
case 'left': return { x: hi(width), y: cy, distance: hi(width) - lo(width) };
|
|
31
|
+
case 'right': return { x: lo(width), y: cy, distance: hi(width) - lo(width) };
|
|
32
|
+
default:
|
|
33
|
+
// The CLI validates directions first; this guards other callers from a
|
|
34
|
+
// swipe with undefined geometry.
|
|
35
|
+
throw new Error(`Unknown swipe direction "${direction}".`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { centredSwipe };
|
package/src/device/tool-args.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// mobile-mcp
|
|
3
|
+
// mobile-mcp 1.0.5 requires `device` on every tool EXCEPT the discovery verb;
|
|
4
|
+
// tests/integration/mobile-mcp-contract.test.js enforces this against the
|
|
5
|
+
// pinned engine's own tool schemas.
|
|
4
6
|
// Centralizing the injection (and its one exception) here keeps every call
|
|
5
7
|
// site correct. Pure + non-mutating so it is trivially testable.
|
|
6
8
|
const NO_DEVICE_TOOLS = new Set(['mobile_list_available_devices']);
|
package/src/observe/event.js
CHANGED
|
@@ -120,10 +120,10 @@ const EVENT_FIELDS = {
|
|
|
120
120
|
// src/device/mobile-mcp-tools.js. A daemon-minted counter needs no redaction
|
|
121
121
|
// argument at all, on exactly the grounds dur_ms is cleared.
|
|
122
122
|
call_id: sendable(integer({ min: 1 }), 'daemon-minted counter scoped to one session; never the client-supplied frame id'),
|
|
123
|
-
stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit'),
|
|
123
|
+
stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit|engine_exit'),
|
|
124
124
|
// Errno NAMES only: the numeric codes an MCP server reports, and Node's
|
|
125
125
|
// ERR_* codes, fail the check and are dropped rather than sent.
|
|
126
|
-
error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED'),
|
|
126
|
+
error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED|ENGINE_CONNECT_TIMEOUT|ENGINE_EXITED'),
|
|
127
127
|
|
|
128
128
|
// --- crash (slice 4) ---------------------------------------------------
|
|
129
129
|
// The ONLY new field slice 4 mints. As with `verb`, `tool` and `session_id`,
|
package/src/observe/vocab.js
CHANGED
|
@@ -43,6 +43,7 @@ const EVENT_NAMES = Object.freeze([
|
|
|
43
43
|
'daemon.start_failure',
|
|
44
44
|
'daemon.listen_failure',
|
|
45
45
|
'daemon.connect_failure',
|
|
46
|
+
'daemon.engine_exit',
|
|
46
47
|
'daemon.lock_conflict',
|
|
47
48
|
'daemon.undeliverable',
|
|
48
49
|
// Emitted by the flush path, which lands in a later slice-5 task; declared
|
|
@@ -86,11 +87,20 @@ const VERB_NAMES = Object.freeze([
|
|
|
86
87
|
]);
|
|
87
88
|
|
|
88
89
|
// Why the daemon stopped. 'crash' is documented but not yet produced.
|
|
89
|
-
|
|
90
|
+
// 'engine_exit': the mobile-mcp engine died under a serving daemon (#156).
|
|
91
|
+
const STOP_REASONS = Object.freeze(['idle', 'signal', 'shutdown', 'crash', 'explicit', 'engine_exit']);
|
|
90
92
|
|
|
91
|
-
// Node/libuv errno names for this platform, plus our own
|
|
92
|
-
// conflict,
|
|
93
|
-
//
|
|
94
|
-
|
|
93
|
+
// Node/libuv errno names for this platform, plus our own codes, all recorded by
|
|
94
|
+
// src/device/: ELOCKED (a lock conflict, session-daemon.js),
|
|
95
|
+
// ENGINE_CONNECT_TIMEOUT (initialize exceeded its bound, mobile-mcp-client.js)
|
|
96
|
+
// and ENGINE_EXITED (the engine died before the daemon was serving,
|
|
97
|
+
// session-daemon.js). Names only: the numeric codes an MCP server reports are
|
|
98
|
+
// not in this set and so never pass.
|
|
99
|
+
const ERROR_CODES = Object.freeze([
|
|
100
|
+
...Object.keys(os.constants.errno),
|
|
101
|
+
'ELOCKED',
|
|
102
|
+
'ENGINE_CONNECT_TIMEOUT',
|
|
103
|
+
'ENGINE_EXITED',
|
|
104
|
+
]);
|
|
95
105
|
|
|
96
106
|
module.exports = { OS_NAMES, EVENT_NAMES, VERB_NAMES, STOP_REASONS, ERROR_CODES };
|