devicely 2.2.10 → 2.2.12

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