@xenon-device-management/xenon 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +446 -0
  2. package/lib/package.json +207 -0
  3. package/lib/public/assets/Layouts-7IT8aFLI.js +11 -0
  4. package/lib/public/assets/Layouts-DPMls9vh.css +1 -0
  5. package/lib/public/assets/ai-settings-BbnfgdEx.js +11 -0
  6. package/lib/public/assets/apps-CRMrI4_p.js +16 -0
  7. package/lib/public/assets/apps-CcM77dgg.css +1 -0
  8. package/lib/public/assets/badge-B1nKs8zj.css +1 -0
  9. package/lib/public/assets/badge-CSvl5xIU.js +11 -0
  10. package/lib/public/assets/button-CJlKn4PZ.css +1 -0
  11. package/lib/public/assets/button-CvLaGFYj.js +26 -0
  12. package/lib/public/assets/calendar-6w-D6Oaw.js +6 -0
  13. package/lib/public/assets/clock-DcdeWBPr.js +6 -0
  14. package/lib/public/assets/cpu-DiSoXT9n.js +6 -0
  15. package/lib/public/assets/device-explorer-CajM63OJ.js +193 -0
  16. package/lib/public/assets/device-explorer-CxdUAoTL.css +1 -0
  17. package/lib/public/assets/index-ByQwMN5T.js +174 -0
  18. package/lib/public/assets/index-C1DBaoSh.js +1 -0
  19. package/lib/public/assets/index-qzCez_kk.css +1 -0
  20. package/lib/public/assets/lock-B23ibZmo.js +6 -0
  21. package/lib/public/assets/maintenance-settings-CirzA6yG.js +6 -0
  22. package/lib/public/assets/mouse-pointer-2-Cz76SHFb.js +6 -0
  23. package/lib/public/assets/plus-BBwlIevt.js +6 -0
  24. package/lib/public/assets/session-dashboard-C2k7FFv_.css +1 -0
  25. package/lib/public/assets/session-dashboard-HPDtwPOZ.js +62 -0
  26. package/lib/public/assets/settings-DrZsZwdc.js +1 -0
  27. package/lib/public/assets/trash-2-DQpvzJec.js +6 -0
  28. package/lib/public/assets/useSocket-Dxsqae2a.js +16 -0
  29. package/lib/public/assets/webhook-settings-CDPgsgkb.css +1 -0
  30. package/lib/public/assets/webhook-settings-Cp-B4Nrw.js +1 -0
  31. package/lib/public/assets/zap-DovP6iow.js +6 -0
  32. package/lib/public/favicon.ico +0 -0
  33. package/lib/public/favicon.png +0 -0
  34. package/lib/public/favicon.svg +9 -0
  35. package/lib/public/index.html +46 -0
  36. package/lib/public/logo.svg +17 -0
  37. package/lib/public/logo192.png +0 -0
  38. package/lib/public/logo512.png +0 -0
  39. package/lib/public/manifest.json +25 -0
  40. package/lib/public/robots.txt +3 -0
  41. package/lib/schema.json +348 -0
  42. package/lib/src/InternalHttpClient.js +212 -0
  43. package/lib/src/PluginContext.js +29 -0
  44. package/lib/src/XenonCapabilityManager.js +199 -0
  45. package/lib/src/app/index.js +167 -0
  46. package/lib/src/app/routers/apps.js +79 -0
  47. package/lib/src/app/routers/config.js +131 -0
  48. package/lib/src/app/routers/control.js +835 -0
  49. package/lib/src/app/routers/dashboard.js +301 -0
  50. package/lib/src/app/routers/grid.js +352 -0
  51. package/lib/src/app/routers/reservation.js +190 -0
  52. package/lib/src/app/routers/webhook.js +83 -0
  53. package/lib/src/app/swagger-docs.js +203 -0
  54. package/lib/src/app/swagger.js +366 -0
  55. package/lib/src/chromeUtils.js +148 -0
  56. package/lib/src/commands/handle.js +19 -0
  57. package/lib/src/commands/index.js +8 -0
  58. package/lib/src/config.js +73 -0
  59. package/lib/src/dashboard/asset-manager.js +84 -0
  60. package/lib/src/dashboard/commands.js +284 -0
  61. package/lib/src/dashboard/event-manager.js +699 -0
  62. package/lib/src/dashboard/services/app-service.js +134 -0
  63. package/lib/src/dashboard/services/failure-analysis-service.js +173 -0
  64. package/lib/src/dashboard/services/session-service.js +113 -0
  65. package/lib/src/data-service/CircuitBreaker.js +83 -0
  66. package/lib/src/data-service/config-service.js +155 -0
  67. package/lib/src/data-service/db.js +122 -0
  68. package/lib/src/data-service/device-service.js +320 -0
  69. package/lib/src/data-service/device-store.interface.js +2 -0
  70. package/lib/src/data-service/device-store.js +345 -0
  71. package/lib/src/data-service/pending-sessions-service.js +25 -0
  72. package/lib/src/data-service/pluginArgs.js +25 -0
  73. package/lib/src/data-service/prisma-service.js +31 -0
  74. package/lib/src/data-service/prisma-store.js +385 -0
  75. package/lib/src/data-service/queue-service.js +150 -0
  76. package/lib/src/data-service/web-config-service.js +130 -0
  77. package/lib/src/device-managers/AndroidDeviceManager.js +1155 -0
  78. package/lib/src/device-managers/ChromeDriverManager.js +68 -0
  79. package/lib/src/device-managers/HealthMonitorService.js +325 -0
  80. package/lib/src/device-managers/IOSDeviceManager.js +351 -0
  81. package/lib/src/device-managers/NodeDevices.js +82 -0
  82. package/lib/src/device-managers/android/AndroidStreamService.js +370 -0
  83. package/lib/src/device-managers/android/DeviceLockManager.js +45 -0
  84. package/lib/src/device-managers/cloud/CapabilityManager.js +26 -0
  85. package/lib/src/device-managers/cloud/Devices.js +86 -0
  86. package/lib/src/device-managers/iOSTracker.js +44 -0
  87. package/lib/src/device-managers/index.js +89 -0
  88. package/lib/src/device-managers/ios/IOSDiscoveryService.js +268 -0
  89. package/lib/src/device-managers/ios/IOSStreamService.js +893 -0
  90. package/lib/src/device-managers/ios/WDAClient.js +866 -0
  91. package/lib/src/device-utils.js +663 -0
  92. package/lib/src/enums/Capabilities.js +8 -0
  93. package/lib/src/enums/Cloud.js +11 -0
  94. package/lib/src/enums/Platform.js +9 -0
  95. package/lib/src/enums/SessionType.js +9 -0
  96. package/lib/src/enums/SocketEvents.js +15 -0
  97. package/lib/src/helpers/UniversalMjpegProxy.js +273 -0
  98. package/lib/src/helpers/index.js +229 -0
  99. package/lib/src/index.js +95 -0
  100. package/lib/src/interceptors/CommandInterceptor.js +524 -0
  101. package/lib/src/interfaces/ICloudManager.js +2 -0
  102. package/lib/src/interfaces/IDevice.js +2 -0
  103. package/lib/src/interfaces/IDeviceFilterOptions.js +2 -0
  104. package/lib/src/interfaces/IDeviceManager.js +2 -0
  105. package/lib/src/interfaces/IOptions.js +2 -0
  106. package/lib/src/interfaces/IPluginArgs.js +55 -0
  107. package/lib/src/interfaces/ISessionCapability.js +2 -0
  108. package/lib/src/logger.js +225 -0
  109. package/lib/src/plugin.js +244 -0
  110. package/lib/src/prisma.js +12 -0
  111. package/lib/src/profiling/AndroidAppProfiler.js +213 -0
  112. package/lib/src/proxy/wd-command-proxy.js +221 -0
  113. package/lib/src/scripts/generate-database-migration.js +59 -0
  114. package/lib/src/scripts/initialize-database.js +55 -0
  115. package/lib/src/scripts/install-go-ios.js +66 -0
  116. package/lib/src/scripts/prepare-prisma.js +89 -0
  117. package/lib/src/services/AICommandService.js +143 -0
  118. package/lib/src/services/AIService.js +466 -0
  119. package/lib/src/services/CleanupService.js +141 -0
  120. package/lib/src/services/EventBus.js +74 -0
  121. package/lib/src/services/InspectorService.js +395 -0
  122. package/lib/src/services/MetricsService.js +134 -0
  123. package/lib/src/services/NetworkConditioningService.js +173 -0
  124. package/lib/src/services/NotificationService.js +163 -0
  125. package/lib/src/services/RequestLogService.js +252 -0
  126. package/lib/src/services/ResourceIsolationService.js +122 -0
  127. package/lib/src/services/SecurityService.js +120 -0
  128. package/lib/src/services/ServerManager.js +284 -0
  129. package/lib/src/services/SessionHeartbeatService.js +158 -0
  130. package/lib/src/services/SessionLifecycleService.js +572 -0
  131. package/lib/src/services/SocketClient.js +71 -0
  132. package/lib/src/services/SocketServer.js +87 -0
  133. package/lib/src/services/TracingService.js +132 -0
  134. package/lib/src/services/VideoPipelineService.js +220 -0
  135. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +333 -0
  136. package/lib/src/services/healing/HealEtalonService.js +98 -0
  137. package/lib/src/services/healing/HealedLocatorGenerator.js +132 -0
  138. package/lib/src/services/healing/HealingOrchestrator.js +165 -0
  139. package/lib/src/services/healing/LlmHealingProvider.js +77 -0
  140. package/lib/src/services/healing/OcrHealingProvider.js +119 -0
  141. package/lib/src/services/healing/ResilioTreeHealingProvider.js +100 -0
  142. package/lib/src/services/healing/VisualAiHealingProvider.js +90 -0
  143. package/lib/src/services/healing/types.js +12 -0
  144. package/lib/src/services/omni-vision/OmniVisionService.js +718 -0
  145. package/lib/src/services/omni-vision/VisionAssertionService.js +68 -0
  146. package/lib/src/sessions/CloudSession.js +42 -0
  147. package/lib/src/sessions/LocalSession.js +313 -0
  148. package/lib/src/sessions/RemoteSession.js +287 -0
  149. package/lib/src/sessions/SessionManager.js +238 -0
  150. package/lib/src/sessions/XenonSession.js +44 -0
  151. package/lib/src/types/CLIArgs.js +2 -0
  152. package/lib/src/types/CloudArgs.js +2 -0
  153. package/lib/src/types/CloudSchema.js +131 -0
  154. package/lib/src/types/DeviceType.js +2 -0
  155. package/lib/src/types/DeviceUpdate.js +2 -0
  156. package/lib/src/types/IOSDevice.js +2 -0
  157. package/lib/src/types/Platform.js +2 -0
  158. package/lib/src/types/SessionStatus.js +11 -0
  159. package/lib/src/validators/CapabilityValidator.js +93 -0
  160. package/lib/test/e2e/android/conf.spec.js +43 -0
  161. package/lib/test/e2e/android/conf2.spec.js +44 -0
  162. package/lib/test/e2e/android/conf3.spec.js +44 -0
  163. package/lib/test/e2e/e2ehelper.js +113 -0
  164. package/lib/test/e2e/hubnode/forward-request.spec.js +224 -0
  165. package/lib/test/e2e/hubnode/hubnode.spec.js +214 -0
  166. package/lib/test/e2e/ios/conf1.spec.js +39 -0
  167. package/lib/test/e2e/ios/conf2.spec.js +39 -0
  168. package/lib/test/e2e/plugin-harness.js +236 -0
  169. package/lib/test/e2e/plugin.spec.js +97 -0
  170. package/lib/test/e2e/telemetry_verification.spec.js +83 -0
  171. package/lib/test/e2e/video-recording-test.spec.js +63 -0
  172. package/lib/test/helpers/test-container.js +112 -0
  173. package/lib/test/integration/androidDevices.spec.js +137 -0
  174. package/lib/test/integration/cliArgs.js +73 -0
  175. package/lib/test/integration/ios/01iOSSimulator.spec.js +291 -0
  176. package/lib/test/integration/ios/02iOSDevices.spec.js +75 -0
  177. package/lib/test/integration/testHelpers.js +74 -0
  178. package/lib/test/unit/AndroidDeviceManager.spec.js +178 -0
  179. package/lib/test/unit/ChromeDriverManager.spec.js +26 -0
  180. package/lib/test/unit/CleanupService.spec.js +21 -0
  181. package/lib/test/unit/DeviceModel.spec.js +157 -0
  182. package/lib/test/unit/FuzzyXmlHealingProvider.test.js +294 -0
  183. package/lib/test/unit/GetAdbOriginal.js +42 -0
  184. package/lib/test/unit/HealingCascade.test.js +128 -0
  185. package/lib/test/unit/IOSDeviceManager.spec.js +261 -0
  186. package/lib/test/unit/RemoteIOs.spec.js +78 -0
  187. package/lib/test/unit/ResilioTreeHealingProvider.test.js +96 -0
  188. package/lib/test/unit/commands.spec.js +27 -0
  189. package/lib/test/unit/config.spec.js +27 -0
  190. package/lib/test/unit/device-service.spec.js +307 -0
  191. package/lib/test/unit/device-utils.spec.js +313 -0
  192. package/lib/test/unit/fixtures/device.config.js +4 -0
  193. package/lib/test/unit/fixtures/devices.js +89 -0
  194. package/lib/test/unit/helpers.spec.js +62 -0
  195. package/lib/test/unit/omni-vision.spec.js +100 -0
  196. package/lib/test/unit/plugin.spec.js +133 -0
  197. package/lib/tsconfig.tsbuildinfo +1 -0
  198. package/package.json +207 -0
  199. package/prisma/data.db +0 -0
  200. package/prisma/dev.db +0 -0
  201. package/prisma/dev.db-journal +0 -0
  202. package/prisma/migrations/20231011074725_initial_tables/migration.sql +47 -0
  203. package/prisma/migrations/20231226115334_update_session_log/migration.sql +2 -0
  204. package/prisma/migrations/20251204113710_add_video_recording_enabled/migration.sql +29 -0
  205. package/prisma/migrations/20251204132449_add_log_table/migration.sql +11 -0
  206. package/prisma/migrations/20251205050111_add_profiling_support/migration.sql +47 -0
  207. package/prisma/migrations/20251205050947_add_is_error_field/migration.sql +24 -0
  208. package/prisma/migrations/20260126201337_add_app_model/migration.sql +18 -0
  209. package/prisma/migrations/20260130115722_add_performance_trace_and_xenon_sync/migration.sql +2 -0
  210. package/prisma/migrations/20260130135114_add_device_models/migration.sql +57 -0
  211. package/prisma/migrations/20260130140655_make_systemport_optional/migration.sql +45 -0
  212. package/prisma/migrations/20260130140932_make_device_fields_optional/migration.sql +45 -0
  213. package/prisma/migrations/20260130141040_final_schema_fix/migration.sql +45 -0
  214. package/prisma/migrations/20260130143234_add_device_health_fields/migration.sql +4 -0
  215. package/prisma/migrations/20260130144921_add_failure_category/migration.sql +2 -0
  216. package/prisma/migrations/20260131151456_add_webhook_config/migration.sql +10 -0
  217. package/prisma/migrations/20260201094507_add_device_tags/migration.sql +11 -0
  218. package/prisma/migrations/20260201103410_add_managed_process/migration.sql +15 -0
  219. package/prisma/migrations/20260201140637_add_web_config/migration.sql +22 -0
  220. package/prisma/migrations/20260201162232_add_session_progress/migration.sql +2 -0
  221. package/prisma/migrations/20260201174231_add_total_healed_count/migration.sql +2 -0
  222. package/prisma/migrations/migration_lock.toml +3 -0
  223. package/prisma/schema.prisma +210 -0
  224. package/schema.json +348 -0
  225. package/scripts/build-xenon.sh +32 -0
  226. package/scripts/dev/debug-gemini.ts +44 -0
  227. package/scripts/generate-types-from-schema.js +86 -0
  228. package/scripts/install-compatible-driver.js +39 -0
@@ -0,0 +1,893 @@
1
+ "use strict";
2
+ /**
3
+ * iOS Stream Service
4
+ *
5
+ * This service manages independent MJPEG streaming for iOS devices without requiring
6
+ * an active Appium session. It uses go-ios to start WDA and forwards the MJPEG stream.
7
+ *
8
+ * Based on GADS implementation approach.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
27
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
28
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
29
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
30
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
31
+ };
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ var __metadata = (this && this.__metadata) || function (k, v) {
50
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
51
+ };
52
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
53
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
54
+ return new (P || (P = Promise))(function (resolve, reject) {
55
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
56
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
57
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
58
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
59
+ });
60
+ };
61
+ var __importDefault = (this && this.__importDefault) || function (mod) {
62
+ return (mod && mod.__esModule) ? mod : { "default": mod };
63
+ };
64
+ Object.defineProperty(exports, "__esModule", { value: true });
65
+ const typedi_1 = require("typedi");
66
+ const child_process_1 = require("child_process");
67
+ const util_1 = require("util");
68
+ const path_1 = __importDefault(require("path"));
69
+ const os_1 = __importDefault(require("os"));
70
+ const http_1 = __importDefault(require("http"));
71
+ const fs_extra_1 = __importDefault(require("fs-extra"));
72
+ const tcp_port_used_1 = __importDefault(require("tcp-port-used"));
73
+ const InternalHttpClient_1 = require("../../InternalHttpClient");
74
+ const logger_1 = __importDefault(require("../../logger"));
75
+ const helpers_1 = require("../../helpers");
76
+ const device_store_1 = require("../../data-service/device-store");
77
+ const device_service_1 = require("../../data-service/device-service");
78
+ const execPromise = (0, util_1.promisify)(child_process_1.exec);
79
+ const typedi_2 = require("typedi");
80
+ const ResourceIsolationService_1 = require("../../services/ResourceIsolationService");
81
+ let IOSStreamService = class IOSStreamService {
82
+ constructor() {
83
+ this.sessions = new Map();
84
+ this.startPromises = new Map();
85
+ this.recoveryCooldowns = new Map(); // Track last recovery attempt time
86
+ this.RECOVERY_COOLDOWN_MS = 30000; // 30s cooldown between recovery attempts
87
+ this.goIOSPath = this.getGoIOSPath();
88
+ this.startWatchdog();
89
+ }
90
+ /**
91
+ * Watchdog: Periodically monitors running streams and cleans up idle ones
92
+ */
93
+ startWatchdog() {
94
+ setInterval(() => __awaiter(this, void 0, void 0, function* () {
95
+ for (const [udid, session] of this.sessions.entries()) {
96
+ const now = Date.now();
97
+ if (session.status === 'running') {
98
+ // Check for inactivity
99
+ if (now - session.lastViewerAt > 600000 && session.viewerCount === 0) {
100
+ // Principal Protection: Never stop a stream if the device is busy with an active session.
101
+ // This prevents the watchdog from killing WDA while a test is running.
102
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
103
+ if (device && device.busy) {
104
+ logger_1.default.debug(`🛡️ [${udid}] [Watchdog] Stream is idle but device is BUSY. Keeping alive.`);
105
+ session.lastViewerAt = Date.now(); // Refresh timer to avoid constant DB checks
106
+ continue;
107
+ }
108
+ logger_1.default.info(`[${udid}] [Watchdog] Stopping idle iOS stream (No viewers for 30s)`);
109
+ this.stopStream(udid);
110
+ continue;
111
+ }
112
+ // 2. Health check & Self-Healing
113
+ // We use elased autonomous methodology to ensure devices are always warm
114
+ const isAlive = yield this.isStreamResponsive(udid);
115
+ if (!isAlive) {
116
+ // Check cooldown before attempting recovery
117
+ if (!this.canAttemptRecovery(udid)) {
118
+ const lastAttempt = this.recoveryCooldowns.get(udid);
119
+ const waitMs = this.RECOVERY_COOLDOWN_MS - (Date.now() - (lastAttempt || 0));
120
+ logger_1.default.debug(`[Watchdog] ${udid} stream unhealthy but recovery cooldown active (${Math.ceil(waitMs / 1000)}s remaining). Skipping recovery attempt.`);
121
+ continue;
122
+ }
123
+ logger_1.default.warn(`Stream watchdog detected failure for ${udid}. Attempting autonomous healing...`);
124
+ try {
125
+ // Get device state to check for Session Shield
126
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
127
+ if (device && device.busy) {
128
+ logger_1.default.info(`🛡️ [Watchdog] Skipping heal for ${udid} as it has an active session.`);
129
+ continue;
130
+ }
131
+ this.markRecoveryAttempt(udid);
132
+ yield this.startStream(udid);
133
+ // Increment healed count for visual feedback
134
+ if (device) {
135
+ yield device_store_1.DeviceStoreFactory.getStore().updateDevice(udid, device.host, {
136
+ totalHealedCount: (device.totalHealedCount || 0) + 1,
137
+ });
138
+ }
139
+ }
140
+ catch (e) {
141
+ logger_1.default.error(`Watchdog healing failed for ${udid}: ${e}`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }), 3600000); // 1hr interval for background stability
147
+ }
148
+ getGoIOSPath() {
149
+ const goIOSDir = (0, helpers_1.cachePath)('goIOS');
150
+ return path_1.default.join(goIOSDir, 'ios');
151
+ }
152
+ /**
153
+ * Check if go-ios binary exists
154
+ */
155
+ isGoIOSAvailable() {
156
+ return __awaiter(this, void 0, void 0, function* () {
157
+ try {
158
+ if (!fs_extra_1.default.existsSync(this.goIOSPath)) {
159
+ logger_1.default.warn(`go-ios binary not found at ${this.goIOSPath}`);
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+ catch (error) {
165
+ return false;
166
+ }
167
+ });
168
+ }
169
+ /**
170
+ * Detect the WDA bundle ID installed on the device
171
+ */
172
+ detectWDABundleId(udid) {
173
+ return __awaiter(this, void 0, void 0, function* () {
174
+ try {
175
+ const { stdout } = yield execPromise(`"${this.goIOSPath}" apps --udid ${udid}`, {
176
+ env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
177
+ });
178
+ const apps = JSON.parse(stdout);
179
+ const wda = apps.find((a) => (a.CFBundleIdentifier && a.CFBundleIdentifier.includes('WebDriverAgentRunner')) ||
180
+ (a.CFBundleName && a.CFBundleName.includes('WebDriverAgentRunner')));
181
+ return wda ? wda.CFBundleIdentifier : null;
182
+ }
183
+ catch (error) {
184
+ logger_1.default.warn(`Failed to detect WDA bundle ID for ${udid}: ${error}`);
185
+ return null;
186
+ }
187
+ });
188
+ }
189
+ /**
190
+ * Check if tunnel is needed (iOS 17+) and ensure it's running
191
+ */
192
+ ensureTunnel(udid) {
193
+ return __awaiter(this, void 0, void 0, function* () {
194
+ var _a, _b;
195
+ try {
196
+ const { stdout } = yield execPromise(`"${this.goIOSPath}" info --udid ${udid}`, {
197
+ env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
198
+ });
199
+ const info = JSON.parse(stdout);
200
+ const versionStr = String(info.ProductVersion || info.HumanReadableProductVersionString || '0');
201
+ // Extract the first numeric sequence (e.g., "15.8.6" or "iOS 17.2")
202
+ const versionMatch = versionStr.match(/(\d+\.?\d*)/);
203
+ const version = versionMatch ? parseFloat(versionMatch[0]) : 0;
204
+ if (version >= 17) {
205
+ logger_1.default.info(`iOS ${version} detected for ${udid} (Raw: "${versionStr}"). Starting tunnel...`);
206
+ // Check if already running in our session tracker
207
+ const existing = [...this.sessions.values()].find((s) => s.udid === udid && s.tunnelProcess && s.tunnelProcess.exitCode === null);
208
+ if (existing && existing.tunnelProcess)
209
+ return existing.tunnelProcess;
210
+ // CRITICAL: Kill any orphan tunnel processes before starting new one
211
+ // This prevents 'address already in use' errors from stale processes
212
+ yield this.cleanupOrphanTunnels(udid);
213
+ const isolationService = typedi_2.Container.get(ResourceIsolationService_1.ResourceIsolationService);
214
+ const { command, args } = isolationService.wrapSpawn(this.goIOSPath, ['tunnel', 'start', '--udid', udid, '--userspace'], 'Performance');
215
+ const tunnelProcess = (0, child_process_1.spawn)(command, args, {
216
+ stdio: ['pipe', 'pipe', 'pipe'],
217
+ env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
218
+ });
219
+ (_a = tunnelProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => logger_1.default.debug(`Tunnel [${udid}]: ${data}`));
220
+ (_b = tunnelProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => logger_1.default.debug(`Tunnel Err [${udid}]: ${data}`));
221
+ // Wait for tunnel to establish by checking the go-ios agent port
222
+ logger_1.default.info('Waiting for tunnel agent on port 60105 to be ready...');
223
+ let tunnelReady = false;
224
+ const tunnelTimeout = 15000;
225
+ const subStartTime = Date.now();
226
+ while (Date.now() - subStartTime < tunnelTimeout) {
227
+ try {
228
+ tunnelReady = yield tcp_port_used_1.default.check(60105, '127.0.0.1');
229
+ if (tunnelReady)
230
+ break;
231
+ }
232
+ catch (e) {
233
+ /* ignore */
234
+ }
235
+ yield new Promise((resolve) => setTimeout(resolve, 1000));
236
+ }
237
+ if (!tunnelReady) {
238
+ logger_1.default.warn(`Tunnel agent port 60105 not ready after ${tunnelTimeout / 1000}s, proceeding anyway...`);
239
+ }
240
+ else {
241
+ logger_1.default.info('Tunnel agent is ready. Settling for 2s...');
242
+ yield new Promise((resolve) => setTimeout(resolve, 2000));
243
+ logger_1.default.info('Tunnel agent settled.');
244
+ }
245
+ return tunnelProcess;
246
+ }
247
+ }
248
+ catch (error) {
249
+ logger_1.default.warn(`Failed to check version or start tunnel for ${udid}: ${error}`);
250
+ }
251
+ return null;
252
+ });
253
+ }
254
+ /**
255
+ * Kill any orphan tunnel processes that might be left from previous runs
256
+ * This is critical for preventing 'address already in use' errors
257
+ */
258
+ cleanupOrphanTunnels(udid) {
259
+ return __awaiter(this, void 0, void 0, function* () {
260
+ logger_1.default.debug(`Cleaning up orphan tunnels for ${udid}...`);
261
+ const pkillCmds = [
262
+ // Kill any existing tunnel for this specific device
263
+ `pkill -9 -f "ios tunnel.*${udid}"`,
264
+ `pkill -9 -f "go-ios.*tunnel.*${udid}"`,
265
+ ];
266
+ for (const cmd of pkillCmds) {
267
+ try {
268
+ yield execPromise(cmd);
269
+ }
270
+ catch (err) {
271
+ /* ignore - process might not exist */
272
+ }
273
+ }
274
+ // Check common go-ios agent ports (60105, 60106) and kill if bound
275
+ const agentPorts = [60105, 60106];
276
+ for (const port of agentPorts) {
277
+ try {
278
+ const { stdout } = yield execPromise(`lsof -ti :${port}`);
279
+ const pids = stdout.trim().split('\n');
280
+ for (const pid of pids) {
281
+ if (pid) {
282
+ logger_1.default.debug(`Killing orphan process ${pid} on go-ios agent port ${port}`);
283
+ yield execPromise(`kill -9 ${pid}`);
284
+ }
285
+ }
286
+ }
287
+ catch (err) {
288
+ /* ignore - lsof returns 1 if no port found */
289
+ }
290
+ }
291
+ // Small delay to ensure OS releases sockets
292
+ yield new Promise((resolve) => setTimeout(resolve, 500));
293
+ });
294
+ }
295
+ /**
296
+ * Check if WDA is already running and responding
297
+ * Principal Resilience: Retries transient connection errors (ECONNRESET) up to 2 times
298
+ * with exponential backoff, as these often indicate WDA is restarting or tunnel is reconnecting.
299
+ */
300
+ isWDARunning(wdaPort_1) {
301
+ return __awaiter(this, arguments, void 0, function* (wdaPort, retries = 2) {
302
+ var _a, _b, _c, _d;
303
+ const axios = (yield Promise.resolve().then(() => __importStar(require('axios')))).default;
304
+ const host = '127.0.0.1'; // Force IPv4 for local tunnels
305
+ const maxRetries = retries;
306
+ let lastError;
307
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
308
+ try {
309
+ const response = yield axios.get(`http://${host}:${wdaPort}/status`, {
310
+ timeout: 2500, // 2.5s for consistency
311
+ httpAgent: new http_1.default.Agent({ keepAlive: false }),
312
+ validateStatus: (status) => status === 200,
313
+ });
314
+ const isReady = ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ready) === true;
315
+ if (!isReady) {
316
+ logger_1.default.debug(`[WDA] Port ${wdaPort} active but not ready. Status: ${JSON.stringify((_c = response.data) === null || _c === void 0 ? void 0 : _c.value)}`);
317
+ }
318
+ return isReady;
319
+ }
320
+ catch (error) {
321
+ lastError = error;
322
+ const isTransientError = error.code === 'ECONNRESET' ||
323
+ error.code === 'ETIMEDOUT' ||
324
+ error.code === 'ECONNABORTED' ||
325
+ (error.response && error.response.status >= 500);
326
+ if (isTransientError && attempt < maxRetries) {
327
+ const backoffMs = Math.min(500 * Math.pow(2, attempt), 2000); // 500ms, 1000ms, 2000ms max
328
+ logger_1.default.debug(`[WDA] Port ${wdaPort} transient error (${error.code || ((_d = error.response) === null || _d === void 0 ? void 0 : _d.status)}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`);
329
+ yield new Promise((resolve) => setTimeout(resolve, backoffMs));
330
+ continue;
331
+ }
332
+ // Permanent error or max retries reached
333
+ if (error.code === 'ECONNREFUSED') {
334
+ logger_1.default.debug(`[WDA] Port ${wdaPort} connection refused (Tunnel likely down)`);
335
+ }
336
+ else if (error.code === 'ECONNABORTED') {
337
+ logger_1.default.debug(`[WDA] Port ${wdaPort} health check timed out (WDA hanging)`);
338
+ }
339
+ else if (error.code === 'ECONNRESET') {
340
+ logger_1.default.debug(`[WDA] Port ${wdaPort} connection reset after ${attempt + 1} attempts (WDA may be restarting or tunnel unstable)`);
341
+ }
342
+ else {
343
+ logger_1.default.debug(`[WDA] Port ${wdaPort} health check failed: ${error.message}`);
344
+ }
345
+ return false;
346
+ }
347
+ }
348
+ return false;
349
+ });
350
+ }
351
+ /**
352
+ * Comprehensive process-level and endpoint-level health check.
353
+ * Principal Intelligence: Differentiates between 'Process dead' and 'Network unreachable'.
354
+ * If only the tunnel is dead but WDA is alive via IP, it will trigger a tunnel restart.
355
+ */
356
+ isStreamResponsive(udid) {
357
+ return __awaiter(this, void 0, void 0, function* () {
358
+ const session = this.sessions.get(udid);
359
+ if (!session || session.status !== 'running')
360
+ return false;
361
+ // 1. Process Check: verify child processes haven't exited
362
+ const isWdaAlive = session.wdaProcess && session.wdaProcess.exitCode === null;
363
+ const isWdaIproxyAlive = session.forwardWDAProcess && session.forwardWDAProcess.exitCode === null;
364
+ const isMjpegIproxyAlive = session.forwardMJPEGProcess && session.forwardMJPEGProcess.exitCode === null;
365
+ if (!isWdaAlive) {
366
+ logger_1.default.warn(`🛡️ [${udid}] [Watchdog] WDA process is dead. Full restart required.`);
367
+ return false;
368
+ }
369
+ if (!isWdaIproxyAlive || !isMjpegIproxyAlive) {
370
+ logger_1.default.warn(`🛡️ [${udid}] [Watchdog] Tunnel processes are dead. Attempting tunnel-only recovery...`);
371
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
372
+ if (device && device.ip) {
373
+ // Double check if WDA is alive via network IP
374
+ const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
375
+ if (isWdaAccessibleViaNetwork) {
376
+ logger_1.default.info(`🛡️ [${udid}] [Watchdog] WDA is alive on network ${device.ip}. Restarting tunnels...`);
377
+ yield this.restartTunnelsOnly(session);
378
+ return true; // We healed it!
379
+ }
380
+ }
381
+ return false; // Cannot heal without network access or if WDA is also dead
382
+ }
383
+ // 2. Network Check: verify endpoint is responding via tunnel
384
+ const isRespondingViaTunnel = yield this.isWDARunning(session.wdaPort);
385
+ if (!isRespondingViaTunnel) {
386
+ logger_1.default.warn(`🛡️ [${udid}] [Watchdog] WDA tunnel on port ${session.wdaPort} is unresponsive. checking network...`);
387
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
388
+ if (device && device.ip) {
389
+ const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
390
+ if (isWdaAccessibleViaNetwork) {
391
+ logger_1.default.info(`🛡️ [${udid}] [Watchdog] WDA is alive on network but tunnel is hung. Restarting tunnels...`);
392
+ yield this.restartTunnelsOnly(session);
393
+ return true;
394
+ }
395
+ }
396
+ return false;
397
+ }
398
+ return true;
399
+ });
400
+ }
401
+ /**
402
+ * Check if WDA is running on a specific host/port
403
+ */
404
+ isWDARunningOnHost(host, port) {
405
+ return __awaiter(this, void 0, void 0, function* () {
406
+ var _a, _b;
407
+ const axios = (yield Promise.resolve().then(() => __importStar(require('axios')))).default;
408
+ try {
409
+ const response = yield axios.get(`http://${host}:${port}/status`, {
410
+ timeout: 3000,
411
+ validateStatus: (status) => status === 200,
412
+ });
413
+ return ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ready) === true;
414
+ }
415
+ catch (e) {
416
+ return false;
417
+ }
418
+ });
419
+ }
420
+ /**
421
+ * Restarts only the iproxy tunnels without stopping WDA
422
+ */
423
+ restartTunnelsOnly(session) {
424
+ return __awaiter(this, void 0, void 0, function* () {
425
+ const udid = session.udid;
426
+ const isolationService = typedi_2.Container.get(ResourceIsolationService_1.ResourceIsolationService);
427
+ // 1. Kill old tunnels
428
+ if (session.forwardWDAProcess)
429
+ session.forwardWDAProcess.kill('SIGKILL');
430
+ if (session.forwardMJPEGProcess)
431
+ session.forwardMJPEGProcess.kill('SIGKILL');
432
+ // 2. Start new tunnels
433
+ const wdaIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${session.wdaPort}:8100`], 'Performance');
434
+ const mjpegIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${session.mjpegPort}:9100`], 'Performance');
435
+ session.forwardWDAProcess = (0, child_process_1.spawn)(wdaIproxy.command, wdaIproxy.args);
436
+ session.forwardMJPEGProcess = (0, child_process_1.spawn)(mjpegIproxy.command, mjpegIproxy.args);
437
+ logger_1.default.info(`🛡️ [${udid}] [Watchdog] Tunnels restarted successfully.`);
438
+ });
439
+ }
440
+ /**
441
+ * Start streaming for a device
442
+ */
443
+ /**
444
+ * Check if recovery is allowed (cooldown period has passed)
445
+ */
446
+ canAttemptRecovery(udid) {
447
+ const lastAttempt = this.recoveryCooldowns.get(udid);
448
+ if (!lastAttempt)
449
+ return true;
450
+ const elapsed = Date.now() - lastAttempt;
451
+ return elapsed >= this.RECOVERY_COOLDOWN_MS;
452
+ }
453
+ /**
454
+ * Mark recovery attempt timestamp
455
+ */
456
+ markRecoveryAttempt(udid) {
457
+ this.recoveryCooldowns.set(udid, Date.now());
458
+ }
459
+ startStream(udid) {
460
+ return __awaiter(this, void 0, void 0, function* () {
461
+ // Return existing promise if already starting
462
+ if (this.startPromises.has(udid)) {
463
+ const existingPromise = this.startPromises.get(udid);
464
+ if (existingPromise)
465
+ return existingPromise;
466
+ }
467
+ // Check if stream is already running - avoid unnecessary restarts
468
+ const existingSession = this.sessions.get(udid);
469
+ if (existingSession && existingSession.status === 'running') {
470
+ const isHealthy = yield this.isWDARunning(existingSession.wdaPort);
471
+ if (isHealthy) {
472
+ logger_1.default.debug(`[${udid}] Stream already running and healthy, reusing existing session`);
473
+ return { wdaPort: existingSession.wdaPort, mjpegPort: existingSession.mjpegPort };
474
+ }
475
+ // Stream exists but unhealthy - check cooldown before recovery
476
+ if (!this.canAttemptRecovery(udid)) {
477
+ const lastAttempt = this.recoveryCooldowns.get(udid);
478
+ const waitMs = this.RECOVERY_COOLDOWN_MS - (Date.now() - (lastAttempt || 0));
479
+ logger_1.default.debug(`[${udid}] Recovery cooldown active (${Math.ceil(waitMs / 1000)}s remaining). Skipping recovery attempt.`);
480
+ throw new Error(`Stream recovery is in cooldown. Last attempt was ${Math.ceil(waitMs / 1000)}s ago.`);
481
+ }
482
+ // Stop unhealthy stream before restarting
483
+ logger_1.default.info(`[${udid}] Stream exists but unhealthy, stopping before restart...`);
484
+ yield this.stopStream(udid);
485
+ }
486
+ // Define the core logic as an internal async function
487
+ const performStartup = () => __awaiter(this, void 0, void 0, function* () {
488
+ var _a, _b, _c, _d;
489
+ try {
490
+ const existingSession = this.sessions.get(udid);
491
+ if (existingSession && existingSession.status === 'running') {
492
+ const isHealthy = yield this.isWDARunning(existingSession.wdaPort);
493
+ if (isHealthy) {
494
+ logger_1.default.debug(`[${udid}] Stream already running and healthy, reusing existing session`);
495
+ return { wdaPort: existingSession.wdaPort, mjpegPort: existingSession.mjpegPort };
496
+ }
497
+ // Stream exists but unhealthy - check cooldown before recovery
498
+ if (!this.canAttemptRecovery(udid)) {
499
+ const lastAttempt = this.recoveryCooldowns.get(udid);
500
+ const waitMs = this.RECOVERY_COOLDOWN_MS - (Date.now() - (lastAttempt || 0));
501
+ logger_1.default.debug(`[${udid}] Recovery cooldown active (${Math.ceil(waitMs / 1000)}s remaining). Skipping recovery attempt.`);
502
+ throw new Error(`Stream recovery is in cooldown. Last attempt was ${Math.ceil(waitMs / 1000)}s ago.`);
503
+ }
504
+ logger_1.default.info(`Existing session for ${udid} not responding, restarting...`);
505
+ this.markRecoveryAttempt(udid);
506
+ }
507
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
508
+ if (!device)
509
+ throw new Error(`Device ${udid} not found`);
510
+ const wdaPort = device.wdaLocalPort || (yield (0, helpers_1.getFreePort)());
511
+ const mjpegPort = device.mjpegServerPort || (yield (0, helpers_1.getFreePort)());
512
+ // Perform aggressive cleanup of any existing processes for THIS device/ports
513
+ yield this.stopStream(udid);
514
+ yield this.killStaleProcesses(udid, wdaPort, mjpegPort);
515
+ const session = {
516
+ udid,
517
+ wdaProcess: null,
518
+ forwardWDAProcess: null,
519
+ forwardMJPEGProcess: null,
520
+ tunnelProcess: null,
521
+ wdaPort,
522
+ mjpegPort,
523
+ status: 'starting',
524
+ startedAt: new Date(),
525
+ lastViewerAt: Date.now(),
526
+ viewerCount: 0,
527
+ };
528
+ this.sessions.set(udid, session);
529
+ const goIOSAvailable = yield this.isGoIOSAvailable();
530
+ if (!goIOSAvailable)
531
+ throw new Error('go-ios not available');
532
+ // 1. Technical Optimization: If real iOS device and WDA IPA exists in repository, use it as priority
533
+ const { APP_SERVICE } = yield Promise.resolve().then(() => __importStar(require('../../dashboard/services/app-service')));
534
+ const wdaApp = yield APP_SERVICE.getWDAApp();
535
+ if (wdaApp && fs_extra_1.default.existsSync(wdaApp.filepath)) {
536
+ logger_1.default.info(`📱 Artisan WDA: Found pre-signed artifact "${wdaApp.name}". Provisioning ${udid} for stream...`);
537
+ try {
538
+ const { Container } = yield Promise.resolve().then(() => __importStar(require('typedi')));
539
+ const { XenonManager } = yield Promise.resolve().then(() => __importStar(require('../index')));
540
+ const IOSDeviceManager = (yield Promise.resolve().then(() => __importStar(require('../IOSDeviceManager')))).default;
541
+ const deviceManager = Container.get(XenonManager);
542
+ const manager = (yield deviceManager.deviceInstances()).find((m) => m instanceof IOSDeviceManager);
543
+ if (manager) {
544
+ yield manager.installApp(udid, wdaApp.filepath);
545
+ logger_1.default.info(`📱 Artisan WDA: Artifact provisioned successfully to ${udid}`);
546
+ }
547
+ }
548
+ catch (e) {
549
+ logger_1.default.warn(`📱 Artisan WDA: Failed to provision WDA to ${udid}: ${e}`);
550
+ }
551
+ }
552
+ // 2. Ensure tunnel for iOS 17+
553
+ session.tunnelProcess = yield this.ensureTunnel(udid);
554
+ // 2. Start Port Forwarding using iproxy (more reliable on Mac)
555
+ logger_1.default.info(`Forwarding ${udid}: ${wdaPort}->8100, ${mjpegPort}->9100 using iproxy`);
556
+ const isolationService = typedi_2.Container.get(ResourceIsolationService_1.ResourceIsolationService);
557
+ const wdaIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${wdaPort}:8100`], 'Performance');
558
+ const mjpegIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${mjpegPort}:9100`], 'Performance');
559
+ session.forwardWDAProcess = (0, child_process_1.spawn)(wdaIproxy.command, wdaIproxy.args);
560
+ session.forwardMJPEGProcess = (0, child_process_1.spawn)(mjpegIproxy.command, mjpegIproxy.args);
561
+ const handleIproxyProcess = (p, name) => {
562
+ p.on('error', (err) => logger_1.default.error(`${name} [${udid}] error: ${err.message}`));
563
+ p.on('exit', (code) => {
564
+ if (code !== 0 && code !== null) {
565
+ logger_1.default.warn(`${name} [${udid}] exited with code ${code}`);
566
+ }
567
+ });
568
+ };
569
+ handleIproxyProcess(session.forwardWDAProcess, 'iproxy-wda');
570
+ handleIproxyProcess(session.forwardMJPEGProcess, 'iproxy-mjpeg');
571
+ // 3. Detect and Start WDA
572
+ const bundleId = (yield this.detectWDABundleId(udid)) || 'com.qasecret.WebDriverAgentRunner.xctrunner';
573
+ logger_1.default.info(`Starting WDA ${bundleId} on ${udid}`);
574
+ const wdaSpawn = isolationService.wrapSpawn(this.goIOSPath, [
575
+ 'runwda',
576
+ '--bundleid',
577
+ bundleId,
578
+ '--testrunnerbundleid',
579
+ bundleId,
580
+ '--xctestconfig',
581
+ 'WebDriverAgentRunner.xctest',
582
+ '--udid',
583
+ udid,
584
+ ], 'Performance'); // WDA deserves Performance mode
585
+ session.wdaProcess = (0, child_process_1.spawn)(wdaSpawn.command, wdaSpawn.args, {
586
+ env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
587
+ });
588
+ const logDir = path_1.default.join(os_1.default.tmpdir(), 'xenon-logs');
589
+ if (!fs_extra_1.default.existsSync(logDir))
590
+ fs_extra_1.default.mkdirSync(logDir);
591
+ const wdaRunLog = path_1.default.join(logDir, `runwda-${udid}.log`);
592
+ (_a = session.wdaProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (d) => fs_extra_1.default.appendFileSync(wdaRunLog, d));
593
+ (_b = session.wdaProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (d) => fs_extra_1.default.appendFileSync(wdaRunLog, d));
594
+ // 4. Wait for WDA to be ready
595
+ const startTime = Date.now();
596
+ const timeout = 120000; // Senior Resiliency: 120s for WDA startup consistency
597
+ while (Date.now() - startTime < timeout) {
598
+ if (yield this.isWDARunning(wdaPort)) {
599
+ session.status = 'running';
600
+ session.startedAt = new Date(); // Reset settlement timer for strict readiness phase
601
+ // Ensure MJPEG server is started by updating WDA settings
602
+ yield this.updateWDASettings(wdaPort);
603
+ // Wait up to 5 seconds for MJPEG server to be ready on the local port
604
+ logger_1.default.info(`Waiting for MJPEG server to be ready on port ${mjpegPort}...`);
605
+ let mjpegReady = false;
606
+ for (let i = 0; i < 5; i++) {
607
+ // Try 5 times with 1-second delay = 5 seconds total
608
+ // Perform a real HTTP check to see if the MJPEG server is actually serving
609
+ try {
610
+ mjpegReady = yield tcp_port_used_1.default.check(mjpegPort, '127.0.0.1');
611
+ if (mjpegReady)
612
+ break;
613
+ }
614
+ catch (e) {
615
+ // Not ready yet
616
+ }
617
+ yield new Promise((resolve) => setTimeout(resolve, 1000));
618
+ }
619
+ if (mjpegReady) {
620
+ logger_1.default.info(`MJPEG server is ready on port ${mjpegPort} for ${udid}`);
621
+ }
622
+ else {
623
+ logger_1.default.warn(`MJPEG server not detected on port ${mjpegPort} for ${udid} after 5s timeout. It might be starting slowly or WDA might be struggling.`);
624
+ }
625
+ // Fetch screen size from WDA
626
+ let screenWidth = 0, screenHeight = 0;
627
+ try {
628
+ const winSize = yield InternalHttpClient_1.InternalHttpClient.get(`http://127.0.0.1:${wdaPort}/window/size`);
629
+ screenWidth = winSize.value.width;
630
+ screenHeight = winSize.value.height;
631
+ session.screenWidth = screenWidth;
632
+ session.screenHeight = screenHeight;
633
+ logger_1.default.info(`Detected screen size for ${udid}: ${screenWidth}x${screenHeight}`);
634
+ }
635
+ catch (e) {
636
+ logger_1.default.warn(`Failed to get screen size for ${udid}: ${e}`);
637
+ }
638
+ // Update device info in store
639
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
640
+ if (device) {
641
+ const updateData = {
642
+ wdaLocalPort: wdaPort,
643
+ mjpegServerPort: mjpegPort,
644
+ };
645
+ if (screenWidth > 0) {
646
+ updateData.screenWidth = screenWidth.toString();
647
+ updateData.screenHeight = screenHeight.toString();
648
+ }
649
+ yield device_store_1.DeviceStoreFactory.getStore().updateDevice(udid, device.host, updateData);
650
+ }
651
+ logger_1.default.info(`WDA is ready for ${udid} at port ${wdaPort}`);
652
+ // Create a WDA session for keyboard/interaction operations
653
+ try {
654
+ const sessionId = yield this.createWDASession(wdaPort);
655
+ if (sessionId) {
656
+ session.sessionId = sessionId;
657
+ logger_1.default.info(`Created WDA session ${sessionId} for ${udid} during stream startup`);
658
+ }
659
+ }
660
+ catch (e) {
661
+ logger_1.default.warn(`Failed to create WDA session during stream startup for ${udid}: ${e.message}`);
662
+ }
663
+ session.lastViewerAt = Date.now(); // Initialize activity
664
+ // Clear recovery cooldown on successful start
665
+ this.recoveryCooldowns.delete(udid);
666
+ return { wdaPort, mjpegPort };
667
+ }
668
+ if (((_c = session.wdaProcess) === null || _c === void 0 ? void 0 : _c.exitCode) !== null) {
669
+ const logContent = fs_extra_1.default.existsSync(wdaRunLog) ? fs_extra_1.default.readFileSync(wdaRunLog, 'utf8') : '';
670
+ throw new Error(`WDA process exited with code ${(_d = session.wdaProcess) === null || _d === void 0 ? void 0 : _d.exitCode}. Log: ${logContent.slice(-200)}`);
671
+ }
672
+ yield new Promise((resolve) => setTimeout(resolve, 1000));
673
+ }
674
+ throw new Error(`WDA failed to start within ${timeout / 1000}s. Check logs.`);
675
+ }
676
+ catch (error) {
677
+ const session = this.sessions.get(udid);
678
+ if (session) {
679
+ session.status = 'error';
680
+ session.lastError = error.message;
681
+ }
682
+ logger_1.default.error(`Stream start failed for ${udid}: ${error.message}`);
683
+ throw error;
684
+ }
685
+ finally {
686
+ this.startPromises.delete(udid);
687
+ }
688
+ });
689
+ // Set the promise in the map IMMEDIATELY before awaiting anything
690
+ const startPromise = performStartup();
691
+ this.startPromises.set(udid, startPromise);
692
+ return startPromise;
693
+ });
694
+ }
695
+ killStaleProcesses(udid, wdaPort, mjpegPort) {
696
+ return __awaiter(this, void 0, void 0, function* () {
697
+ logger_1.default.info(`Cleaning up stale processes for ${udid} (Ports: ${wdaPort}, ${mjpegPort})`);
698
+ // Senior Resiliency: Use centralized tunnel cleanup
699
+ yield this.cleanupOrphanTunnels(udid);
700
+ const pkillCmds = [`pkill -9 -f "iproxy.*${udid}"`, `pkill -9 -f "ios runwda.*${udid}"`];
701
+ for (const cmd of pkillCmds) {
702
+ try {
703
+ yield execPromise(cmd);
704
+ }
705
+ catch (err) {
706
+ /* ignore */
707
+ }
708
+ }
709
+ // Port-based cleanup (more surgical)
710
+ const ports = [wdaPort, mjpegPort];
711
+ for (const port of ports) {
712
+ try {
713
+ const { stdout } = yield execPromise(`lsof -ti :${port}`);
714
+ const pids = stdout.trim().split('\n');
715
+ for (const pid of pids) {
716
+ if (pid) {
717
+ logger_1.default.debug(`Killing stale process ${pid} on port ${port}`);
718
+ yield execPromise(`kill -9 ${pid}`);
719
+ }
720
+ }
721
+ }
722
+ catch (err) {
723
+ /* ignore - lsof returns 1 if no port found */
724
+ }
725
+ }
726
+ // Small delay to ensure OS releases sockets
727
+ yield new Promise((resolve) => setTimeout(resolve, 500));
728
+ });
729
+ }
730
+ stopStream(udid) {
731
+ return __awaiter(this, void 0, void 0, function* () {
732
+ var _a;
733
+ const session = this.sessions.get(udid);
734
+ if (!session)
735
+ return;
736
+ // Kill all processes including tunnel (CRITICAL: tunnel was missing from cleanup!)
737
+ [
738
+ session.wdaProcess,
739
+ session.forwardWDAProcess,
740
+ session.forwardMJPEGProcess,
741
+ session.tunnelProcess,
742
+ ].forEach((p) => {
743
+ if (p)
744
+ try {
745
+ p.kill('SIGKILL');
746
+ }
747
+ catch (e) {
748
+ // ignore
749
+ }
750
+ });
751
+ // Principal Fix: Only release the device lock if THIS STREAM SERVICE owns it.
752
+ // The lock could belong to an Appium automation session (session_id is a real UUID).
753
+ // We should only unblock if it's a manual control lock (session_id starts with 'manual_').
754
+ try {
755
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
756
+ if (device && ((_a = device.session_id) === null || _a === void 0 ? void 0 : _a.startsWith('manual_'))) {
757
+ logger_1.default.info(`Stream Stop: Releasing manual control lock for ${udid}`);
758
+ yield (0, device_service_1.unblockDevice)(udid, device.host);
759
+ }
760
+ else if (device && device.busy) {
761
+ logger_1.default.info(`Stream Stop: Device ${udid} is busy with session ${device.session_id}. NOT releasing lock.`);
762
+ }
763
+ }
764
+ catch (e) {
765
+ logger_1.default.error(`Failed to check/release lock during stream stop for ${udid}: ${e}`);
766
+ }
767
+ // Also clean up any orphan processes (belt and suspenders)
768
+ yield this.cleanupOrphanTunnels(udid);
769
+ this.sessions.delete(udid);
770
+ });
771
+ }
772
+ updateWDASettings(wdaPort) {
773
+ return __awaiter(this, void 0, void 0, function* () {
774
+ const urls = [`http://127.0.0.1:${wdaPort}/appium/settings`];
775
+ const data = {
776
+ settings: {
777
+ mjpegServerPort: 9100,
778
+ mjpegServerFramerate: 15, // Optimal for tunnel stability
779
+ mjpegServerScreenshotQuality: 50, // Reduced quality to avoid ECONNRESETS during swipes
780
+ mjpegScalingFactor: 100,
781
+ },
782
+ };
783
+ for (const url of urls) {
784
+ try {
785
+ logger_1.default.info(`Broadcasting WDA settings to ${url} (15fps/50%)`);
786
+ yield InternalHttpClient_1.InternalHttpClient.post(url, data, { timeout: 10000 });
787
+ return;
788
+ }
789
+ catch (error) {
790
+ logger_1.default.warn(`Failed to broadcast WDA settings via ${url}: ${error.code || error.message}`);
791
+ }
792
+ }
793
+ });
794
+ }
795
+ /**
796
+ * Create a WDA session for keyboard/interaction operations
797
+ * Uses minimal capabilities to avoid disrupting the current app
798
+ * IMPORTANT: First checks if a session already exists (e.g., from active Appium automation)
799
+ * and reuses it to avoid disrupting active automation runs.
800
+ */
801
+ createWDASession(wdaPort) {
802
+ return __awaiter(this, void 0, void 0, function* () {
803
+ var _a, _b, _c, _d;
804
+ const axios = (yield Promise.resolve().then(() => __importStar(require('axios')))).default;
805
+ // SAFETY: First check if a session already exists (e.g., from active Appium session)
806
+ // If yes, reuse it instead of creating a new one that could displace the automation
807
+ try {
808
+ const existingResponse = yield axios.get(`http://127.0.0.1:${wdaPort}/sessions`, {
809
+ timeout: 5000,
810
+ });
811
+ const sessions = ((_a = existingResponse.data) === null || _a === void 0 ? void 0 : _a.value) || [];
812
+ if (sessions.length > 0) {
813
+ const existingSid = sessions[0].id || sessions[0].sessionId;
814
+ if (existingSid) {
815
+ logger_1.default.info(`Reusing existing WDA session ${existingSid} (likely from active automation)`);
816
+ return existingSid;
817
+ }
818
+ }
819
+ }
820
+ catch (err) {
821
+ logger_1.default.debug(`No existing WDA sessions found: ${err.message}`);
822
+ }
823
+ // No existing session - create a new one with minimal capabilities
824
+ const sessionConfigs = [
825
+ // Minimal session - doesn't specify bundleId, uses current app
826
+ { capabilities: { alwaysMatch: {} } },
827
+ // Springboard fallback
828
+ { capabilities: { alwaysMatch: { bundleId: 'com.apple.springboard' } } },
829
+ ];
830
+ for (const config of sessionConfigs) {
831
+ try {
832
+ const response = yield axios.post(`http://127.0.0.1:${wdaPort}/session`, config, {
833
+ timeout: 15000,
834
+ headers: { 'Content-Type': 'application/json' },
835
+ });
836
+ const sid = ((_b = response.data) === null || _b === void 0 ? void 0 : _b.sessionId) || ((_d = (_c = response.data) === null || _c === void 0 ? void 0 : _c.value) === null || _d === void 0 ? void 0 : _d.sessionId);
837
+ if (sid) {
838
+ return sid;
839
+ }
840
+ }
841
+ catch (err) {
842
+ logger_1.default.debug(`WDA session creation attempt failed: ${err.message}`);
843
+ }
844
+ }
845
+ return null;
846
+ });
847
+ }
848
+ getStreamStatus(udid) {
849
+ return this.sessions.get(udid);
850
+ }
851
+ updateViewerCount(udid, delta) {
852
+ const session = this.sessions.get(udid);
853
+ if (session) {
854
+ session.viewerCount = Math.max(0, session.viewerCount + delta);
855
+ session.lastViewerAt = Date.now();
856
+ logger_1.default.debug(`[${udid}] iOS Stream viewer update: delta=${delta}, total=${session.viewerCount}`);
857
+ }
858
+ }
859
+ cleanup() {
860
+ return __awaiter(this, void 0, void 0, function* () {
861
+ for (const udid of this.sessions.keys())
862
+ yield this.stopStream(udid);
863
+ });
864
+ }
865
+ /**
866
+ * Get the cached WDA session ID for a device
867
+ */
868
+ getWDASessionId(udid) {
869
+ var _a;
870
+ return (_a = this.sessions.get(udid)) === null || _a === void 0 ? void 0 : _a.sessionId;
871
+ }
872
+ /**
873
+ * Set/update the WDA session ID for a device.
874
+ * Passing undefined will clear the cached session.
875
+ */
876
+ setWDASessionId(udid, sessionId) {
877
+ const session = this.sessions.get(udid);
878
+ if (session) {
879
+ session.sessionId = sessionId;
880
+ if (sessionId) {
881
+ logger_1.default.info(`Cached WDA Session ID for ${udid}: ${sessionId}`);
882
+ }
883
+ else {
884
+ logger_1.default.debug(`Cleared cached WDA Session ID for ${udid}`);
885
+ }
886
+ }
887
+ }
888
+ };
889
+ IOSStreamService = __decorate([
890
+ (0, typedi_1.Service)({ name: 'IOSStreamService' }),
891
+ __metadata("design:paramtypes", [])
892
+ ], IOSStreamService);
893
+ exports.default = IOSStreamService;