@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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/README.md +39 -0
  3. package/dist/commands/assert-not-visible.js +47 -0
  4. package/dist/commands/assert-visible.js +58 -0
  5. package/dist/commands/back.js +25 -0
  6. package/dist/commands/cheat-sheet.js +100 -0
  7. package/dist/commands/daemon.js +61 -0
  8. package/dist/commands/device-pool.js +202 -0
  9. package/dist/commands/erase-text.js +26 -0
  10. package/dist/commands/foreground-app.js +50 -0
  11. package/dist/commands/hide-keyboard.js +27 -0
  12. package/dist/commands/inspect.js +37 -0
  13. package/dist/commands/install.js +64 -0
  14. package/dist/commands/launch-app.js +42 -0
  15. package/dist/commands/list-apps.js +60 -0
  16. package/dist/commands/list-devices.js +61 -0
  17. package/dist/commands/open-link.js +22 -0
  18. package/dist/commands/press-key.js +91 -0
  19. package/dist/commands/run-flow-inline.js +25 -0
  20. package/dist/commands/run-flow.js +29 -0
  21. package/dist/commands/run-parallel.js +143 -0
  22. package/dist/commands/screenshot.js +29 -0
  23. package/dist/commands/scroll-until-visible.js +69 -0
  24. package/dist/commands/scroll.js +36 -0
  25. package/dist/commands/session.js +49 -0
  26. package/dist/commands/set-location.js +18 -0
  27. package/dist/commands/set-orientation.js +23 -0
  28. package/dist/commands/start-device.js +178 -0
  29. package/dist/commands/stop-app.js +32 -0
  30. package/dist/commands/swipe.js +72 -0
  31. package/dist/commands/tap.js +69 -0
  32. package/dist/commands/type.js +22 -0
  33. package/dist/daemon/client.js +112 -0
  34. package/dist/daemon/protocol.js +25 -0
  35. package/dist/daemon/server.js +208 -0
  36. package/dist/drivers/android.js +343 -0
  37. package/dist/drivers/bootstrap.js +371 -0
  38. package/dist/drivers/element-resolver.js +371 -0
  39. package/dist/drivers/flow-runner.js +1309 -0
  40. package/dist/drivers/ios.js +328 -0
  41. package/dist/drivers/js-engine.js +150 -0
  42. package/dist/drivers/wait.js +211 -0
  43. package/dist/index.js +426 -0
  44. package/dist/output.js +36 -0
  45. package/dist/pkg-root.js +28 -0
  46. package/dist/postinstall.js +12 -0
  47. package/dist/runner.js +190 -0
  48. package/dist/session.js +66 -0
  49. package/dist/update-check.js +109 -0
  50. package/dist/utils.js +19 -0
  51. package/dist/verbose.js +17 -0
  52. package/drivers/android/conductor-app.apk +0 -0
  53. package/drivers/android/conductor-server.apk +0 -0
  54. package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
  55. package/drivers/ios/conductor-driver-ios.zip +0 -0
  56. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  57. package/package.json +52 -0
  58. package/proto/conductor_android.proto +116 -0
  59. package/skills/conductor/SKILL.md +677 -0
  60. 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;