devicely 1.0.7 → 1.0.9

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.
@@ -0,0 +1,3392 @@
1
+ const path = require('path');
2
+
3
+ // Load environment variables from .env file in project root
4
+ require('dotenv').config({ path: path.join(__dirname, '../../.env') });
5
+
6
+ const express = require('express');
7
+ const cors = require('cors');
8
+ const { spawn } = require('child_process');
9
+ const WebSocket = require('ws');
10
+ const fs = require('fs').promises;
11
+ const AIProviderManager = require('./aiProviders');
12
+
13
+ // Import deviceDetection from appropriate location (dev vs npm package)
14
+ let discoverAllDevices;
15
+ try {
16
+ // Try npm package location first (lib/)
17
+ ({ discoverAllDevices } = require('./deviceDetection'));
18
+ } catch (err) {
19
+ // Fall back to development location
20
+ ({ discoverAllDevices } = require('../../deviceDetection'));
21
+ }
22
+
23
+ const { execSync } = require('child_process');
24
+
25
+ const app = express();
26
+ const PORT = process.env.PORT || 3001;
27
+
28
+ // Debug mode - only show verbose logs if DEBUG=true in .env
29
+ const DEBUG = process.env.DEBUG === 'true';
30
+ const log = {
31
+ debug: (...args) => { if (DEBUG) console.log(...args); },
32
+ info: (...args) => console.log(...args),
33
+ error: (...args) => console.error(...args),
34
+ success: (msg) => console.log(`✅ ${msg}`),
35
+ warn: (msg) => console.log(`⚠️ ${msg}`)
36
+ };
37
+
38
+ // Middleware
39
+ app.use(cors());
40
+ app.use(express.json());
41
+
42
+ // Serve static frontend files - check both dev and npm package locations
43
+ const frontendPath = require('fs').existsSync(path.join(__dirname, '../frontend/build'))
44
+ ? path.join(__dirname, '../frontend/build')
45
+ : path.join(__dirname, './frontend');
46
+
47
+ app.use(express.static(frontendPath));
48
+
49
+ // WebSocket server
50
+ const wss = new WebSocket.Server({ noServer: true });
51
+
52
+ // AI Provider Manager - supports multiple AI providers
53
+ const aiManager = new AIProviderManager();
54
+
55
+ // Store connected devices
56
+ let connectedDevices = [];
57
+ let activeConnections = new Map();
58
+ let wdaProcesses = new Map(); // Track WDA processes: Map<deviceName, {xcodebuild: pid, iproxy: pid}>
59
+
60
+ // Track UIAutomator2 sessions for Android devices
61
+ let androidSessions = new Map(); // Map<udid, {sessionId, port, serverPid}>
62
+
63
+ // Helper to find config files in both dev and npm package locations
64
+ function findConfigFile(filename) {
65
+ // Try development location first
66
+ let configPath = path.join(__dirname, '../../', filename);
67
+ if (require('fs').existsSync(configPath)) {
68
+ return configPath;
69
+ }
70
+
71
+ // Try npm package location (lib/ -> config/)
72
+ configPath = path.join(__dirname, '../config/', filename);
73
+ if (require('fs').existsSync(configPath)) {
74
+ return configPath;
75
+ }
76
+
77
+ // Fall back to dev location for creation
78
+ return path.join(__dirname, '../../', filename);
79
+ }
80
+
81
+ function findScriptFile(filename) {
82
+ // Try development location first
83
+ let scriptPath = path.join(__dirname, '../../', filename);
84
+ if (require('fs').existsSync(scriptPath)) {
85
+ return scriptPath;
86
+ }
87
+
88
+ // Try npm package location (lib/ -> scripts/shell/)
89
+ // In npm package, scripts are compiled binaries without .sh extension
90
+ const binaryName = filename.replace('.sh', '');
91
+ scriptPath = path.join(__dirname, '../scripts/shell/', binaryName);
92
+ if (require('fs').existsSync(scriptPath)) {
93
+ return scriptPath;
94
+ }
95
+
96
+ // Try with .sh extension as fallback
97
+ scriptPath = path.join(__dirname, '../scripts/shell/', filename);
98
+ if (require('fs').existsSync(scriptPath)) {
99
+ return scriptPath;
100
+ }
101
+
102
+ return path.join(__dirname, '../../', filename);
103
+ }
104
+
105
+ // Device configuration paths
106
+ const DEVICE_CONFIG = findConfigFile('devices.conf');
107
+ // Use OS temp directory for session file - works for both local and npm package
108
+ const SESSION_MAP_FILE = path.join(require('os').tmpdir(), 'devicely_android_sessions.map');
109
+ const APPS_CONFIG = findConfigFile('apps_presets.conf');
110
+
111
+ // iOS scripts
112
+ const WIRELESS_SCRIPT_PATH = findScriptFile('connect_ios_wireless_multi_final.sh');
113
+ const USB_SCRIPT_PATH = findScriptFile('connect_ios_usb_multi_final.sh');
114
+
115
+ // Android scripts
116
+ const ANDROID_WIRELESS_SCRIPT_PATH = findScriptFile('android_device_control.sh');
117
+ const ANDROID_USB_SCRIPT_PATH = findScriptFile('android_device_control.sh');
118
+
119
+ // Recording storage path
120
+ const RECORDINGS_DIR = path.join(__dirname, '../../recordings');
121
+
122
+ // Screenshots storage path
123
+ const SCREENSHOTS_DIR = path.join(__dirname, './screenshots');
124
+
125
+ // Recording state
126
+ let currentRecording = null; // { name, devices, commands: [], startTime }
127
+ let isRecording = false;
128
+
129
+ // Replay state
130
+ let isReplaying = false;
131
+ let replayAborted = false;
132
+
133
+ // ============================================
134
+ // UIAutomator2 Session Management Functions
135
+ // ============================================
136
+
137
+ /**
138
+ * Start UIAutomator2 session for Android device
139
+ */
140
+ async function startUIAutomator2Session(udid, deviceName) {
141
+ log.debug(`🚀 Starting UIAutomator2 session for ${deviceName} (${udid})...`);
142
+
143
+ try {
144
+ // Find available port
145
+ let port = 8200;
146
+ while (true) {
147
+ try {
148
+ execSync(`lsof -i:${port}`, { stdio: 'ignore' });
149
+ port++; // Port is in use, try next
150
+ } catch {
151
+ break; // Port is available
152
+ }
153
+ }
154
+
155
+ log.debug(`📡 Using port ${port} for ${deviceName}`);
156
+
157
+ // Start UIAutomator2 server on device
158
+ const serverProcess = spawn('adb', ['-s', udid, 'shell', 'am', 'instrument', '-w',
159
+ 'io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner'
160
+ ], { detached: true, stdio: 'ignore' });
161
+
162
+ serverProcess.unref(); // Don't wait for process to exit
163
+
164
+ // Wait for server to start
165
+ await new Promise(resolve => setTimeout(resolve, 2000));
166
+
167
+ // Forward port
168
+ execSync(`adb -s ${udid} forward tcp:${port} tcp:6790`);
169
+ log.debug(`✅ Port forwarding established: localhost:${port} -> device:6790`);
170
+
171
+ // Wait a bit more
172
+ await new Promise(resolve => setTimeout(resolve, 2000));
173
+
174
+ // Create session via HTTP
175
+ const axios = require('axios');
176
+ const sessionResponse = await axios.post(`http://localhost:${port}/wd/hub/session`, {
177
+ capabilities: {
178
+ alwaysMatch: {
179
+ platformName: 'Android',
180
+ 'appium:automationName': 'UiAutomator2',
181
+ 'appium:udid': udid
182
+ }
183
+ }
184
+ });
185
+
186
+ const sessionId = sessionResponse.data.value.sessionId || sessionResponse.data.sessionId;
187
+
188
+ // Store session info
189
+ androidSessions.set(udid, {
190
+ sessionId,
191
+ port,
192
+ serverPid: serverProcess.pid,
193
+ deviceName
194
+ });
195
+
196
+ // Save to session file for scripts to use - remove old entries first
197
+ try {
198
+ let content = '';
199
+ try {
200
+ content = await fs.readFile(SESSION_MAP_FILE, 'utf-8');
201
+ } catch (err) {
202
+ // File doesn't exist yet, that's ok
203
+ }
204
+ const lines = content.split('\n').filter(line => line && !line.startsWith(`${udid}:`));
205
+ lines.push(`${udid}:${sessionId}:${port}`);
206
+ await fs.writeFile(SESSION_MAP_FILE, lines.join('\n') + '\n');
207
+ log.debug(`✅ Session file updated: ${udid}:${sessionId}:${port}`);
208
+ } catch (err) {
209
+ console.error(`⚠️ Failed to update session file: ${err.message}`);
210
+ }
211
+
212
+ log.debug(`✅ UIAutomator2 session created for ${deviceName}`);
213
+ log.debug(` Session ID: ${sessionId}`);
214
+ log.debug(` Port: ${port}`);
215
+
216
+ return { success: true, sessionId, port };
217
+
218
+ } catch (error) {
219
+ console.error(`❌ Failed to start UIAutomator2 for ${deviceName}:`, error.message);
220
+ return { success: false, error: error.message };
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Stop UIAutomator2 session for Android device
226
+ */
227
+ async function stopUIAutomator2Session(udid, deviceName) {
228
+ log.debug(`🛑 Stopping UIAutomator2 session for ${deviceName} (${udid})...`);
229
+
230
+ try {
231
+ const session = androidSessions.get(udid);
232
+
233
+ if (!session) {
234
+ log.debug(`⚠️ No active session found for ${deviceName}`);
235
+ return { success: true, message: 'No active session' };
236
+ }
237
+
238
+ // Delete session via HTTP
239
+ try {
240
+ const axios = require('axios');
241
+ await axios.delete(`http://localhost:${session.port}/wd/hub/session/${session.sessionId}`);
242
+ log.debug(`✅ UIAutomator2 session deleted for ${deviceName}`);
243
+ } catch (err) {
244
+ log.warn(`⚠️ Could not delete session via HTTP: ${err.message}`);
245
+ }
246
+
247
+ // Remove port forwarding
248
+ try {
249
+ execSync(`adb -s ${udid} forward --remove tcp:${session.port}`);
250
+ log.debug(`✅ Port forwarding removed for ${deviceName}`);
251
+ } catch (err) {
252
+ console.log(`⚠️ Could not remove port forwarding: ${err.message}`);
253
+ }
254
+
255
+ // Kill UIAutomator2 server on device
256
+ try {
257
+ execSync(`adb -s ${udid} shell am force-stop io.appium.uiautomator2.server`);
258
+ execSync(`adb -s ${udid} shell am force-stop io.appium.uiautomator2.server.test`);
259
+ console.log(`✅ UIAutomator2 server stopped on ${deviceName}`);
260
+ } catch (err) {
261
+ console.log(`⚠️ Could not stop UIAutomator2 server: ${err.message}`);
262
+ }
263
+
264
+ // Remove from memory
265
+ androidSessions.delete(udid);
266
+
267
+ // Update session file
268
+ try {
269
+ const content = await fs.readFile(SESSION_MAP_FILE, 'utf-8');
270
+ const lines = content.split('\n').filter(line => !line.startsWith(`${udid}:`));
271
+ await fs.writeFile(SESSION_MAP_FILE, lines.join('\n'));
272
+ log.debug(`✅ Session file updated for ${deviceName}`);
273
+ } catch (err) {
274
+ console.log(`⚠️ Could not update session file: ${err.message}`);
275
+ }
276
+
277
+ console.log(`✅ UIAutomator2 session stopped for ${deviceName}`);
278
+ return { success: true };
279
+
280
+ } catch (error) {
281
+ console.error(`❌ Error stopping UIAutomator2 for ${deviceName}:`, error.message);
282
+ return { success: false, error: error.message };
283
+ }
284
+ }
285
+
286
+ // ============================================
287
+
288
+
289
+ // Load device configuration
290
+ async function loadDeviceConfig() {
291
+ try {
292
+ const content = await fs.readFile(DEVICE_CONFIG, 'utf-8');
293
+ const devices = content
294
+ .split('\n')
295
+ .filter(line => line.trim() && !line.startsWith('#'))
296
+ .map(line => {
297
+ const parts = line.split(',').map(s => s.trim());
298
+ const [name, udid, ip, platform, type] = parts;
299
+
300
+ // Smart platform detection if not specified
301
+ let detectedPlatform = platform || 'ios';
302
+ if (!platform && udid) {
303
+ // Android wireless: IP:PORT format
304
+ if (udid.includes(':') && udid.match(/^\d+\.\d+\.\d+\.\d+:\d+$/)) {
305
+ detectedPlatform = 'android';
306
+ }
307
+ // Android USB: Short serial without dashes
308
+ else if (udid.length < 20 && !udid.includes('-')) {
309
+ detectedPlatform = 'android';
310
+ }
311
+ // iOS: Long UUID with dashes
312
+ else if (udid.includes('-') && udid.length > 30) {
313
+ detectedPlatform = 'ios';
314
+ }
315
+ }
316
+
317
+ return {
318
+ name,
319
+ udid,
320
+ ip: ip || '',
321
+ platform: detectedPlatform,
322
+ type: type || (ip ? 'wireless' : 'usb'),
323
+ status: 'unknown'
324
+ };
325
+ });
326
+ return devices;
327
+ } catch (error) {
328
+ console.error('Error loading device config:', error);
329
+ return [];
330
+ }
331
+ }
332
+
333
+ // Discover and check device connectivity (USB + Wireless)
334
+ async function discoverDevices() {
335
+ // Save current connection states
336
+ const previousStates = new Map();
337
+ connectedDevices.forEach(device => {
338
+ previousStates.set(device.udid, {
339
+ status: device.status,
340
+ sessionId: device.sessionId
341
+ });
342
+ });
343
+
344
+ connectedDevices = await discoverAllDevices();
345
+
346
+ // Restore connection states for devices that were previously connected
347
+ connectedDevices.forEach(device => {
348
+ const prevState = previousStates.get(device.udid);
349
+ if (prevState && prevState.status === 'online') {
350
+ // Preserve the online status for previously connected devices
351
+ device.status = 'online';
352
+ if (prevState.sessionId) {
353
+ device.sessionId = prevState.sessionId;
354
+ }
355
+ }
356
+ });
357
+
358
+ // Auto-save USB devices to config for wireless use later
359
+ await autoSaveUSBDevicesToConfig(connectedDevices);
360
+
361
+ return connectedDevices;
362
+ }
363
+
364
+ // Auto-save USB devices to config
365
+ async function autoSaveUSBDevicesToConfig(devices) {
366
+ try {
367
+ const usbDevices = devices.filter(d => d.connectionType === 'usb');
368
+ if (usbDevices.length === 0) return;
369
+
370
+ // Load existing config
371
+ const existingDevices = await loadDeviceConfig();
372
+ const existingUdids = new Set(existingDevices.map(d => d.udid));
373
+
374
+ // Add new USB devices
375
+ const newDevices = usbDevices.filter(d => !existingUdids.has(d.udid));
376
+ if (newDevices.length === 0) return;
377
+
378
+ // Append to config with placeholder IP (will be filled when wireless is configured)
379
+ const allDevices = [...existingDevices, ...newDevices.map(d => ({
380
+ name: d.name,
381
+ udid: d.udid,
382
+ ip: '' // Empty IP for USB devices, to be filled for wireless
383
+ }))];
384
+
385
+ // Save to config
386
+ let content = '# Device Configuration (Auto-updated)\n';
387
+ content += '# Format: device_name,udid,ip_address\n';
388
+ content += '# USB devices auto-detected. Add IP for wireless connection.\n\n';
389
+
390
+ allDevices.forEach(device => {
391
+ const ip = device.ip || '';
392
+ content += `${device.name},${device.udid},${ip}\n`;
393
+ });
394
+
395
+ await fs.writeFile(DEVICE_CONFIG, content);
396
+ console.log(`✅ Auto-saved ${newDevices.length} new USB device(s) to config`);
397
+ } catch (error) {
398
+ console.error('Error auto-saving devices:', error);
399
+ }
400
+ }
401
+
402
+ // Check device connectivity
403
+ function checkDeviceConnectivity(device) {
404
+ return new Promise((resolve) => {
405
+ const curl = spawn('curl', [
406
+ '-s',
407
+ '--connect-timeout', '2',
408
+ '--max-time', '3',
409
+ `http://${device.ip}:8100/status`
410
+ ]);
411
+
412
+ let output = '';
413
+ curl.stdout.on('data', (data) => {
414
+ output += data.toString();
415
+ });
416
+
417
+ curl.on('close', (code) => {
418
+ resolve(output.includes('"state"'));
419
+ });
420
+
421
+ setTimeout(() => {
422
+ curl.kill();
423
+ resolve(false);
424
+ }, 3000);
425
+ });
426
+ }
427
+
428
+ // Load app presets from config file
429
+ function loadAppPresets() {
430
+ try {
431
+ if (!require('fs').existsSync(APPS_CONFIG)) {
432
+ return {};
433
+ }
434
+
435
+ const content = require('fs').readFileSync(APPS_CONFIG, 'utf-8');
436
+ const presets = {};
437
+
438
+ content.split('\n')
439
+ .filter(line => line.trim() && !line.startsWith('#'))
440
+ .forEach(line => {
441
+ const [name, packageId] = line.split(',').map(s => s.trim());
442
+ if (name && packageId) {
443
+ // Support platform-specific notation: name.ios or name.android
444
+ const parts = name.split('.');
445
+ if (parts.length === 2) {
446
+ const [appName, platform] = parts;
447
+ if (!presets[appName]) presets[appName] = {};
448
+ presets[appName][platform] = packageId;
449
+ } else {
450
+ // Generic app (works on both platforms)
451
+ if (!presets[name]) presets[name] = {};
452
+ presets[name].common = packageId;
453
+ }
454
+ }
455
+ });
456
+
457
+ return presets;
458
+ } catch (error) {
459
+ console.error('Error loading app presets:', error);
460
+ return {};
461
+ }
462
+ }
463
+
464
+ // Resolve app preset based on device platform
465
+ function resolveAppPreset(command, platform) {
466
+ const appPresets = loadAppPresets();
467
+
468
+ // Check if command is "launch <appname>"
469
+ const launchMatch = command.match(/^launch\s+(\S+)(.*)$/);
470
+ if (!launchMatch) return command; // Not a launch command
471
+
472
+ const appName = launchMatch[1];
473
+ const restOfCommand = launchMatch[2];
474
+
475
+ // If it already looks like a bundle ID, don't resolve
476
+ if (appName.includes('.')) return command;
477
+
478
+ // Look up preset
479
+ if (appPresets[appName]) {
480
+ const preset = appPresets[appName];
481
+ let bundleId;
482
+
483
+ // Try platform-specific first, then common
484
+ if (preset[platform]) {
485
+ bundleId = preset[platform];
486
+ } else if (preset.common) {
487
+ bundleId = preset.common;
488
+ }
489
+
490
+ if (bundleId) {
491
+ log.debug(` 📱 Resolved app preset: "${appName}" → "${bundleId}" (${platform})`);
492
+ return `launch ${bundleId}${restOfCommand}`;
493
+ }
494
+ }
495
+
496
+ // No preset found, return original
497
+ return command;
498
+ }
499
+
500
+ // Execute command on device(s)
501
+ function executeCommand(deviceNames, command, wsClient) {
502
+ return new Promise(async (resolve, reject) => {
503
+ log.debug(`\n${'#'.repeat(60)}`);
504
+ log.debug(`executeCommand() called`);
505
+ log.debug(`Device names received: [${deviceNames.join(', ')}]`);
506
+ log.debug(`Command: "${command}"`);
507
+ log.debug(`${'#'.repeat(60)}\n`);
508
+
509
+ // Get full device objects from connectedDevices cache
510
+ // Match by name, udid, or serial
511
+ const devices = connectedDevices.filter(d =>
512
+ deviceNames.includes(d.name) ||
513
+ deviceNames.includes(d.udid) ||
514
+ deviceNames.includes(d.serial)
515
+ );
516
+
517
+ log.debug(`Connected devices in cache: ${connectedDevices.length}`);
518
+ log.debug(`Cache contents:`, connectedDevices.map(d => `${d.name}(${d.status})`).join(', '));
519
+ log.debug(`Filtered devices: ${devices.length}`);
520
+
521
+ if (devices.length === 0) {
522
+ console.error(`❌ No devices found for: [${deviceNames.join(', ')}]`);
523
+ return reject({ success: false, error: 'No devices found. Please refresh device list and try again.' });
524
+ }
525
+
526
+ // Check if devices are online/connected
527
+ // Accept both 'online' and 'connected' as valid statuses
528
+ const offlineDevices = devices.filter(d => !['online', 'connected'].includes(d.status));
529
+ if (offlineDevices.length > 0) {
530
+ const offlineNames = offlineDevices.map(d => `${d.name} (status: ${d.status})`).join(', ');
531
+ console.error(`❌ Devices not connected: [${offlineNames}]`);
532
+
533
+ // Send user-friendly message via WebSocket
534
+ if (wsClient) {
535
+ wsClient.send(JSON.stringify({
536
+ type: 'command_output',
537
+ data: `\n⚠️ Error: Devices not connected: ${offlineNames}\n\n💡 Please click the "Connect" button on each device before running commands.\n\n`,
538
+ devices: deviceNames
539
+ }));
540
+ }
541
+
542
+ return reject({
543
+ success: false,
544
+ error: `Devices not connected: ${offlineNames}. Please click "Connect" button on each device first.`
545
+ });
546
+ }
547
+
548
+ log.debug(`✅ Found ${devices.length} connected devices`);
549
+ log.debug('Device details:', devices.map(d => `${d.name}(${d.platform}-${d.connectionType})`).join(', '));
550
+
551
+ // Group devices by platform and connection type
552
+ const iosUSBDevices = devices.filter(d => d.platform === 'ios' && d.connectionType === 'usb');
553
+ const iosWirelessDevices = devices.filter(d => d.platform === 'ios' && d.connectionType === 'wireless');
554
+ const androidUSBDevices = devices.filter(d => d.platform === 'android' && d.connectionType === 'usb');
555
+ const androidWirelessDevices = devices.filter(d => d.platform === 'android' && d.connectionType === 'wireless');
556
+
557
+ log.debug(`iOS USB: ${iosUSBDevices.length}, iOS Wireless: ${iosWirelessDevices.length}`);
558
+ log.debug(`Android USB: ${androidUSBDevices.length}, Android Wireless: ${androidWirelessDevices.length}`);
559
+
560
+ const promises = [];
561
+
562
+ // Execute for iOS USB devices
563
+ if (iosUSBDevices.length > 0) {
564
+ const names = iosUSBDevices.map(d => d.name);
565
+ const iosCommand = resolveAppPreset(command, 'ios');
566
+ log.debug(`Using iOS USB script for: ${names.join(', ')}`);
567
+ promises.push(executeWithScript(USB_SCRIPT_PATH, names, iosCommand, wsClient));
568
+ }
569
+
570
+ // Execute for iOS wireless devices
571
+ if (iosWirelessDevices.length > 0) {
572
+ const names = iosWirelessDevices.map(d => d.name);
573
+ const iosCommand = resolveAppPreset(command, 'ios');
574
+ log.debug(`Using iOS wireless script for: ${names.join(', ')}`);
575
+ promises.push(executeWithScript(WIRELESS_SCRIPT_PATH, names, iosCommand, wsClient));
576
+ }
577
+
578
+ // Execute for Android USB devices
579
+ if (androidUSBDevices.length > 0) {
580
+ const androidCommand = resolveAppPreset(command, 'android');
581
+ log.debug(`Using Android control script for USB devices: ${androidUSBDevices.map(d => d.name).join(', ')}`);
582
+ promises.push(executeAndroidDevices(ANDROID_USB_SCRIPT_PATH, androidUSBDevices, androidCommand, wsClient));
583
+ }
584
+
585
+ // Execute for Android wireless devices
586
+ if (androidWirelessDevices.length > 0) {
587
+ const androidCommand = resolveAppPreset(command, 'android');
588
+ log.debug(`Using Android control script for wireless devices: ${androidWirelessDevices.map(d => d.name).join(', ')}`);
589
+ promises.push(executeAndroidDevices(ANDROID_WIRELESS_SCRIPT_PATH, androidWirelessDevices, androidCommand, wsClient));
590
+ }
591
+
592
+ if (promises.length === 0) {
593
+ console.log('No scripts to execute - no devices found!');
594
+ return reject({ success: false, error: 'No executable devices found' });
595
+ }
596
+
597
+ try {
598
+ const results = await Promise.allSettled(promises);
599
+ const successCount = results.filter(r => r.status === 'fulfilled').length;
600
+ const failCount = results.filter(r => r.status === 'rejected').length;
601
+
602
+ log.info(`ExecuteCommand summary: ${successCount} succeeded, ${failCount} failed`);
603
+
604
+ resolve({
605
+ success: true,
606
+ results,
607
+ summary: `${successCount}/${promises.length} script groups completed`
608
+ });
609
+ } catch (error) {
610
+ console.error('Script execution error:', error);
611
+ reject(error);
612
+ }
613
+ });
614
+ }
615
+
616
+ function executeWithScript(scriptPath, deviceNames, command, wsClient) {
617
+ return new Promise((resolve, reject) => {
618
+ log.debug(`\n${'='.repeat(60)}`);
619
+ log.debug(`EXECUTE WITH SCRIPT - START`);
620
+ log.debug(`Script: ${path.basename(scriptPath)}`);
621
+ log.debug(`Devices: [${deviceNames.join(', ')}]`);
622
+ log.debug(`Command: "${command}"`);
623
+ log.debug(`Total devices: ${deviceNames.length}`);
624
+ log.debug(`${'='.repeat(60)}\n`);
625
+
626
+ // Execute command on each device in parallel
627
+ const devicePromises = deviceNames.map((deviceName, index) => {
628
+ log.debug(`[${index + 1}/${deviceNames.length}] Creating execution promise for: ${deviceName}`);
629
+
630
+ return new Promise((resolveDevice, rejectDevice) => {
631
+ // Use -d flag for each device
632
+ const args = ['-d', deviceName, command];
633
+
634
+ log.debug(` └─ Spawning: ${path.basename(scriptPath)} ${args.join(' ')}`);
635
+
636
+ const child = spawn(scriptPath, args, {
637
+ cwd: path.dirname(scriptPath),
638
+ env: { ...process.env }
639
+ });
640
+
641
+ log.debug(` └─ Process spawned with PID: ${child.pid}`);
642
+
643
+ let output = '';
644
+ let error = '';
645
+
646
+ child.stdout.on('data', (data) => {
647
+ const text = data.toString();
648
+ output += text;
649
+ // In production mode (non-DEBUG), only show final success/failure
650
+ // Filter out all intermediate debug messages from scripts
651
+ if (DEBUG) {
652
+ // Debug mode: show everything
653
+ console.log(`[${deviceName}] ${text.trim()}`);
654
+ }
655
+ // Production mode: don't log script output, only final result
656
+ if (wsClient) {
657
+ wsClient.send(JSON.stringify({
658
+ type: 'command_output',
659
+ data: `[${deviceName}] ${text}`,
660
+ devices: [deviceName]
661
+ }));
662
+ }
663
+ });
664
+
665
+ child.stderr.on('data', (data) => {
666
+ const text = data.toString();
667
+ error += text;
668
+ log.error(`[${deviceName}] ${text.trim()}`);
669
+ });
670
+
671
+ child.on('close', (code) => {
672
+ if (code === 0) {
673
+ log.info(`✅ [${deviceName}] Command completed successfully`);
674
+ resolveDevice({ success: true, output, device: deviceName });
675
+ } else {
676
+ log.error(`❌ [${deviceName}] Command failed with code: ${code}`);
677
+ rejectDevice({ success: false, error: error || `Script exited with code ${code}`, device: deviceName });
678
+ }
679
+ });
680
+
681
+ child.on('error', (err) => {
682
+ console.error(`[${deviceName}] Process error: ${err.message}`);
683
+ rejectDevice({ success: false, error: err.message, device: deviceName });
684
+ });
685
+ });
686
+ });
687
+
688
+ log.debug(`\nWaiting for ${devicePromises.length} device execution(s) to complete...\n`);
689
+
690
+ // Wait for all devices to complete
691
+ Promise.allSettled(devicePromises)
692
+ .then(results => {
693
+ const successful = results.filter(r => r.status === 'fulfilled').length;
694
+ const failed = results.filter(r => r.status === 'rejected').length;
695
+
696
+ console.log(`\n${'='.repeat(60)}`);
697
+ log.debug(`EXECUTION COMPLETE`);
698
+ log.debug(`Success: .*, Failed: ${failed}`);
699
+ console.log(`${'='.repeat(60)}\n`);
700
+
701
+ resolve({
702
+ success: true,
703
+ results,
704
+ summary: `${successful}/${deviceNames.length} devices completed successfully`
705
+ });
706
+ })
707
+ .catch(error => {
708
+ console.error(`Promise.allSettled error: ${error.message}`);
709
+ reject(error);
710
+ });
711
+ });
712
+ }
713
+
714
+ // Execute Android commands (uses device UDID instead of name)
715
+ function executeAndroidDevices(scriptPath, devices, command, wsClient) {
716
+ return new Promise((resolve, reject) => {
717
+ log.debug(`\n${'='.repeat(60)}`);
718
+ log.debug(`EXECUTE ANDROID DEVICES - START`);
719
+ log.debug(`Script: ${path.basename(scriptPath)}`);
720
+ log.debug(`Devices: [${devices.map(d => d.name).join(', ')}]`);
721
+ log.debug(`Command: "${command}"`);
722
+ log.debug(`Total devices: ${devices.length}`);
723
+ log.debug(`${'='.repeat(60)}\n`);
724
+
725
+ // Execute command on each device in parallel
726
+ const devicePromises = devices.map((device, index) => {
727
+ log.debug(`[${index + 1}/${deviceData.length}] Creating execution promise for: ${device.name} (${device.udid})`);
728
+
729
+ return new Promise((resolveDevice, rejectDevice) => {
730
+ // Android script expects: <device_serial> <command> [args...]
731
+ // Use UDID (which is the serial for Android)
732
+ const args = [device.udid, ...command.split(' ')];
733
+
734
+ log.debug(` └─ Spawning: ${path.basename(scriptPath)} ${args.join(' ')}`);
735
+
736
+ const child = spawn(scriptPath, args, {
737
+ cwd: path.dirname(scriptPath),
738
+ env: { ...process.env } // Pass TMPDIR and other env vars
739
+ });
740
+
741
+ log.debug(` └─ Process spawned with PID: ${child.pid}`);
742
+
743
+ let output = '';
744
+ let error = '';
745
+
746
+ child.stdout.on('data', (data) => {
747
+ const text = data.toString();
748
+ output += text;
749
+ // Production mode: don't log script output to console
750
+ if (DEBUG) {
751
+ console.log(`[${device.name}] ${text.trim()}`);
752
+ }
753
+ if (wsClient) {
754
+ wsClient.send(JSON.stringify({
755
+ type: 'output',
756
+ device: device.name,
757
+ data: text
758
+ }));
759
+ }
760
+ });
761
+
762
+ child.stderr.on('data', (data) => {
763
+ const text = data.toString();
764
+ error += text;
765
+ log.error(`[${device.name}] ${text.trim()}`);
766
+ });
767
+
768
+ child.on('close', (code) => {
769
+ if (code === 0) {
770
+ log.info(`✅ [${device.name}] Command completed successfully`);
771
+ resolveDevice({ success: true, output, device: device.name });
772
+ } else {
773
+ log.error(`❌ [${device.name}] Command failed with code: ${code}`);
774
+ if (error || output) {
775
+ log.error(` Error: ${error || output}`);
776
+ }
777
+ resolveDevice({ success: false, error: error || output, device: device.name });
778
+ }
779
+ });
780
+
781
+ child.on('error', (err) => {
782
+ log.error(`[${device.name}] Process error:`, err);
783
+ resolveDevice({ success: false, error: err.message, device: device.name });
784
+ });
785
+ });
786
+ });
787
+
788
+ Promise.allSettled(devicePromises)
789
+ .then(results => {
790
+ console.log(`\n${'='.repeat(60)}`);
791
+ log.debug(`EXECUTION COMPLETE`);
792
+ const successCount = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
793
+ const failCount = results.length - successCount;
794
+ log.debug(`Success: .*, Failed: ${failCount}`);
795
+ console.log(`${'='.repeat(60)}\n`);
796
+
797
+ resolve({ success: true, results });
798
+ })
799
+ .catch(error => {
800
+ console.error('Execution error:', error);
801
+ reject(error);
802
+ });
803
+ });
804
+ }
805
+
806
+ // AI-powered command conversion (supports multiple providers)
807
+ async function convertNaturalLanguageToCommand(text, devices, provider = null) {
808
+ // Determine platforms from devices
809
+ const deviceObjs = connectedDevices.filter(d => devices.includes(d.name));
810
+
811
+ if (deviceObjs.length === 0) {
812
+ // No devices found, try with null platform
813
+ return await aiManager.convertCommand(text, null, provider);
814
+ }
815
+
816
+ // Group devices by platform
817
+ const platforms = [...new Set(deviceObjs.map(d => d.platform))];
818
+
819
+ console.log(`AI converting for ${deviceObjs.length} device(s) across ${platforms.length} platform(s): ${platforms.join(', ')}`);
820
+
821
+ // For multi-platform, use 'both' to get generic app names
822
+ // The executeCommand function will resolve platform-specific package IDs
823
+ const platform = platforms.length === 1 ? platforms[0] : 'both';
824
+
825
+ console.log(`AI using platform mode: ${platform}`);
826
+ return await aiManager.convertCommand(text, platform, provider);
827
+ }
828
+
829
+ // Execute commands sequentially with WAIT support
830
+ async function executeCommandsSequentially(devices, commandsText, wsClient) {
831
+ const lines = commandsText.split('\n').map(line => line.trim()).filter(line => line);
832
+
833
+ console.log(`\n${'~'.repeat(60)}`);
834
+ console.log(`SEQUENTIAL EXECUTION - START`);
835
+ console.log(`Total commands: ${lines.length}`);
836
+ console.log(`Commands:`, lines);
837
+ console.log(`${'~'.repeat(60)}\n`);
838
+
839
+ const results = [];
840
+
841
+ for (let i = 0; i < lines.length; i++) {
842
+ const line = lines[i];
843
+
844
+ // Skip comments
845
+ if (line.startsWith('#')) {
846
+ continue;
847
+ }
848
+
849
+ // Check if it's a WAIT command
850
+ if (line.startsWith('WAIT ')) {
851
+ const waitTime = parseInt(line.replace('WAIT ', ''));
852
+ console.log(`⏳ Waiting ${waitTime}ms before next command...`);
853
+
854
+ if (wsClient) {
855
+ wsClient.send(JSON.stringify({
856
+ type: 'command_output',
857
+ data: `⏳ Waiting ${waitTime}ms...\n`,
858
+ devices: devices
859
+ }));
860
+ }
861
+
862
+ await new Promise(resolve => setTimeout(resolve, waitTime));
863
+ results.push({ command: line, success: true, output: `Waited ${waitTime}ms` });
864
+ continue;
865
+ }
866
+
867
+ // Execute the command on ALL devices simultaneously
868
+ console.log(`\n▶️ Executing command ${i + 1}/${lines.length}: "${line}"`);
869
+
870
+ if (wsClient) {
871
+ wsClient.send(JSON.stringify({
872
+ type: 'command_output',
873
+ data: `▶️ [${i + 1}/${lines.length}] ${line}\n`,
874
+ devices: devices
875
+ }));
876
+ }
877
+
878
+ try {
879
+ const result = await executeCommand(devices, line, wsClient);
880
+ results.push({ command: line, success: true, result });
881
+ console.log(`✅ Command ${i + 1} completed successfully`);
882
+ } catch (error) {
883
+ console.error(`❌ Command ${i + 1} failed:`, error.message);
884
+ results.push({ command: line, success: false, error: error.message });
885
+
886
+ if (wsClient) {
887
+ wsClient.send(JSON.stringify({
888
+ type: 'command_output',
889
+ data: `❌ Error: ${error.message}\n`,
890
+ devices: devices
891
+ }));
892
+ }
893
+ }
894
+ }
895
+
896
+ console.log(`\n${'~'.repeat(60)}`);
897
+ console.log(`SEQUENTIAL EXECUTION - COMPLETE`);
898
+ console.log(`Total: ${results.length}, Success: ${results.filter(r => r.success).length}, Failed: ${results.filter(r => !r.success).length}`);
899
+ console.log(`${'~'.repeat(60)}\n`);
900
+
901
+ return {
902
+ success: true,
903
+ results,
904
+ summary: `Executed ${results.length} commands`
905
+ };
906
+ }
907
+
908
+
909
+ // Create WDA session for a device
910
+ async function createWDASession(host, deviceName) {
911
+ return new Promise(async (resolve) => {
912
+ const sessionFile = '/tmp/ios_multi_sessions';
913
+
914
+ // First, check if session already exists for this device
915
+ try {
916
+ const existingContent = await fs.readFile(sessionFile, 'utf-8').catch(() => '');
917
+ const lines = existingContent.split('\n').filter(line => line.trim());
918
+
919
+ for (const line of lines) {
920
+ const [name, sessionId] = line.split(':');
921
+ if (name === deviceName && sessionId) {
922
+ // Session already exists, verify it's still valid
923
+ const port = host === 'localhost' ? 8100 : 8100;
924
+ const verifyUrl = `http://${host}:${port}/session/${sessionId}`;
925
+
926
+ const verifyProcess = spawn('curl', ['-s', '-X', 'GET', verifyUrl]);
927
+ let verifyOutput = '';
928
+
929
+ verifyProcess.stdout.on('data', (data) => {
930
+ verifyOutput += data.toString();
931
+ });
932
+
933
+ verifyProcess.on('close', (code) => {
934
+ if (code === 0 && verifyOutput && !verifyOutput.includes('invalid session id')) {
935
+ console.log(`✅ Existing valid session found: ${sessionId}`);
936
+ return resolve({ success: true, sessionId, existing: true });
937
+ } else {
938
+ // Session is invalid, create a new one
939
+ createNewSession(host, deviceName, sessionFile, resolve);
940
+ }
941
+ });
942
+
943
+ return;
944
+ }
945
+ }
946
+ } catch (e) {
947
+ // File doesn't exist or error reading, create new session
948
+ }
949
+
950
+ // No existing session, create a new one
951
+ createNewSession(host, deviceName, sessionFile, resolve);
952
+ });
953
+ }
954
+
955
+ function createNewSession(host, deviceName, sessionFile, resolve) {
956
+ const port = host === 'localhost' ? 8100 : 8100;
957
+ const sessionUrl = `http://${host}:${port}/session`;
958
+
959
+ const curlProcess = spawn('curl', [
960
+ '-s',
961
+ '-X', 'POST',
962
+ sessionUrl,
963
+ '-H', 'Content-Type: application/json',
964
+ '-d', '{"capabilities":{"alwaysMatch":{}}}'
965
+ ]);
966
+
967
+ let output = '';
968
+ curlProcess.stdout.on('data', (data) => {
969
+ output += data.toString();
970
+ });
971
+
972
+ curlProcess.on('close', (code) => {
973
+ if (code === 0 && output) {
974
+ try {
975
+ const response = JSON.parse(output);
976
+ const sessionId = response.sessionId || response.value?.sessionId;
977
+
978
+ if (sessionId) {
979
+ // Read existing sessions and update
980
+ fs.readFile(sessionFile, 'utf-8')
981
+ .catch(() => '')
982
+ .then(existingContent => {
983
+ // Remove old session for this device if exists
984
+ const lines = existingContent.split('\n').filter(line => {
985
+ const [name] = line.split(':');
986
+ return line.trim() && name !== deviceName;
987
+ });
988
+
989
+ // Add new session
990
+ lines.push(`${deviceName}:${sessionId}`);
991
+
992
+ // Write back to file
993
+ return fs.writeFile(sessionFile, lines.join('\n') + '\n');
994
+ })
995
+ .then(() => {
996
+ console.log(`✅ New session created and saved: ${sessionId}`);
997
+ resolve({ success: true, sessionId });
998
+ })
999
+ .catch((err) => {
1000
+ console.error(`❌ Failed to save session for ${deviceName}:`, err);
1001
+ resolve({ success: false });
1002
+ });
1003
+ } else {
1004
+ resolve({ success: false });
1005
+ }
1006
+ } catch (e) {
1007
+ console.error('Failed to parse WDA session response:', e);
1008
+ resolve({ success: false });
1009
+ }
1010
+ } else {
1011
+ resolve({ success: false });
1012
+ }
1013
+ });
1014
+
1015
+ curlProcess.on('error', (err) => {
1016
+ console.error('Error creating WDA session:', err);
1017
+ resolve({ success: false });
1018
+ });
1019
+ }
1020
+
1021
+ // Install UIAutomator2 on Android device
1022
+ async function installUIAutomator2(deviceUdid, deviceName) {
1023
+ return new Promise((resolve) => {
1024
+ console.log(`📥 Installing UIAutomator2 on ${deviceName}...`);
1025
+
1026
+ const { execSync } = require('child_process');
1027
+ const os = require('os');
1028
+ const path = require('path');
1029
+
1030
+ try {
1031
+ // Try multiple locations for APKs
1032
+ const possiblePaths = [
1033
+ // Appium installed location
1034
+ path.join(os.homedir(), '.appium', 'node_modules', 'appium-uiautomator2-driver', 'node_modules', 'appium-uiautomator2-server', 'apks'),
1035
+ // Global npm location
1036
+ execSync('npm root -g', { encoding: 'utf-8' }).trim() + '/appium-uiautomator2-driver/node_modules/appium-uiautomator2-server/apks',
1037
+ // Alternative npm location
1038
+ execSync('npm root -g', { encoding: 'utf-8' }).trim() + '/appium/node_modules/appium-uiautomator2-driver/node_modules/appium-uiautomator2-server/apks',
1039
+ ];
1040
+
1041
+ let serverApk = null;
1042
+ let testApk = null;
1043
+ let foundPath = null;
1044
+
1045
+ // Try each path
1046
+ for (const apkPath of possiblePaths) {
1047
+ console.log(`📂 Checking: ${apkPath}`);
1048
+
1049
+ try {
1050
+ const serverCheck = execSync(`ls "${apkPath}"/appium-uiautomator2-server-v*.apk 2>/dev/null || echo ""`, { encoding: 'utf-8' }).trim();
1051
+ const testCheck = execSync(`ls "${apkPath}"/appium-uiautomator2-server-debug-androidTest.apk 2>/dev/null || echo ""`, { encoding: 'utf-8' }).trim();
1052
+
1053
+ if (serverCheck && testCheck) {
1054
+ serverApk = serverCheck;
1055
+ testApk = testCheck;
1056
+ foundPath = apkPath;
1057
+ console.log(`✅ Found APKs in: ${foundPath}`);
1058
+ break;
1059
+ }
1060
+ } catch (e) {
1061
+ continue;
1062
+ }
1063
+ }
1064
+
1065
+ if (serverApk && testApk) {
1066
+ console.log(`📦 Installing server APK: ${serverApk}`);
1067
+ const serverOutput = execSync(`adb -s ${deviceUdid} install -r "${serverApk}" 2>&1`, { encoding: 'utf-8' });
1068
+ console.log(serverOutput);
1069
+
1070
+ console.log(`📦 Installing test APK: ${testApk}`);
1071
+ const testOutput = execSync(`adb -s ${deviceUdid} install -r "${testApk}" 2>&1`, { encoding: 'utf-8' });
1072
+ console.log(testOutput);
1073
+
1074
+ console.log(`✅ UIAutomator2 installed successfully on ${deviceName}`);
1075
+ resolve({
1076
+ success: true,
1077
+ message: 'UIAutomator2 installed successfully'
1078
+ });
1079
+ } else {
1080
+ console.log(`⚠️ APK files not found in any expected location`);
1081
+ console.log(`Tried paths:`, possiblePaths);
1082
+
1083
+ // Try using appium to install
1084
+ try {
1085
+ console.log(`🔄 Trying to install via Appium...`);
1086
+ const appiumOutput = execSync(`appium driver install uiautomator2 2>&1`, { encoding: 'utf-8' });
1087
+ console.log(appiumOutput);
1088
+
1089
+ resolve({
1090
+ success: false,
1091
+ message: 'APKs not found. Tried to reinstall driver. Please run: appium driver install uiautomator2'
1092
+ });
1093
+ } catch (appiumError) {
1094
+ resolve({
1095
+ success: false,
1096
+ message: 'UIAutomator2 APKs not found. Please run: appium driver install uiautomator2'
1097
+ });
1098
+ }
1099
+ }
1100
+ } catch (error) {
1101
+ console.error(`❌ Failed to install UIAutomator2: ${error.message}`);
1102
+ resolve({
1103
+ success: false,
1104
+ message: `Failed to install UIAutomator2: ${error.message}`
1105
+ });
1106
+ }
1107
+ });
1108
+ }
1109
+
1110
+ // Connect to device - starts WDA if needed
1111
+ async function connectToDevice(device) {
1112
+ return new Promise(async (resolve, reject) => {
1113
+ const { name, udid, type, ip, platform } = device;
1114
+
1115
+ log.debug(`Connecting to ${name} (${type}, platform: ${platform})...`);
1116
+
1117
+ // Handle Android devices differently
1118
+ if (platform === 'android') {
1119
+ log.debug(`📱 Android device detected: ${name}`);
1120
+
1121
+ // Check if device is connected via adb
1122
+ const checkAdb = spawn('adb', ['devices']);
1123
+ let output = '';
1124
+
1125
+ checkAdb.stdout.on('data', (data) => {
1126
+ output += data.toString();
1127
+ });
1128
+
1129
+ checkAdb.on('close', async (code) => {
1130
+ const deviceConnected = output.includes(udid.replace(':5555', '')) || output.includes(udid);
1131
+
1132
+ if (deviceConnected) {
1133
+ log.debug(`✅ Android device ${name} is connected via ADB`);
1134
+
1135
+ // Update device status to online
1136
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1137
+ if (deviceIndex !== -1) {
1138
+ connectedDevices[deviceIndex].status = 'online';
1139
+ }
1140
+
1141
+ // Broadcast updated device list
1142
+ wss.clients.forEach(client => {
1143
+ if (client.readyState === WebSocket.OPEN) {
1144
+ client.send(JSON.stringify({
1145
+ type: 'devices_updated',
1146
+ devices: connectedDevices
1147
+ }));
1148
+ }
1149
+ });
1150
+
1151
+ // Check if UiAutomator2 is installed
1152
+ log.debug(`📦 Checking UiAutomator2 server for ${name}...`);
1153
+ const checkUiAutomator = spawn('adb', ['-s', udid, 'shell', 'pm', 'list', 'packages', 'io.appium.uiautomator2']);
1154
+ let uiautomatorOutput = '';
1155
+
1156
+ checkUiAutomator.stdout.on('data', (data) => {
1157
+ uiautomatorOutput += data.toString();
1158
+ });
1159
+
1160
+ checkUiAutomator.on('close', async () => {
1161
+ if (uiautomatorOutput.includes('io.appium.uiautomator2')) {
1162
+ log.debug(`✅ UiAutomator2 already installed on ${name}`);
1163
+
1164
+ // Start UIAutomator2 session
1165
+ const sessionResult = await startUIAutomator2Session(udid, name);
1166
+ if (sessionResult.success) {
1167
+ resolve({
1168
+ success: true,
1169
+ message: `Connected to ${name} - UIAutomator2 Ready ✅`
1170
+ });
1171
+ } else {
1172
+ resolve({
1173
+ success: true,
1174
+ message: `Connected to ${name} - ADB Ready ✅`
1175
+ });
1176
+ }
1177
+ } else {
1178
+ console.log(`⚠️ UiAutomator2 not found on ${name}, attempting auto-install...`);
1179
+
1180
+ // Auto-install UIAutomator2
1181
+ const installResult = await installUIAutomator2(udid, name);
1182
+
1183
+ if (installResult.success) {
1184
+ // Start UIAutomator2 session after installation
1185
+ const sessionResult = await startUIAutomator2Session(udid, name);
1186
+ resolve({
1187
+ success: true,
1188
+ message: `Connected to ${name} - UIAutomator2 Installed & Ready ✅`
1189
+ });
1190
+ } else {
1191
+ resolve({
1192
+ success: true,
1193
+ message: `Connected to ${name} - ADB Ready ✅`
1194
+ });
1195
+ }
1196
+ }
1197
+ });
1198
+ } else {
1199
+ console.log(`❌ Android device ${name} not found in ADB`);
1200
+
1201
+ // If wireless, try to connect via adb
1202
+ if (type === 'wireless' && ip) {
1203
+ console.log(`📡 Attempting to connect to ${name} wirelessly at ${ip}:5555...`);
1204
+ const adbConnect = spawn('adb', ['connect', `${ip}:5555`]);
1205
+
1206
+ adbConnect.on('close', async (code) => {
1207
+ if (code === 0) {
1208
+ console.log(`✅ Connected to ${name} wirelessly`);
1209
+
1210
+ // Update device status
1211
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1212
+ if (deviceIndex !== -1) {
1213
+ connectedDevices[deviceIndex].status = 'online';
1214
+ }
1215
+
1216
+ // Broadcast updated device list
1217
+ wss.clients.forEach(client => {
1218
+ if (client.readyState === WebSocket.OPEN) {
1219
+ client.send(JSON.stringify({
1220
+ type: 'devices_updated',
1221
+ devices: connectedDevices
1222
+ }));
1223
+ }
1224
+ });
1225
+
1226
+ // Auto-install UIAutomator2
1227
+ console.log(`📦 Checking and installing UIAutomator2 on ${name}...`);
1228
+ const installResult = await installUIAutomator2(`${ip}:5555`, name);
1229
+
1230
+ let message = '';
1231
+ if (installResult.success) {
1232
+ // Start UIAutomator2 session after installation
1233
+ const sessionResult = await startUIAutomator2Session(`${ip}:5555`, name);
1234
+ if (sessionResult.success) {
1235
+ message = `Connected to ${name} - UIAutomator2 Ready ✅`;
1236
+ } else {
1237
+ message = `Connected to ${name} - ADB Ready ✅`;
1238
+ }
1239
+ } else {
1240
+ message = `Connected to ${name} - ADB Ready ✅`;
1241
+ }
1242
+
1243
+ resolve({
1244
+ success: true,
1245
+ message: message
1246
+ });
1247
+ } else {
1248
+ reject({
1249
+ success: false,
1250
+ message: `Failed to connect to ${name}. Make sure USB debugging is enabled and device is on the same network.`
1251
+ });
1252
+ }
1253
+ });
1254
+ } else {
1255
+ reject({
1256
+ success: false,
1257
+ message: `Android device ${name} not connected. Please connect via USB or enable wireless debugging.`
1258
+ });
1259
+ }
1260
+ }
1261
+ });
1262
+
1263
+ checkAdb.on('error', (err) => {
1264
+ console.error(`❌ ADB error: ${err.message}`);
1265
+ reject({
1266
+ success: false,
1267
+ message: `ADB not available. Please install Android SDK Platform-Tools.`
1268
+ });
1269
+ });
1270
+
1271
+ return; // Exit early for Android devices
1272
+ }
1273
+
1274
+ // iOS device handling continues below
1275
+ if (type === 'usb') {
1276
+ // For USB devices, check if WDA is already running
1277
+ const checkWDA = spawn('curl', [
1278
+ '-s',
1279
+ '--connect-timeout', '2',
1280
+ 'http://localhost:8100/status'
1281
+ ]);
1282
+
1283
+ let output = '';
1284
+ checkWDA.stdout.on('data', (data) => {
1285
+ output += data.toString();
1286
+ });
1287
+
1288
+ checkWDA.on('error', (err) => {
1289
+ console.error(`❌ Error checking WDA: ${err.message}`);
1290
+ reject({ success: false, message: `Failed to check WDA status: ${err.message}` });
1291
+ });
1292
+
1293
+ checkWDA.on('close', async (code) => {
1294
+ if (output.includes('"state"')) {
1295
+ console.log(`✅ WDA already running for ${name}`);
1296
+
1297
+ // Create WDA session if not exists
1298
+ console.log(`📱 Creating WDA session for ${name}...`);
1299
+ const sessionResult = await createWDASession('localhost', name);
1300
+
1301
+ if (sessionResult.success) {
1302
+ console.log(`✅ Session created: ${sessionResult.sessionId}`);
1303
+ } else {
1304
+ console.log(`⚠️ Session creation failed, will be created on first command`);
1305
+ }
1306
+
1307
+ // Update status to online
1308
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1309
+ if (deviceIndex !== -1) {
1310
+ connectedDevices[deviceIndex].status = 'online';
1311
+ }
1312
+
1313
+ // Broadcast updated device list
1314
+ wss.clients.forEach(client => {
1315
+ if (client.readyState === WebSocket.OPEN) {
1316
+ client.send(JSON.stringify({
1317
+ type: 'devices_updated',
1318
+ devices: connectedDevices
1319
+ }));
1320
+ }
1321
+ });
1322
+
1323
+ resolve({ success: true, message: `Connected to ${name} - WDA Ready ✅` });
1324
+ } else {
1325
+ console.log(`🚀 Starting WDA for ${name} (UDID: ${udid})...`);
1326
+
1327
+ // Start WDA using xcodebuild
1328
+ // First, check if we have WebDriverAgent path
1329
+ const wdaPath = process.env.WDA_PATH || path.join(process.env.HOME, 'Downloads/WebDriverAgent-10.2.1');
1330
+
1331
+ console.log(`📂 WDA Path: ${wdaPath}`);
1332
+ console.log(`📱 Device UDID: ${udid}`);
1333
+
1334
+ // Start xcodebuild in background with piped output for debugging
1335
+ const wdaProcess = spawn('xcodebuild', [
1336
+ 'test',
1337
+ '-project', path.join(wdaPath, 'WebDriverAgent.xcodeproj'),
1338
+ '-scheme', 'WebDriverAgentRunner',
1339
+ '-destination', `id=${udid}`,
1340
+ 'IPHONEOS_DEPLOYMENT_TARGET=14.0'
1341
+ ], {
1342
+ cwd: wdaPath,
1343
+ detached: true,
1344
+ stdio: ['ignore', 'pipe', 'pipe']
1345
+ });
1346
+
1347
+ // Log output for debugging
1348
+ wdaProcess.stdout.on('data', (data) => {
1349
+ const output = data.toString();
1350
+ if (output.includes('ServerURLHere') || output.includes('WebDriverAgent')) {
1351
+ console.log(`📱 WDA: ${output.trim()}`);
1352
+ }
1353
+ });
1354
+
1355
+ wdaProcess.stderr.on('data', (data) => {
1356
+ const error = data.toString();
1357
+ if (!error.includes('note:') && !error.includes('warning:')) {
1358
+ console.error(`⚠️ WDA Error: ${error.trim()}`);
1359
+ }
1360
+ });
1361
+
1362
+ wdaProcess.on('error', (err) => {
1363
+ console.error(`❌ Failed to start xcodebuild: ${err.message}`);
1364
+ });
1365
+
1366
+ wdaProcess.unref();
1367
+
1368
+ // Also start iproxy to forward port
1369
+ console.log(`🔌 Starting iproxy for port forwarding...`);
1370
+ const iproxyProcess = spawn('iproxy', ['8100', '8100'], {
1371
+ detached: true,
1372
+ stdio: ['ignore', 'pipe', 'pipe']
1373
+ });
1374
+
1375
+ iproxyProcess.stdout.on('data', (data) => {
1376
+ console.log(`🔌 iproxy: ${data.toString().trim()}`);
1377
+ });
1378
+
1379
+ iproxyProcess.on('error', (err) => {
1380
+ console.error(`❌ Failed to start iproxy: ${err.message}`);
1381
+ console.error(` Make sure libimobiledevice is installed: brew install libimobiledevice`);
1382
+ });
1383
+
1384
+ iproxyProcess.unref();
1385
+
1386
+ // Track WDA processes for cleanup on disconnect
1387
+ wdaProcesses.set(name, {
1388
+ xcodebuild: wdaProcess.pid,
1389
+ iproxy: iproxyProcess.pid
1390
+ });
1391
+
1392
+ console.log(`✅ WDA starting for ${name}. Process ID: ${wdaProcess.pid}`);
1393
+
1394
+ // Wait a bit for WDA to start, then verify
1395
+ setTimeout(() => {
1396
+ const verifyWDA = spawn('curl', [
1397
+ '-s',
1398
+ '--connect-timeout', '5',
1399
+ 'http://localhost:8100/status'
1400
+ ]);
1401
+
1402
+ let verifyOutput = '';
1403
+ verifyWDA.stdout.on('data', (data) => {
1404
+ verifyOutput += data.toString();
1405
+ });
1406
+
1407
+ verifyWDA.on('close', async () => {
1408
+ if (verifyOutput.includes('"state"')) {
1409
+ console.log(`✅ WDA successfully started for ${name}`);
1410
+
1411
+ // Create WDA session for USB device
1412
+ console.log(`📱 Creating WDA session for ${name}...`);
1413
+ const sessionResult = await createWDASession('localhost', name);
1414
+
1415
+ if (sessionResult.success) {
1416
+ console.log(`✅ Session created: ${sessionResult.sessionId}`);
1417
+ } else {
1418
+ console.log(`⚠️ Session creation failed, will be created on first command`);
1419
+ }
1420
+
1421
+ // Update device status to online in connectedDevices
1422
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1423
+ if (deviceIndex !== -1) {
1424
+ connectedDevices[deviceIndex].status = 'online';
1425
+ }
1426
+
1427
+ // Broadcast updated device list and connection status to all WebSocket clients
1428
+ wss.clients.forEach(client => {
1429
+ if (client.readyState === WebSocket.OPEN) {
1430
+ client.send(JSON.stringify({
1431
+ type: 'devices_updated',
1432
+ devices: connectedDevices
1433
+ }));
1434
+ // Send connection complete message
1435
+ client.send(JSON.stringify({
1436
+ type: 'command_output',
1437
+ output: `✅ ${name} connected - WDA Ready!\n${'─'.repeat(50)}\n`,
1438
+ device: name
1439
+ }));
1440
+ }
1441
+ });
1442
+ } else {
1443
+ console.log(`⚠️ WDA still starting for ${name}...`);
1444
+ }
1445
+ });
1446
+ }, 8000); // Wait 8 seconds for WDA to start
1447
+
1448
+ // Return immediately with starting status
1449
+ resolve({
1450
+ success: true,
1451
+ message: `Connecting to ${name} - Starting WDA...`,
1452
+ wdaStarting: true
1453
+ });
1454
+ }
1455
+ });
1456
+
1457
+ checkWDA.on('error', () => {
1458
+ console.log(`⚠️ Could not check WDA status, attempting to start...`);
1459
+ resolve({ success: false, message: `Error checking WDA. Please start manually in Xcode.` });
1460
+ });
1461
+ } else {
1462
+ // For wireless devices, check if WDA is reachable
1463
+ const checkWDA = spawn('curl', [
1464
+ '-s',
1465
+ '--connect-timeout', '3',
1466
+ `http://${ip}:8100/status`
1467
+ ]);
1468
+
1469
+ let output = '';
1470
+ checkWDA.stdout.on('data', (data) => {
1471
+ output += data.toString();
1472
+ });
1473
+
1474
+ checkWDA.on('close', async () => {
1475
+ if (output.includes('"state"')) {
1476
+ console.log(`✅ WDA reachable for ${name}`);
1477
+
1478
+ // Create WDA session for wireless device
1479
+ console.log(`📱 Creating WDA session for ${name}...`);
1480
+ const sessionResult = await createWDASession(ip, name);
1481
+
1482
+ if (sessionResult.success) {
1483
+ console.log(`✅ Session created: ${sessionResult.sessionId}`);
1484
+ } else {
1485
+ console.log(`⚠️ Session creation failed, will be created on first command`);
1486
+ }
1487
+
1488
+ // Update status to online
1489
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1490
+ if (deviceIndex !== -1) {
1491
+ connectedDevices[deviceIndex].status = 'online';
1492
+ }
1493
+
1494
+ // Broadcast updated device list
1495
+ wss.clients.forEach(client => {
1496
+ if (client.readyState === WebSocket.OPEN) {
1497
+ client.send(JSON.stringify({
1498
+ type: 'devices_updated',
1499
+ devices: connectedDevices
1500
+ }));
1501
+ }
1502
+ });
1503
+
1504
+ resolve({ success: true, message: `Connected to ${name} - WDA Ready ✅` });
1505
+ } else {
1506
+ console.log(`❌ WDA not running on ${name}, attempting to start...`);
1507
+
1508
+ // Start WDA wirelessly using xcodebuild
1509
+ const wdaPath = process.env.WDA_PATH || path.join(process.env.HOME, 'Downloads/WebDriverAgent-10.2.1');
1510
+
1511
+ console.log(`📂 WDA Path: ${wdaPath}`);
1512
+ console.log(`📱 Device UDID: ${udid}`);
1513
+ console.log(`📡 Device IP: ${ip}`);
1514
+
1515
+ // Start xcodebuild in background for wireless device
1516
+ const wdaProcess = spawn('xcodebuild', [
1517
+ 'test',
1518
+ '-project', path.join(wdaPath, 'WebDriverAgent.xcodeproj'),
1519
+ '-scheme', 'WebDriverAgentRunner',
1520
+ '-destination', `id=${udid}`,
1521
+ 'IPHONEOS_DEPLOYMENT_TARGET=14.0'
1522
+ ], {
1523
+ cwd: wdaPath,
1524
+ detached: true,
1525
+ stdio: ['ignore', 'pipe', 'pipe']
1526
+ });
1527
+
1528
+ // Log output for debugging
1529
+ wdaProcess.stdout.on('data', (data) => {
1530
+ const output = data.toString();
1531
+ if (output.includes('ServerURLHere') || output.includes('WebDriverAgent')) {
1532
+ console.log(`📱 WDA (${name}): ${output.trim()}`);
1533
+ }
1534
+ });
1535
+
1536
+ wdaProcess.stderr.on('data', (data) => {
1537
+ const error = data.toString();
1538
+ if (!error.includes('note:') && !error.includes('warning:')) {
1539
+ console.error(`⚠️ WDA Error (${name}): ${error.trim()}`);
1540
+ }
1541
+ });
1542
+
1543
+ wdaProcess.on('error', (err) => {
1544
+ console.error(`❌ Failed to start xcodebuild for ${name}: ${err.message}`);
1545
+ });
1546
+
1547
+ wdaProcess.unref();
1548
+
1549
+ // Track WDA process for cleanup on disconnect
1550
+ wdaProcesses.set(name, {
1551
+ xcodebuild: wdaProcess.pid
1552
+ });
1553
+
1554
+ console.log(`✅ WDA starting for ${name} (wireless). Process ID: ${wdaProcess.pid}`);
1555
+
1556
+ // Wait for WDA to start, then verify
1557
+ setTimeout(() => {
1558
+ const verifyWDA = spawn('curl', [
1559
+ '-s',
1560
+ '--connect-timeout', '5',
1561
+ `http://${ip}:8100/status`
1562
+ ]);
1563
+
1564
+ let verifyOutput = '';
1565
+ verifyWDA.stdout.on('data', (data) => {
1566
+ verifyOutput += data.toString();
1567
+ });
1568
+
1569
+ verifyWDA.on('close', async () => {
1570
+ if (verifyOutput.includes('"state"')) {
1571
+ console.log(`✅ WDA successfully started for ${name}`);
1572
+
1573
+ // Create WDA session for wireless device
1574
+ console.log(`📱 Creating WDA session for ${name}...`);
1575
+ const sessionResult = await createWDASession(ip, name);
1576
+
1577
+ if (sessionResult.success) {
1578
+ console.log(`✅ Session created: ${sessionResult.sessionId}`);
1579
+ } else {
1580
+ console.log(`⚠️ Session creation failed, will be created on first command`);
1581
+ }
1582
+
1583
+ // Update device status to online
1584
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1585
+ if (deviceIndex !== -1) {
1586
+ connectedDevices[deviceIndex].status = 'online';
1587
+ }
1588
+
1589
+ // Broadcast updated device list
1590
+ wss.clients.forEach(client => {
1591
+ if (client.readyState === WebSocket.OPEN) {
1592
+ client.send(JSON.stringify({
1593
+ type: 'devices_updated',
1594
+ devices: connectedDevices
1595
+ }));
1596
+ }
1597
+ });
1598
+
1599
+ resolve({
1600
+ success: true,
1601
+ message: `Connected to ${name} via WiFi - WDA Started!`,
1602
+ wdaStarted: true
1603
+ });
1604
+ } else {
1605
+ console.log(`⚠️ WDA starting for ${name}, may take a moment...`);
1606
+ resolve({
1607
+ success: true,
1608
+ message: `WDA is starting for ${name}. Please wait 10-15 seconds...`,
1609
+ wdaStarting: true
1610
+ });
1611
+ }
1612
+ });
1613
+ }, 10000); // Wait 10 seconds for wireless WDA to start
1614
+ }
1615
+ });
1616
+
1617
+ checkWDA.on('error', () => {
1618
+ console.log(`⚠️ Cannot reach ${name} at ${ip}. Check network connection.`);
1619
+ reject({ success: false, message: `Cannot reach ${name} at ${ip}. Check network connection.` });
1620
+ });
1621
+ }
1622
+ });
1623
+ }
1624
+
1625
+ // Disconnect from device
1626
+ async function disconnectFromDevice(device) {
1627
+ return new Promise((resolve, reject) => {
1628
+ const { name, connectionType, udid, platform } = device;
1629
+ console.log(`🔌 Disconnecting from ${name} (${connectionType}, platform: ${platform})...`);
1630
+
1631
+ // Handle Android devices
1632
+ if (platform === 'android') {
1633
+ console.log(`📱 Android device disconnect: ${name}`);
1634
+
1635
+ // Stop UIAutomator2 session first
1636
+ stopUIAutomator2Session(udid, name).catch(err => {
1637
+ console.error(`Error stopping UIAutomator2 session: ${err.message}`);
1638
+ });
1639
+
1640
+ // For wireless Android devices, disconnect adb
1641
+ if (connectionType === 'wireless') {
1642
+ const adbDisconnect = spawn('adb', ['disconnect', udid]);
1643
+
1644
+ adbDisconnect.on('close', (code) => {
1645
+ console.log(`✅ Disconnected Android device ${name} from ADB`);
1646
+
1647
+ // Update device status
1648
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1649
+ if (deviceIndex !== -1) {
1650
+ connectedDevices[deviceIndex].status = 'offline';
1651
+ }
1652
+
1653
+ // Broadcast update
1654
+ wss.clients.forEach(client => {
1655
+ if (client.readyState === WebSocket.OPEN) {
1656
+ client.send(JSON.stringify({
1657
+ type: 'devices_updated',
1658
+ devices: connectedDevices
1659
+ }));
1660
+ }
1661
+ });
1662
+
1663
+ resolve({
1664
+ success: true,
1665
+ message: `Disconnected from ${name} - ADB disconnected`
1666
+ });
1667
+ });
1668
+ } else {
1669
+ // For USB Android devices, just update status
1670
+ console.log(`✅ Android USB device ${name} - connection maintained`);
1671
+
1672
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1673
+ if (deviceIndex !== -1) {
1674
+ connectedDevices[deviceIndex].status = 'offline';
1675
+ }
1676
+
1677
+ // Broadcast update
1678
+ wss.clients.forEach(client => {
1679
+ if (client.readyState === WebSocket.OPEN) {
1680
+ client.send(JSON.stringify({
1681
+ type: 'devices_updated',
1682
+ devices: connectedDevices
1683
+ }));
1684
+ }
1685
+ });
1686
+
1687
+ resolve({
1688
+ success: true,
1689
+ message: `Disconnected from ${name}`
1690
+ });
1691
+ }
1692
+
1693
+ return; // Exit early for Android
1694
+ }
1695
+
1696
+ // iOS device handling - Find the device in connectedDevices to get its port
1697
+ const deviceInfo = connectedDevices.find(d => d.name === name);
1698
+ const port = deviceInfo?.port;
1699
+
1700
+ // Strategy 1: Kill WDA processes tracked in wdaProcesses Map
1701
+ if (wdaProcesses.has(name)) {
1702
+ const processes = wdaProcesses.get(name);
1703
+
1704
+ try {
1705
+ if (processes.xcodebuild) {
1706
+ try {
1707
+ process.kill(processes.xcodebuild, 'SIGTERM');
1708
+ console.log(`✅ Killed xcodebuild for ${name} (PID: ${processes.xcodebuild})`);
1709
+ } catch (e) {
1710
+ console.log(`⚠️ xcodebuild process ${processes.xcodebuild} already stopped`);
1711
+ }
1712
+ }
1713
+
1714
+ if (processes.iproxy) {
1715
+ try {
1716
+ process.kill(processes.iproxy, 'SIGTERM');
1717
+ console.log(`✅ Killed iproxy for ${name} (PID: ${processes.iproxy})`);
1718
+ } catch (e) {
1719
+ console.log(`⚠️ iproxy process ${processes.iproxy} already stopped`);
1720
+ }
1721
+ }
1722
+
1723
+ wdaProcesses.delete(name);
1724
+ } catch (error) {
1725
+ console.error(`Error cleaning up tracked processes for ${name}:`, error);
1726
+ }
1727
+ }
1728
+
1729
+ // Strategy 2: Kill processes by port (works for script-started WDA too)
1730
+ if (port) {
1731
+ console.log(`🔍 Killing processes on port ${port} for ${name}...`);
1732
+ const killPort = spawn('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null || true`]);
1733
+
1734
+ killPort.on('close', () => {
1735
+ console.log(`✅ Cleaned up port ${port} for ${name}`);
1736
+ });
1737
+ }
1738
+
1739
+ // Strategy 3: Kill xcodebuild processes for this specific UDID
1740
+ if (udid && connectionType === 'usb') {
1741
+ console.log(`🔍 Killing WDA for UDID ${udid}...`);
1742
+ const killWDA = spawn('sh', ['-c', `pkill -f "xcodebuild.*${udid}" || true`]);
1743
+
1744
+ killWDA.on('close', () => {
1745
+ console.log(`✅ Cleaned up WDA processes for ${name}`);
1746
+ });
1747
+ }
1748
+
1749
+ // Strategy 4: For wireless, kill iproxy processes associated with this device's port
1750
+ if (connectionType === 'usb' && port) {
1751
+ const killIproxy = spawn('sh', ['-c', `pkill -f "iproxy.*${port}" || true`]);
1752
+ killIproxy.on('close', () => {
1753
+ console.log(`✅ Cleaned up iproxy for ${name}`);
1754
+ });
1755
+ }
1756
+
1757
+ // Wait a moment for all cleanup to complete
1758
+ setTimeout(() => {
1759
+ // Update device status to offline in connectedDevices
1760
+ const deviceIndex = connectedDevices.findIndex(d => d.name === name);
1761
+ if (deviceIndex !== -1) {
1762
+ connectedDevices[deviceIndex].status = 'offline';
1763
+ }
1764
+
1765
+ // Broadcast updated device list to all WebSocket clients
1766
+ wss.clients.forEach(client => {
1767
+ if (client.readyState === WebSocket.OPEN) {
1768
+ client.send(JSON.stringify({
1769
+ type: 'devices_updated',
1770
+ devices: connectedDevices
1771
+ }));
1772
+ }
1773
+ });
1774
+
1775
+ console.log(`✅ Successfully disconnected ${name}`);
1776
+ resolve({
1777
+ success: true,
1778
+ message: `Disconnected from ${name} - WDA stopped`
1779
+ });
1780
+ }, 500);
1781
+ });
1782
+ }
1783
+
1784
+ // ============================================
1785
+ // Recording & Replay Functions
1786
+ // ============================================
1787
+
1788
+ // Ensure recordings directory exists
1789
+ async function ensureRecordingsDir() {
1790
+ try {
1791
+ await fs.access(RECORDINGS_DIR);
1792
+ } catch {
1793
+ await fs.mkdir(RECORDINGS_DIR, { recursive: true });
1794
+ }
1795
+ }
1796
+
1797
+ // Start recording
1798
+ async function startRecording(name, devices) {
1799
+ await ensureRecordingsDir();
1800
+
1801
+ currentRecording = {
1802
+ name: name || `recording_${Date.now()}`,
1803
+ devices: devices || [],
1804
+ commands: [],
1805
+ startTime: new Date().toISOString()
1806
+ };
1807
+ isRecording = true;
1808
+
1809
+ console.log(`🔴 Recording started: ${currentRecording.name}`);
1810
+ return { success: true, recording: currentRecording };
1811
+ }
1812
+
1813
+ // Add command to current recording
1814
+ function recordCommand(devices, command, useAI, convertedCommand) {
1815
+ if (!isRecording || !currentRecording) {
1816
+ return false;
1817
+ }
1818
+
1819
+ currentRecording.commands.push({
1820
+ timestamp: new Date().toISOString(),
1821
+ devices,
1822
+ originalCommand: command,
1823
+ useAI,
1824
+ finalCommand: convertedCommand || command
1825
+ });
1826
+
1827
+ console.log(`📝 Recorded command: ${command} (${currentRecording.commands.length} total)`);
1828
+ return true;
1829
+ }
1830
+
1831
+ // Stop recording and save
1832
+ async function stopRecording() {
1833
+ if (!isRecording || !currentRecording) {
1834
+ return { success: false, error: 'No active recording' };
1835
+ }
1836
+
1837
+ currentRecording.endTime = new Date().toISOString();
1838
+
1839
+ const filename = `${currentRecording.name}.json`;
1840
+ const filepath = path.join(RECORDINGS_DIR, filename);
1841
+
1842
+ await fs.writeFile(filepath, JSON.stringify(currentRecording, null, 2));
1843
+
1844
+ const result = {
1845
+ success: true,
1846
+ recording: currentRecording,
1847
+ filepath: filename
1848
+ };
1849
+
1850
+ console.log(`⏹️ Recording stopped: ${currentRecording.name} (${currentRecording.commands.length} commands)`);
1851
+
1852
+ isRecording = false;
1853
+ currentRecording = null;
1854
+
1855
+ return result;
1856
+ }
1857
+
1858
+ // List all recordings
1859
+ async function listRecordings() {
1860
+ await ensureRecordingsDir();
1861
+
1862
+ try {
1863
+ const files = await fs.readdir(RECORDINGS_DIR);
1864
+ const recordings = [];
1865
+
1866
+ for (const file of files) {
1867
+ if (file.endsWith('.json')) {
1868
+ try {
1869
+ const filepath = path.join(RECORDINGS_DIR, file);
1870
+ const content = await fs.readFile(filepath, 'utf-8');
1871
+ const recording = JSON.parse(content);
1872
+
1873
+ // Handle both old format (array of commands) and new format (string)
1874
+ let commandCount = 0;
1875
+ if (Array.isArray(recording.commands)) {
1876
+ commandCount = recording.commands.length;
1877
+ } else if (typeof recording.commands === 'string') {
1878
+ commandCount = recording.commands.split('\n').filter(cmd => cmd.trim()).length;
1879
+ }
1880
+
1881
+ recordings.push({
1882
+ filename: file,
1883
+ name: recording.name || file.replace('.json', ''),
1884
+ devices: recording.devices || [],
1885
+ commandCount: commandCount,
1886
+ startTime: recording.startTime || recording.date,
1887
+ endTime: recording.endTime || recording.date,
1888
+ type: recording.type || 'manual'
1889
+ });
1890
+ } catch (e) {
1891
+ console.error(`Error reading recording ${file}:`, e);
1892
+ }
1893
+ }
1894
+ }
1895
+
1896
+ return recordings;
1897
+ } catch (error) {
1898
+ console.error('Error listing recordings:', error);
1899
+ return [];
1900
+ }
1901
+ }
1902
+
1903
+ // Load a recording
1904
+ async function loadRecording(filename) {
1905
+ const filepath = path.join(RECORDINGS_DIR, filename);
1906
+
1907
+ try {
1908
+ const content = await fs.readFile(filepath, 'utf-8');
1909
+ return JSON.parse(content);
1910
+ } catch (error) {
1911
+ console.error(`Error loading recording ${filename}:`, error);
1912
+ throw error;
1913
+ }
1914
+ }
1915
+
1916
+ // Delete a recording
1917
+ async function deleteRecording(filename) {
1918
+ const filepath = path.join(RECORDINGS_DIR, filename);
1919
+
1920
+ try {
1921
+ await fs.unlink(filepath);
1922
+ return { success: true };
1923
+ } catch (error) {
1924
+ console.error(`Error deleting recording ${filename}:`, error);
1925
+ throw error;
1926
+ }
1927
+ }
1928
+
1929
+ // Replay a recording
1930
+ async function replayRecording(filename, targetDevices, wsClient) {
1931
+ const recording = await loadRecording(filename);
1932
+
1933
+ // Normalize commands to array format
1934
+ let commands = [];
1935
+ if (Array.isArray(recording.commands)) {
1936
+ // Old format: array of command objects
1937
+ commands = recording.commands.map(cmd => cmd.finalCommand || cmd.originalCommand);
1938
+ } else if (typeof recording.commands === 'string') {
1939
+ // New format: string with newlines
1940
+ commands = recording.commands.split('\n').filter(cmd => cmd.trim());
1941
+ }
1942
+
1943
+ const devices = recording.devices && recording.devices.length > 0
1944
+ ? recording.devices
1945
+ : targetDevices;
1946
+
1947
+ console.log(`\n${'▶'.repeat(60)}`);
1948
+ console.log(`REPLAYING: ${recording.name}`);
1949
+ console.log(`Original devices: ${devices.join(', ')}`);
1950
+ console.log(`Target devices: ${targetDevices.join(', ')}`);
1951
+ console.log(`Commands to replay: ${commands.length}`);
1952
+ console.log(`${'▶'.repeat(60)}\n`);
1953
+
1954
+ // Reset abort flag at start
1955
+ replayAborted = false;
1956
+ isReplaying = true;
1957
+
1958
+ // Send initial summary to terminal
1959
+ if (wsClient) {
1960
+ wsClient.send(JSON.stringify({
1961
+ type: 'command_output',
1962
+ data: `📼 Recording: ${recording.name}\n` +
1963
+ `📱 Devices: ${targetDevices.join(', ')}\n` +
1964
+ `📝 Commands: ${commands.length}\n` +
1965
+ `${'━'.repeat(50)}\n\n`,
1966
+ devices: targetDevices
1967
+ }));
1968
+ }
1969
+
1970
+ const results = [];
1971
+
1972
+ for (let i = 0; i < commands.length; i++) {
1973
+ // Check if replay was aborted
1974
+ if (replayAborted) {
1975
+ console.log('\n⏹️ Replay aborted by user\n');
1976
+ if (wsClient) {
1977
+ wsClient.send(JSON.stringify({
1978
+ type: 'command_output',
1979
+ data: '\n⏹️ Replay stopped by user\n',
1980
+ devices: targetDevices
1981
+ }));
1982
+ }
1983
+ isReplaying = false;
1984
+ throw new Error('Replay aborted by user');
1985
+ }
1986
+
1987
+ const cmd = commands[i];
1988
+
1989
+ console.log(`\n🎬 Replaying command ${i + 1}/${commands.length}`);
1990
+ console.log(` Command: ${cmd}`);
1991
+
1992
+ if (wsClient) {
1993
+ wsClient.send(JSON.stringify({
1994
+ type: 'command_output',
1995
+ data: `🎬 [${i + 1}/${commands.length}] Executing: ${cmd}\n`,
1996
+ devices: targetDevices
1997
+ }));
1998
+ }
1999
+
2000
+ try {
2001
+ // Execute the command
2002
+ const result = await executeCommand(targetDevices, cmd, null); // null to suppress websocket output from executeCommand
2003
+ results.push({ command: cmd, success: true, result });
2004
+
2005
+ if (wsClient) {
2006
+ wsClient.send(JSON.stringify({
2007
+ type: 'command_output',
2008
+ data: `✅ Completed successfully\n\n`,
2009
+ devices: targetDevices
2010
+ }));
2011
+ }
2012
+
2013
+ // Add a small delay between commands
2014
+ if (i < commands.length - 1) {
2015
+ await new Promise(resolve => setTimeout(resolve, 1000));
2016
+ }
2017
+ } catch (error) {
2018
+ console.error(`❌ Replay command ${i + 1} failed:`, error.message);
2019
+ results.push({ command: cmd, success: false, error: error.message });
2020
+
2021
+ if (wsClient) {
2022
+ wsClient.send(JSON.stringify({
2023
+ type: 'command_output',
2024
+ data: `❌ Failed: ${error.message}\n\n`,
2025
+ devices: targetDevices
2026
+ }));
2027
+ }
2028
+ }
2029
+ }
2030
+
2031
+ isReplaying = false;
2032
+
2033
+ console.log(`\n${'▶'.repeat(60)}`);
2034
+ console.log(`REPLAY COMPLETE`);
2035
+ console.log(`Total: ${results.length}, Success: ${results.filter(r => r.success).length}, Failed: ${results.filter(r => !r.success).length}`);
2036
+ console.log(`${'▶'.repeat(60)}\n`);
2037
+
2038
+ return {
2039
+ success: true,
2040
+ recording: recording.name,
2041
+ results
2042
+ };
2043
+ }
2044
+
2045
+ // API Routes
2046
+ app.get('/api/devices', async (req, res) => {
2047
+ try {
2048
+ const devices = await discoverDevices();
2049
+ res.json({ success: true, devices });
2050
+ } catch (error) {
2051
+ res.status(500).json({ success: false, error: error.message });
2052
+ }
2053
+ });
2054
+
2055
+ app.post('/api/devices/refresh', async (req, res) => {
2056
+ try {
2057
+ const devices = await discoverDevices();
2058
+
2059
+ // Broadcast to all WebSocket clients
2060
+ wss.clients.forEach(client => {
2061
+ if (client.readyState === WebSocket.OPEN) {
2062
+ client.send(JSON.stringify({
2063
+ type: 'devices_updated',
2064
+ devices
2065
+ }));
2066
+ }
2067
+ });
2068
+
2069
+ res.json({ success: true, devices });
2070
+ } catch (error) {
2071
+ res.status(500).json({ success: false, error: error.message });
2072
+ }
2073
+ });
2074
+
2075
+ app.post('/api/execute', async (req, res) => {
2076
+ try {
2077
+ const { devices, command, useAI, aiProvider } = req.body;
2078
+
2079
+ if (!devices || devices.length === 0) {
2080
+ return res.status(400).json({ success: false, error: 'No devices specified' });
2081
+ }
2082
+
2083
+ let finalCommand = command;
2084
+ if (useAI) {
2085
+ finalCommand = await convertNaturalLanguageToCommand(command, devices, aiProvider);
2086
+ }
2087
+
2088
+ // Check if command has multiple lines (sequential execution)
2089
+ const hasMultipleCommands = finalCommand.includes('\n');
2090
+
2091
+ let result;
2092
+ if (hasMultipleCommands) {
2093
+ console.log(`Detected multi-step command, using sequential execution`);
2094
+ result = await executeCommandsSequentially(devices, finalCommand, null);
2095
+ } else {
2096
+ result = await executeCommand(devices, finalCommand, null);
2097
+ }
2098
+
2099
+ res.json(result);
2100
+ } catch (error) {
2101
+ res.status(500).json({ success: false, error: error.message });
2102
+ }
2103
+ });
2104
+
2105
+ app.post('/api/convert-command', async (req, res) => {
2106
+ try {
2107
+ const { text, devices, provider } = req.body;
2108
+ const command = await convertNaturalLanguageToCommand(text, devices || [], provider);
2109
+ res.json({ success: true, command });
2110
+ } catch (error) {
2111
+ res.status(500).json({ success: false, error: error.message });
2112
+ }
2113
+ });
2114
+
2115
+ // Connect to device endpoint
2116
+ app.post('/api/devices/connect', async (req, res) => {
2117
+ try {
2118
+ const { device } = req.body;
2119
+ log.debug(`📡 Connect request received for device:`, device);
2120
+
2121
+ if (!device) {
2122
+ console.error('❌ No device provided in request');
2123
+ return res.status(400).json({ success: false, error: 'No device provided' });
2124
+ }
2125
+
2126
+ if (!device.name || !device.udid) {
2127
+ console.error('❌ Invalid device object:', device);
2128
+ return res.status(400).json({ success: false, error: 'Invalid device object' });
2129
+ }
2130
+
2131
+ const result = await connectToDevice(device);
2132
+ log.debug(`✅ Connect result:`, result);
2133
+ res.json(result);
2134
+ } catch (error) {
2135
+ console.error(`❌ Connect error:`, error);
2136
+ res.status(500).json({ success: false, error: error.message });
2137
+ }
2138
+ });
2139
+
2140
+ // Disconnect from device endpoint
2141
+ app.post('/api/devices/disconnect', async (req, res) => {
2142
+ try {
2143
+ const { device } = req.body;
2144
+ const result = await disconnectFromDevice(device);
2145
+ res.json(result);
2146
+ } catch (error) {
2147
+ res.status(500).json({ success: false, error: error.message });
2148
+ }
2149
+ });
2150
+
2151
+ // Disconnect all devices endpoint
2152
+ app.post('/api/devices/disconnect-all', async (req, res) => {
2153
+ try {
2154
+ console.log(`🔌 Disconnecting ALL devices...`);
2155
+
2156
+ // Get all connected devices
2157
+ const devicesToDisconnect = connectedDevices.filter(d => d.status === 'online');
2158
+
2159
+ if (devicesToDisconnect.length === 0) {
2160
+ return res.json({
2161
+ success: true,
2162
+ message: 'No devices to disconnect'
2163
+ });
2164
+ }
2165
+
2166
+ console.log(`📱 Disconnecting ${devicesToDisconnect.length} device(s)...`);
2167
+
2168
+ // Disconnect all devices in parallel
2169
+ const disconnectPromises = devicesToDisconnect.map(device => disconnectFromDevice(device));
2170
+ await Promise.all(disconnectPromises);
2171
+
2172
+ console.log(`✅ All devices disconnected`);
2173
+
2174
+ res.json({
2175
+ success: true,
2176
+ message: `Disconnected ${devicesToDisconnect.length} device(s)`,
2177
+ count: devicesToDisconnect.length
2178
+ });
2179
+ } catch (error) {
2180
+ console.error(`❌ Error disconnecting all devices:`, error);
2181
+ res.status(500).json({ success: false, error: error.message });
2182
+ }
2183
+ });
2184
+
2185
+
2186
+ // New endpoints for AI provider management
2187
+ app.get('/api/ai/providers', (req, res) => {
2188
+ try {
2189
+ const providers = aiManager.getAvailableProviders();
2190
+ const current = aiManager.getCurrentProvider();
2191
+ res.json({ success: true, providers, current: current.id });
2192
+ } catch (error) {
2193
+ res.status(500).json({ success: false, error: error.message });
2194
+ }
2195
+ });
2196
+
2197
+ app.post('/api/ai/provider', (req, res) => {
2198
+ try {
2199
+ const { provider } = req.body;
2200
+ const success = aiManager.setProvider(provider);
2201
+ if (success) {
2202
+ res.json({ success: true, provider });
2203
+ } else {
2204
+ res.status(400).json({ success: false, error: 'Invalid or unavailable provider' });
2205
+ }
2206
+ } catch (error) {
2207
+ res.status(500).json({ success: false, error: error.message });
2208
+ }
2209
+ });
2210
+
2211
+ // Settings API - Get API configuration
2212
+ app.get('/api/settings/api-config', async (req, res) => {
2213
+ try {
2214
+ const configPath = path.join(__dirname, '../../.env');
2215
+ let config = {
2216
+ provider: process.env.AI_PROVIDER || 'gemini',
2217
+ model: process.env.AI_MODEL || '',
2218
+ openai_api_key: process.env.OPENAI_API_KEY || '',
2219
+ gemini_api_key: process.env.GEMINI_API_KEY || '',
2220
+ claude_api_key: process.env.CLAUDE_API_KEY || '',
2221
+ github_token: process.env.GITHUB_TOKEN || '',
2222
+ groq_api_key: process.env.GROQ_API_KEY || '',
2223
+ cohere_api_key: process.env.COHERE_API_KEY || '',
2224
+ mistral_api_key: process.env.MISTRAL_API_KEY || '',
2225
+ };
2226
+
2227
+ // Try to read from .env file if exists
2228
+ try {
2229
+ const envContent = await fs.readFile(configPath, 'utf-8');
2230
+ const lines = envContent.split('\n');
2231
+ lines.forEach(line => {
2232
+ const [key, value] = line.split('=');
2233
+ if (key && value) {
2234
+ const trimmedKey = key.trim();
2235
+ const trimmedValue = value.trim();
2236
+ if (trimmedKey === 'AI_PROVIDER') config.provider = trimmedValue;
2237
+ if (trimmedKey === 'AI_MODEL') config.model = trimmedValue;
2238
+ if (trimmedKey === 'OPENAI_API_KEY') config.openai_api_key = trimmedValue;
2239
+ if (trimmedKey === 'GEMINI_API_KEY') config.gemini_api_key = trimmedValue;
2240
+ if (trimmedKey === 'CLAUDE_API_KEY') config.claude_api_key = trimmedValue;
2241
+ if (trimmedKey === 'GITHUB_TOKEN') config.github_token = trimmedValue;
2242
+ if (trimmedKey === 'GROQ_API_KEY') config.groq_api_key = trimmedValue;
2243
+ if (trimmedKey === 'COHERE_API_KEY') config.cohere_api_key = trimmedValue;
2244
+ if (trimmedKey === 'MISTRAL_API_KEY') config.mistral_api_key = trimmedValue;
2245
+ }
2246
+ });
2247
+ } catch (err) {
2248
+ // .env doesn't exist, return defaults
2249
+ }
2250
+
2251
+ // Mask API keys for security - only show last 4 characters
2252
+ const maskKey = (key) => {
2253
+ if (!key || key.length < 8) return '';
2254
+ return '•'.repeat(key.length - 4) + key.slice(-4);
2255
+ };
2256
+
2257
+ res.json({
2258
+ provider: config.provider,
2259
+ model: config.model,
2260
+ openai_api_key: maskKey(config.openai_api_key),
2261
+ gemini_api_key: maskKey(config.gemini_api_key),
2262
+ claude_api_key: maskKey(config.claude_api_key),
2263
+ github_token: maskKey(config.github_token),
2264
+ groq_api_key: maskKey(config.groq_api_key),
2265
+ cohere_api_key: maskKey(config.cohere_api_key),
2266
+ mistral_api_key: maskKey(config.mistral_api_key),
2267
+ has_openai_key: !!config.openai_api_key,
2268
+ has_gemini_key: !!config.gemini_api_key,
2269
+ has_claude_key: !!config.claude_api_key,
2270
+ has_github_token: !!config.github_token,
2271
+ has_groq_key: !!config.groq_api_key,
2272
+ has_cohere_key: !!config.cohere_api_key,
2273
+ has_mistral_key: !!config.mistral_api_key,
2274
+ });
2275
+ } catch (error) {
2276
+ res.status(500).json({ success: false, error: error.message });
2277
+ }
2278
+ });
2279
+
2280
+ // Settings API - Save API configuration
2281
+ app.post('/api/settings/api-config', async (req, res) => {
2282
+ try {
2283
+ const {
2284
+ provider,
2285
+ model,
2286
+ openai_api_key,
2287
+ gemini_api_key,
2288
+ claude_api_key,
2289
+ github_token,
2290
+ groq_api_key,
2291
+ cohere_api_key,
2292
+ mistral_api_key
2293
+ } = req.body;
2294
+
2295
+ const configPath = path.join(__dirname, '../../.env');
2296
+
2297
+ let envContent = `# AI Provider Configuration\nAI_PROVIDER=${provider}\n`;
2298
+ if (model) {
2299
+ envContent += `AI_MODEL=${model}\n`;
2300
+ }
2301
+ if (openai_api_key) {
2302
+ envContent += `OPENAI_API_KEY=${openai_api_key}\n`;
2303
+ }
2304
+ if (gemini_api_key) {
2305
+ envContent += `GEMINI_API_KEY=${gemini_api_key}\n`;
2306
+ }
2307
+ if (claude_api_key) {
2308
+ envContent += `CLAUDE_API_KEY=${claude_api_key}\n`;
2309
+ }
2310
+ if (github_token) {
2311
+ envContent += `GITHUB_TOKEN=${github_token}\n`;
2312
+ }
2313
+ if (groq_api_key) {
2314
+ envContent += `GROQ_API_KEY=${groq_api_key}\n`;
2315
+ }
2316
+ if (cohere_api_key) {
2317
+ envContent += `COHERE_API_KEY=${cohere_api_key}\n`;
2318
+ }
2319
+ if (mistral_api_key) {
2320
+ envContent += `MISTRAL_API_KEY=${mistral_api_key}\n`;
2321
+ }
2322
+
2323
+ // Add WDA path if exists
2324
+ if (process.env.WDA_PATH) {
2325
+ envContent += `WDA_PATH=${process.env.WDA_PATH}\n`;
2326
+ }
2327
+
2328
+ await fs.writeFile(configPath, envContent);
2329
+
2330
+ // Update environment variables
2331
+ process.env.AI_PROVIDER = provider;
2332
+ if (model) process.env.AI_MODEL = model;
2333
+ if (openai_api_key) process.env.OPENAI_API_KEY = openai_api_key;
2334
+ if (gemini_api_key) process.env.GEMINI_API_KEY = gemini_api_key;
2335
+ if (claude_api_key) process.env.CLAUDE_API_KEY = claude_api_key;
2336
+ if (github_token) process.env.GITHUB_TOKEN = github_token;
2337
+ if (groq_api_key) process.env.GROQ_API_KEY = groq_api_key;
2338
+ if (cohere_api_key) process.env.COHERE_API_KEY = cohere_api_key;
2339
+ if (mistral_api_key) process.env.MISTRAL_API_KEY = mistral_api_key;
2340
+
2341
+ // Reinitialize AI manager with new config
2342
+ aiManager.provider = provider;
2343
+ if (model) aiManager.model = model;
2344
+ aiManager.initializeProviders();
2345
+
2346
+ res.json({ success: true, message: 'API configuration saved' });
2347
+ } catch (error) {
2348
+ res.status(500).json({ success: false, error: error.message });
2349
+ }
2350
+ });
2351
+
2352
+ // Settings API - Get available AI providers and their models
2353
+ app.get('/api/settings/ai-providers', (req, res) => {
2354
+ try {
2355
+ const { AI_PROVIDERS_CONFIG } = require('./aiProvidersConfig');
2356
+ res.json({ success: true, providers: AI_PROVIDERS_CONFIG });
2357
+ } catch (error) {
2358
+ res.status(500).json({ success: false, error: error.message });
2359
+ }
2360
+ });
2361
+
2362
+ // Settings API - Get devices configuration
2363
+ app.get('/api/settings/devices', async (req, res) => {
2364
+ try {
2365
+ const devices = await loadDeviceConfig();
2366
+ res.json({ success: true, devices });
2367
+ } catch (error) {
2368
+ res.status(500).json({ success: false, error: error.message });
2369
+ }
2370
+ });
2371
+
2372
+ // Settings API - Save devices configuration
2373
+ app.post('/api/settings/devices', async (req, res) => {
2374
+ try {
2375
+ const { devices } = req.body;
2376
+
2377
+ // Format: device_name,udid,ip_address,platform,type
2378
+ let content = '# Device Configuration (Auto-updated)\n';
2379
+ content += '# Format: device_name,udid,ip_address,platform,type\n';
2380
+ content += '# Platform: ios | android\n';
2381
+ content += '# Type: usb | wireless\n';
2382
+ content += '# \n';
2383
+ content += '# 🔌 USB DEVICES: Automatically detected and saved here\n';
2384
+ content += '# - No configuration needed for USB connection\n';
2385
+ content += '# - Add IP address to enable wireless support later\n';
2386
+ content += '#\n';
2387
+ content += '# 📡 WIRELESS DEVICES: Manually add with IP address\n';
2388
+ content += '# - Format: device_name,udid,ip_address,platform,wireless\n';
2389
+ content += '# - iOS: Device must be paired in Xcode, WebDriverAgent running\n';
2390
+ content += '# - Android: Enable wireless ADB first\n';
2391
+ content += '#\n';
2392
+ content += '# Example entries:\n';
2393
+ content += '# MyiPhone,00008130-001A45C83ED8402E,,ios,usb (iOS USB - auto-detected)\n';
2394
+ content += '# MyiPad,00008140-002B24361E41901C,192.168.1.100,ios,wireless (iOS Wireless)\n';
2395
+ content += '# Pixel8,1A2B3C4D5E6F,,android,usb (Android USB - auto-detected)\n';
2396
+ content += '# GalaxyS23,ABCD1234,192.168.1.150,android,wireless (Android Wireless)\n\n';
2397
+
2398
+ devices.forEach(device => {
2399
+ const ip = device.ip || '';
2400
+ const platform = device.platform || 'ios';
2401
+ const type = device.type || device.connectionType || (ip ? 'wireless' : 'usb');
2402
+ content += `${device.name},${device.udid},${ip},${platform},${type}\n`;
2403
+ });
2404
+
2405
+ await fs.writeFile(DEVICE_CONFIG, content);
2406
+
2407
+ res.json({ success: true, message: 'Devices configuration saved' });
2408
+ } catch (error) {
2409
+ res.status(500).json({ success: false, error: error.message });
2410
+ }
2411
+ });
2412
+
2413
+ // Get app presets
2414
+ app.get('/api/apps/presets', async (req, res) => {
2415
+ try {
2416
+ const content = await fs.readFile(APPS_CONFIG, 'utf-8');
2417
+ const apps = content
2418
+ .split('\n')
2419
+ .filter(line => line.trim() && !line.startsWith('#'))
2420
+ .map(line => {
2421
+ const [name, packageId] = line.split(',');
2422
+ return { name: name.trim(), packageId: packageId.trim() };
2423
+ });
2424
+
2425
+ res.json({ success: true, apps });
2426
+ } catch (error) {
2427
+ res.status(500).json({ success: false, error: error.message });
2428
+ }
2429
+ });
2430
+
2431
+ // Get locators from device's current screen
2432
+ app.post('/api/locators', async (req, res) => {
2433
+ try {
2434
+ const { deviceName } = req.body;
2435
+
2436
+ if (!deviceName) {
2437
+ return res.status(400).json({ success: false, error: 'Device name required' });
2438
+ }
2439
+
2440
+ console.log(`\n🔍 API: Getting locators for device: ${deviceName}`);
2441
+
2442
+ // Find the device info
2443
+ const device = connectedDevices.find(d => d.name === deviceName);
2444
+ if (!device) {
2445
+ return res.status(404).json({ success: false, error: 'Device not found' });
2446
+ }
2447
+
2448
+ // For Android devices, ensure UIAutomator2 session exists
2449
+ if (device.platform === 'android') {
2450
+ const session = androidSessions.get(device.udid);
2451
+ if (!session) {
2452
+ console.log(`⚠️ No UIAutomator2 session for ${deviceName}, auto-connecting...`);
2453
+
2454
+ // Auto-connect the device
2455
+ try {
2456
+ const connectResult = await connectDevice(deviceName);
2457
+ if (!connectResult.success) {
2458
+ return res.status(500).json({
2459
+ success: false,
2460
+ error: 'Failed to connect device. Please use the Connect button first.'
2461
+ });
2462
+ }
2463
+ console.log(`✅ Auto-connected ${deviceName} for getLocators`);
2464
+ // Wait a moment for session to stabilize
2465
+ await new Promise(resolve => setTimeout(resolve, 1000));
2466
+ } catch (err) {
2467
+ return res.status(500).json({
2468
+ success: false,
2469
+ error: `Auto-connection failed: ${err.message}`
2470
+ });
2471
+ }
2472
+ }
2473
+ }
2474
+
2475
+ // Execute getLocators command - this uses the fast shell script implementation
2476
+ const result = await executeCommand([deviceName], 'getLocators', null);
2477
+
2478
+ console.log(`Result success: ${result.success}`);
2479
+ console.log(`Results array length: ${result.results?.length || 0}`);
2480
+
2481
+ if (result.success && result.results) {
2482
+ let output = '';
2483
+
2484
+ // executeCommand returns: { success, results: [scriptResults] }
2485
+ // scriptResults is from executeAndroidDevices/executeWithScript
2486
+ // Structure: { status: 'fulfilled', value: { success, results: [allSettledResults] } }
2487
+ // allSettledResults contains: { status: 'fulfilled', value: { success, output, device } }
2488
+
2489
+ for (const scriptResult of result.results) {
2490
+ console.log(`Script result:`, scriptResult);
2491
+
2492
+ // Check if scriptResult has value.results (fulfilled promise)
2493
+ if (scriptResult.status === 'fulfilled' && scriptResult.value && scriptResult.value.results) {
2494
+ // This is the result from executeAndroidDevices/executeWithScript
2495
+ for (const r of scriptResult.value.results) {
2496
+ console.log(`Result status: ${r.status}`);
2497
+ if (r.status === 'fulfilled' && r.value) {
2498
+ // Check if it's a successful execution
2499
+ if (r.value.success && r.value.output) {
2500
+ output = r.value.output;
2501
+ console.log(`✅ Found output: ${output.length} characters`);
2502
+ break;
2503
+ } else if (r.value.error) {
2504
+ // Script executed but returned an error
2505
+ console.log(`❌ Script error:`, r.value.error);
2506
+ return res.status(500).json({
2507
+ success: false,
2508
+ error: 'Failed to get UI elements. Please ensure UIAutomator2 is installed for Android devices.'
2509
+ });
2510
+ }
2511
+ }
2512
+ }
2513
+ }
2514
+ if (output) break;
2515
+ }
2516
+
2517
+ if (!output) {
2518
+ console.log('❌ No output found in results');
2519
+ console.log('Full result structure:', JSON.stringify(result, null, 2));
2520
+ return res.status(500).json({ success: false, error: 'No output received from device' });
2521
+ }
2522
+
2523
+ // Parse the output into locator objects
2524
+ const locators = parseLocatorsOutput(output);
2525
+ console.log(`✅ Parsed ${locators.length} locators`);
2526
+
2527
+ // Filter out empty/invalid locators
2528
+ const validLocators = locators.filter(loc => loc.name || loc.label || loc.text);
2529
+ console.log(`✅ Filtered to ${validLocators.length} valid locators`);
2530
+
2531
+ res.json({ success: true, locators: validLocators });
2532
+ } else {
2533
+ console.log('❌ Command execution failed');
2534
+ res.status(500).json({ success: false, error: 'Failed to get locators' });
2535
+ }
2536
+ } catch (error) {
2537
+ console.error(`❌ API error:`, error);
2538
+ res.status(500).json({ success: false, error: error.message });
2539
+ }
2540
+ });
2541
+
2542
+ // Click on a locator
2543
+ app.post('/api/locators/click', async (req, res) => {
2544
+ try {
2545
+ const { deviceName, locatorName, x, y } = req.body;
2546
+
2547
+ if (!deviceName) {
2548
+ return res.status(400).json({ success: false, error: 'Device name required' });
2549
+ }
2550
+
2551
+ let command;
2552
+ // If coordinates are provided, use them directly
2553
+ if (x !== undefined && y !== undefined) {
2554
+ command = `tap ${x},${y}`;
2555
+ console.log(`\n🖱️ API: Tapping at coordinates (${x},${y}) on device: ${deviceName}`);
2556
+ } else if (locatorName) {
2557
+ command = `click "${locatorName}"`;
2558
+ console.log(`\n🖱️ API: Clicking locator "${locatorName}" on device: ${deviceName}`);
2559
+ } else {
2560
+ return res.status(400).json({ success: false, error: 'Locator name or coordinates required' });
2561
+ }
2562
+
2563
+ // Execute click command
2564
+ const result = await executeCommand([deviceName], command, null);
2565
+
2566
+ if (result.success) {
2567
+ res.json({ success: true, message: 'Tap executed successfully' });
2568
+ } else {
2569
+ res.status(500).json({ success: false, error: 'Failed to execute tap' });
2570
+ }
2571
+ } catch (error) {
2572
+ console.error('❌ Tap error:', error);
2573
+ res.status(500).json({ success: false, error: error.message });
2574
+ }
2575
+ });
2576
+
2577
+ // Helper function to parse locators output
2578
+ function parseLocatorsOutput(output) {
2579
+ console.log('🔍 Parsing locators output...');
2580
+ console.log('Output length:', output?.length || 0);
2581
+
2582
+ const locators = [];
2583
+ if (!output) {
2584
+ console.log('❌ No output to parse');
2585
+ return locators;
2586
+ }
2587
+
2588
+ const lines = output.split('\n');
2589
+ console.log('Total lines:', lines.length);
2590
+
2591
+ for (const line of lines) {
2592
+ // New format: " 1. 📍 ButtonName │ [Button] │ Label:LabelText │ Val:ValueText │ @ (x,y) WxH │ ✓Vis ✓En"
2593
+ // Look for lines with numbered locators starting with 📍
2594
+ if (line.includes('📍') && /^\s*\d+\./.test(line)) {
2595
+ try {
2596
+ const locator = {};
2597
+
2598
+ // Extract index number
2599
+ const indexMatch = line.match(/^\s*(\d+)\./);
2600
+ if (indexMatch) locator.index = parseInt(indexMatch[1]);
2601
+
2602
+ // Split by pipe separator if present (new format), otherwise use old logic
2603
+ if (line.includes('│')) {
2604
+ const parts = line.split('│').map(p => p.trim());
2605
+
2606
+ // Part 0: "1. 📍 ButtonName"
2607
+ const nameMatch = parts[0].match(/📍\s+(.+)/);
2608
+ if (nameMatch) {
2609
+ locator.name = nameMatch[1].trim();
2610
+ locator.text = nameMatch[1].trim();
2611
+ }
2612
+
2613
+ // Part 1: "[Button]" or "[TextView]"
2614
+ const typeMatch = parts[1] ? parts[1].match(/\[([^\]]+)\]/) : null;
2615
+ if (typeMatch) locator.type = typeMatch[1].trim();
2616
+
2617
+ // Process remaining parts for Label, Val, ID, coordinates, flags
2618
+ for (let i = 2; i < parts.length; i++) {
2619
+ const part = parts[i];
2620
+
2621
+ // Label:...
2622
+ if (part.includes('Label:')) {
2623
+ const labelMatch = part.match(/Label:([^\s]+)/);
2624
+ if (labelMatch) locator.label = labelMatch[1];
2625
+ }
2626
+
2627
+ // Val:...
2628
+ if (part.includes('Val:')) {
2629
+ const valueMatch = part.match(/Val:([^\s]+)/);
2630
+ if (valueMatch) locator.value = valueMatch[1];
2631
+ }
2632
+
2633
+ // ID:...
2634
+ if (part.includes('ID:')) {
2635
+ const idMatch = part.match(/ID:([^\s]+)/);
2636
+ if (idMatch) locator.resourceId = idMatch[1];
2637
+ }
2638
+
2639
+ // @ (x,y) WxH
2640
+ if (part.includes('@')) {
2641
+ const coordMatch = part.match(/@\s*\((\d+),(\d+)\)\s*(\d+)x(\d+)/);
2642
+ if (coordMatch) {
2643
+ locator.x = parseInt(coordMatch[1]);
2644
+ locator.y = parseInt(coordMatch[2]);
2645
+ locator.width = parseInt(coordMatch[3]);
2646
+ locator.height = parseInt(coordMatch[4]);
2647
+ locator.coordinates = `(${locator.x},${locator.y}) ${locator.width}x${locator.height}`;
2648
+ }
2649
+ }
2650
+
2651
+ // Flags
2652
+ if (part.includes('✓Vis')) locator.visible = true;
2653
+ if (part.includes('✓En')) locator.enabled = true;
2654
+ if (part.includes('✓Click')) locator.clickable = true;
2655
+ }
2656
+ } else {
2657
+ // Old format fallback
2658
+ const nameMatch = line.match(/📍\s+([^\[]+?)(?:\s*\[|\s*Label)/);
2659
+ if (nameMatch) {
2660
+ locator.name = nameMatch[1].trim();
2661
+ locator.text = nameMatch[1].trim();
2662
+ }
2663
+
2664
+ const typeMatch = line.match(/\[([^\]]+)\]/);
2665
+ if (typeMatch) locator.type = typeMatch[1].trim();
2666
+
2667
+ const labelMatch = line.match(/Label:([^\s]+?)(?:\s*Val:|\s*@|\s*✓)/);
2668
+ if (labelMatch) locator.label = labelMatch[1].trim();
2669
+
2670
+ const valueMatch = line.match(/Val:([^\s]+?)(?:\s*@|\s*✓)/);
2671
+ if (valueMatch) locator.value = valueMatch[1].trim();
2672
+
2673
+ const coordMatch = line.match(/@\s*\((\d+),(\d+)\)\s*(\d+)x(\d+)/);
2674
+ if (coordMatch) {
2675
+ locator.x = parseInt(coordMatch[1]);
2676
+ locator.y = parseInt(coordMatch[2]);
2677
+ locator.width = parseInt(coordMatch[3]);
2678
+ locator.height = parseInt(coordMatch[4]);
2679
+ locator.coordinates = `(${locator.x},${locator.y}) ${locator.width}x${locator.height}`;
2680
+ }
2681
+
2682
+ const idMatch = line.match(/ID:([^\s]+)/);
2683
+ if (idMatch) locator.resourceId = idMatch[1].trim();
2684
+
2685
+ locator.visible = line.includes('✓Vis');
2686
+ locator.enabled = line.includes('✓En');
2687
+ }
2688
+
2689
+ // Only add if we have meaningful data
2690
+ if (locator.name || locator.label || locator.text) {
2691
+ console.log('✅ Parsed locator:', locator);
2692
+ locators.push(locator);
2693
+ }
2694
+ } catch (e) {
2695
+ // Skip malformed lines
2696
+ console.error('Error parsing locator line:', line, e);
2697
+ }
2698
+ }
2699
+ }
2700
+
2701
+ console.log(`✅ Total parsed locators: ${locators.length}`);
2702
+ return locators;
2703
+ }
2704
+
2705
+ // WebSocket handling
2706
+ wss.on('connection', (ws) => {
2707
+ console.log('New WebSocket connection');
2708
+
2709
+ ws.on('message', async (message) => {
2710
+ try {
2711
+ const data = JSON.parse(message);
2712
+
2713
+ switch (data.type) {
2714
+ case 'execute_command':
2715
+ const { devices, command, useAI, aiProvider } = data;
2716
+
2717
+ console.log(`\n${'*'.repeat(60)}`);
2718
+ log.debug(`WEBSOCKET: Received execute_command`);
2719
+ console.log(`Devices received: [${devices.join(', ')}]`);
2720
+ console.log(`Command: "${command}"`);
2721
+ console.log(`Use AI: ${useAI}`);
2722
+ if (aiProvider) console.log(`AI Provider: ${aiProvider}`);
2723
+ console.log(`${'*'.repeat(60)}\n`);
2724
+
2725
+ let finalCommand = command;
2726
+
2727
+ if (useAI) {
2728
+ try {
2729
+ finalCommand = await convertNaturalLanguageToCommand(command, devices, aiProvider);
2730
+ console.log(`✅ AI converted command to:\n${finalCommand}`);
2731
+ ws.send(JSON.stringify({
2732
+ type: 'command_converted',
2733
+ original: command,
2734
+ converted: finalCommand,
2735
+ provider: aiProvider || aiManager.provider
2736
+ }));
2737
+ } catch (error) {
2738
+ console.error(`❌ AI conversion failed: ${error.message}`);
2739
+
2740
+ // Send error to frontend - DO NOT execute command
2741
+ ws.send(JSON.stringify({
2742
+ type: 'ai_error',
2743
+ error: error.message,
2744
+ original: command
2745
+ }));
2746
+
2747
+ // Stop execution - don't run unconverted commands
2748
+ return;
2749
+ }
2750
+ } else {
2751
+ console.log(`ℹ️ AI not enabled, using command as-is`);
2752
+ }
2753
+
2754
+ // Check if command has multiple lines (sequential execution)
2755
+ const hasMultipleCommands = finalCommand.includes('\n');
2756
+
2757
+ if (hasMultipleCommands) {
2758
+ console.log(`Detected multi-step command, using sequential execution`);
2759
+ executeCommandsSequentially(devices, finalCommand, ws)
2760
+ .then(result => {
2761
+ console.log(`Sequential execution completed`);
2762
+ ws.send(JSON.stringify({
2763
+ type: 'command_complete',
2764
+ result
2765
+ }));
2766
+ })
2767
+ .catch(error => {
2768
+ console.error(`Sequential execution failed: ${error.message}`);
2769
+ ws.send(JSON.stringify({
2770
+ type: 'command_error',
2771
+ error: error.message
2772
+ }));
2773
+ });
2774
+ } else {
2775
+ log.debug(`Calling executeCommand with ${devices.length} device(s)...`);
2776
+ executeCommand(devices, finalCommand, ws)
2777
+ .then(result => {
2778
+ console.log(`executeCommand completed successfully`);
2779
+
2780
+ // Record command if recording is active
2781
+ if (isRecording) {
2782
+ recordCommand(devices, command, useAI, finalCommand);
2783
+ }
2784
+
2785
+ ws.send(JSON.stringify({
2786
+ type: 'command_complete',
2787
+ result
2788
+ }));
2789
+ })
2790
+ .catch(error => {
2791
+ console.error(`executeCommand failed: ${error.message}`);
2792
+ ws.send(JSON.stringify({
2793
+ type: 'command_error',
2794
+ error: error.message
2795
+ }));
2796
+ });
2797
+ }
2798
+ break;
2799
+
2800
+ case 'start_recording':
2801
+ const startResult = await startRecording(data.name, data.devices);
2802
+ ws.send(JSON.stringify({
2803
+ type: 'recording_started',
2804
+ ...startResult
2805
+ }));
2806
+ break;
2807
+
2808
+ case 'stop_recording':
2809
+ const stopResult = await stopRecording();
2810
+ ws.send(JSON.stringify({
2811
+ type: 'recording_stopped',
2812
+ ...stopResult
2813
+ }));
2814
+ break;
2815
+
2816
+ case 'replay_recording':
2817
+ const { filename, targetDevices } = data;
2818
+
2819
+ ws.send(JSON.stringify({
2820
+ type: 'command_output',
2821
+ data: `\n🎬 Starting replay: ${filename}\n\n`,
2822
+ devices: targetDevices
2823
+ }));
2824
+
2825
+ replayRecording(filename, targetDevices, ws)
2826
+ .then(result => {
2827
+ ws.send(JSON.stringify({
2828
+ type: 'replay_complete',
2829
+ ...result
2830
+ }));
2831
+ })
2832
+ .catch(error => {
2833
+ ws.send(JSON.stringify({
2834
+ type: 'replay_error',
2835
+ error: error.message
2836
+ }));
2837
+ });
2838
+ break;
2839
+
2840
+ case 'stop_replay':
2841
+ if (isReplaying) {
2842
+ replayAborted = true;
2843
+ ws.send(JSON.stringify({
2844
+ type: 'command_output',
2845
+ data: '\n⏹️ Stopping replay...\n'
2846
+ }));
2847
+ }
2848
+ break;
2849
+
2850
+ case 'refresh_devices':
2851
+ const updatedDevices = await discoverDevices();
2852
+ ws.send(JSON.stringify({
2853
+ type: 'devices_updated',
2854
+ devices: updatedDevices
2855
+ }));
2856
+ break;
2857
+
2858
+ case 'get_ai_providers':
2859
+ ws.send(JSON.stringify({
2860
+ type: 'ai_providers',
2861
+ providers: aiManager.getAvailableProviders(),
2862
+ current: aiManager.provider
2863
+ }));
2864
+ break;
2865
+
2866
+ case 'set_ai_provider':
2867
+ const success = aiManager.setProvider(data.provider);
2868
+ ws.send(JSON.stringify({
2869
+ type: 'ai_provider_set',
2870
+ success,
2871
+ provider: data.provider
2872
+ }));
2873
+ break;
2874
+ }
2875
+ } catch (error) {
2876
+ ws.send(JSON.stringify({
2877
+ type: 'error',
2878
+ message: error.message
2879
+ }));
2880
+ }
2881
+ });
2882
+
2883
+ ws.on('close', () => {
2884
+ console.log('WebSocket connection closed');
2885
+ });
2886
+
2887
+ // Send initial device list
2888
+ discoverDevices().then(devices => {
2889
+ ws.send(JSON.stringify({
2890
+ type: 'devices_updated',
2891
+ devices
2892
+ }));
2893
+ });
2894
+ });
2895
+
2896
+ // Android UIAutomator2 Installation
2897
+ app.post('/api/android/install-uiautomator2', async (req, res) => {
2898
+ const { device } = req.body;
2899
+
2900
+ if (!device) {
2901
+ return res.status(400).json({ success: false, error: 'Device name required' });
2902
+ }
2903
+
2904
+ try {
2905
+ console.log(`📥 Installing UIAutomator2 on ${device}...`);
2906
+
2907
+ // Find device UDID
2908
+ const deviceObj = connectedDevices.find(d => d.name === device);
2909
+ if (!deviceObj) {
2910
+ return res.json({
2911
+ success: false,
2912
+ error: 'Device not found or not connected'
2913
+ });
2914
+ }
2915
+
2916
+ // Use the helper function
2917
+ const result = await installUIAutomator2(deviceObj.udid, device);
2918
+
2919
+ res.json(result);
2920
+
2921
+ } catch (error) {
2922
+ console.error('❌ UIAutomator2 installation error:', error.message);
2923
+ res.json({
2924
+ success: false,
2925
+ error: error.message,
2926
+ suggestion: 'Please install manually using: npm install -g appium-uiautomator2-driver'
2927
+ });
2928
+ }
2929
+ });
2930
+
2931
+ // ============================================
2932
+ // Recording & Replay API Endpoints
2933
+ // ============================================
2934
+
2935
+ // Start recording
2936
+ app.post('/api/recording/start', async (req, res) => {
2937
+ try {
2938
+ const { name, devices } = req.body;
2939
+ const result = await startRecording(name, devices);
2940
+ res.json(result);
2941
+ } catch (error) {
2942
+ res.status(500).json({ success: false, error: error.message });
2943
+ }
2944
+ });
2945
+
2946
+ // Stop recording
2947
+ app.post('/api/recording/stop', async (req, res) => {
2948
+ try {
2949
+ const result = await stopRecording();
2950
+ res.json(result);
2951
+ } catch (error) {
2952
+ res.status(500).json({ success: false, error: error.message });
2953
+ }
2954
+ });
2955
+
2956
+ // Get recording status
2957
+ app.get('/api/recording/status', (req, res) => {
2958
+ res.json({
2959
+ isRecording,
2960
+ recording: currentRecording
2961
+ });
2962
+ });
2963
+
2964
+ // List all recordings
2965
+ app.get('/api/recordings', async (req, res) => {
2966
+ try {
2967
+ const recordings = await listRecordings();
2968
+ res.json({ success: true, recordings });
2969
+ } catch (error) {
2970
+ res.status(500).json({ success: false, error: error.message });
2971
+ }
2972
+ });
2973
+
2974
+ // Get specific recording
2975
+ app.get('/api/recordings/:filename', async (req, res) => {
2976
+ try {
2977
+ const recording = await loadRecording(req.params.filename);
2978
+ res.json({ success: true, recording });
2979
+ } catch (error) {
2980
+ res.status(404).json({ success: false, error: 'Recording not found' });
2981
+ }
2982
+ });
2983
+
2984
+ // Delete recording
2985
+ app.delete('/api/recordings/:filename', async (req, res) => {
2986
+ try {
2987
+ await deleteRecording(req.params.filename);
2988
+ res.json({ success: true });
2989
+ } catch (error) {
2990
+ res.status(500).json({ success: false, error: error.message });
2991
+ }
2992
+ });
2993
+
2994
+ // Update recording
2995
+ app.put('/api/recordings/:filename', async (req, res) => {
2996
+ try {
2997
+ const { filename } = req.params;
2998
+ const updatedRecording = req.body;
2999
+
3000
+ const filepath = path.join(RECORDINGS_DIR, filename);
3001
+ await fs.writeFile(filepath, JSON.stringify(updatedRecording, null, 2));
3002
+
3003
+ res.json({ success: true, recording: updatedRecording });
3004
+ } catch (error) {
3005
+ res.status(500).json({ success: false, error: error.message });
3006
+ }
3007
+ });
3008
+
3009
+ // Save new recording
3010
+ app.post('/api/recordings/save', async (req, res) => {
3011
+ try {
3012
+ const { name, commands, date, type } = req.body;
3013
+
3014
+ if (!name || !commands) {
3015
+ return res.status(400).json({ success: false, error: 'Name and commands required' });
3016
+ }
3017
+
3018
+ // Create filename from name (sanitize)
3019
+ const sanitizedName = name.replace(/[^a-z0-9_-]/gi, '_');
3020
+ const filename = `${sanitizedName}_${Date.now()}.json`;
3021
+ const filepath = path.join(RECORDINGS_DIR, filename);
3022
+
3023
+ const recording = {
3024
+ name,
3025
+ commands,
3026
+ date: date || new Date().toISOString(),
3027
+ type: type || 'manual'
3028
+ };
3029
+
3030
+ await fs.writeFile(filepath, JSON.stringify(recording, null, 2));
3031
+
3032
+ res.json({ success: true, filename, recording });
3033
+ } catch (error) {
3034
+ console.error('Error saving recording:', error);
3035
+ res.status(500).json({ success: false, error: error.message });
3036
+ }
3037
+ });
3038
+
3039
+ // Replay recording
3040
+ app.post('/api/recording/replay', async (req, res) => {
3041
+ try {
3042
+ const { filename, devices } = req.body;
3043
+
3044
+ if (!filename) {
3045
+ return res.status(400).json({ success: false, error: 'Filename required' });
3046
+ }
3047
+
3048
+ if (!devices || devices.length === 0) {
3049
+ return res.status(400).json({ success: false, error: 'Target devices required' });
3050
+ }
3051
+
3052
+ // Start replay in background, send immediate response
3053
+ res.json({ success: true, message: 'Replay started' });
3054
+
3055
+ // Replay will send updates via WebSocket
3056
+ // This is handled by WebSocket message 'replay_recording'
3057
+ } catch (error) {
3058
+ res.status(500).json({ success: false, error: error.message });
3059
+ }
3060
+ });
3061
+
3062
+ // Screenshots API
3063
+ app.get('/api/screenshots', async (req, res) => {
3064
+ try {
3065
+ // Ensure screenshots directory exists
3066
+ await fs.mkdir(SCREENSHOTS_DIR, { recursive: true });
3067
+
3068
+ const files = await fs.readdir(SCREENSHOTS_DIR);
3069
+ const screenshots = files
3070
+ .filter(f => f.endsWith('.png'))
3071
+ .map(f => {
3072
+ const stat = require('fs').statSync(path.join(SCREENSHOTS_DIR, f));
3073
+ return {
3074
+ filename: f,
3075
+ path: `/api/screenshots/${f}`,
3076
+ size: stat.size,
3077
+ timestamp: stat.mtime.getTime(),
3078
+ created: stat.mtime
3079
+ };
3080
+ })
3081
+ .sort((a, b) => b.timestamp - a.timestamp);
3082
+
3083
+ res.json({ success: true, screenshots });
3084
+ } catch (error) {
3085
+ res.status(500).json({ success: false, error: error.message });
3086
+ }
3087
+ });
3088
+
3089
+ // Serve screenshot files
3090
+ app.get('/api/screenshots/:filename', (req, res) => {
3091
+ const filepath = path.join(SCREENSHOTS_DIR, req.params.filename);
3092
+ res.sendFile(filepath, (err) => {
3093
+ if (err) {
3094
+ res.status(404).json({ success: false, error: 'Screenshot not found' });
3095
+ }
3096
+ });
3097
+ });
3098
+
3099
+ // Delete screenshot
3100
+ app.delete('/api/screenshots/:filename', async (req, res) => {
3101
+ try {
3102
+ const filepath = path.join(SCREENSHOTS_DIR, req.params.filename);
3103
+ await fs.unlink(filepath);
3104
+ res.json({ success: true });
3105
+ } catch (error) {
3106
+ res.status(500).json({ success: false, error: error.message });
3107
+ }
3108
+ });
3109
+
3110
+ // List installed apps on device
3111
+ app.post('/api/apps/list', async (req, res) => {
3112
+ try {
3113
+ const { devices } = req.body;
3114
+
3115
+ if (!devices || devices.length === 0) {
3116
+ return res.status(400).json({ success: false, error: 'Device selection required' });
3117
+ }
3118
+
3119
+ const deviceObjects = connectedDevices.filter(d =>
3120
+ devices.includes(d.name) || devices.includes(d.udid) || devices.includes(d.serial)
3121
+ );
3122
+
3123
+ if (deviceObjects.length === 0) {
3124
+ return res.status(400).json({ success: false, error: 'No connected devices found' });
3125
+ }
3126
+
3127
+ const results = {};
3128
+
3129
+ for (const device of deviceObjects) {
3130
+ try {
3131
+ let apps = [];
3132
+
3133
+ if (device.platform === 'android') {
3134
+ // List Android apps
3135
+ const { execSync } = require('child_process');
3136
+ const serial = device.udid || device.serial;
3137
+ const output = execSync(`adb -s ${serial} shell pm list packages -3`, { encoding: 'utf-8' });
3138
+ apps = output
3139
+ .split('\n')
3140
+ .filter(line => line.startsWith('package:'))
3141
+ .map(line => line.replace('package:', '').trim())
3142
+ .filter(pkg => pkg)
3143
+ .sort();
3144
+ } else if (device.platform === 'ios') {
3145
+ // List iOS apps - requires ideviceinstaller
3146
+ const { execSync } = require('child_process');
3147
+ const udid = device.udid;
3148
+ try {
3149
+ // Try new syntax first (ideviceinstaller list)
3150
+ let output;
3151
+ try {
3152
+ output = execSync(`ideviceinstaller -u ${udid} list --user`, { encoding: 'utf-8', timeout: 30000 });
3153
+ } catch (e) {
3154
+ // Fallback to old syntax
3155
+ output = execSync(`ideviceinstaller -u ${udid} -l`, { encoding: 'utf-8', timeout: 30000 });
3156
+ }
3157
+
3158
+ // Parse output - format is "BundleID - AppName" or CFBundleIdentifier format
3159
+ apps = output
3160
+ .split('\n')
3161
+ .filter(line => {
3162
+ const trimmed = line.trim();
3163
+ // Skip empty lines, Total: line, and CSV header
3164
+ return trimmed &&
3165
+ !trimmed.startsWith('Total:') &&
3166
+ !trimmed.startsWith('CFBundleIdentifier');
3167
+ })
3168
+ .map(line => {
3169
+ // Try different formats
3170
+ if (line.includes(' - ')) {
3171
+ const match = line.match(/^(.+?)\s+-\s+(.+?)$/);
3172
+ return match ? { id: match[1].trim(), name: match[2].trim() } : null;
3173
+ } else if (line.includes(',')) {
3174
+ // Format: "BundleID", "Version", "Name"
3175
+ const parts = line.split(',').map(p => p.trim().replace(/"/g, ''));
3176
+ return parts.length >= 3 ? { id: parts[0], name: parts[2] } : null;
3177
+ } else if (line.match(/^[a-zA-Z0-9.-]+$/)) {
3178
+ // Just bundle ID
3179
+ return { id: line.trim(), name: line.trim() };
3180
+ }
3181
+ return null;
3182
+ })
3183
+ .filter(app => app)
3184
+ .sort((a, b) => a.name.localeCompare(b.name));
3185
+
3186
+ if (apps.length === 0) {
3187
+ apps = [{ error: 'No user apps found or unable to parse output' }];
3188
+ }
3189
+ } catch (error) {
3190
+ console.error('iOS apps list error:', error.message);
3191
+ apps = [{ error: `Failed to list apps: ${error.message}` }];
3192
+ }
3193
+ }
3194
+
3195
+ results[device.name] = {
3196
+ success: true,
3197
+ platform: device.platform,
3198
+ apps: apps,
3199
+ count: Array.isArray(apps) ? apps.length : 0
3200
+ };
3201
+ } catch (error) {
3202
+ results[device.name] = {
3203
+ success: false,
3204
+ error: error.message
3205
+ };
3206
+ }
3207
+ }
3208
+
3209
+ res.json({ success: true, results });
3210
+ } catch (error) {
3211
+ res.status(500).json({ success: false, error: error.message });
3212
+ }
3213
+ });
3214
+
3215
+ // Get app info
3216
+ app.post('/api/apps/info', async (req, res) => {
3217
+ try {
3218
+ const { device, bundleId } = req.body;
3219
+
3220
+ if (!device || !bundleId) {
3221
+ return res.status(400).json({ success: false, error: 'Device and bundle ID required' });
3222
+ }
3223
+
3224
+ const deviceObj = discoveredDevices.find(d => d.name === device);
3225
+ if (!deviceObj) {
3226
+ return res.status(404).json({ success: false, error: 'Device not found' });
3227
+ }
3228
+
3229
+ const { execSync } = require('child_process');
3230
+ let appInfo = {};
3231
+
3232
+ try {
3233
+ if (deviceObj.platform === 'android') {
3234
+ // Get Android app info
3235
+ const serial = deviceObj.udid || deviceObj.serial;
3236
+
3237
+ // Get app version
3238
+ try {
3239
+ const versionOutput = execSync(
3240
+ `adb -s ${serial} shell dumpsys package ${bundleId} | grep versionName`,
3241
+ { encoding: 'utf-8', timeout: 10000 }
3242
+ );
3243
+ const versionMatch = versionOutput.match(/versionName=([^\s]+)/);
3244
+ if (versionMatch) {
3245
+ appInfo.version = versionMatch[1];
3246
+ }
3247
+ } catch (e) {
3248
+ appInfo.version = 'Unknown';
3249
+ }
3250
+
3251
+ // Get app label/name
3252
+ try {
3253
+ const labelOutput = execSync(
3254
+ `adb -s ${serial} shell dumpsys package ${bundleId} | grep -A 1 "applicationInfo"`,
3255
+ { encoding: 'utf-8', timeout: 10000 }
3256
+ );
3257
+ appInfo.name = bundleId;
3258
+ } catch (e) {
3259
+ appInfo.name = bundleId;
3260
+ }
3261
+
3262
+ // Get install location
3263
+ try {
3264
+ const pathOutput = execSync(
3265
+ `adb -s ${serial} shell pm path ${bundleId}`,
3266
+ { encoding: 'utf-8', timeout: 10000 }
3267
+ );
3268
+ appInfo.path = pathOutput.replace('package:', '').trim();
3269
+ } catch (e) {
3270
+ appInfo.path = 'Unknown';
3271
+ }
3272
+
3273
+ appInfo.bundleId = bundleId;
3274
+ appInfo.platform = 'Android';
3275
+
3276
+ } else if (deviceObj.platform === 'ios') {
3277
+ // Get iOS app info
3278
+ const udid = deviceObj.udid;
3279
+
3280
+ try {
3281
+ // Try to get app info using ideviceinstaller
3282
+ const output = execSync(
3283
+ `ideviceinstaller -u ${udid} list --user | grep "${bundleId}"`,
3284
+ { encoding: 'utf-8', timeout: 10000 }
3285
+ );
3286
+
3287
+ // Parse CSV format: BundleID, Version, Name
3288
+ const parts = output.split(',').map(p => p.trim().replace(/"/g, ''));
3289
+ if (parts.length >= 3) {
3290
+ appInfo.bundleId = parts[0];
3291
+ appInfo.version = parts[1];
3292
+ appInfo.name = parts[2];
3293
+ } else {
3294
+ appInfo.bundleId = bundleId;
3295
+ appInfo.name = bundleId;
3296
+ appInfo.version = 'Unknown';
3297
+ }
3298
+ } catch (e) {
3299
+ // Fallback
3300
+ appInfo.bundleId = bundleId;
3301
+ appInfo.name = bundleId;
3302
+ appInfo.version = 'Unknown';
3303
+ appInfo.error = 'Could not retrieve detailed info';
3304
+ }
3305
+
3306
+ appInfo.platform = 'iOS';
3307
+ }
3308
+
3309
+ res.json({ success: true, appInfo });
3310
+
3311
+ } catch (error) {
3312
+ res.json({
3313
+ success: false,
3314
+ error: error.message,
3315
+ appInfo: {
3316
+ bundleId: bundleId,
3317
+ platform: deviceObj.platform,
3318
+ error: 'Failed to get app info'
3319
+ }
3320
+ });
3321
+ }
3322
+
3323
+ } catch (error) {
3324
+ res.status(500).json({ success: false, error: error.message });
3325
+ }
3326
+ });
3327
+
3328
+ // Upgrade HTTP server to WebSocket
3329
+ let server;
3330
+
3331
+ function startServer(options = {}) {
3332
+ const port = options.port || PORT;
3333
+ const shouldOpenBrowser = options.browser !== false;
3334
+ const frontendUrl = 'https://devicely-ai.vercel.app';
3335
+
3336
+ console.log(`Frontend available at: ${frontendUrl}\n`);
3337
+
3338
+ return new Promise((resolve, reject) => {
3339
+ server = app.listen(port, () => {
3340
+ console.log(`Server running on port ${port}`);
3341
+ console.log(`WebSocket server ready`);
3342
+
3343
+ // Initial device discovery
3344
+ discoverDevices().then(devices => {
3345
+ console.log(`Discovered ${devices.length} devices`);
3346
+
3347
+ // Open browser if not disabled
3348
+ if (shouldOpenBrowser) {
3349
+ console.log(`\n🌐 Opening browser to ${frontendUrl}...`);
3350
+ const open = require('open');
3351
+ open(frontendUrl).catch(err => {
3352
+ console.log(`\n📱 Please open ${frontendUrl} in your browser`);
3353
+ });
3354
+ }
3355
+
3356
+ resolve({ port, devices });
3357
+ }).catch(reject);
3358
+ });
3359
+
3360
+ server.on('upgrade', (request, socket, head) => {
3361
+ wss.handleUpgrade(request, socket, head, (ws) => {
3362
+ wss.emit('connection', ws, request);
3363
+ });
3364
+ });
3365
+
3366
+ server.on('error', reject);
3367
+ });
3368
+ }
3369
+
3370
+ function stopServer() {
3371
+ return new Promise((resolve) => {
3372
+ if (server) {
3373
+ server.close(resolve);
3374
+ } else {
3375
+ resolve();
3376
+ }
3377
+ });
3378
+ }
3379
+
3380
+ // Export for CLI usage
3381
+ module.exports = {
3382
+ startServer,
3383
+ stopServer
3384
+ };
3385
+
3386
+ // Start server if run directly (not imported as module)
3387
+ if (require.main === module) {
3388
+ startServer().catch(err => {
3389
+ console.error('Failed to start server:', err);
3390
+ process.exit(1);
3391
+ });
3392
+ }