devicely 1.0.5

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