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