@anuraghq/mobius 0.0.0-stage → 0.1.1
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 +202 -0
- package/README.md +371 -2
- package/bin/mobiusagent-driver-darwin-arm64 +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +151 -0
- package/dist/core/protocol.d.ts +294 -0
- package/dist/core/protocol.js +82 -0
- package/dist/execution/connector.d.ts +82 -0
- package/dist/execution/connector.js +11 -0
- package/dist/execution/drivers/mobiusagent.d.ts +57 -0
- package/dist/execution/drivers/mobiusagent.js +165 -0
- package/dist/execution/drivers/nodes.d.ts +47 -0
- package/dist/execution/drivers/nodes.js +104 -0
- package/dist/execution/observe.d.ts +48 -0
- package/dist/execution/observe.js +246 -0
- package/dist/execution/session.d.ts +112 -0
- package/dist/execution/session.js +328 -0
- package/dist/infrastructure/plane.d.ts +25 -0
- package/dist/infrastructure/plane.js +16 -0
- package/dist/infrastructure/registry.d.ts +19 -0
- package/dist/infrastructure/registry.js +49 -0
- package/dist/interface/engine.d.ts +24 -0
- package/dist/interface/engine.js +38 -0
- package/dist/interface/http.d.ts +4 -0
- package/dist/interface/http.js +85 -0
- package/dist/interface/mcp.d.ts +21 -0
- package/dist/interface/mcp.js +378 -0
- package/dist/interface/mobius-tools.d.ts +10 -0
- package/dist/interface/mobius-tools.js +328 -0
- package/dist/observability/audit.d.ts +52 -0
- package/dist/observability/audit.js +98 -0
- package/dist/observability/signing.d.ts +37 -0
- package/dist/observability/signing.js +87 -0
- package/dist/policy/policy.d.ts +147 -0
- package/dist/policy/policy.js +347 -0
- package/ios-agent/brand.py +62 -0
- package/ios-agent/brand.sh +19 -0
- package/ios-agent/setup.sh +32 -0
- package/ios-agent/wda-mobius-fast-touch.patch +73 -0
- package/package.json +49 -4
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Execution: the Mobius device driver (drivers/mobiusagent-driver, Rust). One long-lived process speaks JSON lines over stdio.
|
|
2
|
+
// Android: stock adb + an on-device helper. iOS: WebDriverAgent over usbmuxd (once built with ios-agent/setup.sh).
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { MobiusError } from "../connector.js";
|
|
10
|
+
import { detectAndroidFramework, toRawNode } from "./nodes.js";
|
|
11
|
+
/** The driver binary: $MOBIUS_DRIVER_BIN, the one bundled in the npm package for this platform, or a source build. */
|
|
12
|
+
export const driverPath = () => {
|
|
13
|
+
if (process.env.MOBIUS_DRIVER_BIN)
|
|
14
|
+
return process.env.MOBIUS_DRIVER_BIN;
|
|
15
|
+
const bundled = fileURLToPath(new URL(`../../../bin/mobiusagent-driver-${process.platform}-${process.arch}`, import.meta.url));
|
|
16
|
+
return existsSync(bundled) ? bundled : fileURLToPath(new URL('../../../../../drivers/mobiusagent-driver/target/release/mobiusagent-driver', import.meta.url));
|
|
17
|
+
};
|
|
18
|
+
/** The driver binary is built (`cargo build --release` in drivers/mobiusagent-driver). */
|
|
19
|
+
export const driverBuilt = () => existsSync(driverPath());
|
|
20
|
+
/** iOS needs WebDriverAgent built and signed once (drivers/mobiusagent-driver/ios-agent/setup.sh). */
|
|
21
|
+
export const iosAgentReady = () => {
|
|
22
|
+
const dir = join(process.env.MOBIUS_WDA_DIR ?? join(homedir(), '.mobius', 'wda'), 'build', 'Build', 'Products');
|
|
23
|
+
try {
|
|
24
|
+
return readdirSync(dir).some(f => f.endsWith('.xctestrun'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export class MobiusAgentDriver {
|
|
31
|
+
proc = null;
|
|
32
|
+
next = 1;
|
|
33
|
+
waiting = new Map();
|
|
34
|
+
start() {
|
|
35
|
+
const p = spawn(driverPath(), [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
36
|
+
createInterface({ input: p.stdout }).on('line', line => {
|
|
37
|
+
let r;
|
|
38
|
+
try {
|
|
39
|
+
r = JSON.parse(line);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const w = this.waiting.get(r.id);
|
|
45
|
+
if (!w)
|
|
46
|
+
return;
|
|
47
|
+
this.waiting.delete(r.id);
|
|
48
|
+
r.ok ? w.ok(r.data) : w.fail(new MobiusError('INTERNAL', String(r.error)));
|
|
49
|
+
});
|
|
50
|
+
p.on('exit', () => {
|
|
51
|
+
for (const w of this.waiting.values())
|
|
52
|
+
w.fail(new MobiusError('INTERNAL', 'mobiusagent-driver exited', { retryable: true }));
|
|
53
|
+
this.waiting.clear();
|
|
54
|
+
this.proc = null;
|
|
55
|
+
});
|
|
56
|
+
this.proc = p;
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
// 2 minutes: the first iOS read may have to start the runner (seconds directly, up to ~90 s through xcodebuild).
|
|
60
|
+
call(op, args = {}, timeoutMs = 120_000) {
|
|
61
|
+
const p = this.proc ?? this.start();
|
|
62
|
+
const id = this.next++;
|
|
63
|
+
return new Promise((ok, fail) => {
|
|
64
|
+
const t = setTimeout(() => {
|
|
65
|
+
this.waiting.delete(id);
|
|
66
|
+
// ponytail: a wedged adb call blocks the one driver process; restart it rather than queue behind it.
|
|
67
|
+
this.proc?.kill();
|
|
68
|
+
fail(new MobiusError('TIMEOUT', `mobiusagent-driver ${op} timed out`, { retryable: true }));
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
this.waiting.set(id, { ok: v => { clearTimeout(t); ok(v); }, fail: e => { clearTimeout(t); fail(e); } });
|
|
71
|
+
p.stdin.write(JSON.stringify({ id, op, ...args }) + '\n');
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async devices() { return (await this.call('devices')).devices; }
|
|
75
|
+
close() { this.proc?.kill(); this.proc = null; }
|
|
76
|
+
}
|
|
77
|
+
const KEYS = { back: 'back', home: 'home', recents: 'recents', enter: 'enter' };
|
|
78
|
+
export class MobiusAgentConnector {
|
|
79
|
+
platform;
|
|
80
|
+
deviceId;
|
|
81
|
+
capabilities = new Set([
|
|
82
|
+
'observe', 'screenshot', 'tap', 'type', 'swipe', 'scroll', 'press_key', 'list_apps', 'open_app', 'open_url', 'wait_for',
|
|
83
|
+
]);
|
|
84
|
+
limitations;
|
|
85
|
+
size = null;
|
|
86
|
+
fwCache = new Map();
|
|
87
|
+
drv;
|
|
88
|
+
dev;
|
|
89
|
+
constructor(drv, dev) {
|
|
90
|
+
this.drv = drv;
|
|
91
|
+
this.dev = dev;
|
|
92
|
+
this.deviceId = dev.id;
|
|
93
|
+
this.platform = dev.platform;
|
|
94
|
+
this.limitations = [
|
|
95
|
+
'Interaction is coordinate-based (taps at element centres), not semantic accessibility actions.',
|
|
96
|
+
...(dev.platform === 'ios'
|
|
97
|
+
? ['iOS: real devices over USB with WebDriverAgent (developer setup). Back and Recents keys do not exist; use on-screen navigation.']
|
|
98
|
+
: ['Android: FLAG_SECURE screens screenshot as black.']),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
get virtual() { return this.dev.type === 'emulator' || this.dev.type === 'simulator'; }
|
|
102
|
+
get d() { return { device: this.dev.id }; }
|
|
103
|
+
async info() {
|
|
104
|
+
return { device_id: this.dev.id, platform: this.dev.platform, connector: 'mobiusagent-driver', name: this.dev.name, model: this.dev.model,
|
|
105
|
+
os_version: this.dev.version, online: this.dev.state === 'online', screen_on: null, locked: null };
|
|
106
|
+
}
|
|
107
|
+
async dump() {
|
|
108
|
+
const r = await this.drv.call('dump', this.d).catch(e => {
|
|
109
|
+
throw new MobiusError('DEVICE_LOCKED', `Could not read the screen: ${e.message}`, { retryable: true, hint: 'Ask the user to wake and unlock the phone.' });
|
|
110
|
+
});
|
|
111
|
+
this.size = { width: r.width, height: r.height };
|
|
112
|
+
const root = { cls: 'hierarchy', pkg: '', text: '', desc: '', resourceId: '', clickable: false, longClickable: false,
|
|
113
|
+
enabled: true, focused: false, scrollable: false, checkable: false, checked: false, selected: false, password: false,
|
|
114
|
+
bounds: { x: 0, y: 0, width: r.width, height: r.height }, path: 'r', children: [] };
|
|
115
|
+
r.tree.hierarchy.forEach((w, i) => { const n = toRawNode(w, `r.${i}`); if (n)
|
|
116
|
+
root.children.push(n); });
|
|
117
|
+
return { root, width: r.width, height: r.height, rotation: r.rotation, foregroundApp: r.foregroundApp };
|
|
118
|
+
}
|
|
119
|
+
async framework(pkg) {
|
|
120
|
+
if (this.platform === 'ios')
|
|
121
|
+
return 'unknown';
|
|
122
|
+
const hit = this.fwCache.get(pkg);
|
|
123
|
+
if (hit)
|
|
124
|
+
return hit;
|
|
125
|
+
const fw = await detectAndroidFramework(this.dev.id, pkg).catch(() => 'unknown');
|
|
126
|
+
this.fwCache.set(pkg, fw);
|
|
127
|
+
return fw;
|
|
128
|
+
}
|
|
129
|
+
async screenshot(maxSize) { return this.drv.call('screenshot', { ...this.d, max: maxSize }); }
|
|
130
|
+
async tap(x, y, mode) { await this.drv.call('tap', { ...this.d, x, y, mode }); }
|
|
131
|
+
async type(text, submit) {
|
|
132
|
+
await this.drv.call('text', { ...this.d, text, submit });
|
|
133
|
+
}
|
|
134
|
+
async swipe(direction, from, distance) {
|
|
135
|
+
if (!this.size)
|
|
136
|
+
this.size = await this.drv.call('size', this.d);
|
|
137
|
+
const s = this.size;
|
|
138
|
+
const start = from ?? { x: s.width / 2, y: s.height / 2 };
|
|
139
|
+
const dist = distance ?? Math.round((direction === 'up' || direction === 'down' ? s.height : s.width) * 0.4);
|
|
140
|
+
const [dx, dy] = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction];
|
|
141
|
+
const clamp = (v, hi) => Math.max(1, Math.min(hi - 1, Math.round(v)));
|
|
142
|
+
await this.drv.call('swipe', { ...this.d, x1: clamp(start.x, s.width), y1: clamp(start.y, s.height),
|
|
143
|
+
x2: clamp(start.x + dx * dist, s.width), y2: clamp(start.y + dy * dist, s.height), ms: 400 });
|
|
144
|
+
}
|
|
145
|
+
async key(key) {
|
|
146
|
+
const k = KEYS[key];
|
|
147
|
+
if (!k || (this.platform === 'ios' && (key === 'back' || key === 'recents')))
|
|
148
|
+
throw new MobiusError('UNSUPPORTED_CAPABILITY', `Key "${key}" is not available on this device`);
|
|
149
|
+
await this.drv.call('key', { ...this.d, key: k });
|
|
150
|
+
}
|
|
151
|
+
async openApp(appId) {
|
|
152
|
+
await this.drv.call('launch', { ...this.d, app: appId }).catch(e => {
|
|
153
|
+
throw new MobiusError('APP_NOT_INSTALLED', e.message, { hint: 'Call list_apps to see installed apps.' });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async openUrl(url) { await this.drv.call('url', { ...this.d, url }); }
|
|
157
|
+
async listApps() { return (await this.drv.call('apps', this.d)).apps; }
|
|
158
|
+
async extra(op, args = {}) {
|
|
159
|
+
// Long-running ops get their own budget: sessions (`seconds`), routes, installs, first OCR build.
|
|
160
|
+
const secs = Number(args.seconds ?? 0);
|
|
161
|
+
const timeout = op === 'gps_route' ? 3_700_000 : op === 'install' ? 300_000
|
|
162
|
+
: ['perf_report', 'watch_crashes'].includes(op) ? (secs + 120) * 1000 : ['ocr', 'bug_report', 'replay'].includes(op) ? 180_000 : 60_000;
|
|
163
|
+
return this.drv.call(op, { ...this.d, ...args }, timeout);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Framework } from '../../core/protocol.ts';
|
|
2
|
+
import type { RawNode } from '../connector.ts';
|
|
3
|
+
/** A node as drivers report it: Android (uiautomator / on-device helper) or iOS (XCUITest snapshot). */
|
|
4
|
+
export interface SnapshotNode {
|
|
5
|
+
class?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
'content-desc'?: string;
|
|
9
|
+
'resource-id'?: string;
|
|
10
|
+
focused?: boolean;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
checked?: boolean;
|
|
13
|
+
checkable?: boolean;
|
|
14
|
+
clickable?: boolean;
|
|
15
|
+
selected?: boolean;
|
|
16
|
+
visible?: boolean;
|
|
17
|
+
editable?: boolean;
|
|
18
|
+
/** The platform password flag, when the driver reports it. */
|
|
19
|
+
password?: boolean;
|
|
20
|
+
rect?: {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
};
|
|
26
|
+
children?: SnapshotNode[] | null;
|
|
27
|
+
type?: string;
|
|
28
|
+
label?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
value?: string | null;
|
|
31
|
+
title?: string;
|
|
32
|
+
elementType?: number;
|
|
33
|
+
identifier?: string;
|
|
34
|
+
hasFocus?: boolean;
|
|
35
|
+
placeholderValue?: string;
|
|
36
|
+
frame?: {
|
|
37
|
+
X: number;
|
|
38
|
+
Y: number;
|
|
39
|
+
Width: number;
|
|
40
|
+
Height: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export declare function toRawNode(n: SnapshotNode, path: string): RawNode | null;
|
|
44
|
+
/** Flutter / React Native detection from the APK's native libs, via adb (Android only). */
|
|
45
|
+
export declare function detectAndroidFramework(serial: string, pkg: string): Promise<Framework>;
|
|
46
|
+
/** Flutter / React Native detection from a simulator app bundle's embedded frameworks. */
|
|
47
|
+
export declare function detectIosSimFramework(udid: string, bundleId: string): Promise<Framework>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Execution: normalizing what device drivers report into Mobius's RawNode tree, and detecting an app's UI framework.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
// XCUIElement.ElementType codes we care about → Android-ish class names the normalizer understands.
|
|
6
|
+
const IOS_TYPES = {
|
|
7
|
+
7: 'Alert', 9: 'Button', 10: 'RadioButton', 12: 'CheckBox', 14: 'PopUpButton', 17: 'ToolbarButton', 19: 'Keyboard',
|
|
8
|
+
20: 'Button(Key)', 21: 'NavigationBar', 22: 'TabBar', 25: 'StatusBar', 26: 'Table(ScrollView)', 32: 'CollectionView',
|
|
9
|
+
33: 'Slider', 40: 'Switch', 41: 'Switch(Toggle)', 42: 'Button(Link)', 43: 'Image', 45: 'TextField(Search)',
|
|
10
|
+
46: 'ScrollView', 48: 'StaticText', 49: 'TextField', 50: 'SecureTextField', 52: 'TextField(TextView)', 75: 'Cell', 80: 'Tab',
|
|
11
|
+
};
|
|
12
|
+
const IOS_TAPPABLE = new Set([9, 10, 12, 14, 17, 20, 40, 41, 42, 45, 49, 50, 52, 75, 80]);
|
|
13
|
+
function iosToRawNode(n, path) {
|
|
14
|
+
const t = n.elementType ?? 0;
|
|
15
|
+
if (t === 25)
|
|
16
|
+
return null; // status bar
|
|
17
|
+
const f = n.frame ?? { X: 0, Y: 0, Width: 0, Height: 0 };
|
|
18
|
+
const cls = IOS_TYPES[t] ?? 'Other';
|
|
19
|
+
const editable = t === 45 || t === 49 || t === 50 || t === 52;
|
|
20
|
+
const value = typeof n.value === 'string' ? n.value : '';
|
|
21
|
+
const label = n.label || n.title || '';
|
|
22
|
+
const node = {
|
|
23
|
+
cls, pkg: '',
|
|
24
|
+
text: editable || t === 48 ? value || (t === 48 ? label : '') : '',
|
|
25
|
+
desc: t === 48 ? (value && value !== label ? label : '') : label || (editable ? n.placeholderValue ?? '' : ''),
|
|
26
|
+
resourceId: n.identifier ?? '',
|
|
27
|
+
clickable: IOS_TAPPABLE.has(t),
|
|
28
|
+
longClickable: false,
|
|
29
|
+
enabled: n.enabled !== false,
|
|
30
|
+
focused: !!n.hasFocus,
|
|
31
|
+
scrollable: t === 26 || t === 32 || t === 46,
|
|
32
|
+
checkable: t === 12 || t === 40 || t === 41,
|
|
33
|
+
checked: (t === 40 || t === 41 || t === 12) && (value === '1' || value === 'on'),
|
|
34
|
+
selected: !!n.selected,
|
|
35
|
+
password: t === 50 || (editable && SECRET_FIELD.test(`${label} ${n.placeholderValue ?? ''} ${n.identifier ?? ''}`)),
|
|
36
|
+
bounds: { x: f.X, y: f.Y, width: f.Width, height: f.Height },
|
|
37
|
+
path,
|
|
38
|
+
children: [],
|
|
39
|
+
};
|
|
40
|
+
(n.children ?? []).forEach((c, i) => { const ch = iosToRawNode(c, `${path}.${i}`); if (ch)
|
|
41
|
+
node.children.push(ch); });
|
|
42
|
+
return node;
|
|
43
|
+
}
|
|
44
|
+
// Status bar holds notification previews (message text, OTP senders): never shown to the agent.
|
|
45
|
+
const HIDDEN_IDS = /^com\.android\.systemui:id\/(status_bar_container|status_bar_launch_animation_container)$/;
|
|
46
|
+
// Secret fields: the driver's password flag when it has one, plus hint/label/id patterns as a backstop.
|
|
47
|
+
const SECRET_FIELD = /\b(password|passcode|passwd|pin|mpin|upi pin|otp|cvv|cvc|secret)\b|one[- ]?time/i;
|
|
48
|
+
export function toRawNode(n, path) {
|
|
49
|
+
if (n.elementType !== undefined)
|
|
50
|
+
return iosToRawNode(n, path);
|
|
51
|
+
const resourceId = n['resource-id'] ?? '';
|
|
52
|
+
if (HIDDEN_IDS.test(resourceId))
|
|
53
|
+
return null;
|
|
54
|
+
const cls = n.class ?? n.type ?? '';
|
|
55
|
+
const editable = n.editable ?? /TextField|EditText|SecureTextField/.test(cls);
|
|
56
|
+
const hint = n.hint ?? '';
|
|
57
|
+
const desc = n['content-desc'] ?? n.label ?? '';
|
|
58
|
+
const node = {
|
|
59
|
+
cls: editable && !/EditText|TextField/.test(cls) ? `${cls}(EditText)` : cls,
|
|
60
|
+
pkg: '',
|
|
61
|
+
text: n.text ?? n.value ?? '',
|
|
62
|
+
desc: desc || (editable ? hint : ''),
|
|
63
|
+
resourceId: resourceId || n.name || '',
|
|
64
|
+
clickable: !!n.clickable,
|
|
65
|
+
longClickable: false,
|
|
66
|
+
enabled: n.enabled !== false,
|
|
67
|
+
focused: !!n.focused,
|
|
68
|
+
scrollable: /RecyclerView|ScrollView|ListView|GridView|ViewPager|ScrollView|Table|CollectionView/.test(cls),
|
|
69
|
+
checkable: !!n.checkable,
|
|
70
|
+
checked: !!n.checked,
|
|
71
|
+
selected: !!n.selected,
|
|
72
|
+
password: n.password === true || /SecureTextField/.test(cls) || (editable && SECRET_FIELD.test(`${hint} ${desc} ${resourceId}`)),
|
|
73
|
+
bounds: n.visible === false ? { x: 0, y: 0, width: 0, height: 0 } : (n.rect ?? { x: 0, y: 0, width: 0, height: 0 }),
|
|
74
|
+
path,
|
|
75
|
+
children: [],
|
|
76
|
+
};
|
|
77
|
+
(n.children ?? []).forEach((c, i) => {
|
|
78
|
+
const child = toRawNode(c, `${path}.${i}`);
|
|
79
|
+
if (child)
|
|
80
|
+
node.children.push(child);
|
|
81
|
+
});
|
|
82
|
+
return node;
|
|
83
|
+
}
|
|
84
|
+
/** Flutter / React Native detection from the APK's native libs, via adb (Android only). */
|
|
85
|
+
export async function detectAndroidFramework(serial, pkg) {
|
|
86
|
+
if (!/^[\w.]+$/.test(pkg))
|
|
87
|
+
return 'unknown';
|
|
88
|
+
const sh = async (cmd) => (await run('adb', ['-s', serial, 'shell', cmd], { timeout: 10_000 })).stdout;
|
|
89
|
+
const apks = (await sh(`pm path ${pkg}`)).split('\n').map(l => l.replace('package:', '').trim()).filter(Boolean);
|
|
90
|
+
if (!apks.length)
|
|
91
|
+
return 'unknown';
|
|
92
|
+
const libs = await sh(`for a in ${apks.map(a => `'${a}'`).join(' ')}; do unzip -l "$a" 2>/dev/null; done | grep -oE "lib(flutter|reactnativejni|hermes)\\.so" | sort -u`);
|
|
93
|
+
return libs.includes('libflutter') ? 'flutter' : /reactnativejni|hermes/.test(libs) ? 'react_native' : 'native';
|
|
94
|
+
}
|
|
95
|
+
/** Flutter / React Native detection from a simulator app bundle's embedded frameworks. */
|
|
96
|
+
export async function detectIosSimFramework(udid, bundleId) {
|
|
97
|
+
if (!/^[\w.\-]+$/.test(bundleId) || !/^[\w\-]+$/.test(udid))
|
|
98
|
+
return 'unknown';
|
|
99
|
+
const dir = (await run('xcrun', ['simctl', 'get_app_container', udid, bundleId, 'app'], { timeout: 10_000 })).stdout.trim();
|
|
100
|
+
const { readdir } = await import('node:fs/promises');
|
|
101
|
+
const fws = await readdir(`${dir}/Frameworks`).catch(() => []);
|
|
102
|
+
return fws.includes('Flutter.framework') ? 'flutter'
|
|
103
|
+
: fws.some(f => /React|hermes/i.test(f)) ? 'react_native' : 'native';
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Element, Framework, Observation } from '../core/protocol.ts';
|
|
2
|
+
import { type RawScreen } from './connector.ts';
|
|
3
|
+
type Role = Element['role'];
|
|
4
|
+
export declare function scrub(s: string): string;
|
|
5
|
+
export interface Fingerprint {
|
|
6
|
+
role: Role;
|
|
7
|
+
text: string | null;
|
|
8
|
+
label: string | null;
|
|
9
|
+
resource_id: string | null;
|
|
10
|
+
cls: string;
|
|
11
|
+
path: string;
|
|
12
|
+
dup_index: number;
|
|
13
|
+
}
|
|
14
|
+
export interface Normalized {
|
|
15
|
+
elements: Element[];
|
|
16
|
+
fingerprints: Map<string, Fingerprint>;
|
|
17
|
+
screen_hash: string;
|
|
18
|
+
tree_quality: Observation['tree_quality'];
|
|
19
|
+
keyboard: Observation['keyboard'];
|
|
20
|
+
}
|
|
21
|
+
export declare const MAX_ELEMENTS = 400;
|
|
22
|
+
export declare function normalize(screen: RawScreen, obsId: string): Normalized & {
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Per-device observation history. Element ids are only honoured against the latest screen.
|
|
26
|
+
* With `persistAs` (the real CLI), recent identities and the id counter survive a restart, so an id an agent got from
|
|
27
|
+
* an earlier Mobius process still resolves against the live screen (same identity rules, same ambiguity checks). */
|
|
28
|
+
export declare class ObservationStore {
|
|
29
|
+
private seq;
|
|
30
|
+
private history;
|
|
31
|
+
private saved;
|
|
32
|
+
private readonly file;
|
|
33
|
+
constructor(persistAs?: string);
|
|
34
|
+
nextId(): string;
|
|
35
|
+
add(obs: Observation, fps: Map<string, Fingerprint>): void;
|
|
36
|
+
latest(): Observation | null;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve an element id to an element on the latest screen.
|
|
39
|
+
* Same observation → direct. Older observation → re-find by identity (role/text/label/resource_id/dup_index);
|
|
40
|
+
* unique match survives benign re-renders, otherwise STALE_ELEMENT / AMBIGUOUS_TARGET.
|
|
41
|
+
*/
|
|
42
|
+
resolve(id: string): Element;
|
|
43
|
+
}
|
|
44
|
+
export declare function buildObservation(screen: RawScreen, deviceId: string, platform: Observation['platform'], obsId: string, framework: Framework): {
|
|
45
|
+
obs: Observation;
|
|
46
|
+
fps: Map<string, Fingerprint>;
|
|
47
|
+
};
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mobiusHome } from "../observability/audit.js";
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { MobiusError } from "./connector.js";
|
|
6
|
+
function roleOf(n) {
|
|
7
|
+
const c = n.cls;
|
|
8
|
+
if (/EditText|TextField|AutoCompleteTextView/.test(c))
|
|
9
|
+
return 'textbox';
|
|
10
|
+
if (/Switch/.test(c))
|
|
11
|
+
return 'switch';
|
|
12
|
+
if (/CheckBox/.test(c))
|
|
13
|
+
return 'checkbox';
|
|
14
|
+
if (/RadioButton/.test(c))
|
|
15
|
+
return 'radio';
|
|
16
|
+
if (/Button/.test(c))
|
|
17
|
+
return 'button';
|
|
18
|
+
if (/RecyclerView|ListView|ScrollView|GridView|ViewPager/.test(c))
|
|
19
|
+
return 'list';
|
|
20
|
+
if (/Tab/.test(c))
|
|
21
|
+
return 'tab';
|
|
22
|
+
if (n.checkable)
|
|
23
|
+
return 'checkbox';
|
|
24
|
+
if (n.clickable || n.longClickable)
|
|
25
|
+
return 'button';
|
|
26
|
+
if (/ImageView|Image/.test(c))
|
|
27
|
+
return 'image';
|
|
28
|
+
if (n.text)
|
|
29
|
+
return 'text';
|
|
30
|
+
return 'view';
|
|
31
|
+
}
|
|
32
|
+
// ponytail: pattern-based redaction (password flag, card-like digit runs, OTP/verification codes).
|
|
33
|
+
// Applied to text AND labels (Flutter merges row text into labels). Per-app rules come with the policy engine.
|
|
34
|
+
const REDACTED = '[redacted]';
|
|
35
|
+
const CARD_LIKE = /\b(?:\d[ -]?){12,18}\d\b/g;
|
|
36
|
+
// Digits may be spaced or dashed ("4 8 2 9 1 3", "482-913") to dodge a plain \d{6} match.
|
|
37
|
+
const OTP_LIKE = /\b(otp|one[- ]?time|verification code|security code|passcode|pin|cvv|cvc)\b([^\d]{0,25})\d(?:[ -]?\d){2,7}\b/gi;
|
|
38
|
+
export function scrub(s) {
|
|
39
|
+
return s.replace(CARD_LIKE, REDACTED).replace(OTP_LIKE, (_m, kw, mid) => `${kw}${mid}${REDACTED}`);
|
|
40
|
+
}
|
|
41
|
+
/** A field asking for a secret, whatever the driver reported (an app may not set the password flag on a PIN box). */
|
|
42
|
+
const SECRET_FIELD = /\b(password|passcode|passwd|pin|mpin|upi pin|otp|cvv|cvc|secret)\b|one[- ]?time/i;
|
|
43
|
+
const isSensitive = (n) => n.password || scrub(n.text) !== n.text || scrub(n.desc) !== n.desc
|
|
44
|
+
|| (roleOf(n) === 'textbox' && SECRET_FIELD.test(`${n.desc} ${n.resourceId}`));
|
|
45
|
+
const visible = (n) => n.bounds.width > 0 && n.bounds.height > 0;
|
|
46
|
+
const interactive = (n) => n.clickable || n.longClickable || n.scrollable || n.checkable || roleOf(n) === 'textbox';
|
|
47
|
+
/** Text of non-interactive descendants, for labelling a clickable container that has none itself. */
|
|
48
|
+
function descendantText(n, out = []) {
|
|
49
|
+
for (const c of n.children) {
|
|
50
|
+
if (out.length >= 3)
|
|
51
|
+
break;
|
|
52
|
+
if (interactive(c))
|
|
53
|
+
continue;
|
|
54
|
+
const t = c.text || c.desc;
|
|
55
|
+
if (t && !c.password)
|
|
56
|
+
out.push(scrub(t));
|
|
57
|
+
descendantText(c, out);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
// Soft keyboards: iOS Keyboard element, or Android IME packages (Gboard, Samsung, SwiftKey...).
|
|
62
|
+
const isKeyboard = (n) => n.cls === 'Keyboard' || /inputmethod|keyboard|honeyboard|swiftkey/i.test(n.resourceId.split(':')[0] ?? '');
|
|
63
|
+
export const MAX_ELEMENTS = 400;
|
|
64
|
+
export function normalize(screen, obsId) {
|
|
65
|
+
const picked = [];
|
|
66
|
+
let kb = null;
|
|
67
|
+
const walk = (n, insideClickable) => {
|
|
68
|
+
if (isKeyboard(n) && visible(n)) {
|
|
69
|
+
const b = n.bounds;
|
|
70
|
+
kb = kb ? { x1: Math.min(kb.x1, b.x), y1: Math.min(kb.y1, b.y), x2: Math.max(kb.x2, b.x + b.width), y2: Math.max(kb.y2, b.y + b.height) }
|
|
71
|
+
: { x1: b.x, y1: b.y, x2: b.x + b.width, y2: b.y + b.height };
|
|
72
|
+
return; // keys are noise for the agent; the area is reported as `keyboard`
|
|
73
|
+
}
|
|
74
|
+
if (!visible(n) && n.cls !== 'hierarchy') {
|
|
75
|
+
// Zero-size containers (common on iOS) can still hold visible children.
|
|
76
|
+
for (const c of n.children)
|
|
77
|
+
walk(c, insideClickable);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const own = n.text || n.desc;
|
|
81
|
+
const isInteractive = interactive(n);
|
|
82
|
+
if (isInteractive) {
|
|
83
|
+
let text = n.text ? (n.password ? REDACTED : scrub(n.text)) : null;
|
|
84
|
+
const label = n.desc ? scrub(n.desc) : null;
|
|
85
|
+
if (!text && !label && (n.clickable || n.longClickable))
|
|
86
|
+
text = descendantText(n).join(' · ') || null;
|
|
87
|
+
picked.push({ n, text, label });
|
|
88
|
+
}
|
|
89
|
+
else if (own && !insideClickable) {
|
|
90
|
+
// Plain text not owned by a clickable ancestor stays readable for the agent.
|
|
91
|
+
picked.push({ n, text: n.text ? (n.password ? REDACTED : scrub(n.text)) : null, label: n.desc ? scrub(n.desc) : null });
|
|
92
|
+
}
|
|
93
|
+
const clickableHere = insideClickable || n.clickable || n.longClickable;
|
|
94
|
+
for (const c of n.children)
|
|
95
|
+
walk(c, clickableHere);
|
|
96
|
+
};
|
|
97
|
+
if (screen.root)
|
|
98
|
+
walk(screen.root, false);
|
|
99
|
+
const truncated = picked.length > MAX_ELEMENTS;
|
|
100
|
+
const dupCount = new Map();
|
|
101
|
+
const fingerprints = new Map();
|
|
102
|
+
const elements = picked.slice(0, MAX_ELEMENTS).map(({ n, text, label }, i) => {
|
|
103
|
+
const role = roleOf(n);
|
|
104
|
+
const key = `${role}|${text}|${label}`;
|
|
105
|
+
const dup_index = text || label ? dupCount.get(key) ?? 0 : 0; // unlabeled nodes aren't "duplicates"
|
|
106
|
+
if (text || label)
|
|
107
|
+
dupCount.set(key, dup_index + 1);
|
|
108
|
+
const id = `${obsId}:e${i + 1}`;
|
|
109
|
+
const resource_id = n.resourceId || null;
|
|
110
|
+
fingerprints.set(id, { role, text, label, resource_id, cls: n.cls, path: n.path, dup_index });
|
|
111
|
+
return {
|
|
112
|
+
id, role, text, label, resource_id,
|
|
113
|
+
clickable: n.clickable, long_clickable: n.longClickable, enabled: n.enabled, focused: n.focused,
|
|
114
|
+
scrollable: n.scrollable, editable: role === 'textbox', checked: n.checkable ? n.checked : null,
|
|
115
|
+
selected: n.selected, sensitive: isSensitive(n), bounds: n.bounds, dup_index,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const screen_hash = createHash('sha1')
|
|
119
|
+
.update(JSON.stringify(elements.map(e => [e.role, e.text, e.label, e.resource_id, e.bounds, e.enabled, e.checked])))
|
|
120
|
+
.digest('hex').slice(0, 12);
|
|
121
|
+
const actionable = elements.filter(e => e.clickable || e.editable || e.long_clickable);
|
|
122
|
+
const unlabeled = actionable.filter(e => !e.text && !e.label && !e.resource_id).length;
|
|
123
|
+
const tree_quality = elements.length === 0 ? 'empty'
|
|
124
|
+
: actionable.length <= 1 || unlabeled / Math.max(actionable.length, 1) > 0.5 ? 'sparse'
|
|
125
|
+
: 'good';
|
|
126
|
+
const k = kb;
|
|
127
|
+
const keyboard = k ? { x: k.x1, y: k.y1, width: k.x2 - k.x1, height: k.y2 - k.y1 } : null;
|
|
128
|
+
return { elements, fingerprints, screen_hash, tree_quality, truncated, keyboard };
|
|
129
|
+
}
|
|
130
|
+
const sameIdentity = (a, e) => a.role === e.role && a.text === e.text && a.label === e.label && a.resource_id === e.resource_id;
|
|
131
|
+
/** Identity of an element as a hash: what gets saved to disk, so no screen text is ever written there. */
|
|
132
|
+
const identityHash = (f) => createHash('sha256').update(JSON.stringify([f.role, f.text, f.label, f.resource_id])).digest('base64url').slice(0, 22);
|
|
133
|
+
/** Per-device observation history. Element ids are only honoured against the latest screen.
|
|
134
|
+
* With `persistAs` (the real CLI), recent identities and the id counter survive a restart, so an id an agent got from
|
|
135
|
+
* an earlier Mobius process still resolves against the live screen (same identity rules, same ambiguity checks). */
|
|
136
|
+
export class ObservationStore {
|
|
137
|
+
seq = 0;
|
|
138
|
+
history = [];
|
|
139
|
+
saved = [];
|
|
140
|
+
file;
|
|
141
|
+
constructor(persistAs) {
|
|
142
|
+
this.file = persistAs ? join(mobiusHome(), 'obs', `${persistAs.replace(/[^\w.-]/g, '_')}.json`) : null;
|
|
143
|
+
if (!this.file)
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
const d = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
147
|
+
this.seq = d.seq | 0;
|
|
148
|
+
this.saved = Array.isArray(d.saved) ? d.saved.slice(-5) : [];
|
|
149
|
+
}
|
|
150
|
+
catch { /* first run on this device */ }
|
|
151
|
+
}
|
|
152
|
+
nextId() { return `o${++this.seq}`; }
|
|
153
|
+
add(obs, fps) {
|
|
154
|
+
this.history.push({ obs, fps });
|
|
155
|
+
// ponytail: settle/verify polls add an observation every ~100 ms, so a small window evicts the ids the agent
|
|
156
|
+
// just got from observe after one slow action. 64 covers a full verify timeout; the on-disk copy stays at 5.
|
|
157
|
+
if (this.history.length > 64)
|
|
158
|
+
this.history.shift();
|
|
159
|
+
if (!this.file)
|
|
160
|
+
return;
|
|
161
|
+
this.saved.push([...fps].map(([id, f]) => ({ id, hash: identityHash(f), dup: f.dup_index, rid: !!f.resource_id })));
|
|
162
|
+
this.saved = this.saved.slice(-5);
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
165
|
+
writeFileSync(this.file, JSON.stringify({ seq: this.seq, saved: this.saved }), { mode: 0o600 });
|
|
166
|
+
}
|
|
167
|
+
catch { /* best effort: ids still work within this process */ }
|
|
168
|
+
}
|
|
169
|
+
latest() { return this.history.at(-1)?.obs ?? null; }
|
|
170
|
+
/**
|
|
171
|
+
* Resolve an element id to an element on the latest screen.
|
|
172
|
+
* Same observation → direct. Older observation → re-find by identity (role/text/label/resource_id/dup_index);
|
|
173
|
+
* unique match survives benign re-renders, otherwise STALE_ELEMENT / AMBIGUOUS_TARGET.
|
|
174
|
+
*/
|
|
175
|
+
resolve(id) {
|
|
176
|
+
const latest = this.history.at(-1);
|
|
177
|
+
if (!latest)
|
|
178
|
+
throw new MobiusError('STALE_ELEMENT', `No observation yet for ${id}`, { retryable: true, hint: 'Call observe first.' });
|
|
179
|
+
const direct = latest.obs.elements.find(e => e.id === id);
|
|
180
|
+
if (direct)
|
|
181
|
+
return direct;
|
|
182
|
+
const fp = this.history.find(h => h.fps.has(id))?.fps.get(id);
|
|
183
|
+
if (!fp) {
|
|
184
|
+
// An id from an earlier process: match by saved identity hash on the live screen.
|
|
185
|
+
const sv = this.saved.flat().find(x => x.id === id);
|
|
186
|
+
if (sv) {
|
|
187
|
+
const m = latest.obs.elements.filter(e => identityHash(e) === sv.hash);
|
|
188
|
+
const { width: W, height: H } = latest.obs.screen;
|
|
189
|
+
const vis = m.filter(e => e.bounds.y + e.bounds.height > 0 && e.bounds.y < H && e.bounds.x + e.bounds.width > 0 && e.bounds.x < W);
|
|
190
|
+
if (m.length === 1)
|
|
191
|
+
return m[0];
|
|
192
|
+
if (vis.length === 1)
|
|
193
|
+
return vis[0];
|
|
194
|
+
const exact = m.filter(e => e.dup_index === sv.dup);
|
|
195
|
+
if (m.length > 1 && exact.length === 1 && sv.rid)
|
|
196
|
+
return exact[0];
|
|
197
|
+
if (m.length > 1)
|
|
198
|
+
throw new MobiusError('AMBIGUOUS_TARGET', `${id} matches ${m.length} elements on the current screen`, { hint: `Pick one of: ${m.map(e => e.id).join(', ')}` });
|
|
199
|
+
throw new MobiusError('STALE_ELEMENT', `${id} is not on the current screen (${latest.obs.observation_id})`, { retryable: true, hint: 'The screen changed. Call observe and pick a fresh id.' });
|
|
200
|
+
}
|
|
201
|
+
throw new MobiusError('ELEMENT_NOT_FOUND', `Unknown element id ${id}`, {
|
|
202
|
+
hint: `Use an id from the latest observation (${latest.obs.observation_id}).`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const matches = latest.obs.elements.filter(e => sameIdentity(fp, e));
|
|
206
|
+
const exact = matches.filter(e => e.dup_index === fp.dup_index);
|
|
207
|
+
if (matches.length === 1)
|
|
208
|
+
return matches[0];
|
|
209
|
+
// A copy scrolled far off-screen (sticky headers leave one in the tree; Slikk, live run) isn't what the agent means.
|
|
210
|
+
const { width: W, height: H } = latest.obs.screen;
|
|
211
|
+
const visible = matches.filter(e => e.bounds.y + e.bounds.height > 0 && e.bounds.y < H && e.bounds.x + e.bounds.width > 0 && e.bounds.x < W);
|
|
212
|
+
if (visible.length === 1)
|
|
213
|
+
return visible[0];
|
|
214
|
+
if (exact.length === 1 && matches.length > 1 && fp.resource_id)
|
|
215
|
+
return exact[0];
|
|
216
|
+
if (matches.length > 1) {
|
|
217
|
+
throw new MobiusError('AMBIGUOUS_TARGET', `${id} matches ${matches.length} elements on the current screen`, {
|
|
218
|
+
hint: `Pick one of: ${matches.map(m => m.id).join(', ')}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
throw new MobiusError('STALE_ELEMENT', `${id} is not on the current screen (${latest.obs.observation_id})`, {
|
|
222
|
+
retryable: true, hint: 'The screen changed. Call observe and pick a fresh id.',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
export function buildObservation(screen, deviceId, platform, obsId, framework) {
|
|
227
|
+
const n = normalize(screen, obsId);
|
|
228
|
+
return {
|
|
229
|
+
obs: {
|
|
230
|
+
observation_id: obsId,
|
|
231
|
+
device_id: deviceId,
|
|
232
|
+
platform,
|
|
233
|
+
foreground_app: screen.foregroundApp,
|
|
234
|
+
screen: { width: screen.width, height: screen.height, rotation: screen.rotation },
|
|
235
|
+
screen_hash: n.screen_hash,
|
|
236
|
+
framework,
|
|
237
|
+
tree_quality: n.tree_quality,
|
|
238
|
+
sources: ['a11y'],
|
|
239
|
+
elements: n.elements,
|
|
240
|
+
untrusted_content: true,
|
|
241
|
+
truncated: n.truncated,
|
|
242
|
+
keyboard: n.keyboard,
|
|
243
|
+
},
|
|
244
|
+
fps: n.fingerprints,
|
|
245
|
+
};
|
|
246
|
+
}
|