@houwert/conductor 0.2.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/.claude-plugin/plugin.json +6 -0
- package/README.md +39 -0
- package/dist/commands/assert-not-visible.js +47 -0
- package/dist/commands/assert-visible.js +58 -0
- package/dist/commands/back.js +25 -0
- package/dist/commands/cheat-sheet.js +100 -0
- package/dist/commands/daemon.js +61 -0
- package/dist/commands/device-pool.js +202 -0
- package/dist/commands/erase-text.js +26 -0
- package/dist/commands/foreground-app.js +50 -0
- package/dist/commands/hide-keyboard.js +27 -0
- package/dist/commands/inspect.js +37 -0
- package/dist/commands/install.js +64 -0
- package/dist/commands/launch-app.js +42 -0
- package/dist/commands/list-apps.js +60 -0
- package/dist/commands/list-devices.js +61 -0
- package/dist/commands/open-link.js +22 -0
- package/dist/commands/press-key.js +91 -0
- package/dist/commands/run-flow-inline.js +25 -0
- package/dist/commands/run-flow.js +29 -0
- package/dist/commands/run-parallel.js +143 -0
- package/dist/commands/screenshot.js +29 -0
- package/dist/commands/scroll-until-visible.js +69 -0
- package/dist/commands/scroll.js +36 -0
- package/dist/commands/session.js +49 -0
- package/dist/commands/set-location.js +18 -0
- package/dist/commands/set-orientation.js +23 -0
- package/dist/commands/start-device.js +178 -0
- package/dist/commands/stop-app.js +32 -0
- package/dist/commands/swipe.js +72 -0
- package/dist/commands/tap.js +69 -0
- package/dist/commands/type.js +22 -0
- package/dist/daemon/client.js +112 -0
- package/dist/daemon/protocol.js +25 -0
- package/dist/daemon/server.js +208 -0
- package/dist/drivers/android.js +343 -0
- package/dist/drivers/bootstrap.js +371 -0
- package/dist/drivers/element-resolver.js +371 -0
- package/dist/drivers/flow-runner.js +1309 -0
- package/dist/drivers/ios.js +328 -0
- package/dist/drivers/js-engine.js +150 -0
- package/dist/drivers/wait.js +211 -0
- package/dist/index.js +426 -0
- package/dist/output.js +36 -0
- package/dist/pkg-root.js +28 -0
- package/dist/postinstall.js +12 -0
- package/dist/runner.js +190 -0
- package/dist/session.js +66 -0
- package/dist/update-check.js +109 -0
- package/dist/utils.js +19 -0
- package/dist/verbose.js +17 -0
- package/drivers/android/conductor-app.apk +0 -0
- package/drivers/android/conductor-server.apk +0 -0
- package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
- package/drivers/ios/conductor-driver-ios.zip +0 -0
- package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
- package/package.json +52 -0
- package/proto/conductor_android.proto +116 -0
- package/skills/conductor/SKILL.md +677 -0
- package/skills/conductor/references/flow-syntax.md +179 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startDaemon = startDaemon;
|
|
7
|
+
exports.stopDaemon = stopDaemon;
|
|
8
|
+
exports.listDaemonSessions = listDaemonSessions;
|
|
9
|
+
exports.daemonStatus = daemonStatus;
|
|
10
|
+
const net_1 = __importDefault(require("net"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const protocol_js_1 = require("./protocol.js");
|
|
16
|
+
const verbose_js_1 = require("../verbose.js");
|
|
17
|
+
const STARTUP_POLL_MS = 200;
|
|
18
|
+
const STARTUP_MAX_WAIT_MS = 10000;
|
|
19
|
+
async function socketExists(sessionName) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const sock = net_1.default.createConnection((0, protocol_js_1.socketPath)(sessionName));
|
|
22
|
+
sock.on('connect', () => {
|
|
23
|
+
sock.destroy();
|
|
24
|
+
resolve(true);
|
|
25
|
+
});
|
|
26
|
+
sock.on('error', () => resolve(false));
|
|
27
|
+
sock.setTimeout(500);
|
|
28
|
+
sock.on('timeout', () => {
|
|
29
|
+
sock.destroy();
|
|
30
|
+
resolve(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function waitForDaemon(sessionName) {
|
|
35
|
+
const deadline = Date.now() + STARTUP_MAX_WAIT_MS;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
if (await socketExists(sessionName))
|
|
38
|
+
return true;
|
|
39
|
+
await new Promise((r) => setTimeout(r, STARTUP_POLL_MS));
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
async function startDaemon(sessionName = 'default') {
|
|
44
|
+
if (await socketExists(sessionName))
|
|
45
|
+
return true;
|
|
46
|
+
const serverScript = path_1.default.join(__dirname, 'server.js');
|
|
47
|
+
(0, verbose_js_1.log)(`daemon [${sessionName}] not running — spawning ${serverScript}`);
|
|
48
|
+
const child = (0, child_process_1.spawn)(process.execPath, [serverScript, sessionName], {
|
|
49
|
+
detached: true,
|
|
50
|
+
stdio: 'ignore',
|
|
51
|
+
});
|
|
52
|
+
child.unref();
|
|
53
|
+
(0, verbose_js_1.log)(`waiting for daemon [${sessionName}] to be ready...`);
|
|
54
|
+
const ready = await waitForDaemon(sessionName);
|
|
55
|
+
(0, verbose_js_1.log)(ready
|
|
56
|
+
? `daemon [${sessionName}] ready`
|
|
57
|
+
: `daemon [${sessionName}] failed to start within timeout`);
|
|
58
|
+
return ready;
|
|
59
|
+
}
|
|
60
|
+
async function stopDaemon(sessionName = 'default') {
|
|
61
|
+
let killed = false;
|
|
62
|
+
try {
|
|
63
|
+
const pid = parseInt(fs_1.default.readFileSync((0, protocol_js_1.pidFile)(sessionName), 'utf-8').trim(), 10);
|
|
64
|
+
if (!isNaN(pid)) {
|
|
65
|
+
process.kill(pid, 'SIGTERM');
|
|
66
|
+
killed = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* pid file missing or process already gone */
|
|
71
|
+
}
|
|
72
|
+
// Clean up the daemon directory regardless — removes stale dirs from crashed daemons
|
|
73
|
+
const dir = path_1.default.join(os_1.default.homedir(), '.conductor', 'daemons', sessionName);
|
|
74
|
+
for (const file of [(0, protocol_js_1.socketPath)(sessionName), (0, protocol_js_1.pidFile)(sessionName), (0, protocol_js_1.logFile)(sessionName)]) {
|
|
75
|
+
try {
|
|
76
|
+
fs_1.default.unlinkSync(file);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* ok */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
fs_1.default.rmdirSync(dir);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* ok if non-empty or already gone */
|
|
87
|
+
}
|
|
88
|
+
return killed;
|
|
89
|
+
}
|
|
90
|
+
function listDaemonSessions() {
|
|
91
|
+
const daemonsDir = path_1.default.join(os_1.default.homedir(), '.conductor', 'daemons');
|
|
92
|
+
try {
|
|
93
|
+
return fs_1.default.readdirSync(daemonsDir).filter((name) => {
|
|
94
|
+
return fs_1.default.statSync(path_1.default.join(daemonsDir, name)).isDirectory();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function daemonStatus(sessionName = 'default') {
|
|
102
|
+
const running = await socketExists(sessionName);
|
|
103
|
+
if (!running)
|
|
104
|
+
return { running: false };
|
|
105
|
+
try {
|
|
106
|
+
const pid = parseInt(fs_1.default.readFileSync((0, protocol_js_1.pidFile)(sessionName), 'utf-8').trim(), 10);
|
|
107
|
+
return { running: true, pid: isNaN(pid) ? undefined : pid };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { running: true };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.IDLE_TIMEOUT_MS = void 0;
|
|
7
|
+
exports.socketPath = socketPath;
|
|
8
|
+
exports.pidFile = pidFile;
|
|
9
|
+
exports.logFile = logFile;
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const DIR = path_1.default.join(os_1.default.homedir(), '.conductor');
|
|
13
|
+
function daemonDir(sessionName = 'default') {
|
|
14
|
+
return path_1.default.join(DIR, 'daemons', sessionName);
|
|
15
|
+
}
|
|
16
|
+
function socketPath(sessionName = 'default') {
|
|
17
|
+
return path_1.default.join(daemonDir(sessionName), 'daemon.sock');
|
|
18
|
+
}
|
|
19
|
+
function pidFile(sessionName = 'default') {
|
|
20
|
+
return path_1.default.join(daemonDir(sessionName), 'daemon.pid');
|
|
21
|
+
}
|
|
22
|
+
function logFile(sessionName = 'default') {
|
|
23
|
+
return path_1.default.join(daemonDir(sessionName), 'daemon.log');
|
|
24
|
+
}
|
|
25
|
+
exports.IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
/**
|
|
7
|
+
* Daemon process — run as a detached background process.
|
|
8
|
+
*
|
|
9
|
+
* Repurposed from the MCP proxy: now manages the underlying device driver process
|
|
10
|
+
* (iOS XCTest HTTP server or Android gRPC instrumentation).
|
|
11
|
+
*
|
|
12
|
+
* The Unix socket is kept alive purely for status checks (daemonStatus() tests
|
|
13
|
+
* if the socket is connectable). No tool-call proxying happens here.
|
|
14
|
+
*
|
|
15
|
+
* Spawned by: node dist/daemon/server.js [sessionName]
|
|
16
|
+
*/
|
|
17
|
+
const net_1 = __importDefault(require("net"));
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const protocol_js_1 = require("./protocol.js");
|
|
21
|
+
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
22
|
+
const sessionName = process.argv[2] ?? 'default';
|
|
23
|
+
const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
|
|
24
|
+
const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
|
|
25
|
+
const LOG_FILE = (0, protocol_js_1.logFile)(sessionName);
|
|
26
|
+
function dlog(msg) {
|
|
27
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
28
|
+
try {
|
|
29
|
+
fs_1.default.appendFileSync(LOG_FILE, line);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ── Driver lifecycle ──────────────────────────────────────────────────────────
|
|
36
|
+
let driverPort = 1075;
|
|
37
|
+
let driverPlatform = 'ios';
|
|
38
|
+
const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
|
|
39
|
+
let _restartInProgress = false;
|
|
40
|
+
async function ensureDriverRunning() {
|
|
41
|
+
if (_restartInProgress)
|
|
42
|
+
return;
|
|
43
|
+
const alive = await (0, bootstrap_js_1.isPortOpen)(driverPort);
|
|
44
|
+
if (!alive) {
|
|
45
|
+
if (driverPlatform === 'ios' && !(await (0, bootstrap_js_1.isSimulatorBooted)(sessionName))) {
|
|
46
|
+
dlog(`Simulator ${sessionName} is not booted — skipping driver restart`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
_restartInProgress = true;
|
|
50
|
+
dlog(`Driver on port ${driverPort} not responding — restarting`);
|
|
51
|
+
try {
|
|
52
|
+
if (driverPlatform === 'ios') {
|
|
53
|
+
await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
|
|
57
|
+
}
|
|
58
|
+
dlog(`Driver restarted on port ${driverPort}`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
dlog(`Failed to restart driver: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
_restartInProgress = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Daemon main ──────────────────────────────────────────────────────────────
|
|
69
|
+
async function main() {
|
|
70
|
+
// Ensure per-session daemon directory exists
|
|
71
|
+
fs_1.default.mkdirSync(path_1.default.dirname(PID_FILE), { recursive: true });
|
|
72
|
+
fs_1.default.writeFileSync(PID_FILE, String(process.pid));
|
|
73
|
+
dlog(`daemon started pid=${process.pid} session=${sessionName}`);
|
|
74
|
+
// Remove stale socket
|
|
75
|
+
try {
|
|
76
|
+
fs_1.default.unlinkSync(SOCKET_PATH);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* ok */
|
|
80
|
+
}
|
|
81
|
+
let idleTimer;
|
|
82
|
+
let healthTimer;
|
|
83
|
+
const idleTimeoutMs = Number(process.env.CONDUCTOR_IDLE_TIMEOUT_MS) || protocol_js_1.IDLE_TIMEOUT_MS;
|
|
84
|
+
function resetIdleTimer() {
|
|
85
|
+
if (idleTimer)
|
|
86
|
+
clearTimeout(idleTimer);
|
|
87
|
+
idleTimer = setTimeout(() => {
|
|
88
|
+
dlog('Idle timeout reached — shutting down');
|
|
89
|
+
cleanup().then(() => process.exit(0));
|
|
90
|
+
}, idleTimeoutMs);
|
|
91
|
+
}
|
|
92
|
+
async function cleanup() {
|
|
93
|
+
if (healthTimer)
|
|
94
|
+
clearInterval(healthTimer);
|
|
95
|
+
try {
|
|
96
|
+
fs_1.default.unlinkSync(SOCKET_PATH);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* ok */
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
fs_1.default.unlinkSync(PID_FILE);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* ok */
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
fs_1.default.unlinkSync(LOG_FILE);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* ok if non-empty or already gone */
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
fs_1.default.rmdirSync(path_1.default.dirname(PID_FILE));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* ok */
|
|
118
|
+
}
|
|
119
|
+
if (sessionName !== 'default') {
|
|
120
|
+
dlog(`Stopping driver on port ${driverPort}`);
|
|
121
|
+
try {
|
|
122
|
+
if (driverPlatform === 'ios') {
|
|
123
|
+
await (0, bootstrap_js_1.stopIOSDriver)(sessionName);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
await (0, bootstrap_js_1.stopAndroidDriver)(sessionName);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
dlog(`Stop driver error: ${err instanceof Error ? err.message : String(err)}`);
|
|
131
|
+
}
|
|
132
|
+
dlog(`Uninstalling driver from ${sessionName}`);
|
|
133
|
+
try {
|
|
134
|
+
await (0, bootstrap_js_1.uninstallDriver)(sessionName, driverPlatform);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
dlog(`Uninstall driver error: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
process.on('SIGTERM', () => {
|
|
142
|
+
cleanup().then(() => process.exit(0));
|
|
143
|
+
});
|
|
144
|
+
process.on('SIGINT', () => {
|
|
145
|
+
cleanup().then(() => process.exit(0));
|
|
146
|
+
});
|
|
147
|
+
// Periodically check driver health and restart if needed
|
|
148
|
+
if (sessionName !== 'default') {
|
|
149
|
+
healthTimer = setInterval(() => {
|
|
150
|
+
ensureDriverRunning().catch((err) => dlog(`Health check error: ${err.message}`));
|
|
151
|
+
}, DRIVER_HEALTH_INTERVAL_MS);
|
|
152
|
+
healthTimer.unref(); // Don't keep the process alive just for health checks
|
|
153
|
+
}
|
|
154
|
+
// Create socket — accept connections as aliveness pings (no message exchange needed)
|
|
155
|
+
const server = net_1.default.createServer({ allowHalfOpen: true }, (socket) => {
|
|
156
|
+
resetIdleTimer();
|
|
157
|
+
// Close immediately — the connect/accept is enough for daemonStatus()
|
|
158
|
+
socket.end();
|
|
159
|
+
socket.on('error', () => {
|
|
160
|
+
/* ignore */
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
server.listen(SOCKET_PATH, () => {
|
|
164
|
+
dlog(`socket ready at ${SOCKET_PATH}`);
|
|
165
|
+
resetIdleTimer();
|
|
166
|
+
// Start driver in the background after the socket is ready (so the client
|
|
167
|
+
// doesn't time out waiting for the socket while the driver is starting).
|
|
168
|
+
if (sessionName !== 'default') {
|
|
169
|
+
(0, bootstrap_js_1.detectPlatform)(sessionName)
|
|
170
|
+
.then(async (platform) => {
|
|
171
|
+
driverPlatform = platform;
|
|
172
|
+
driverPort = await (0, bootstrap_js_1.getDriverPort)(platform, sessionName);
|
|
173
|
+
dlog(`Platform: ${platform}, port: ${driverPort}`);
|
|
174
|
+
if (await (0, bootstrap_js_1.isPortOpen)(driverPort)) {
|
|
175
|
+
dlog(`Driver already running on port ${driverPort}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Android: install APKs before starting the driver.
|
|
179
|
+
// iOS: startIOSDriver uses xcodebuild which installs silently via DependentProductPaths.
|
|
180
|
+
if (platform === 'android') {
|
|
181
|
+
dlog(`Installing Android driver on ${sessionName}`);
|
|
182
|
+
await (0, bootstrap_js_1.installDriver)(sessionName);
|
|
183
|
+
dlog(`Driver installation complete`);
|
|
184
|
+
}
|
|
185
|
+
dlog(`Starting ${platform} driver on port ${driverPort}`);
|
|
186
|
+
try {
|
|
187
|
+
if (platform === 'ios') {
|
|
188
|
+
await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
|
|
192
|
+
}
|
|
193
|
+
dlog(`Driver started successfully`);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
dlog(`Driver startup error: ${err instanceof Error ? err.message : String(err)}`);
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
.catch((err) => {
|
|
200
|
+
dlog(`Platform detection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
main().catch((err) => {
|
|
206
|
+
console.error('Daemon error:', err);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.AndroidDriver = void 0;
|
|
40
|
+
/**
|
|
41
|
+
* Direct gRPC + ADB client for the Conductor Android driver.
|
|
42
|
+
*
|
|
43
|
+
* Protocol:
|
|
44
|
+
* - gRPC plaintext on localhost:3763 (ADB-forwarded) for: tap, inputText, eraseAllText,
|
|
45
|
+
* screenshot, viewHierarchy, launchApp, deviceInfo
|
|
46
|
+
* - ADB shell for: back, swipe/scroll, pressKey, stopApp (not in gRPC proto)
|
|
47
|
+
*/
|
|
48
|
+
const grpc = __importStar(require("@grpc/grpc-js"));
|
|
49
|
+
const protoLoader = __importStar(require("@grpc/proto-loader"));
|
|
50
|
+
const child_process_1 = require("child_process");
|
|
51
|
+
const fs_1 = __importDefault(require("fs"));
|
|
52
|
+
const path_1 = __importDefault(require("path"));
|
|
53
|
+
// __dirname is available in CommonJS — points to dist/drivers/
|
|
54
|
+
const PROTO_PATH = path_1.default.join(__dirname, '../../proto/conductor_android.proto');
|
|
55
|
+
let _packageDef = null;
|
|
56
|
+
function loadPackageDef() {
|
|
57
|
+
if (!_packageDef) {
|
|
58
|
+
_packageDef = protoLoader.loadSync(PROTO_PATH, {
|
|
59
|
+
keepCase: true,
|
|
60
|
+
longs: String,
|
|
61
|
+
enums: String,
|
|
62
|
+
defaults: true,
|
|
63
|
+
oneofs: true,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return _packageDef;
|
|
67
|
+
}
|
|
68
|
+
class AndroidDriver {
|
|
69
|
+
constructor(deviceId, port = 3763) {
|
|
70
|
+
this.deviceId = deviceId;
|
|
71
|
+
this.port = port;
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
this.client = null;
|
|
74
|
+
this._recordingProcess = null;
|
|
75
|
+
this._recordingOutputPath = '';
|
|
76
|
+
}
|
|
77
|
+
async connect() {
|
|
78
|
+
const packageDef = loadPackageDef();
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
const proto = grpc.loadPackageDefinition(packageDef);
|
|
81
|
+
const ConductorDriver = proto.conductor_android.ConductorDriver;
|
|
82
|
+
this.client = new ConductorDriver(`localhost:${this.port}`, grpc.credentials.createInsecure(), {
|
|
83
|
+
'grpc.keepalive_time_ms': 120000,
|
|
84
|
+
'grpc.keepalive_timeout_ms': 20000,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async close() {
|
|
88
|
+
if (this.client) {
|
|
89
|
+
await new Promise((resolve) => this.client.close(() => resolve()));
|
|
90
|
+
this.client = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async isAlive() {
|
|
94
|
+
try {
|
|
95
|
+
await this.deviceInfo();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
call(method, req) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
if (!this.client) {
|
|
105
|
+
reject(new Error('AndroidDriver: not connected'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
|
+
this.client[method](req, (err, resp) => {
|
|
110
|
+
if (err)
|
|
111
|
+
reject(err);
|
|
112
|
+
else
|
|
113
|
+
resolve(resp);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async deviceInfo() {
|
|
118
|
+
const resp = await this.call('deviceInfo', {});
|
|
119
|
+
return { widthPixels: resp.widthPixels, heightPixels: resp.heightPixels };
|
|
120
|
+
}
|
|
121
|
+
async tap(x, y) {
|
|
122
|
+
await this.call('tap', { x: Math.round(x), y: Math.round(y) });
|
|
123
|
+
}
|
|
124
|
+
async inputText(text) {
|
|
125
|
+
await this.call('inputText', { text });
|
|
126
|
+
}
|
|
127
|
+
async eraseAllText(charactersToErase = 50) {
|
|
128
|
+
await this.call('eraseAllText', { charactersToErase });
|
|
129
|
+
}
|
|
130
|
+
async launchApp(packageName, args) {
|
|
131
|
+
const arguments_ = [];
|
|
132
|
+
if (args) {
|
|
133
|
+
for (const [key, value] of Object.entries(args)) {
|
|
134
|
+
arguments_.push({ key, value, type: 'string' });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
await this.call('launchApp', { packageName, arguments: arguments_ });
|
|
138
|
+
}
|
|
139
|
+
async viewHierarchy() {
|
|
140
|
+
const resp = await this.call('viewHierarchy', {});
|
|
141
|
+
return resp.hierarchy;
|
|
142
|
+
}
|
|
143
|
+
async screenshot() {
|
|
144
|
+
const resp = await this.call('screenshot', {});
|
|
145
|
+
return Buffer.from(resp.bytes);
|
|
146
|
+
}
|
|
147
|
+
// ── ADB-shell operations (not in gRPC proto) ─────────────────────────────
|
|
148
|
+
adb(args) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const proc = (0, child_process_1.spawn)('adb', ['-s', this.deviceId, ...args], { stdio: 'ignore' });
|
|
151
|
+
proc.on('close', (code) => {
|
|
152
|
+
if (code === 0)
|
|
153
|
+
resolve();
|
|
154
|
+
else
|
|
155
|
+
reject(new Error(`adb ${args.join(' ')} failed with exit code ${code}`));
|
|
156
|
+
});
|
|
157
|
+
proc.on('error', reject);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async back() {
|
|
161
|
+
await this.adb(['shell', 'input', 'keyevent', '4']);
|
|
162
|
+
}
|
|
163
|
+
async stopApp(packageName) {
|
|
164
|
+
await this.adb(['shell', 'am', 'force-stop', packageName]);
|
|
165
|
+
}
|
|
166
|
+
async swipe(startX, startY, endX, endY, durationMs) {
|
|
167
|
+
await this.adb([
|
|
168
|
+
'shell',
|
|
169
|
+
'input',
|
|
170
|
+
'swipe',
|
|
171
|
+
String(Math.round(startX)),
|
|
172
|
+
String(Math.round(startY)),
|
|
173
|
+
String(Math.round(endX)),
|
|
174
|
+
String(Math.round(endY)),
|
|
175
|
+
String(Math.round(durationMs)),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
/** Press a key by Android keyevent code. */
|
|
179
|
+
async pressKeyEvent(keycode) {
|
|
180
|
+
await this.adb(['shell', 'input', 'keyevent', String(keycode)]);
|
|
181
|
+
}
|
|
182
|
+
adbOutput(args) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const proc = (0, child_process_1.spawn)('adb', ['-s', this.deviceId, ...args], {
|
|
185
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
186
|
+
});
|
|
187
|
+
let stdout = '';
|
|
188
|
+
proc.stdout.on('data', (chunk) => {
|
|
189
|
+
stdout += chunk.toString();
|
|
190
|
+
});
|
|
191
|
+
proc.on('close', (code) => {
|
|
192
|
+
if (code === 0)
|
|
193
|
+
resolve(stdout);
|
|
194
|
+
else
|
|
195
|
+
reject(new Error(`adb ${args.join(' ')} failed with exit code ${code}`));
|
|
196
|
+
});
|
|
197
|
+
proc.on('error', reject);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async getForegroundApp() {
|
|
201
|
+
const output = await this.adbOutput(['shell', 'dumpsys', 'activity', 'activities']);
|
|
202
|
+
const match = output.match(/mResumedActivity.*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
|
|
203
|
+
if (!match)
|
|
204
|
+
throw new Error('Could not determine foreground app');
|
|
205
|
+
return match[1];
|
|
206
|
+
}
|
|
207
|
+
async clearAppState(packageName) {
|
|
208
|
+
await this.adb(['shell', 'pm', 'clear', packageName]);
|
|
209
|
+
}
|
|
210
|
+
async clearKeychain() {
|
|
211
|
+
// No-op on Android — keychain is an iOS concept
|
|
212
|
+
}
|
|
213
|
+
async openLink(url) {
|
|
214
|
+
await this.adb(['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]);
|
|
215
|
+
}
|
|
216
|
+
async setLocation(latitude, longitude) {
|
|
217
|
+
await this.call('setLocation', { latitude, longitude });
|
|
218
|
+
}
|
|
219
|
+
async setOrientation(orientation) {
|
|
220
|
+
const rotationMap = {
|
|
221
|
+
PORTRAIT: '0',
|
|
222
|
+
LANDSCAPE: '1',
|
|
223
|
+
PORTRAIT_REVERSE: '2',
|
|
224
|
+
LANDSCAPE_REVERSE: '3',
|
|
225
|
+
};
|
|
226
|
+
const rotation = rotationMap[orientation.toUpperCase()] ?? '0';
|
|
227
|
+
// Disable auto-rotation first, then set the rotation value
|
|
228
|
+
await this.adb(['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']);
|
|
229
|
+
await this.adb(['shell', 'settings', 'put', 'system', 'user_rotation', rotation]);
|
|
230
|
+
}
|
|
231
|
+
async setPermissions(appId, permissions) {
|
|
232
|
+
const PERMISSION_MAP = {
|
|
233
|
+
camera: ['android.permission.CAMERA'],
|
|
234
|
+
microphone: ['android.permission.RECORD_AUDIO'],
|
|
235
|
+
location: [
|
|
236
|
+
'android.permission.ACCESS_FINE_LOCATION',
|
|
237
|
+
'android.permission.ACCESS_COARSE_LOCATION',
|
|
238
|
+
],
|
|
239
|
+
storage: [
|
|
240
|
+
'android.permission.READ_EXTERNAL_STORAGE',
|
|
241
|
+
'android.permission.WRITE_EXTERNAL_STORAGE',
|
|
242
|
+
],
|
|
243
|
+
contacts: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'],
|
|
244
|
+
calendar: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
|
|
245
|
+
phone: ['android.permission.CALL_PHONE', 'android.permission.READ_PHONE_STATE'],
|
|
246
|
+
sms: ['android.permission.SEND_SMS', 'android.permission.RECEIVE_SMS'],
|
|
247
|
+
notifications: [], // Not manageable via pm grant on Android
|
|
248
|
+
};
|
|
249
|
+
const toProcess = [];
|
|
250
|
+
if ('all' in permissions) {
|
|
251
|
+
const value = permissions['all'];
|
|
252
|
+
if (value !== 'unset') {
|
|
253
|
+
for (const perms of Object.values(PERMISSION_MAP)) {
|
|
254
|
+
for (const perm of perms)
|
|
255
|
+
toProcess.push({ perm, value });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
for (const [name, value] of Object.entries(permissions)) {
|
|
261
|
+
for (const perm of PERMISSION_MAP[name.toLowerCase()] ?? []) {
|
|
262
|
+
toProcess.push({ perm, value });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const { perm, value } of toProcess) {
|
|
267
|
+
const action = value === 'allow' || value === 'always' || value === 'whenInUse' ? 'grant' : 'revoke';
|
|
268
|
+
await this.adb(['shell', 'pm', action, appId, perm]).catch(() => {
|
|
269
|
+
/* ignore if permission not declared */
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async addMedia(filePath) {
|
|
274
|
+
if (!this.client)
|
|
275
|
+
throw new Error('AndroidDriver: not connected');
|
|
276
|
+
const data = fs_1.default.readFileSync(filePath);
|
|
277
|
+
const ext = path_1.default.extname(filePath).slice(1);
|
|
278
|
+
const name = path_1.default.basename(filePath, path_1.default.extname(filePath));
|
|
279
|
+
const CHUNK_SIZE = 256 * 1024;
|
|
280
|
+
await new Promise((resolve, reject) => {
|
|
281
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
282
|
+
const call = this.client.addMedia((err) => {
|
|
283
|
+
if (err)
|
|
284
|
+
reject(err);
|
|
285
|
+
else
|
|
286
|
+
resolve();
|
|
287
|
+
});
|
|
288
|
+
let offset = 0;
|
|
289
|
+
const writeNext = () => {
|
|
290
|
+
if (offset >= data.length) {
|
|
291
|
+
call.end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const chunk = data.slice(offset, offset + CHUNK_SIZE);
|
|
295
|
+
offset += chunk.length;
|
|
296
|
+
call.write({ payload: { data: chunk }, media_name: name, media_ext: ext });
|
|
297
|
+
writeNext();
|
|
298
|
+
};
|
|
299
|
+
writeNext();
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async setAirplaneMode(enabled) {
|
|
303
|
+
const state = enabled ? '1' : '0';
|
|
304
|
+
await this.adb(['shell', 'settings', 'put', 'global', 'airplane_mode_on', state]);
|
|
305
|
+
await this.adb([
|
|
306
|
+
'shell',
|
|
307
|
+
'am',
|
|
308
|
+
'broadcast',
|
|
309
|
+
'-a',
|
|
310
|
+
'android.intent.action.AIRPLANE_MODE',
|
|
311
|
+
'--ez',
|
|
312
|
+
'state',
|
|
313
|
+
enabled ? 'true' : 'false',
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
async getAirplaneMode() {
|
|
317
|
+
const output = await this.adbOutput(['shell', 'settings', 'get', 'global', 'airplane_mode_on']);
|
|
318
|
+
return output.trim() === '1';
|
|
319
|
+
}
|
|
320
|
+
async startRecording(outputPath) {
|
|
321
|
+
if (this._recordingProcess)
|
|
322
|
+
await this.stopRecording();
|
|
323
|
+
this._recordingOutputPath = outputPath;
|
|
324
|
+
this._recordingProcess = (0, child_process_1.spawn)('adb', ['-s', this.deviceId, 'shell', 'screenrecord', '/sdcard/conductor_recording.mp4'], { stdio: 'ignore' });
|
|
325
|
+
}
|
|
326
|
+
async stopRecording() {
|
|
327
|
+
if (this._recordingProcess) {
|
|
328
|
+
this._recordingProcess.kill('SIGINT');
|
|
329
|
+
await new Promise((r) => setTimeout(r, 1500)); // wait for file to flush
|
|
330
|
+
this._recordingProcess = null;
|
|
331
|
+
if (this._recordingOutputPath) {
|
|
332
|
+
await this.adb([
|
|
333
|
+
'pull',
|
|
334
|
+
'/sdcard/conductor_recording.mp4',
|
|
335
|
+
this._recordingOutputPath,
|
|
336
|
+
]).catch(() => { });
|
|
337
|
+
await this.adb(['shell', 'rm', '-f', '/sdcard/conductor_recording.mp4']).catch(() => { });
|
|
338
|
+
this._recordingOutputPath = '';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
exports.AndroidDriver = AndroidDriver;
|