neoagent 2.3.1-beta.0 → 2.3.1-beta.100

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 (406) hide show
  1. package/.env.example +63 -5
  2. package/README.md +16 -5
  3. package/com.neoagent.plist +1 -1
  4. package/docs/automation.md +82 -41
  5. package/docs/capabilities.md +85 -89
  6. package/docs/configuration.md +89 -87
  7. package/docs/getting-started.md +46 -27
  8. package/docs/hardware.md +1 -1
  9. package/docs/index.md +24 -30
  10. package/docs/integrations.md +46 -60
  11. package/docs/migration.md +41 -146
  12. package/docs/operations.md +23 -20
  13. package/docs/skills.md +20 -20
  14. package/docs/why-neoagent.md +15 -13
  15. package/extensions/chrome-browser/background.mjs +10 -2
  16. package/flutter_app/.metadata +42 -0
  17. package/flutter_app/DESIGN_SYSTEM.md +166 -0
  18. package/flutter_app/README.md +21 -0
  19. package/flutter_app/analysis_options.yaml +32 -0
  20. package/flutter_app/android/app/build.gradle.kts +111 -0
  21. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  22. package/flutter_app/android/app/src/main/AndroidManifest.xml +185 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +888 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/auto/NeoAgentCarAppService.kt +114 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  30. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  31. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  32. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  33. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  34. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  35. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnection.kt +118 -0
  36. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnectionService.kt +86 -0
  37. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  38. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  39. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  40. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  41. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  42. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  43. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  44. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  45. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  46. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  47. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  48. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  49. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  50. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  51. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  52. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  53. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  54. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  55. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  56. package/flutter_app/android/app/src/main/res/values/arrays.xml +8 -0
  57. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  58. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  59. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  60. package/flutter_app/android/app/src/main/res/xml/automotive_app_desc.xml +4 -0
  61. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  62. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  63. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  64. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  65. package/flutter_app/android/build.gradle.kts +24 -0
  66. package/flutter_app/android/ci-release.keystore +0 -0
  67. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  68. package/flutter_app/android/gradle.properties +3 -0
  69. package/flutter_app/android/key.properties +4 -0
  70. package/flutter_app/android/settings.gradle.kts +26 -0
  71. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  72. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  73. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  74. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  75. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  76. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  77. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  78. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  79. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  80. package/flutter_app/lib/features/location/location_service.dart +117 -0
  81. package/flutter_app/lib/features/notifications/notification_interceptor.dart +73 -0
  82. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +571 -0
  83. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +275 -0
  84. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +366 -0
  85. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +57 -0
  86. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +218 -0
  87. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +46 -0
  88. package/flutter_app/lib/main.dart +107 -0
  89. package/flutter_app/lib/main_account_settings.dart +1340 -0
  90. package/flutter_app/lib/main_admin.dart +900 -0
  91. package/flutter_app/lib/main_app_shell.dart +2162 -0
  92. package/flutter_app/lib/main_chat.dart +3902 -0
  93. package/flutter_app/lib/main_controller.dart +7229 -0
  94. package/flutter_app/lib/main_devices.dart +1738 -0
  95. package/flutter_app/lib/main_integrations.dart +917 -0
  96. package/flutter_app/lib/main_launcher.dart +959 -0
  97. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  98. package/flutter_app/lib/main_models.dart +3807 -0
  99. package/flutter_app/lib/main_navigation.dart +206 -0
  100. package/flutter_app/lib/main_operations.dart +5249 -0
  101. package/flutter_app/lib/main_recordings.dart +920 -0
  102. package/flutter_app/lib/main_runtime.dart +869 -0
  103. package/flutter_app/lib/main_settings.dart +2288 -0
  104. package/flutter_app/lib/main_shared.dart +3393 -0
  105. package/flutter_app/lib/main_spacing.dart +33 -0
  106. package/flutter_app/lib/main_theme.dart +276 -0
  107. package/flutter_app/lib/main_unified.dart +388 -0
  108. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  109. package/flutter_app/lib/src/analytics_service.dart +294 -0
  110. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  111. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  112. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  113. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  114. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  115. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  116. package/flutter_app/lib/src/android_auto_bridge.dart +59 -0
  117. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  118. package/flutter_app/lib/src/app_launch_bridge.dart +58 -0
  119. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  120. package/flutter_app/lib/src/backend_client.dart +1870 -0
  121. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  122. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  123. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  124. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  125. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  126. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  127. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  128. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  129. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  130. package/flutter_app/lib/src/health_bridge.dart +136 -0
  131. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  132. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  133. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  134. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  135. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  136. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  137. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  138. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  139. package/flutter_app/lib/src/oauth_launcher_io.dart +95 -0
  140. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  141. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  142. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  143. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  144. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  145. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  146. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  147. package/flutter_app/lib/src/theme/palette.dart +81 -0
  148. package/flutter_app/lib/src/web_app_update_monitor.dart +17 -0
  149. package/flutter_app/lib/src/web_app_update_monitor_stub.dart +24 -0
  150. package/flutter_app/lib/src/web_app_update_monitor_web.dart +123 -0
  151. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  152. package/flutter_app/linux/CMakeLists.txt +128 -0
  153. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  154. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +47 -0
  155. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  156. package/flutter_app/linux/flutter/generated_plugins.cmake +32 -0
  157. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  158. package/flutter_app/linux/runner/main.cc +6 -0
  159. package/flutter_app/linux/runner/my_application.cc +144 -0
  160. package/flutter_app/linux/runner/my_application.h +18 -0
  161. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  162. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  163. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  164. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +48 -0
  165. package/flutter_app/macos/Podfile +42 -0
  166. package/flutter_app/macos/Podfile.lock +87 -0
  167. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  168. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  169. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  170. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  171. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  172. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  173. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  174. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  175. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  176. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  177. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  178. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  179. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  180. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  181. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  182. package/flutter_app/macos/Runner/Info.plist +36 -0
  183. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  184. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  185. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  186. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  187. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  188. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  189. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  190. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  191. package/flutter_app/patch_strings.py +12 -0
  192. package/flutter_app/pubspec.lock +1256 -0
  193. package/flutter_app/pubspec.yaml +59 -0
  194. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  195. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  196. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  197. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  198. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  199. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  200. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  201. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  202. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  203. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  204. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  205. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  206. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  207. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  208. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  209. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  210. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  211. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  212. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  213. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  214. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  215. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  216. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  217. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  218. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  219. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  220. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  221. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  222. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  223. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  224. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  225. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  226. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  227. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  228. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  229. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  230. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  231. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  232. package/flutter_app/web/favicon.png +0 -0
  233. package/flutter_app/web/favicon.svg +12 -0
  234. package/flutter_app/web/icons/Icon-192.png +0 -0
  235. package/flutter_app/web/icons/Icon-512.png +0 -0
  236. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  237. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  238. package/flutter_app/web/index.html +39 -0
  239. package/flutter_app/web/manifest.json +35 -0
  240. package/flutter_app/windows/CMakeLists.txt +108 -0
  241. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  242. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +50 -0
  243. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  244. package/flutter_app/windows/flutter/generated_plugins.cmake +36 -0
  245. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  246. package/flutter_app/windows/runner/Runner.rc +121 -0
  247. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  248. package/flutter_app/windows/runner/flutter_window.h +37 -0
  249. package/flutter_app/windows/runner/main.cpp +53 -0
  250. package/flutter_app/windows/runner/resource.h +16 -0
  251. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  252. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  253. package/flutter_app/windows/runner/utils.cpp +65 -0
  254. package/flutter_app/windows/runner/utils.h +19 -0
  255. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  256. package/flutter_app/windows/runner/win32_window.h +102 -0
  257. package/lib/install_helpers.js +31 -0
  258. package/lib/manager.js +465 -43
  259. package/package.json +11 -3
  260. package/runtime/paths.js +137 -0
  261. package/server/config/analytics.js +30 -0
  262. package/server/db/database.js +363 -21
  263. package/server/guest-agent.android.package.json +13 -0
  264. package/server/guest-agent.browser.package.json +14 -0
  265. package/server/guest_agent.js +98 -52
  266. package/server/http/middleware.js +55 -2
  267. package/server/http/routes.js +6 -1
  268. package/server/http/static.js +41 -1
  269. package/server/index.js +16 -16
  270. package/server/public/.last_build_id +1 -1
  271. package/server/public/assets/AssetManifest.bin +1 -1
  272. package/server/public/assets/AssetManifest.bin.json +1 -1
  273. package/server/public/assets/AssetManifest.json +1 -1
  274. package/server/public/assets/NOTICES +679 -129
  275. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  276. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  277. package/server/public/assets/packages/mixpanel_flutter/assets/mixpanel.js +3 -0
  278. package/server/public/canvaskit/wimp.wasm +0 -0
  279. package/server/public/flutter_bootstrap.js +2 -2
  280. package/server/public/main.dart.js +95866 -87333
  281. package/server/routes/account.js +26 -1
  282. package/server/routes/agents.js +6 -2
  283. package/server/routes/android.js +22 -6
  284. package/server/routes/auth.js +25 -5
  285. package/server/routes/browser.js +47 -13
  286. package/server/routes/integrations.js +119 -9
  287. package/server/routes/memory.js +179 -8
  288. package/server/routes/runtime.js +14 -0
  289. package/server/routes/screenHistory.js +46 -0
  290. package/server/routes/settings.js +102 -24
  291. package/server/routes/skills.js +12 -0
  292. package/server/routes/social_video.js +66 -0
  293. package/server/routes/tasks.js +22 -2
  294. package/server/routes/triggers.js +90 -0
  295. package/server/routes/wearable.js +67 -0
  296. package/server/routes/widgets.js +4 -4
  297. package/server/services/ai/capabilityHealth.js +23 -51
  298. package/server/services/ai/compaction.js +1 -1
  299. package/server/services/ai/deliverables/artifact_helpers.js +190 -0
  300. package/server/services/ai/deliverables/contracts.js +113 -0
  301. package/server/services/ai/deliverables/index.js +20 -0
  302. package/server/services/ai/deliverables/selector.js +94 -0
  303. package/server/services/ai/deliverables/validator.js +63 -0
  304. package/server/services/ai/deliverables/workflows.js +207 -0
  305. package/server/services/ai/engine.js +372 -27
  306. package/server/services/ai/hooks.js +127 -0
  307. package/server/services/ai/integrated_tools/index.js +98 -0
  308. package/server/services/ai/integrated_tools/remotion.js +373 -0
  309. package/server/services/ai/integrated_tools/shared.js +204 -0
  310. package/server/services/ai/integrated_tools/slidev.js +207 -0
  311. package/server/services/ai/loopPolicy.js +146 -0
  312. package/server/services/ai/models.js +36 -0
  313. package/server/services/ai/providers/githubCopilot.js +97 -0
  314. package/server/services/ai/providers/openai.js +2 -1
  315. package/server/services/ai/providers/openaiCodex.js +384 -0
  316. package/server/services/ai/recordingInsights.js +11 -13
  317. package/server/services/ai/runEvents.js +100 -0
  318. package/server/services/ai/settings.js +20 -0
  319. package/server/services/ai/systemPrompt.js +16 -2
  320. package/server/services/ai/taskAnalysis.js +2 -0
  321. package/server/services/ai/toolResult.js +20 -0
  322. package/server/services/ai/toolRunner.js +46 -6
  323. package/server/services/ai/toolSelector.js +54 -1
  324. package/server/services/ai/tools.js +360 -127
  325. package/server/services/android/android_bootstrap_worker.js +63 -0
  326. package/server/services/android/controller.js +442 -1760
  327. package/server/services/bootstrap_helpers.js +0 -34
  328. package/server/services/browser/controller.js +326 -52
  329. package/server/services/cli/executor.js +40 -1
  330. package/server/services/desktop/screenRecorder.js +182 -0
  331. package/server/services/integrations/env.js +10 -0
  332. package/server/services/integrations/figma/provider.js +1 -0
  333. package/server/services/integrations/github/common.js +106 -0
  334. package/server/services/integrations/github/provider.js +499 -0
  335. package/server/services/integrations/github/repos.js +1124 -0
  336. package/server/services/integrations/google/provider.js +1 -0
  337. package/server/services/integrations/manager.js +88 -12
  338. package/server/services/integrations/microsoft/provider.js +1 -0
  339. package/server/services/integrations/oauth_provider.js +25 -8
  340. package/server/services/integrations/provider_config_store.js +85 -0
  341. package/server/services/integrations/registry.js +8 -2
  342. package/server/services/integrations/spotify/provider.js +488 -0
  343. package/server/services/integrations/trello/provider.js +842 -0
  344. package/server/services/integrations/weather/provider.js +559 -0
  345. package/server/services/integrations/whatsapp/provider.js +6 -2
  346. package/server/services/manager.js +109 -154
  347. package/server/services/mcp/client.js +120 -23
  348. package/server/services/memory/llm_transfer.js +218 -0
  349. package/server/services/memory/manager.js +299 -35
  350. package/server/services/messaging/access_policy.js +10 -0
  351. package/server/services/messaging/automation.js +1 -1
  352. package/server/services/messaging/manager.js +93 -8
  353. package/server/services/messaging/meshtastic.js +277 -0
  354. package/server/services/messaging/meshtastic_env.js +40 -0
  355. package/server/services/messaging/meshtastic_protocol.js +530 -0
  356. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  357. package/server/services/runtime/backends/local-vm.js +221 -158
  358. package/server/services/runtime/docker-vm-manager.js +392 -0
  359. package/server/services/runtime/guest_bootstrap.js +654 -0
  360. package/server/services/runtime/manager.js +85 -29
  361. package/server/services/runtime/settings.js +22 -31
  362. package/server/services/runtime/validation.js +16 -31
  363. package/server/services/skills/base_catalog.js +33 -0
  364. package/server/services/social_video/adapters/base.js +26 -0
  365. package/server/services/social_video/adapters/index.js +27 -0
  366. package/server/services/social_video/adapters/instagram.js +17 -0
  367. package/server/services/social_video/adapters/tiktok.js +17 -0
  368. package/server/services/social_video/adapters/x.js +17 -0
  369. package/server/services/social_video/adapters/youtube.js +17 -0
  370. package/server/services/social_video/captions.js +187 -0
  371. package/server/services/social_video/frame.js +42 -0
  372. package/server/services/social_video/index.js +7 -0
  373. package/server/services/social_video/metadata.js +63 -0
  374. package/server/services/social_video/result.js +63 -0
  375. package/server/services/social_video/service.js +720 -0
  376. package/server/services/social_video/url.js +83 -0
  377. package/server/services/tasks/adapters/index.js +2 -0
  378. package/server/services/tasks/adapters/manual.js +12 -0
  379. package/server/services/tasks/adapters/schedule.js +33 -5
  380. package/server/services/tasks/adapters/weather_event.js +84 -0
  381. package/server/services/tasks/integration_runtime.js +86 -1
  382. package/server/services/tasks/runtime.js +3 -3
  383. package/server/services/tasks/task_repository.js +3 -6
  384. package/server/services/voice/agentBridge.js +20 -4
  385. package/server/services/voice/message.js +3 -0
  386. package/server/services/voice/openaiClient.js +4 -1
  387. package/server/services/voice/openaiSpeech.js +6 -1
  388. package/server/services/voice/providers.js +144 -15
  389. package/server/services/voice/runtime.js +8 -8
  390. package/server/services/voice/runtimeManager.js +292 -20
  391. package/server/services/voice/turnRunner.js +29 -9
  392. package/server/services/wearable/firmware_manifest.js +353 -0
  393. package/server/services/wearable/gateway.js +350 -0
  394. package/server/services/wearable/protocol.js +45 -0
  395. package/server/services/wearable/service.js +240 -0
  396. package/server/services/websocket.js +3 -1
  397. package/server/services/widgets/focus_widget.js +137 -0
  398. package/server/services/widgets/service.js +182 -6
  399. package/server/services/workspace/manager.js +301 -0
  400. package/server/utils/deployment.js +16 -9
  401. package/server/utils/local_secrets.js +56 -0
  402. package/server/utils/logger.js +37 -9
  403. package/server/services/integrations/home_assistant/provider.js +0 -350
  404. package/server/services/messaging/access_policy.test.js +0 -228
  405. package/server/services/runtime/backends/host.js +0 -37
  406. package/server/services/runtime/qemu.js +0 -391
@@ -1,1919 +1,601 @@
1
+ 'use strict';
2
+
3
+ const { spawn, spawnSync } = require('child_process');
1
4
  const fs = require('fs');
5
+ const https = require('https');
6
+ const net = require('net');
2
7
  const os = require('os');
3
8
  const path = require('path');
4
- const https = require('https');
5
- const { spawn, spawnSync } = require('child_process');
6
- const lockfile = require('proper-lockfile');
7
- const { CLIExecutor } = require('../cli/executor');
8
- const { DATA_DIR, RUNTIME_HOME } = require('../../../runtime/paths');
9
- const { findBestNode, parseUiDump, summarizeNode } = require('./uia');
10
-
11
- const ANDROID_ROOT = path.join(RUNTIME_HOME, 'android');
12
- const SDK_ROOT = path.join(ANDROID_ROOT, 'sdk');
13
- const CMDLINE_ROOT = path.join(SDK_ROOT, 'cmdline-tools');
14
- const CMDLINE_LATEST = path.join(CMDLINE_ROOT, 'latest');
15
- const ARTIFACTS_DIR = path.join(DATA_DIR, 'android');
16
- const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
17
- const UI_DUMPS_DIR = path.join(ARTIFACTS_DIR, 'ui-dumps');
18
- const LOGS_DIR = path.join(ARTIFACTS_DIR, 'logs');
19
- const TMP_DIR = path.join(ARTIFACTS_DIR, 'tmp');
20
- const AVD_HOME = path.join(ANDROID_ROOT, 'avd');
21
- const STATE_DIR = path.join(ARTIFACTS_DIR, 'state');
22
- const STATE_FILE = path.join(ARTIFACTS_DIR, 'state.json');
23
- const OWNERSHIP_FILE = path.join(ARTIFACTS_DIR, 'device-ownership.json');
24
- const DEFAULT_AVD_NAME = 'neoagent-default';
25
- const DEFAULT_DATA_PARTITION = '1024M';
26
- const DEFAULT_SDCARD_SIZE = '128M';
27
- const DEFAULT_RAM_SIZE = '1024';
28
- const DEFAULT_KEYEVENTS = Object.freeze({
29
- home: 3,
30
- back: 4,
31
- up: 19,
32
- down: 20,
33
- left: 21,
34
- right: 22,
35
- enter: 66,
36
- menu: 82,
37
- search: 84,
38
- app_switch: 187,
39
- delete: 67,
40
- escape: 111,
41
- space: 62,
42
- tab: 61,
43
- });
44
-
45
- for (const dir of [ANDROID_ROOT, SDK_ROOT, ARTIFACTS_DIR, SCREENSHOTS_DIR, UI_DUMPS_DIR, LOGS_DIR, TMP_DIR, AVD_HOME, STATE_DIR]) {
46
- fs.mkdirSync(dir, { recursive: true });
47
- }
48
-
49
- function sanitizeScopeKey(value) {
50
- const normalized = String(value || '')
51
- .trim()
52
- .toLowerCase()
53
- .replace(/[^a-z0-9_-]+/g, '-');
54
- return normalized.replace(/^-+|-+$/g, '').slice(0, 48) || 'default';
55
- }
56
-
57
- function resolveStateFile(scopeKey) {
58
- const key = sanitizeScopeKey(scopeKey);
59
- if (key === 'default') {
60
- return STATE_FILE;
61
- }
62
- return path.join(STATE_DIR, `${key}.json`);
63
- }
64
-
65
- function readOwnership() {
66
- return withOwnershipLock(() => {
67
- return readOwnershipUnlocked();
68
- });
69
- }
70
-
71
- function writeOwnership(nextOwners) {
72
- withOwnershipLock(() => {
73
- writeOwnershipUnlocked(nextOwners);
74
- });
75
- }
76
-
77
- function readOwnershipUnlocked() {
78
- try {
79
- const parsed = JSON.parse(fs.readFileSync(OWNERSHIP_FILE, 'utf8'));
80
- if (!parsed || typeof parsed !== 'object') {
81
- return {};
82
- }
83
- return parsed;
84
- } catch {
85
- return {};
86
- }
87
- }
9
+ const { DATA_DIR } = require('../../../runtime/paths');
88
10
 
89
- function writeOwnershipUnlocked(nextOwners) {
90
- const tempPath = `${OWNERSHIP_FILE}.tmp`;
91
- fs.writeFileSync(tempPath, JSON.stringify(nextOwners, null, 2));
92
- fs.renameSync(tempPath, OWNERSHIP_FILE);
93
- }
11
+ // ─── Constants ───────────────────────────────────────────────────────────────
94
12
 
95
- function withOwnershipLock(work) {
96
- fs.mkdirSync(path.dirname(OWNERSHIP_FILE), { recursive: true });
97
- if (!fs.existsSync(OWNERSHIP_FILE)) {
98
- fs.writeFileSync(OWNERSHIP_FILE, '{}');
99
- }
100
- let lastError = null;
101
- for (let attempt = 0; attempt < 3; attempt += 1) {
102
- try {
103
- const release = lockfile.lockSync(OWNERSHIP_FILE, {
104
- realpath: false,
105
- });
106
- try {
107
- return work();
108
- } finally {
109
- release();
110
- }
111
- } catch (err) {
112
- lastError = err;
113
- if (attempt === 2) {
114
- break;
115
- }
116
- }
117
- }
118
- throw lastError;
119
- }
13
+ const DEFAULT_SDK_DIR = path.join(os.homedir(), '.neoagent', 'android-sdk');
14
+ const STATE_DIR = path.join(DATA_DIR, 'android', 'state');
15
+ const LOGO_PATH = path.join(__dirname, '..', '..', '..', 'flutter_app', 'assets', 'branding', 'app_icon_512.png');
120
16
 
121
- function normalizeOwnerKey(userId) {
122
- if (userId == null || String(userId).trim() === '') {
123
- return 'system';
124
- }
125
- return `user-${sanitizeScopeKey(userId)}`;
126
- }
17
+ // Even ports in 5554–5682 (documented ADB range). 65 slots for 65 concurrent users.
18
+ const ADB_PORT_BASE = 5554;
19
+ const ADB_PORT_SLOTS = 65;
127
20
 
128
- function pruneOwnershipByDevices(owners, devices = []) {
129
- const presentSerials = new Set(
130
- devices
131
- .map((device) => String(device?.serial || '').trim())
132
- .filter(Boolean)
133
- );
134
- const next = {};
135
- let changed = false;
136
-
137
- for (const [serial, owner] of Object.entries(owners || {})) {
138
- if (!presentSerials.has(serial)) {
139
- changed = true;
140
- continue;
141
- }
142
- next[serial] = owner;
143
- }
21
+ const CMDLINE_TOOLS_VERSION = '14742923';
22
+ const CMDLINE_TOOLS_URLS = {
23
+ darwin: `https://dl.google.com/android/repository/commandlinetools-mac-${CMDLINE_TOOLS_VERSION}_latest.zip`,
24
+ linux: `https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip`,
25
+ win32: `https://dl.google.com/android/repository/commandlinetools-win-${CMDLINE_TOOLS_VERSION}_latest.zip`,
26
+ };
144
27
 
145
- return { next, changed };
146
- }
28
+ fs.mkdirSync(STATE_DIR, { recursive: true });
147
29
 
148
- function sleep(ms) {
149
- return new Promise((resolve) => setTimeout(resolve, ms));
150
- }
30
+ // ─── State persistence ───────────────────────────────────────────────────────
151
31
 
152
- function tailFile(filePath, maxLines = 40) {
153
- try {
154
- const lines = fs.readFileSync(filePath, 'utf8')
155
- .split('\n')
156
- .map((line) => line.trim())
157
- .filter(Boolean);
158
- return lines.slice(-maxLines);
159
- } catch {
160
- return [];
161
- }
162
- }
32
+ function stateFile(userId) { return path.join(STATE_DIR, `${userId}.json`); }
163
33
 
164
- function commandExists(command) {
165
- const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
166
- return probe.status === 0;
34
+ function readState(userId) {
35
+ try { return JSON.parse(fs.readFileSync(stateFile(userId), 'utf8')); }
36
+ catch { return { userId, bootstrapped: false, starting: false, startupPhase: null, lastStartError: null, pid: null, adbSerial: null }; }
167
37
  }
168
38
 
169
- function parseResolvedLaunchComponent(output, packageName) {
170
- const lines = String(output || '')
171
- .split('\n')
172
- .map((line) => line.trim())
173
- .filter(Boolean);
174
- const normalizedPackage = String(packageName || '').trim();
175
- const componentPattern = /^[A-Za-z0-9._$]+\/[A-Za-z0-9._$]+$/;
176
- const relativePattern = /^[A-Za-z0-9._$]+\/\.[A-Za-z0-9._$]+$/;
177
-
178
- const exact = lines.find((line) =>
179
- normalizedPackage
180
- ? line.startsWith(`${normalizedPackage}/`)
181
- : componentPattern.test(line) || relativePattern.test(line)
182
- );
183
- if (exact) return exact;
184
-
185
- return lines.find((line) => componentPattern.test(line) || relativePattern.test(line)) || null;
39
+ function writeState(userId, patch) {
40
+ const current = readState(userId);
41
+ fs.writeFileSync(stateFile(userId), JSON.stringify({ ...current, ...patch }, null, 2));
186
42
  }
187
43
 
188
- function appendState(patch, stateFile = STATE_FILE) {
189
- const current = readState(stateFile);
190
- const next = {
191
- ...current,
192
- ...patch,
193
- updatedAt: new Date().toISOString(),
194
- };
195
- fs.writeFileSync(stateFile, JSON.stringify(next, null, 2));
196
- return next;
197
- }
44
+ // ─── SDK resolution ──────────────────────────────────────────────────────────
198
45
 
199
- function readState(stateFile = STATE_FILE) {
200
- try {
201
- return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
202
- } catch {
203
- return {
204
- avdName: DEFAULT_AVD_NAME,
205
- serial: null,
206
- emulatorPid: null,
207
- bootstrapped: false,
208
- updatedAt: null,
209
- };
46
+ function findExistingSdk() {
47
+ const candidates = [
48
+ process.env.ANDROID_HOME,
49
+ process.env.ANDROID_SDK_ROOT,
50
+ path.join(os.homedir(), 'Library', 'Android', 'sdk'),
51
+ path.join(os.homedir(), 'Android', 'Sdk'),
52
+ path.join(os.homedir(), '.android', 'sdk'),
53
+ DEFAULT_SDK_DIR,
54
+ ].filter(Boolean);
55
+ for (const dir of candidates) {
56
+ if (fs.existsSync(path.join(dir, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator'))) return dir;
210
57
  }
58
+ return null;
211
59
  }
212
60
 
213
- function platformTag() {
214
- if (process.platform === 'darwin') return 'mac';
215
- if (process.platform === 'linux') return 'linux';
216
- throw new Error(`Android runtime bootstrap is only supported on macOS and Linux, not ${process.platform}`);
61
+ function sdkManagerBin(sdkDir) {
62
+ return path.join(sdkDir, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
217
63
  }
218
-
219
- function systemImageArch() {
220
- if (process.arch === 'arm64') return 'arm64-v8a';
221
- return 'x86_64';
64
+ function avdManagerBin(sdkDir) {
65
+ return path.join(sdkDir, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
222
66
  }
223
-
224
- function parseCsvEnv(value) {
225
- return String(value || '')
226
- .split(',')
227
- .map((entry) => entry.trim())
228
- .filter(Boolean);
67
+ function emulatorBin(sdkDir) {
68
+ return path.join(sdkDir, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
229
69
  }
230
-
231
- function configuredSystemImagePackage() {
232
- return String(process.env.ANDROID_SYSTEM_IMAGE_PACKAGE || '').trim() || null;
70
+ function adbBin(sdkDir) {
71
+ return path.join(sdkDir, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
233
72
  }
234
73
 
235
- function configuredSystemImagePlatform() {
236
- return String(process.env.ANDROID_SYSTEM_IMAGE_PLATFORM || '').trim() || null;
237
- }
74
+ // ─── System image selection ──────────────────────────────────────────────────
238
75
 
239
- function shouldForceSdkRefresh() {
240
- return String(process.env.ANDROID_FORCE_SDK_REFRESH || '').trim().toLowerCase() === 'true';
241
- }
76
+ function pickSystemImage(sdkDir) {
77
+ const siRoot = path.join(sdkDir, 'system-images');
78
+ if (!fs.existsSync(siRoot)) return null;
242
79
 
243
- function systemImageArchCandidates() {
244
- const configured = parseCsvEnv(process.env.ANDROID_SYSTEM_IMAGE_ARCH);
245
- if (configured.length > 0) {
246
- return configured.filter((arch, index, list) => list.indexOf(arch) === index);
247
- }
248
- const preferred = systemImageArch();
249
- const fallbacks = ['x86_64', 'arm64-v8a'];
250
- return [preferred, ...fallbacks].filter((arch, index, list) => list.indexOf(arch) === index);
251
- }
80
+ const hostArm = os.arch() === 'arm64' || os.arch() === 'arm';
81
+ const preferred = hostArm ? 'arm64-v8a' : 'x86_64';
82
+ const fallback = hostArm ? 'x86_64' : 'arm64-v8a';
252
83
 
253
- function parseSystemImagePlatform(platformId) {
254
- const stable = String(platformId || '').match(/^android-(\d+)$/);
255
- if (stable) {
256
- return {
257
- platformId,
258
- apiLevel: Number(stable[1] || 0),
259
- stable: true,
260
- };
84
+ const images = [];
85
+ for (const api of fs.readdirSync(siRoot)) {
86
+ const apiPath = path.join(siRoot, api);
87
+ if (!fs.statSync(apiPath).isDirectory()) continue;
88
+ for (const tag of fs.readdirSync(apiPath)) {
89
+ const tagPath = path.join(apiPath, tag);
90
+ if (!fs.statSync(tagPath).isDirectory()) continue;
91
+ for (const abi of fs.readdirSync(tagPath)) {
92
+ images.push({ api, tag, abi, key: `system-images;${api};${tag};${abi}` });
93
+ }
94
+ }
261
95
  }
262
96
 
263
- const preview = String(platformId || '').match(/^android-([A-Za-z][A-Za-z0-9_-]*)$/);
264
- if (preview) {
265
- return {
266
- platformId,
267
- apiLevel: 0,
268
- stable: false,
97
+ images.sort((a, b) => {
98
+ const score = img => {
99
+ let s = 0;
100
+ if (img.abi === preferred) s += 100;
101
+ else if (img.abi === fallback) s += 10;
102
+ if (img.tag === 'google_apis') s += 5;
103
+ else if (img.tag === 'google_apis_playstore') s += 3;
104
+ s += parseInt(img.api.replace('android-', '') || '0', 10);
105
+ return s;
269
106
  };
270
- }
271
-
272
- return {
273
- platformId,
274
- apiLevel: 0,
275
- stable: false,
276
- };
277
- }
278
-
279
- function sdkEnv() {
280
- const base = {
281
- ...process.env,
282
- ANDROID_HOME: SDK_ROOT,
283
- ANDROID_SDK_ROOT: SDK_ROOT,
284
- ANDROID_AVD_HOME: AVD_HOME,
285
- AVD_HOME,
286
- };
287
- const pathParts = [
288
- path.join(SDK_ROOT, 'platform-tools'),
289
- path.join(SDK_ROOT, 'emulator'),
290
- path.join(CMDLINE_LATEST, 'bin'),
291
- process.env.PATH || '',
292
- ].filter(Boolean);
293
- base.PATH = pathParts.join(path.delimiter);
294
- return base;
295
- }
296
-
297
- function adbBinary() {
298
- return process.env.ANDROID_ADB_PATH || path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
299
- }
300
-
301
- function sdkManagerBinary() {
302
- return path.join(CMDLINE_LATEST, 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
303
- }
304
-
305
- function avdManagerBinary() {
306
- return path.join(CMDLINE_LATEST, 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
307
- }
308
-
309
- function emulatorBinary() {
310
- return path.join(SDK_ROOT, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
107
+ return score(b) - score(a);
108
+ });
109
+ return images[0]?.key || null;
311
110
  }
312
111
 
313
- function isExecutable(filePath) {
314
- try {
315
- fs.accessSync(filePath, fs.constants.X_OK);
316
- return true;
317
- } catch {
318
- return false;
319
- }
112
+ function defaultSystemImage() {
113
+ const abi = (os.arch() === 'arm64' || os.arch() === 'arm') ? 'arm64-v8a' : 'x86_64';
114
+ return `system-images;android-33;google_apis;${abi}`;
320
115
  }
321
116
 
322
- function fetchText(url) {
323
- return new Promise((resolve, reject) => {
324
- https.get(url, (res) => {
325
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
326
- return resolve(fetchText(res.headers.location));
327
- }
328
- if (res.statusCode !== 200) {
329
- reject(new Error(`GET ${url} failed with status ${res.statusCode}`));
330
- return;
331
- }
332
- let body = '';
333
- res.setEncoding('utf8');
334
- res.on('data', (chunk) => { body += chunk; });
335
- res.on('end', () => resolve(body));
336
- }).on('error', reject);
337
- });
338
- }
117
+ // ─── SDK setup ───────────────────────────────────────────────────────────────
339
118
 
340
119
  function downloadFile(url, dest) {
341
120
  return new Promise((resolve, reject) => {
342
- const out = fs.createWriteStream(dest);
343
- https.get(url, (res) => {
344
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
345
- out.close();
346
- fs.rmSync(dest, { force: true });
347
- return resolve(downloadFile(res.headers.location, dest));
348
- }
349
- if (res.statusCode !== 200) {
350
- out.close();
351
- fs.rmSync(dest, { force: true });
352
- reject(new Error(`Download failed with status ${res.statusCode}`));
353
- return;
121
+ const file = fs.createWriteStream(dest);
122
+ const follow = u => https.get(u, res => {
123
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
124
+ file.close(); return follow(res.headers.location);
354
125
  }
355
- res.pipe(out);
356
- out.on('finish', () => out.close(resolve));
357
- }).on('error', (err) => {
358
- out.close();
359
- fs.rmSync(dest, { force: true });
360
- reject(err);
361
- });
126
+ if (res.statusCode !== 200) { file.close(); return reject(new Error(`HTTP ${res.statusCode}`)); }
127
+ res.pipe(file);
128
+ file.on('finish', () => file.close(resolve));
129
+ }).on('error', err => { file.close(); fs.unlink(dest, () => {}); reject(err); });
130
+ follow(url);
362
131
  });
363
132
  }
364
133
 
365
- function extractZip(zipPath, destDir) {
366
- if (commandExists('unzip')) {
367
- const res = spawnSync('unzip', ['-qo', zipPath, '-d', destDir], { encoding: 'utf8' });
368
- if (res.status === 0) return;
369
- throw new Error(res.stderr || `unzip failed for ${zipPath}`);
370
- }
371
-
372
- if (process.platform === 'darwin' && commandExists('ditto')) {
373
- const res = spawnSync('ditto', ['-x', '-k', zipPath, destDir], { encoding: 'utf8' });
374
- if (res.status === 0) return;
375
- throw new Error(res.stderr || `ditto failed for ${zipPath}`);
376
- }
377
-
378
- throw new Error('Neither unzip nor ditto is available to extract Android SDK archives');
379
- }
380
-
381
- function listFilesRecursive(rootDir, predicate, bucket = []) {
382
- for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
383
- const fullPath = path.join(rootDir, entry.name);
384
- if (entry.isDirectory()) {
385
- listFilesRecursive(fullPath, predicate, bucket);
386
- continue;
387
- }
388
- if (!predicate || predicate(fullPath, entry)) {
389
- bucket.push(fullPath);
390
- }
391
- }
392
- return bucket;
393
- }
394
-
395
- function resolveBundleInstallTargets(bundleDir) {
396
- const apkFiles = listFilesRecursive(bundleDir, (filePath) => path.extname(filePath).toLowerCase() === '.apk')
397
- .sort((a, b) => a.localeCompare(b));
398
- if (apkFiles.length === 0) {
399
- throw new Error('APK bundle did not contain any installable .apk files.');
400
- }
134
+ async function ensureSdk(sdkDir, onProgress) {
135
+ if (fs.existsSync(sdkManagerBin(sdkDir))) return;
136
+ const url = CMDLINE_TOOLS_URLS[process.platform];
137
+ if (!url) throw new Error(`No cmdline-tools download for platform: ${process.platform}`);
401
138
 
402
- const universalApk = apkFiles.find((filePath) => path.basename(filePath).toLowerCase() === 'universal.apk');
403
- if (universalApk) {
404
- return {
405
- mode: 'single',
406
- installPaths: [universalApk],
407
- layout: 'universal',
408
- };
409
- }
139
+ fs.mkdirSync(sdkDir, { recursive: true });
140
+ onProgress('Downloading Android SDK command-line tools (~150 MB)…');
141
+ const zip = path.join(os.tmpdir(), 'cmdline-tools.zip');
142
+ await downloadFile(url, zip);
410
143
 
411
- if (apkFiles.length === 1) {
412
- return {
413
- mode: 'single',
414
- installPaths: apkFiles,
415
- layout: 'single-apk',
416
- };
417
- }
144
+ onProgress('Extracting…');
145
+ const toolsDir = path.join(sdkDir, 'cmdline-tools');
146
+ fs.mkdirSync(toolsDir, { recursive: true });
147
+ const unzip = spawnSync('unzip', ['-qo', zip, '-d', toolsDir]);
148
+ fs.unlinkSync(zip);
149
+ if (unzip.status !== 0) throw new Error('unzip failed');
418
150
 
419
- throw new Error(
420
- 'APK bundles must include a universal APK. Export a universal .apks bundle or upload a single .apk instead.'
421
- );
151
+ const extracted = path.join(toolsDir, 'cmdline-tools');
152
+ const latest = path.join(toolsDir, 'latest');
153
+ if (fs.existsSync(extracted) && !fs.existsSync(latest)) fs.renameSync(extracted, latest);
154
+ if (!fs.existsSync(sdkManagerBin(sdkDir))) throw new Error('sdkmanager not found after extraction');
422
155
  }
423
156
 
424
- function parseLatestCmdlineToolsUrl(xml) {
425
- const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
426
- const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="cmdline-tools;latest">([\\s\\S]*?)<\\/remotePackage>`));
427
- if (!packageMatch) throw new Error('Could not locate cmdline-tools;latest in Android repository metadata');
157
+ async function ensurePackages(sdkDir, onProgress) {
158
+ const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
159
+ const sdkman = sdkManagerBin(sdkDir);
428
160
 
429
- const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
430
- for (const block of archiveBlocks) {
431
- if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
432
- const urlMatch = block.match(/<url>\s*([^<]*commandlinetools-[^<]+_latest\.zip)\s*<\/url>/);
433
- if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
434
- }
161
+ onProgress('Accepting Android SDK licenses…');
162
+ spawnSync(sdkman, ['--licenses', `--sdk_root=${sdkDir}`], { input: 'y\n'.repeat(20), encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'] });
435
163
 
436
- throw new Error(`Could not find a command line tools archive for ${tag}`);
437
- }
438
-
439
- function systemImageTagScore(tag) {
440
- const value = String(tag || '').toLowerCase();
441
- if (value.startsWith('google_apis_playstore')) return 50;
442
- if (value.startsWith('google_apis')) return 40;
443
- if (value === 'google_atd') return 30;
444
- if (value === 'aosp_atd') return 20;
445
- if (value === 'default') return 10;
446
- return 0;
447
- }
448
-
449
- function parseSystemImageCandidates(entries = []) {
450
- return entries.map((entry) => {
451
- const platform = parseSystemImagePlatform(entry.platformId);
452
- return {
453
- packageName: entry.packageName,
454
- platformId: entry.platformId,
455
- tag: entry.tag,
456
- arch: entry.arch,
457
- apiLevel: platform.apiLevel,
458
- stable: platform.stable,
459
- tagScore: systemImageTagScore(entry.tag),
460
- };
164
+ const img = defaultSystemImage();
165
+ onProgress(`Installing platform-tools, emulator, ${img} (~1–2 GB, first run only)…`);
166
+ const r = spawnSync(sdkman, ['platform-tools', 'emulator', img, `--sdk_root=${sdkDir}`], {
167
+ encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 20 * 60 * 1000,
461
168
  });
169
+ if (r.status !== 0) throw new Error(`sdkmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
462
170
  }
463
171
 
464
- function parseSystemImages(listOutput) {
465
- const matches = [];
466
- const regex = /system-images;(android-[^;\s]+);([^;\s]+);([^;\s]+)/g;
467
- let match = regex.exec(listOutput);
468
- while (match) {
469
- matches.push({
470
- packageName: match[0],
471
- platformId: match[1],
472
- tag: match[2],
473
- arch: match[3],
474
- });
475
- match = regex.exec(listOutput);
476
- }
477
-
478
- return parseSystemImageCandidates(matches);
479
- }
480
-
481
- function parseInstalledSystemImages() {
482
- const root = path.join(SDK_ROOT, 'system-images');
483
- if (!fs.existsSync(root)) {
484
- return [];
485
- }
486
-
487
- const matches = [];
488
- const platforms = fs.readdirSync(root, { withFileTypes: true })
489
- .filter((entry) => entry.isDirectory())
490
- .map((entry) => entry.name);
491
-
492
- for (const platformId of platforms) {
493
- const platformDir = path.join(root, platformId);
494
- const tags = fs.readdirSync(platformDir, { withFileTypes: true })
495
- .filter((entry) => entry.isDirectory())
496
- .map((entry) => entry.name);
497
- for (const tag of tags) {
498
- const tagDir = path.join(platformDir, tag);
499
- const archs = fs.readdirSync(tagDir, { withFileTypes: true })
500
- .filter((entry) => entry.isDirectory())
501
- .map((entry) => entry.name);
502
- for (const arch of archs) {
503
- const packageXml = path.join(tagDir, arch, 'package.xml');
504
- if (!fs.existsSync(packageXml)) {
505
- continue;
506
- }
507
- matches.push({
508
- packageName: `system-images;${platformId};${tag};${arch}`,
509
- platformId,
510
- tag,
511
- arch,
512
- });
513
- }
514
- }
515
- }
516
-
517
- return parseSystemImageCandidates(matches);
518
- }
519
-
520
- function rankSystemImagePool(pool) {
521
- const preferredMatches = pool.filter((candidate) => candidate.tagScore > 0);
522
- const rankedPool = preferredMatches.length > 0 ? preferredMatches : pool;
523
-
524
- rankedPool.sort((a, b) =>
525
- Number(b.stable) - Number(a.stable) ||
526
- b.tagScore - a.tagScore ||
527
- b.apiLevel - a.apiLevel ||
528
- a.packageName.localeCompare(b.packageName)
529
- );
530
-
531
- return rankedPool;
532
- }
533
-
534
- function chooseConfiguredSystemImage(listOutput) {
535
- const matches = Array.isArray(listOutput)
536
- ? parseSystemImageCandidates(listOutput)
537
- : parseSystemImages(listOutput);
538
- const packageName = configuredSystemImagePackage();
539
- if (packageName) {
540
- return matches.find((candidate) => candidate.packageName === packageName) || null;
541
- }
542
-
543
- const platformId = configuredSystemImagePlatform();
544
- if (!platformId) return null;
545
-
546
- const pool = matches.filter((candidate) => candidate.platformId === platformId);
547
- if (pool.length === 0) return null;
548
-
549
- let archPool = [];
550
- for (const arch of systemImageArchCandidates()) {
551
- archPool = pool.filter((candidate) => candidate.arch === arch);
552
- if (archPool.length > 0) break;
553
- }
554
-
555
- return rankSystemImagePool(archPool.length > 0 ? archPool : pool)[0] || null;
556
- }
557
-
558
- function chooseLatestSystemImage(listOutput, preferredArchs = systemImageArchCandidates()) {
559
- const matches = Array.isArray(listOutput)
560
- ? parseSystemImageCandidates(listOutput)
561
- : parseSystemImages(listOutput);
562
- const archPool = Array.isArray(preferredArchs) && preferredArchs.length > 0
563
- ? preferredArchs
564
- : systemImageArchCandidates();
565
-
566
- let pool = [];
567
- for (const arch of archPool) {
568
- pool = matches.filter((candidate) => candidate.arch === arch);
569
- if (pool.length > 0) break;
570
- }
571
-
572
- if (pool.length === 0) {
573
- pool = matches;
574
- }
575
-
576
- return rankSystemImagePool(pool)[0] || null;
577
- }
578
-
579
- function formatSystemImageError(listOutput) {
580
- const candidates = Array.isArray(listOutput)
581
- ? parseSystemImageCandidates(listOutput)
582
- : parseSystemImages(listOutput);
583
- const availableArchs = [...new Set(candidates.map((candidate) => candidate.arch))].sort();
584
- const wantedArchs = systemImageArchCandidates().join(', ');
585
- const packageName = configuredSystemImagePackage();
586
- const platformId = configuredSystemImagePlatform();
587
- const available = availableArchs.length > 0 ? availableArchs.join(', ') : 'none';
588
- const overrideDetails = [
589
- packageName ? `package=${packageName}` : null,
590
- platformId ? `platform=${platformId}` : null,
591
- ].filter(Boolean);
592
- const overrideText = overrideDetails.length > 0 ? ` Configured override: ${overrideDetails.join(', ')}.` : '';
593
- return `No compatible Android system image found. Preferred architectures: ${wantedArchs}. Available architectures: ${available}.${overrideText}`;
172
+ function ensureEmulatorRegistered(sdkDir) {
173
+ const packageXml = path.join(sdkDir, 'emulator', 'package.xml');
174
+ if (fs.existsSync(packageXml) || !fs.existsSync(emulatorBin(sdkDir))) return;
175
+ const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
176
+ spawnSync(sdkManagerBin(sdkDir), ['emulator', `--sdk_root=${sdkDir}`], {
177
+ encoding: 'utf8', env, input: 'y\n'.repeat(5), stdio: ['pipe', 'pipe', 'pipe'], timeout: 5 * 60 * 1000,
178
+ });
594
179
  }
595
180
 
596
- function parseApiLevelFromSystemImage(packageName) {
597
- const match = String(packageName || '').match(/system-images;android-(\d+);/);
598
- return match ? Number(match[1] || 0) : 0;
599
- }
181
+ function ensureAvd(sdkDir, avdName, onProgress) {
182
+ const avdDir = path.join(os.homedir(), '.android', 'avd', `${avdName}.avd`);
183
+ if (fs.existsSync(avdDir)) return;
600
184
 
601
- function androidTextEscape(text) {
602
- return String(text || '')
603
- .replace(/\\/g, '\\\\')
604
- .replace(/ /g, '%s')
605
- .replace(/"/g, '\\"')
606
- .replace(/'/g, "\\'")
607
- .replace(/[&()<>|;$`]/g, '');
608
- }
185
+ ensureEmulatorRegistered(sdkDir);
186
+ const img = pickSystemImage(sdkDir) || defaultSystemImage();
187
+ onProgress(`Creating AVD "${avdName}" using ${img}…`);
609
188
 
610
- function quoteShell(value) {
611
- return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
612
- }
189
+ const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
190
+ const r = spawnSync(avdManagerBin(sdkDir), ['create', 'avd', '-n', avdName, '-k', img, '--device', 'pixel', '--force'], {
191
+ encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], input: '\n',
192
+ });
193
+ if (r.status !== 0) throw new Error(`avdmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
613
194
 
614
- function updateIniValue(content, key, value) {
615
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
616
- const line = `${key}=${value}`;
617
- if (new RegExp(`^${escapedKey}=.*$`, 'm').test(content)) {
618
- return content.replace(new RegExp(`^${escapedKey}=.*$`, 'm'), line);
195
+ // Patch config: sparse QCOW2 (no pre-allocation), smaller cache partition.
196
+ const cfgPath = path.join(avdDir, 'config.ini');
197
+ if (fs.existsSync(cfgPath)) {
198
+ let cfg = fs.readFileSync(cfgPath, 'utf8');
199
+ cfg = cfg.replace(/disk\.dataPartition\.size\s*=\s*\S+/, `disk.dataPartition.size = ${2 * 1024 * 1024 * 1024}`);
200
+ cfg = cfg.replace(/disk\.cachePartition\.size\s*=\s*\S+/, `disk.cachePartition.size = ${32 * 1024 * 1024}`);
201
+ cfg = cfg.replace(/userdata\.useQcow2\s*=\s*\S+/, 'userdata.useQcow2 = yes');
202
+ if (!/userdata\.useQcow2/.test(cfg)) cfg += '\nuserdata.useQcow2 = yes\n';
203
+ fs.writeFileSync(cfgPath, cfg);
619
204
  }
620
- return `${content.replace(/\s*$/, '')}\n${line}\n`;
621
205
  }
622
206
 
623
- function readIniValue(content, key) {
624
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
625
- const match = content.match(new RegExp(`^${escapedKey}=(.*)$`, 'm'));
626
- return match ? String(match[1] || '').trim() : null;
627
- }
207
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
628
208
 
629
- function systemImagePackageToRelativeDir(packageName) {
630
- const parts = String(packageName || '').split(';').filter(Boolean);
631
- if (parts.length !== 4 || parts[0] !== 'system-images') {
632
- return null;
633
- }
634
- return `${parts.join('/')}/`;
209
+ function hashCode(str) {
210
+ let h = 0;
211
+ for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
212
+ return h;
635
213
  }
636
214
 
637
- function systemImagePackageToAbi(packageName) {
638
- const parts = String(packageName || '').split(';').filter(Boolean);
639
- if (parts.length !== 4 || parts[0] !== 'system-images') {
640
- return null;
641
- }
642
- return parts[3] || null;
215
+ // Escape a string for safe use inside single-quoted Android shell (`mksh`) commands.
216
+ function shellEscape(str) {
217
+ return String(str).replace(/'/g, "'\\''");
643
218
  }
644
219
 
645
- function abiToCpuArch(abi) {
646
- const value = String(abi || '').trim().toLowerCase();
647
- if (value === 'arm64-v8a') return 'arm64';
648
- if (value === 'armeabi-v7a' || value === 'armeabi') return 'arm';
649
- if (value === 'x86_64') return 'x86_64';
650
- if (value === 'x86') return 'x86';
651
- return null;
220
+ // Validate an Android package name or intent action (alphanumeric + dots + underscores).
221
+ function isSafeIdentifier(str) {
222
+ return /^[\w.]+$/.test(String(str || ''));
652
223
  }
653
224
 
654
- function systemImagePackageToCpuArch(packageName) {
655
- return abiToCpuArch(systemImagePackageToAbi(packageName));
656
- }
657
-
658
- function describeAutoFixChanges(current, next, fields = []) {
659
- return fields
660
- .map((field) => {
661
- const before = current?.[field] ?? null;
662
- const after = next?.[field] ?? null;
663
- if (before === after) return null;
664
- return `${field}: ${before ?? 'null'} -> ${after ?? 'null'}`;
665
- })
666
- .filter(Boolean)
667
- .join(', ');
668
- }
669
-
670
- function sanitizeUiXml(raw) {
671
- const text = String(raw || '');
672
- const start = text.indexOf('<?xml');
673
- const end = text.lastIndexOf('</hierarchy>');
674
- if (start >= 0 && end >= start) {
675
- return text.slice(start, end + '</hierarchy>'.length);
676
- }
677
- return text.trim();
678
- }
225
+ // ─── AndroidController ───────────────────────────────────────────────────────
679
226
 
680
227
  class AndroidController {
681
228
  constructor(options = {}) {
682
- this.io = options?.io;
683
- this.userId = options?.userId != null ? String(options.userId) : null;
684
- this.artifactStore = options?.artifactStore || null;
685
- this.runtimeBackend = options?.runtimeBackend || 'host';
686
- this.scopeKey = sanitizeScopeKey(this.userId ? `user-${this.userId}` : 'default');
687
- this.ownerKey = normalizeOwnerKey(this.userId);
688
- this.stateFile = resolveStateFile(this.scopeKey);
689
- this.cli = new CLIExecutor();
690
- this.avdName = this.#readState().avdName || (this.userId ? `neoagent-${this.scopeKey}` : DEFAULT_AVD_NAME);
691
- this.bootstrapPromise = null;
692
- this.startPromise = null;
693
- this.#registerProcessCleanup();
694
- }
695
-
696
- static cleanupRegistered = false;
697
- static cleanupControllers = new Set();
698
-
699
- #readState() {
700
- return readState(this.stateFile);
701
- }
702
-
703
- #appendState(patch) {
704
- return appendState(patch, this.stateFile);
705
- }
706
-
707
- #readOwnership() {
708
- return readOwnership();
709
- }
710
-
711
- #writeOwnership(nextOwners) {
712
- writeOwnership(nextOwners);
229
+ this.userId = String(options.userId || 'default').trim();
230
+ this.avdName = `neoagent_${this.userId}`;
231
+ // Deterministic ADB console port per user, within documented range 5554–5682 (even only).
232
+ this.adbPort = ADB_PORT_BASE + ((hashCode(this.userId) >>> 0) % ADB_PORT_SLOTS) * 2;
233
+ this.adbSerial = `emulator-${this.adbPort}`;
234
+ this.sdkDir = options.sdkDir || findExistingSdk() || DEFAULT_SDK_DIR;
235
+ this.artifactStore = options.artifactStore || null;
236
+ this.startPromise = null;
713
237
  }
714
238
 
715
- #releaseSerialOwnership(serial) {
716
- const normalizedSerial = String(serial || '').trim();
717
- if (!normalizedSerial) return;
718
- withOwnershipLock(() => {
719
- const owners = readOwnershipUnlocked();
720
- const current = owners[normalizedSerial];
721
- if (!current || current.ownerKey !== this.ownerKey) {
722
- return;
723
- }
239
+ // ── Status ────────────────────────────────────────────────────────────────
724
240
 
725
- delete owners[normalizedSerial];
726
- writeOwnershipUnlocked(owners);
727
- });
728
- }
241
+ getStatusSync() { return readState(this.userId); }
729
242
 
730
- #claimSerial(serial) {
731
- const normalizedSerial = String(serial || '').trim();
732
- if (!normalizedSerial) {
733
- throw new Error('Cannot claim an empty Android serial.');
734
- }
243
+ async getStatus() {
244
+ const state = readState(this.userId);
245
+ const base = {
246
+ bootstrapped: state.bootstrapped || false,
247
+ starting: state.starting || false,
248
+ startupPhase: state.startupPhase || null,
249
+ lastStartError: state.lastStartError || null,
250
+ adbSerial: state.adbSerial || null,
251
+ devices: [],
252
+ };
735
253
 
736
- withOwnershipLock(() => {
737
- const owners = readOwnershipUnlocked();
738
- const existing = owners[normalizedSerial];
739
- if (existing && existing.ownerKey && existing.ownerKey !== this.ownerKey) {
740
- throw new Error(`Android device ${normalizedSerial} is currently reserved by another user.`);
741
- }
254
+ if (!state.adbSerial) return base;
255
+ if (!this.#isPidAlive(state.pid)) return { ...base, bootstrapped: false };
742
256
 
743
- owners[normalizedSerial] = {
744
- ownerKey: this.ownerKey,
745
- ownerUserId: this.userId,
746
- updatedAt: new Date().toISOString(),
257
+ try {
258
+ const r = spawnSync(adbBin(this.sdkDir), ['-s', state.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
259
+ { encoding: 'utf8', timeout: 5000 });
260
+ const booted = r.stdout?.trim() === '1';
261
+ return {
262
+ ...base,
263
+ bootstrapped: booted,
264
+ devices: booted ? [{ serial: state.adbSerial, status: 'device', emulator: true }] : [],
747
265
  };
748
- writeOwnershipUnlocked(owners);
749
- });
750
- }
751
-
752
- #isSerialOwnedByAnother(serial, owners = null) {
753
- const normalizedSerial = String(serial || '').trim();
754
- if (!normalizedSerial) return false;
755
- const effectiveOwners = owners || this.#readOwnership();
756
- const existing = effectiveOwners[normalizedSerial];
757
- return Boolean(existing?.ownerKey && existing.ownerKey !== this.ownerKey);
758
- }
759
-
760
- #assertSerialAccess(serial, options = {}) {
761
- const normalizedSerial = String(serial || '').trim();
762
- if (!normalizedSerial) {
763
- throw new Error('Android serial is required.');
266
+ } catch {
267
+ return base;
764
268
  }
765
-
766
- const claimIfUnowned = options.claimIfUnowned !== false;
767
- withOwnershipLock(() => {
768
- const owners = readOwnershipUnlocked();
769
- const existing = owners[normalizedSerial];
770
-
771
- if (existing?.ownerKey && existing.ownerKey !== this.ownerKey) {
772
- throw new Error(`Android device ${normalizedSerial} is currently reserved by another user.`);
773
- }
774
-
775
- if (claimIfUnowned) {
776
- owners[normalizedSerial] = {
777
- ownerKey: this.ownerKey,
778
- ownerUserId: this.userId,
779
- updatedAt: new Date().toISOString(),
780
- };
781
- writeOwnershipUnlocked(owners);
782
- }
783
- });
784
- }
785
-
786
- #pruneOwnership(devices = []) {
787
- withOwnershipLock(() => {
788
- const owners = readOwnershipUnlocked();
789
- const { next, changed } = pruneOwnershipByDevices(owners, devices);
790
- if (changed) {
791
- writeOwnershipUnlocked(next);
792
- }
793
- });
794
- }
795
-
796
- #registerProcessCleanup() {
797
- AndroidController.cleanupControllers.add(this);
798
- if (AndroidController.cleanupRegistered) {
799
- return;
800
- }
801
- AndroidController.cleanupRegistered = true;
802
-
803
- const cleanup = () => {
804
- for (const controller of AndroidController.cleanupControllers) {
805
- try {
806
- controller.#stopTrackedEmulatorSync();
807
- } catch {}
808
- }
809
- };
810
-
811
- process.once('exit', cleanup);
812
- process.once('uncaughtException', cleanup);
813
- process.once('unhandledRejection', cleanup);
814
269
  }
815
270
 
816
- #stopTrackedEmulatorSync() {
817
- const state = this.#readState();
818
- const serial = state.serial;
271
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
819
272
 
820
- if (serial && isExecutable(adbBinary())) {
821
- try {
822
- spawnSync(adbBinary(), ['-s', serial, 'emu', 'kill'], {
823
- stdio: 'ignore',
824
- env: sdkEnv(),
825
- });
826
- } catch {}
827
- }
828
-
829
- if (state.emulatorPid) {
830
- try {
831
- process.kill(state.emulatorPid, 0);
832
- process.kill(state.emulatorPid, 'SIGTERM');
833
- } catch {}
273
+ async requestStartEmulator() {
274
+ console.log(`[Android] requestStartEmulator for user ${this.userId}`);
275
+ const state = readState(this.userId);
276
+ if (state.adbSerial && this.#isPidAlive(state.pid)) {
277
+ console.log(`[Android] Emulator already running (pid=${state.pid})`);
278
+ return { success: true, pending: false, adbSerial: state.adbSerial };
834
279
  }
835
-
836
- this.#releaseSerialOwnership(serial);
837
- this.#appendState({ serial: null, emulatorPid: null });
838
- }
839
-
840
- async #run(command, options = {}) {
841
- const result = await this.cli.execute(command, {
842
- timeout: options.timeout || 120000,
843
- env: sdkEnv(),
844
- cwd: options.cwd || ANDROID_ROOT,
845
- });
846
- if (result.exitCode !== 0) {
847
- throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
280
+ if (!this.startPromise) {
281
+ writeState(this.userId, { starting: true, startupPhase: 'Initializing', lastStartError: null });
282
+ this.startPromise = this.#setup().finally(() => { this.startPromise = null; });
283
+ this.startPromise.catch(() => {});
848
284
  }
849
- return result.stdout || '';
285
+ const s = readState(this.userId);
286
+ return { success: true, pending: true, bootstrapped: false, starting: true, startupPhase: s.startupPhase };
850
287
  }
851
288
 
852
- async #runAllowFailure(command, options = {}) {
853
- return this.cli.execute(command, {
854
- timeout: options.timeout || 120000,
855
- env: sdkEnv(),
856
- cwd: options.cwd || ANDROID_ROOT,
857
- });
289
+ async stopEmulator() {
290
+ const state = readState(this.userId);
291
+ if (state.pid) { try { process.kill(Number(state.pid), 'SIGTERM'); } catch {} }
292
+ writeState(this.userId, { bootstrapped: false, starting: false, pid: null, adbSerial: null, startupPhase: null });
293
+ console.log('[Android] Emulator stopped');
858
294
  }
859
295
 
860
- async ensureBootstrapped() {
861
- const binariesReady =
862
- isExecutable(adbBinary()) &&
863
- isExecutable(sdkManagerBinary()) &&
864
- isExecutable(emulatorBinary());
865
-
866
- if (!binariesReady) {
867
- if (this.bootstrapPromise) {
868
- await this.bootstrapPromise;
869
- } else {
870
- this.bootstrapPromise = this.#bootstrapRuntime();
871
- try {
872
- await this.bootstrapPromise;
873
- } finally {
874
- this.bootstrapPromise = null;
875
- }
876
- }
877
- }
878
-
879
- const state = this.#readState();
880
- if (!shouldForceSdkRefresh()) {
881
- const installedImages = parseInstalledSystemImages();
882
- if (installedImages.length > 0) {
883
- const preferredInstalled =
884
- chooseConfiguredSystemImage(installedImages) ||
885
- chooseLatestSystemImage(installedImages);
886
- if (!preferredInstalled) {
887
- throw new Error(formatSystemImageError(installedImages));
888
- }
889
- const stateApiLevel = Number(state.apiLevel || 0) || 0;
890
- const stateNeedsRefresh =
891
- preferredInstalled.packageName !== state.systemImage ||
892
- preferredInstalled.apiLevel !== stateApiLevel ||
893
- preferredInstalled.arch !== state.systemImageArch;
894
- if (stateNeedsRefresh) {
895
- const changeSummary = describeAutoFixChanges(
896
- {
897
- systemImage: state.systemImage || null,
898
- apiLevel: stateApiLevel || null,
899
- systemImageArch: state.systemImageArch || null,
900
- },
901
- {
902
- systemImage: preferredInstalled.packageName,
903
- apiLevel: preferredInstalled.apiLevel,
904
- systemImageArch: preferredInstalled.arch,
905
- },
906
- ['systemImage', 'apiLevel', 'systemImageArch']
907
- );
908
- if (changeSummary) {
909
- console.log(`[Android] Auto-fixed preferred system image (${changeSummary})`);
910
- }
911
- this.#appendState({
912
- bootstrapped: true,
913
- systemImage: preferredInstalled.packageName,
914
- apiLevel: preferredInstalled.apiLevel,
915
- systemImageArch: preferredInstalled.arch,
916
- });
917
- }
918
- return;
919
- } else if (state.bootstrapped === true && state.systemImage) {
920
- return;
921
- }
922
- }
296
+ async close() { await this.stopEmulator().catch(() => {}); }
923
297
 
924
- this.#appendState({ bootstrapped: true });
925
- const sdkmanager = sdkManagerBinary();
926
- const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
927
- const latestSystemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
928
- if (!latestSystemImage) throw new Error(formatSystemImageError(available));
929
-
930
- const refreshedState = this.#readState();
931
- const currentApiLevel = parseApiLevelFromSystemImage(refreshedState.systemImage);
932
- const shouldUpgrade =
933
- refreshedState.systemImage !== latestSystemImage.packageName ||
934
- currentApiLevel < latestSystemImage.apiLevel;
935
-
936
- if (shouldUpgrade) {
937
- await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${latestSystemImage.packageName}"`, {
938
- timeout: 300000,
939
- });
940
- this.#appendState({
941
- bootstrapped: true,
942
- systemImage: latestSystemImage.packageName,
943
- apiLevel: latestSystemImage.apiLevel,
944
- systemImageArch: latestSystemImage.arch,
945
- });
298
+ async waitForDevice(options = {}) {
299
+ const deadline = Date.now() + (options.timeoutMs || 600000);
300
+ while (Date.now() < deadline) {
301
+ const s = await this.getStatus();
302
+ if (s.bootstrapped) return this.adbSerial;
303
+ await new Promise(r => setTimeout(r, 2000));
946
304
  }
305
+ throw new Error('Android emulator did not become ready in time');
947
306
  }
948
307
 
949
- async #bootstrapRuntime() {
950
- const metadata = await fetchText('https://dl.google.com/android/repository/repository2-1.xml');
951
- const url = parseLatestCmdlineToolsUrl(metadata);
952
- const zipPath = path.join(TMP_DIR, path.basename(url));
953
- const extractDir = path.join(TMP_DIR, `cmdline-tools-${Date.now()}`);
954
-
955
- fs.mkdirSync(extractDir, { recursive: true });
956
- await downloadFile(url, zipPath);
957
- extractZip(zipPath, extractDir);
958
-
959
- const candidates = [
960
- path.join(extractDir, 'cmdline-tools'),
961
- path.join(extractDir, 'tools'),
962
- extractDir,
963
- ];
964
- const extractedRoot = candidates.find((candidate) => fs.existsSync(path.join(candidate, 'bin')));
965
- if (!extractedRoot) throw new Error('Downloaded Android command line tools archive did not contain a bin directory');
966
-
967
- fs.rmSync(CMDLINE_LATEST, { recursive: true, force: true });
968
- fs.mkdirSync(CMDLINE_ROOT, { recursive: true });
969
- fs.cpSync(extractedRoot, CMDLINE_LATEST, { recursive: true });
970
- fs.rmSync(zipPath, { force: true });
971
- fs.rmSync(extractDir, { recursive: true, force: true });
972
-
973
- const sdkmanager = sdkManagerBinary();
974
- await this.#run(`yes | ${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --licenses`, { timeout: 300000 });
975
- await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "platform-tools" "emulator"`, { timeout: 300000 });
976
-
977
- const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
978
- const systemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
979
- if (!systemImage) throw new Error(formatSystemImageError(available));
980
-
981
- await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${systemImage.packageName}"`, { timeout: 300000 });
982
- this.#appendState({
983
- bootstrapped: true,
984
- systemImage: systemImage.packageName,
985
- apiLevel: systemImage.apiLevel,
986
- systemImageArch: systemImage.arch,
987
- });
308
+ async listDevices() {
309
+ const s = await this.getStatus();
310
+ return s.bootstrapped ? [{ serial: this.adbSerial, status: 'device', emulator: true }] : [];
988
311
  }
989
312
 
990
- async ensureAvd() {
991
- await this.ensureBootstrapped();
992
-
993
- const state = this.#readState();
994
- const list = await this.#run(`${quoteShell(avdManagerBinary())} list avd`, { timeout: 120000 }).catch(() => '');
995
- const pkg = state.systemImage;
996
- if (!pkg) throw new Error('Android system image not installed');
997
- const avdExists = list.includes(`Name: ${this.avdName}`);
998
- let avdNeedsRecreate = avdExists && (!state.avdSystemImage || state.avdSystemImage !== pkg);
999
- const avdRecreateReasons = [];
1000
- if (avdNeedsRecreate && state.avdSystemImage !== pkg) {
1001
- avdRecreateReasons.push(`systemImage: ${state.avdSystemImage || 'null'} -> ${pkg}`);
1002
- }
1003
- const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1004
- if (avdExists && fs.existsSync(configPath)) {
1005
- try {
1006
- const config = fs.readFileSync(configPath, 'utf8');
1007
- const currentImageDir = readIniValue(config, 'image.sysdir.1');
1008
- const expectedImageDir = systemImagePackageToRelativeDir(pkg);
1009
- const currentAbi = readIniValue(config, 'abi.type');
1010
- const expectedAbi = systemImagePackageToAbi(pkg);
1011
- const currentCpuArch = readIniValue(config, 'hw.cpu.arch');
1012
- const expectedCpuArch = systemImagePackageToCpuArch(pkg);
1013
- if (expectedImageDir && currentImageDir && currentImageDir !== expectedImageDir) {
1014
- avdNeedsRecreate = true;
1015
- avdRecreateReasons.push(`image.sysdir.1: ${currentImageDir} -> ${expectedImageDir}`);
1016
- }
1017
- if (expectedAbi && currentAbi && currentAbi !== expectedAbi) {
1018
- avdNeedsRecreate = true;
1019
- avdRecreateReasons.push(`abi.type: ${currentAbi} -> ${expectedAbi}`);
1020
- }
1021
- if (expectedCpuArch && currentCpuArch && currentCpuArch !== expectedCpuArch) {
1022
- avdNeedsRecreate = true;
1023
- avdRecreateReasons.push(`hw.cpu.arch: ${currentCpuArch} -> ${expectedCpuArch}`);
1024
- }
1025
- } catch {}
1026
- }
1027
-
1028
- if (avdNeedsRecreate) {
1029
- if (avdRecreateReasons.length > 0) {
1030
- console.log(`[Android] Recreating AVD to repair config mismatch (${avdRecreateReasons.join(', ')})`);
1031
- }
1032
- await this.stopEmulator().catch(() => {});
1033
- await this.#run(`${quoteShell(avdManagerBinary())} delete avd -n ${quoteShell(this.avdName)}`, {
1034
- timeout: 120000,
1035
- }).catch(() => {});
1036
- fs.rmSync(path.join(AVD_HOME, `${this.avdName}.avd`), { recursive: true, force: true });
1037
- fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1038
- } else if (avdExists) {
1039
- return;
1040
- }
1041
-
1042
- await this.#run(`printf 'no\\n' | ${quoteShell(avdManagerBinary())} create avd -n ${quoteShell(this.avdName)} -k "${pkg}" --force`, {
1043
- timeout: 120000,
313
+ async ensureBootstrapped() {
314
+ const s = readState(this.userId);
315
+ if (!s.bootstrapped) await this.requestStartEmulator();
316
+ }
317
+
318
+ // ── Shell / ADB ───────────────────────────────────────────────────────────
319
+
320
+ async shell(commandOrObj) {
321
+ const command = typeof commandOrObj === 'string' ? commandOrObj : String(commandOrObj?.command || '');
322
+ const serial = this.#requireSerial();
323
+ const adb = adbBin(this.sdkDir);
324
+ return new Promise((resolve, reject) => {
325
+ const proc = spawn(adb, ['-s', serial, 'shell', command], { encoding: 'utf8' });
326
+ let out = '', err = '';
327
+ proc.stdout?.on('data', d => { out += d; });
328
+ proc.stderr?.on('data', d => { err += d; });
329
+ proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || out || `exit ${code}`)));
330
+ proc.on('error', reject);
1044
331
  });
1045
- this.#normalizeAvdConfig();
1046
- this.#appendState({ avdSystemImage: pkg });
1047
- }
1048
-
1049
- #normalizeAvdConfig() {
1050
- const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1051
- if (!fs.existsSync(configPath)) return;
1052
-
1053
- let content = fs.readFileSync(configPath, 'utf8');
1054
- content = updateIniValue(content, 'disk.dataPartition.size', DEFAULT_DATA_PARTITION);
1055
- content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE);
1056
- content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE);
1057
- fs.writeFileSync(configPath, content);
1058
- }
1059
-
1060
- async listDevices(options = {}) {
1061
- if (options.ensureBootstrapped !== false) {
1062
- await this.ensureBootstrapped();
1063
- }
1064
- if (!isExecutable(adbBinary())) {
1065
- return [];
1066
- }
1067
- const out = await this.#run(`${quoteShell(adbBinary())} devices -l`);
1068
- const lines = out.split('\n').map((line) => line.trim()).filter(Boolean);
1069
- const devices = lines
1070
- .filter((line) => !line.toLowerCase().startsWith('list of devices'))
1071
- .map((line) => {
1072
- const parts = line.split(/\s+/);
1073
- return {
1074
- serial: parts[0] || '',
1075
- status: parts[1] || 'unknown',
1076
- details: parts.slice(2).join(' '),
1077
- emulator: (parts[0] || '').startsWith('emulator-'),
1078
- };
1079
- });
1080
- this.#pruneOwnership(devices);
1081
- return devices;
1082
- }
1083
-
1084
- async getPrimarySerial(options = {}) {
1085
- const state = this.#readState();
1086
- const devices = await this.listDevices(options);
1087
- const owners = this.#readOwnership();
1088
- const canUse = (device) => device.status === 'device' && !this.#isSerialOwnedByAnother(device.serial, owners);
1089
-
1090
- const preferred = state.serial ? devices.find((device) => device.serial === state.serial && canUse(device)) : null;
1091
- if (preferred) {
1092
- this.#claimSerial(preferred.serial);
1093
- return preferred.serial;
1094
- }
1095
-
1096
- const emulator = devices.find((device) => device.emulator && canUse(device));
1097
- if (emulator) {
1098
- this.#claimSerial(emulator.serial);
1099
- this.#appendState({ serial: emulator.serial });
1100
- return emulator.serial;
1101
- }
1102
-
1103
- const online = devices.find((device) => canUse(device));
1104
- if (online) {
1105
- this.#claimSerial(online.serial);
1106
- this.#appendState({ serial: online.serial });
1107
- return online.serial;
1108
- }
1109
-
1110
- return null;
1111
332
  }
1112
333
 
1113
- async #startEmulatorBlocking(options = {}) {
1114
- this.#appendState({
1115
- starting: true,
1116
- startupPhase: 'Preparing Android runtime',
1117
- lastStartError: null,
1118
- startRequestedAt: this.#readState().startRequestedAt || new Date().toISOString(),
1119
- });
1120
- console.log('[Android] Preparing emulator start');
1121
- await this.ensureAvd();
1122
- this.#appendState({
1123
- starting: true,
1124
- startupPhase: 'Checking for an existing Android device',
1125
- lastStartError: null,
1126
- });
1127
- this.#normalizeAvdConfig();
1128
- const serial = await this.getPrimarySerial();
1129
- if (serial) {
1130
- this.#appendState({
1131
- starting: false,
1132
- startupPhase: null,
1133
- serial,
1134
- lastStartError: null,
1135
- lastLogLine: 'Android device already running.',
1136
- });
1137
- return {
1138
- success: true,
1139
- serial,
1140
- reused: true,
1141
- bootstrapped: this.#readState().bootstrapped === true,
1142
- };
1143
- }
1144
-
1145
- const logPath = path.join(LOGS_DIR, `emulator-${Date.now()}.log`);
1146
- const out = fs.openSync(logPath, 'a');
1147
- const args = [
1148
- `@${this.avdName}`,
1149
- '-no-boot-anim',
1150
- '-gpu',
1151
- process.platform === 'darwin' ? 'host' : 'swiftshader_indirect',
1152
- '-netdelay',
1153
- 'none',
1154
- '-netspeed',
1155
- 'full',
1156
- ];
1157
-
1158
- if (options.headless !== false) {
1159
- args.push('-no-window', '-no-audio');
1160
- }
1161
-
1162
- const child = spawn(emulatorBinary(), args, {
1163
- detached: true,
1164
- stdio: ['ignore', out, out],
1165
- env: sdkEnv(),
1166
- });
1167
-
1168
- console.log(`[Android] Emulator process started (pid ${child.pid})`);
1169
- this.#appendState({
1170
- emulatorPid: child.pid,
1171
- avdName: this.avdName,
1172
- logPath,
1173
- starting: true,
1174
- startupPhase: 'Waiting for Android emulator to boot',
1175
- lastStartError: null,
1176
- lastLogLine: 'Android emulator process started. Waiting for boot completion...',
1177
- });
1178
-
1179
- const processExit = new Promise((resolve) => {
1180
- child.once('exit', (code, signal) => {
1181
- resolve({ code, signal });
1182
- });
1183
- child.once('error', (error) => {
1184
- resolve({ code: null, signal: null, error });
1185
- });
1186
- });
1187
-
1188
- child.unref();
1189
-
1190
- const bootResult = await Promise.race([
1191
- this.waitForDevice({ timeoutMs: options.timeoutMs || 240000 }).then((serial) => ({
1192
- serial,
1193
- exited: false,
1194
- })),
1195
- processExit.then((result) => ({
1196
- exited: true,
1197
- ...result,
1198
- })),
1199
- ]);
1200
-
1201
- if (bootResult.exited) {
1202
- const recentLogLines = tailFile(logPath, 12);
1203
- const lastLine =
1204
- bootResult.error?.message ||
1205
- recentLogLines[recentLogLines.length - 1] ||
1206
- `Emulator process exited before boot completed (code ${bootResult.code ?? 'unknown'}, signal ${bootResult.signal ?? 'none'}).`;
1207
- throw new Error(lastLine);
1208
- }
1209
-
1210
- const onlineSerial = bootResult.serial;
1211
- this.#appendState({
1212
- serial: onlineSerial,
1213
- emulatorPid: child.pid,
1214
- starting: false,
1215
- startupPhase: null,
1216
- lastStartError: null,
1217
- lastLogLine: 'Android emulator boot completed.',
334
+ async adb(...args) {
335
+ const state = readState(this.userId);
336
+ const adb = adbBin(this.sdkDir);
337
+ return new Promise((resolve, reject) => {
338
+ const proc = spawn(adb, ['-s', state.adbSerial || this.adbSerial, ...args], { encoding: 'utf8' });
339
+ let out = '', err = '';
340
+ proc.stdout?.on('data', d => { out += d; });
341
+ proc.stderr?.on('data', d => { err += d; });
342
+ proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || `adb ${args[0]} exit ${code}`)));
343
+ proc.on('error', reject);
1218
344
  });
1219
- console.log(`[Android] Emulator ready on ${onlineSerial}`);
1220
-
1221
- return {
1222
- success: true,
1223
- serial: onlineSerial,
1224
- emulatorPid: child.pid,
1225
- logPath,
1226
- };
1227
345
  }
1228
346
 
1229
- async startEmulator(options = {}) {
1230
- if (this.startPromise) {
1231
- await this.startPromise;
1232
- const serial = await this.getPrimarySerial();
1233
- if (!serial) {
1234
- throw new Error(this.#readState().lastStartError || 'Android emulator did not finish starting.');
1235
- }
1236
- return {
1237
- success: true,
1238
- serial,
1239
- reused: false,
1240
- bootstrapped: this.#readState().bootstrapped === true,
1241
- };
1242
- }
347
+ // ── Actions ───────────────────────────────────────────────────────────────
1243
348
 
1244
- return this.#startEmulatorBlocking(options);
349
+ async screenshot(_opts = {}) {
350
+ const serial = this.#requireSerial();
351
+ const r = this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
352
+ if (!r?.length) throw new Error('screencap returned no data');
353
+ return { screenshotPath: this.#saveArtifact(r) };
1245
354
  }
1246
355
 
1247
- async requestStartEmulator(options = {}) {
1248
- const serial = await this.getPrimarySerial({ ensureBootstrapped: false }).catch(() => null);
1249
- if (serial) {
1250
- this.#appendState({
1251
- starting: false,
1252
- startupPhase: null,
1253
- serial,
1254
- lastStartError: null,
1255
- lastLogLine: 'Android device already running.',
1256
- });
1257
- return {
1258
- success: true,
1259
- pending: false,
1260
- serial,
1261
- reused: true,
1262
- bootstrapped: this.#readState().bootstrapped === true,
1263
- };
1264
- }
1265
-
1266
- if (!this.startPromise) {
1267
- const requestedAt = new Date().toISOString();
1268
- this.#appendState({
1269
- starting: true,
1270
- startupPhase: 'Preparing Android runtime',
1271
- lastStartError: null,
1272
- startRequestedAt: requestedAt,
1273
- lastLogLine: 'Android start requested.',
1274
- });
1275
- const startPromise = this.#startEmulatorBlocking(options).catch((err) => {
1276
- const state = this.#readState();
1277
- const recentLogLines = state.logPath ? tailFile(state.logPath, 12) : [];
1278
- const detailedMessage = recentLogLines[recentLogLines.length - 1] || err.message;
1279
- this.#appendState({
1280
- starting: false,
1281
- startupPhase: 'Start failed',
1282
- lastStartError: detailedMessage,
1283
- lastLogLine: detailedMessage,
1284
- });
1285
- console.error('[Android] Emulator start failed:', detailedMessage);
1286
- throw new Error(detailedMessage);
1287
- }).finally(() => {
1288
- if (this.startPromise === startPromise) {
1289
- this.startPromise = null;
1290
- }
1291
- });
1292
- this.startPromise = startPromise;
1293
- startPromise.catch(() => {});
1294
- }
356
+ async observe(_opts = {}) { return this.screenshot(); }
1295
357
 
1296
- const state = this.#readState();
1297
- return {
1298
- success: true,
1299
- pending: true,
1300
- bootstrapped: state.bootstrapped === true,
1301
- starting: true,
1302
- startupPhase: state.startupPhase || 'Preparing Android runtime',
1303
- startRequestedAt: state.startRequestedAt || null,
1304
- logPath: state.logPath || null,
1305
- };
358
+ async tap({ x, y } = {}) {
359
+ await this.shell(`input tap ${Math.round(x)} ${Math.round(y)}`);
360
+ return { success: true, screenshotPath: this.#saveArtifact(this.#adbCapture(this.#requireSerial(), ['exec-out', 'screencap', '-p'])) };
1306
361
  }
1307
362
 
1308
- async waitForDevice(options = {}) {
1309
- const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
1310
- const deadline = Date.now() + timeoutMs;
1311
-
1312
- while (Date.now() < deadline) {
1313
- const serial = await this.getPrimarySerial();
1314
- if (serial) {
1315
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
1316
- const boot = await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop sys.boot_completed`, { timeout: 10000 });
1317
- if ((boot.stdout || '').trim() === '1') {
1318
- return serial;
1319
- }
1320
- }
1321
- await sleep(3000);
1322
- }
1323
-
1324
- throw new Error(`Android emulator did not finish booting within ${timeoutMs} ms`);
1325
- }
1326
-
1327
- async ensureDevice() {
1328
- const serial = await this.getPrimarySerial();
1329
- if (serial) {
1330
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
1331
- return serial;
1332
- }
1333
- const started = await this.startEmulator();
1334
- this.#assertSerialAccess(started.serial, { claimIfUnowned: true });
1335
- return started.serial;
1336
- }
1337
-
1338
- async stopEmulator() {
1339
- const state = this.#readState();
1340
- const serial = await this.getPrimarySerial();
1341
- if (serial) {
1342
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
1343
- await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} emu kill`, { timeout: 15000 });
1344
- }
1345
- if (state.emulatorPid) {
1346
- try { process.kill(state.emulatorPid, 'SIGTERM'); } catch {}
1347
- }
1348
- this.#releaseSerialOwnership(serial);
1349
- this.#releaseSerialOwnership(state.serial);
1350
- this.#appendState({
1351
- serial: null,
1352
- emulatorPid: null,
1353
- starting: false,
1354
- startupPhase: null,
1355
- lastStartError: null,
1356
- lastLogLine: 'Android emulator stopped.',
1357
- });
1358
-
1359
- const deadline = Date.now() + 30000;
1360
- while (Date.now() < deadline) {
1361
- const devices = await this.listDevices().catch(() => []);
1362
- const stillPresent = devices.some((device) => device.emulator && device.status === 'device');
1363
- let pidAlive = false;
1364
- if (state.emulatorPid) {
1365
- try {
1366
- process.kill(state.emulatorPid, 0);
1367
- pidAlive = true;
1368
- } catch {
1369
- pidAlive = false;
1370
- }
1371
- }
1372
- if (!stillPresent && !pidAlive) break;
1373
- await sleep(1000);
1374
- }
1375
-
363
+ async longPress({ x, y, durationMs = 1000 } = {}) {
364
+ await this.shell(`input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${durationMs}`);
1376
365
  return { success: true };
1377
366
  }
1378
367
 
1379
- async #adb(serial, command, options = {}) {
1380
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
1381
- return this.#run(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} ${command}`, options);
368
+ async swipe({ x1, y1, x2, y2, durationMs = 300 } = {}) {
369
+ await this.shell(`input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${durationMs}`);
370
+ return { success: true, screenshotPath: this.#saveArtifact(this.#adbCapture(this.#requireSerial(), ['exec-out', 'screencap', '-p'])) };
1382
371
  }
1383
372
 
1384
- async screenshot(options = {}) {
1385
- const serial = options.serial || await this.ensureDevice();
1386
- let artifactRecord = null;
1387
- let filename = `android_${Date.now()}.png`;
1388
- let fullPath = path.join(SCREENSHOTS_DIR, filename);
1389
- if (this.artifactStore && this.userId != null) {
1390
- artifactRecord = this.artifactStore.allocateFile(this.userId, {
1391
- kind: 'android-screenshot',
1392
- backend: this.runtimeBackend,
1393
- extension: 'png',
1394
- contentType: 'image/png',
1395
- filenameBase: 'android-screenshot',
1396
- metadata: {
1397
- serial,
1398
- },
1399
- });
1400
- fullPath = artifactRecord.storagePath;
1401
- filename = path.basename(fullPath);
1402
- }
1403
- await this.#adb(serial, `exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
1404
- if (artifactRecord) {
1405
- this.artifactStore.finalizeFile(artifactRecord.artifactId, fullPath);
1406
- }
1407
- return {
1408
- success: true,
1409
- serial,
1410
- screenshotPath: artifactRecord ? artifactRecord.url : `/screenshots/${filename}`,
1411
- artifactId: artifactRecord?.artifactId || null,
1412
- fullPath,
1413
- };
373
+ async type({ text, pressEnter } = {}) {
374
+ if (!text) return { success: true };
375
+ // ADB input text encoding: %% = literal %, %s = space.
376
+ const encoded = String(text).replace(/%/g, '%%').replace(/ /g, '%s');
377
+ await this.shell(`input text '${shellEscape(encoded)}'`);
378
+ if (pressEnter) await this.shell('input keyevent KEYCODE_ENTER');
379
+ return { success: true };
1414
380
  }
1415
381
 
1416
- async dumpUi(options = {}) {
1417
- const serial = options.serial || await this.ensureDevice();
1418
- let xml = await this.#adb(serial, 'shell uiautomator dump --compressed /dev/tty', { timeout: 30000 });
1419
- if (!String(xml || '').includes('<hierarchy')) {
1420
- const remote = '/sdcard/neoagent-ui.xml';
1421
- await this.#adb(serial, `shell uiautomator dump --compressed ${quoteShell(remote)}`, { timeout: 30000 });
1422
- xml = await this.#adb(serial, `shell cat ${quoteShell(remote)}`, { timeout: 30000 });
1423
- }
1424
- xml = sanitizeUiXml(xml);
1425
- let artifactRecord = null;
1426
- let filename = `android_ui_${Date.now()}.xml`;
1427
- let fullPath = path.join(UI_DUMPS_DIR, filename);
1428
- if (this.artifactStore && this.userId != null) {
1429
- artifactRecord = this.artifactStore.allocateFile(this.userId, {
1430
- kind: 'android-ui-dump',
1431
- backend: this.runtimeBackend,
1432
- extension: 'xml',
1433
- contentType: 'application/xml',
1434
- filenameBase: 'android-ui',
1435
- metadata: {
1436
- serial,
1437
- },
1438
- });
1439
- fullPath = artifactRecord.storagePath;
1440
- filename = path.basename(fullPath);
1441
- }
1442
- fs.writeFileSync(fullPath, xml);
1443
- if (artifactRecord) {
1444
- this.artifactStore.finalizeFile(artifactRecord.artifactId, fullPath);
1445
- }
1446
-
1447
- const nodes = parseUiDump(xml);
1448
- return {
1449
- success: true,
1450
- serial,
1451
- nodeCount: nodes.length,
1452
- uiDumpPath: artifactRecord ? artifactRecord.url : fullPath,
1453
- uiDumpArtifactId: artifactRecord?.artifactId || null,
1454
- preview: options.includeNodes === false ? undefined : nodes.slice(0, 25).map((node) => summarizeNode(node)),
1455
- xml,
382
+ async pressKey(keyOrObj) {
383
+ const raw = typeof keyOrObj === 'string' ? keyOrObj : (keyOrObj?.key || '');
384
+ const KEY_MAP = {
385
+ back: 'KEYCODE_BACK', home: 'KEYCODE_HOME', app_switch: 'KEYCODE_APP_SWITCH',
386
+ enter: 'KEYCODE_ENTER', del: 'KEYCODE_DEL', escape: 'KEYCODE_ESCAPE',
387
+ menu: 'KEYCODE_MENU', power: 'KEYCODE_POWER',
388
+ volume_up: 'KEYCODE_VOLUME_UP', volume_down: 'KEYCODE_VOLUME_DOWN',
1456
389
  };
390
+ const keycode = KEY_MAP[raw.toLowerCase()] || raw.toUpperCase();
391
+ await this.shell(`input keyevent ${keycode}`);
392
+ return { success: true };
1457
393
  }
1458
394
 
1459
- async #captureObservation(serial, options = {}) {
1460
- const resolvedSerial = serial || await this.ensureDevice();
1461
- const observation = {
1462
- serial: resolvedSerial,
1463
- screenshotPath: null,
1464
- fullPath: null,
1465
- uiDumpPath: null,
1466
- nodeCount: null,
1467
- preview: undefined,
1468
- observationWarnings: [],
1469
- };
1470
-
1471
- if (options.screenshot !== false) {
1472
- try {
1473
- const shot = await this.screenshot({ serial: resolvedSerial });
1474
- observation.screenshotPath = shot?.screenshotPath || null;
1475
- observation.fullPath = shot?.fullPath || null;
1476
- } catch (err) {
1477
- observation.observationWarnings.push(`screenshot: ${err.message}`);
1478
- }
1479
- }
1480
-
1481
- if (options.uiDump !== false) {
1482
- try {
1483
- const dump = await this.dumpUi({
1484
- serial: resolvedSerial,
1485
- includeNodes: options.includeNodes !== false,
1486
- });
1487
- observation.uiDumpPath = dump.uiDumpPath;
1488
- observation.nodeCount = dump.nodeCount;
1489
- observation.preview = dump.preview;
1490
- } catch (err) {
1491
- observation.observationWarnings.push(`ui_dump: ${err.message}`);
1492
- }
1493
- }
1494
-
1495
- if (observation.observationWarnings.length === 0) {
1496
- delete observation.observationWarnings;
1497
- }
1498
-
1499
- return observation;
395
+ async dumpUi(_opts = {}) {
396
+ const serial = this.#requireSerial();
397
+ await this.shell('uiautomator dump /sdcard/window_dump.xml');
398
+ const r = spawnSync(adbBin(this.sdkDir), ['-s', serial, 'shell', 'cat', '/sdcard/window_dump.xml'], { encoding: 'utf8', timeout: 10000 });
399
+ return { xml: r.stdout || '' };
1500
400
  }
1501
401
 
1502
- async observe(options = {}) {
1503
- const serial = options.serial || await this.ensureDevice();
1504
- const observation = await this.#captureObservation(serial, options);
1505
- if (!observation.screenshotPath && !observation.uiDumpPath) {
1506
- throw new Error(
1507
- Array.isArray(observation.observationWarnings) && observation.observationWarnings.length > 0
1508
- ? observation.observationWarnings.join(' | ')
1509
- : 'Unable to capture Android observation',
1510
- );
1511
- }
1512
- return {
1513
- success: true,
1514
- ...observation,
1515
- };
402
+ async listApps({ includeSystem = false } = {}) {
403
+ const out = await this.shell(includeSystem ? 'pm list packages' : 'pm list packages -3');
404
+ const packages = out.trim().split('\n').filter(Boolean).map(l => l.replace('package:', '').trim());
405
+ return { packages };
1516
406
  }
1517
407
 
1518
- async #resolveSelector(args = {}) {
1519
- const dump = await this.dumpUi({ includeNodes: false });
1520
- const selector = {
1521
- text: args.text,
1522
- resourceId: args.resourceId,
1523
- description: args.description,
1524
- className: args.className,
1525
- packageName: args.packageName,
1526
- clickable: args.clickable,
1527
- };
1528
- const node = findBestNode(dump.xml, selector);
1529
- if (!node) throw new Error('No Android UI element matched the selector');
1530
- return {
1531
- serial: dump.serial,
1532
- uiDumpPath: dump.uiDumpPath,
1533
- node,
1534
- };
408
+ async openApp({ packageName } = {}) {
409
+ if (!isSafeIdentifier(packageName)) throw new Error('Invalid package name');
410
+ await this.shell(`monkey -p '${shellEscape(packageName)}' -c android.intent.category.LAUNCHER 1`);
411
+ await new Promise(r => setTimeout(r, 1500));
412
+ return this.screenshot();
1535
413
  }
1536
414
 
1537
- async tap(args = {}) {
1538
- let x = Number(args.x);
1539
- let y = Number(args.y);
1540
- let node = null;
1541
- let serial = await this.ensureDevice();
1542
- let resolvedFromUiDumpPath = null;
1543
-
1544
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
1545
- const resolved = await this.#resolveSelector(args);
1546
- serial = resolved.serial;
1547
- node = resolved.node;
1548
- resolvedFromUiDumpPath = resolved.uiDumpPath;
1549
- x = node.bounds.centerX;
1550
- y = node.bounds.centerY;
415
+ async openIntent({ action, dataUri, extras = {} } = {}) {
416
+ const safeAction = isSafeIdentifier(action) ? action : 'android.intent.action.VIEW';
417
+ let cmd = `am start -a '${shellEscape(safeAction)}'`;
418
+ if (dataUri) cmd += ` -d '${shellEscape(dataUri)}'`;
419
+ for (const [k, v] of Object.entries(extras || {})) {
420
+ if (isSafeIdentifier(k)) cmd += ` --es '${shellEscape(k)}' '${shellEscape(v)}'`;
1551
421
  }
1552
-
1553
- await this.#adb(serial, `shell input tap ${Math.round(x)} ${Math.round(y)}`, { timeout: 15000 });
1554
- const observation = await this.#captureObservation(serial, args);
1555
- return {
1556
- success: true,
1557
- serial,
1558
- x: Math.round(x),
1559
- y: Math.round(y),
1560
- target: summarizeNode(node),
1561
- resolvedFromUiDumpPath,
1562
- ...observation,
1563
- };
422
+ await this.shell(cmd);
423
+ await new Promise(r => setTimeout(r, 2000));
424
+ return this.screenshot();
1564
425
  }
1565
426
 
1566
- async longPress(args = {}) {
1567
- let x = Number(args.x);
1568
- let y = Number(args.y);
1569
- let node = null;
1570
- let serial = await this.ensureDevice();
1571
- let resolvedFromUiDumpPath = null;
1572
-
1573
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
1574
- const resolved = await this.#resolveSelector(args);
1575
- serial = resolved.serial;
1576
- node = resolved.node;
1577
- resolvedFromUiDumpPath = resolved.uiDumpPath;
1578
- x = node.bounds.centerX;
1579
- y = node.bounds.centerY;
1580
- }
1581
-
1582
- const durationMs = Math.max(250, Number(args.durationMs) || 650);
1583
- await this.#adb(
1584
- serial,
1585
- `shell input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${Math.round(durationMs)}`,
1586
- { timeout: Math.max(15000, durationMs + 5000) },
1587
- );
1588
- const observation = await this.#captureObservation(serial, args);
1589
- return {
1590
- success: true,
1591
- serial,
1592
- x: Math.round(x),
1593
- y: Math.round(y),
1594
- durationMs,
1595
- target: summarizeNode(node),
1596
- resolvedFromUiDumpPath,
1597
- ...observation,
1598
- };
427
+ async waitFor({ timeout = 10000 } = {}) {
428
+ const deadline = Date.now() + timeout;
429
+ while (Date.now() < deadline) {
430
+ const s = await this.getStatus();
431
+ if (s.bootstrapped) return { ready: true };
432
+ await new Promise(r => setTimeout(r, 1000));
433
+ }
434
+ return { ready: false };
435
+ }
436
+
437
+ async installApk({ apkPath } = {}) {
438
+ if (!apkPath) throw new Error('apkPath required');
439
+ const serial = this.#requireSerial();
440
+ const adb = adbBin(this.sdkDir);
441
+ return new Promise((resolve, reject) => {
442
+ const proc = spawn(adb, ['-s', serial, 'install', '-r', apkPath]);
443
+ let out = '', err = '';
444
+ proc.stdout?.on('data', d => { out += d; });
445
+ proc.stderr?.on('data', d => { err += d; });
446
+ proc.on('close', code => {
447
+ if (code === 0 && out.includes('Success')) resolve({ success: true, output: out });
448
+ else reject(new Error(err || out || `adb install exit ${code}`));
449
+ });
450
+ proc.on('error', reject);
451
+ });
1599
452
  }
1600
453
 
1601
- async type(args = {}) {
1602
- const serial = await this.ensureDevice();
1603
- if (args.clear === true) {
1604
- await this.#adb(serial, 'shell input keyevent 123', { timeout: 10000 }).catch(() => {});
1605
- await this.#adb(serial, 'shell input keyevent 67', { timeout: 10000 }).catch(() => {});
1606
- }
454
+ // ── Private helpers ───────────────────────────────────────────────────────
1607
455
 
1608
- if (args.selector || args.textSelector || args.resourceId || args.description) {
1609
- await this.tap({
1610
- text: args.textSelector,
1611
- resourceId: args.resourceId,
1612
- description: args.description,
1613
- className: args.className,
1614
- clickable: true,
1615
- screenshot: false,
1616
- uiDump: false,
1617
- }).catch(() => {});
1618
- }
456
+ #requireSerial() {
457
+ const state = readState(this.userId);
458
+ if (!state.adbSerial) throw new Error('No emulator running');
459
+ return state.adbSerial;
460
+ }
1619
461
 
1620
- await this.#adb(serial, `shell input text ${quoteShell(androidTextEscape(args.text || ''))}`, { timeout: 20000 });
1621
- if (args.pressEnter) {
1622
- await this.#adb(serial, 'shell input keyevent 66', { timeout: 10000 });
1623
- }
1624
- const observation = await this.#captureObservation(serial, args);
1625
- return {
1626
- success: true,
1627
- serial,
1628
- typed: args.text || '',
1629
- ...observation,
1630
- };
462
+ #isPidAlive(pid) {
463
+ if (!pid || !Number.isInteger(Number(pid))) return false;
464
+ try { process.kill(Number(pid), 0); return true; } catch { return false; }
1631
465
  }
1632
466
 
1633
- async swipe(args = {}) {
1634
- const serial = await this.ensureDevice();
1635
- const x1 = Number(args.x1);
1636
- const y1 = Number(args.y1);
1637
- const x2 = Number(args.x2);
1638
- const y2 = Number(args.y2);
1639
- const duration = Math.max(50, Number(args.durationMs) || 300);
1640
- if (![x1, y1, x2, y2].every(Number.isFinite)) {
1641
- throw new Error('x1, y1, x2, and y2 are required for android_swipe');
1642
- }
1643
- await this.#adb(serial, `shell input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${Math.round(duration)}`, { timeout: 15000 });
1644
- const observation = await this.#captureObservation(serial, args);
1645
- return {
1646
- success: true,
1647
- serial,
1648
- ...observation,
1649
- };
467
+ #adbCapture(serial, args) {
468
+ const r = spawnSync(adbBin(this.sdkDir), ['-s', serial, ...args], { maxBuffer: 20 * 1024 * 1024, timeout: 15000 });
469
+ return (r.status === 0 && r.stdout?.length) ? r.stdout : null;
1650
470
  }
1651
471
 
1652
- async pressKey(args = {}) {
1653
- const serial = await this.ensureDevice();
1654
- const raw = String(args.key || '').trim().toLowerCase();
1655
- const keyCode = Number.isFinite(Number(raw)) ? Number(raw) : (DEFAULT_KEYEVENTS[raw] || null);
1656
- if (!keyCode) throw new Error(`Unsupported Android key: ${args.key}`);
1657
- await this.#adb(serial, `shell input keyevent ${keyCode}`, { timeout: 10000 });
1658
- const observation = await this.#captureObservation(serial, args);
1659
- return {
1660
- success: true,
1661
- serial,
1662
- key: args.key,
1663
- keyCode,
1664
- ...observation,
1665
- };
472
+ #saveArtifact(data) {
473
+ if (!data || !this.artifactStore) return null;
474
+ const alloc = this.artifactStore.allocateFile(this.userId, { kind: 'screenshot', extension: 'png', contentType: 'image/png' });
475
+ fs.writeFileSync(alloc.storagePath, data);
476
+ const fin = this.artifactStore.finalizeFile(alloc.artifactId, alloc.storagePath);
477
+ return fin.url;
1666
478
  }
1667
479
 
1668
- async waitFor(args = {}) {
1669
- const timeoutMs = Math.max(1000, Number(args.timeoutMs) || 20000);
1670
- const intervalMs = Math.max(250, Number(args.intervalMs) || 1500);
1671
- const deadline = Date.now() + timeoutMs;
480
+ // ── Setup pipeline ────────────────────────────────────────────────────────
1672
481
 
1673
- while (Date.now() < deadline) {
1674
- const dump = await this.dumpUi({ includeNodes: false });
1675
- const node = findBestNode(dump.xml, {
1676
- text: args.text,
1677
- resourceId: args.resourceId,
1678
- description: args.description,
1679
- className: args.className,
1680
- packageName: args.packageName,
1681
- clickable: args.clickable,
482
+ async #resolveAdbPort() {
483
+ const base = (hashCode(this.userId) >>> 0) % ADB_PORT_SLOTS;
484
+ for (let i = 0; i < ADB_PORT_SLOTS; i++) {
485
+ const slot = (base + i) % ADB_PORT_SLOTS;
486
+ const port = ADB_PORT_BASE + slot * 2;
487
+ const free = await new Promise(resolve => {
488
+ const srv = net.createServer();
489
+ srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
490
+ srv.on('error', () => resolve(false));
1682
491
  });
1683
- if (node) {
1684
- const observation = await this.#captureObservation(dump.serial, {
1685
- screenshot: args.screenshot !== false,
1686
- uiDump: args.uiDump !== false,
1687
- includeNodes: args.includeNodes,
1688
- });
1689
- return {
1690
- success: true,
1691
- serial: dump.serial,
1692
- matched: summarizeNode(node),
1693
- matchedFromUiDumpPath: dump.uiDumpPath,
1694
- ...observation,
1695
- };
492
+ if (free) {
493
+ this.adbPort = port;
494
+ this.adbSerial = `emulator-${port}`;
495
+ return;
1696
496
  }
1697
- await sleep(intervalMs);
1698
497
  }
1699
-
1700
- throw new Error(`Timed out after ${timeoutMs} ms waiting for Android UI element`);
498
+ throw new Error(`No free ADB port in range ${ADB_PORT_BASE}–${ADB_PORT_BASE + ADB_PORT_SLOTS * 2}`);
1701
499
  }
1702
500
 
1703
- async openApp(args = {}) {
1704
- const serial = await this.ensureDevice();
1705
- if (args.activity) {
1706
- await this.#adb(serial, `shell am start -n ${quoteShell(`${args.packageName}/${args.activity}`)}`, { timeout: 20000 });
1707
- } else if (args.packageName) {
1708
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
1709
- const resolved = await this.#runAllowFailure(
1710
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER ${quoteShell(args.packageName)}`,
1711
- { timeout: 15000 },
1712
- );
1713
- const component = parseResolvedLaunchComponent(
1714
- `${resolved.stdout || ''}\n${resolved.stderr || ''}`,
1715
- args.packageName,
1716
- );
1717
-
1718
- if (component) {
1719
- await this.#adb(serial, `shell am start -n ${quoteShell(component)}`, { timeout: 20000 });
501
+ async #setup() {
502
+ const progress = msg => {
503
+ console.log(`[Android] ${msg}`);
504
+ writeState(this.userId, { startupPhase: msg });
505
+ };
506
+ try {
507
+ await this.#resolveAdbPort();
508
+ const existing = findExistingSdk();
509
+ if (existing) {
510
+ this.sdkDir = existing;
511
+ progress(`Found existing Android SDK at ${existing}`);
1720
512
  } else {
1721
- await this.#adb(serial, `shell monkey -p ${quoteShell(args.packageName)} -c android.intent.category.LAUNCHER 1`, { timeout: 30000 });
513
+ progress('Downloading Android SDK…');
514
+ await ensureSdk(this.sdkDir, progress);
515
+ await ensurePackages(this.sdkDir, progress);
1722
516
  }
1723
- } else {
1724
- throw new Error('packageName is required for android_open_app');
517
+ ensureAvd(this.sdkDir, this.avdName, progress);
518
+ await this.#startEmulatorProcess(progress);
519
+ } catch (err) {
520
+ console.error(`[Android] Setup failed: ${err.message}`);
521
+ writeState(this.userId, { starting: false, startupPhase: 'Failed', lastStartError: err.message });
1725
522
  }
1726
- const observation = await this.#captureObservation(serial, args);
1727
- return {
1728
- success: true,
1729
- serial,
1730
- packageName: args.packageName,
1731
- activity: args.activity || null,
1732
- ...observation,
1733
- };
1734
523
  }
1735
524
 
1736
- async openIntent(args = {}) {
1737
- const serial = await this.ensureDevice();
1738
- const parts = ['shell am start'];
1739
- if (args.action) parts.push('-a', quoteShell(args.action));
1740
- if (args.dataUri) parts.push('-d', quoteShell(args.dataUri));
1741
- if (args.packageName) parts.push('-p', quoteShell(args.packageName));
1742
- if (args.component) parts.push('-n', quoteShell(args.component));
1743
- if (args.mimeType) parts.push('-t', quoteShell(args.mimeType));
1744
-
1745
- if (args.extras && typeof args.extras === 'object') {
1746
- for (const [key, value] of Object.entries(args.extras)) {
1747
- parts.push('--es', quoteShell(key), quoteShell(String(value)));
1748
- }
1749
- }
525
+ async #startEmulatorProcess(progress) {
526
+ progress('Starting Android emulator…');
527
+ const env = { ...process.env, ANDROID_SDK_ROOT: this.sdkDir, ANDROID_HOME: this.sdkDir };
528
+ const proc = spawn(emulatorBin(this.sdkDir), [
529
+ '-avd', this.avdName,
530
+ '-no-window', '-no-audio', '-no-boot-anim',
531
+ '-port', String(this.adbPort),
532
+ '-gpu', 'swiftshader_indirect',
533
+ '-partition-size', '800',
534
+ ], { env, detached: false, stdio: ['ignore', 'pipe', 'pipe'] });
1750
535
 
1751
- await this.#adb(serial, parts.join(' '), { timeout: 20000 });
1752
- const observation = await this.#captureObservation(serial, args);
1753
- return {
1754
- success: true,
1755
- serial,
1756
- ...observation,
1757
- };
1758
- }
536
+ proc.stdout.on('data', d => console.log(`[Android/emu] ${d.toString().trimEnd()}`));
537
+ proc.stderr.on('data', d => console.log(`[Android/emu] ${d.toString().trimEnd()}`));
538
+ writeState(this.userId, { pid: proc.pid, adbSerial: this.adbSerial });
1759
539
 
1760
- async listApps(args = {}) {
1761
- const serial = await this.ensureDevice();
1762
- const cmd = args.includeSystem === true ? 'shell pm list packages' : 'shell pm list packages -3';
1763
- const out = await this.#adb(serial, cmd, { timeout: 30000 });
1764
- const packages = out
1765
- .split('\n')
1766
- .map((line) => line.trim())
1767
- .filter(Boolean)
1768
- .map((line) => line.replace(/^package:/, ''))
1769
- .sort();
1770
- return {
1771
- success: true,
1772
- serial,
1773
- count: packages.length,
1774
- packages,
1775
- };
1776
- }
540
+ proc.on('exit', code => {
541
+ console.log(`[Android] Emulator exited with code ${code}`);
542
+ writeState(this.userId, { bootstrapped: false, starting: false, pid: null });
543
+ });
1777
544
 
1778
- async installApk(args = {}) {
1779
- const apkPath = path.resolve(String(args.apkPath || ''));
1780
- if (!apkPath || !fs.existsSync(apkPath)) throw new Error(`APK not found: ${apkPath}`);
1781
- const serial = await this.ensureDevice();
1782
- const extension = path.extname(apkPath).toLowerCase();
545
+ progress('Waiting for Android to boot (can take 2–5 min on first run)…');
546
+ await this.#waitForBoot();
1783
547
 
1784
- if (extension === '.aab') {
1785
- throw new Error('.aab app bundles are not directly installable. Export a .apks bundle or .apk first.');
1786
- }
548
+ writeState(this.userId, { bootstrapped: true, starting: false, startupPhase: null, lastStartError: null });
549
+ console.log(`[Android] Emulator ready on ${this.adbSerial}`);
550
+
551
+ // Set wallpaper — best-effort, never fails the boot sequence.
552
+ this.#setWallpaper(this.adbSerial).catch(err => {
553
+ console.warn(`[Android] Wallpaper not set: ${err.message}`);
554
+ });
555
+ }
1787
556
 
1788
- if (extension === '.apks') {
1789
- const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'apk-bundle-'));
557
+ async #waitForBoot(timeoutMs = 10 * 60 * 1000) {
558
+ const adb = adbBin(this.sdkDir);
559
+ const deadline = Date.now() + timeoutMs;
560
+ while (Date.now() < deadline) {
1790
561
  try {
1791
- extractZip(apkPath, extractDir);
1792
- const bundle = resolveBundleInstallTargets(extractDir);
1793
- await this.#adb(serial, `install -r ${quoteShell(bundle.installPaths[0])}`, { timeout: 300000 });
1794
- return {
1795
- success: true,
1796
- serial,
1797
- apkPath,
1798
- artifactType: 'apks',
1799
- installedPaths: bundle.installPaths,
1800
- bundleLayout: bundle.layout,
1801
- };
1802
- } finally {
1803
- fs.rmSync(extractDir, { recursive: true, force: true });
1804
- }
562
+ const r = spawnSync(adb, ['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'], { encoding: 'utf8', timeout: 5000 });
563
+ if (r.stdout?.trim() === '1') return;
564
+ } catch {}
565
+ await new Promise(r => setTimeout(r, 3000));
1805
566
  }
1806
-
1807
- await this.#adb(serial, `install -r ${quoteShell(apkPath)}`, { timeout: 300000 });
1808
- return {
1809
- success: true,
1810
- serial,
1811
- apkPath,
1812
- artifactType: 'apk',
1813
- installedPaths: [apkPath],
1814
- };
567
+ throw new Error('Emulator did not boot within timeout');
1815
568
  }
1816
569
 
1817
- async shell(args = {}) {
1818
- const serial = await this.ensureDevice();
1819
- const command = String(args.command || '').trim();
1820
- if (!command) throw new Error('command is required for android_shell');
1821
-
1822
- const timeout = Math.max(1000, Number(args.timeoutMs) || 20000);
1823
- const stdout = await this.#adb(serial, `shell ${quoteShell(command)}`, { timeout });
1824
- const observation = args.screenshot === true
1825
- ? await this.#captureObservation(serial)
1826
- : null;
1827
- return {
1828
- success: true,
1829
- serial,
1830
- command,
1831
- stdout,
1832
- screenshotPath: observation?.screenshotPath || null,
1833
- fullPath: observation?.fullPath || null,
1834
- uiDumpPath: observation?.uiDumpPath || null,
1835
- nodeCount: observation?.nodeCount,
1836
- preview: observation?.preview,
1837
- };
1838
- }
570
+ async #setWallpaper(serial) {
571
+ if (!fs.existsSync(LOGO_PATH)) return;
572
+ const adb = adbBin(this.sdkDir);
1839
573
 
1840
- async getStatus() {
1841
- const devices = isExecutable(adbBinary())
1842
- ? await this.listDevices({ ensureBootstrapped: false }).catch(() => [])
1843
- : [];
1844
- const state = this.#readState();
1845
- const serialInState = String(state.serial || '').trim();
1846
- const serialOwnedByCurrentUser = serialInState
1847
- ? !this.#isSerialOwnedByAnother(serialInState)
1848
- : null;
1849
- let lastLogLine =
1850
- state.lastStartError ||
1851
- state.lastLogLine ||
1852
- state.startupPhase ||
1853
- null;
1854
- if (state.logPath && fs.existsSync(state.logPath)) {
1855
- try {
1856
- const lines = fs.readFileSync(state.logPath, 'utf8')
1857
- .split('\n')
1858
- .map((line) => line.trim())
1859
- .filter(Boolean);
1860
- const emulatorLogLine = [...lines].reverse().find((line) =>
1861
- /fatal|error|warning|boot completed|disk space|running avd/i.test(line)
1862
- ) || lines[lines.length - 1] || null;
1863
- lastLogLine = emulatorLogLine || lastLogLine;
1864
- } catch {
1865
- lastLogLine =
1866
- state.lastStartError ||
1867
- state.lastLogLine ||
1868
- state.startupPhase ||
1869
- null;
1870
- }
574
+ // Try to gain root access (works on AOSP default images).
575
+ spawnSync(adb, ['-s', serial, 'root'], { timeout: 5000 });
576
+ await new Promise(r => setTimeout(r, 1500));
577
+
578
+ // Push PNG to device sdcard.
579
+ const push = spawnSync(adb, ['-s', serial, 'push', LOGO_PATH, '/sdcard/neoagent-wallpaper.png'], { timeout: 15000 });
580
+ if (push.status !== 0) throw new Error('adb push logo failed');
581
+
582
+ // cmd wallpaper set-stream reads PNG from stdin (Android 7.1+).
583
+ const logoData = fs.readFileSync(LOGO_PATH);
584
+ const r = spawnSync(adb, ['-s', serial, 'shell', 'cmd', 'wallpaper', 'set-stream'], {
585
+ input: logoData, timeout: 15000,
586
+ });
587
+ if (r.status === 0) {
588
+ console.log('[Android] Wallpaper set');
589
+ return;
1871
590
  }
1872
- return {
1873
- bootstrapped: state.bootstrapped === true,
1874
- starting: state.starting === true || this.startPromise != null,
1875
- startupPhase: state.startupPhase || null,
1876
- startRequestedAt: state.startRequestedAt || null,
1877
- lastStartError: state.lastStartError || null,
1878
- sdkRoot: SDK_ROOT,
1879
- avdHome: AVD_HOME,
1880
- avdName: this.avdName,
1881
- adbPath: adbBinary(),
1882
- emulatorPath: emulatorBinary(),
1883
- serial: state.serial,
1884
- serialOwnedByCurrentUser,
1885
- emulatorPid: state.emulatorPid,
1886
- systemImage: state.systemImage || null,
1887
- systemImageArch: state.systemImageArch || null,
1888
- preferredSystemImageArchs: systemImageArchCandidates(),
1889
- configuredSystemImagePackage: configuredSystemImagePackage(),
1890
- configuredSystemImagePlatform: configuredSystemImagePlatform(),
1891
- apiLevel: Number(state.apiLevel || 0) || null,
1892
- avdSystemImage: state.avdSystemImage || null,
1893
- logPath: state.logPath || null,
1894
- lastLogLine,
1895
- devices,
1896
- canBootstrap: process.platform === 'darwin' || process.platform === 'linux',
1897
- };
1898
- }
1899
591
 
1900
- async close() {
1901
- AndroidController.cleanupControllers.delete(this);
1902
- return this.stopEmulator().catch(() => {});
592
+ // Fallback: direct file copy for rooted images (Android 11 AOSP).
593
+ spawnSync(adb, ['-s', serial, 'shell', 'cp /sdcard/neoagent-wallpaper.png /data/system/users/0/wallpaper'], { timeout: 5000 });
594
+ spawnSync(adb, ['-s', serial, 'shell', 'chmod 600 /data/system/users/0/wallpaper'], { timeout: 5000 });
595
+ spawnSync(adb, ['-s', serial, 'shell', 'chown system:system /data/system/users/0/wallpaper'], { timeout: 5000 });
596
+ spawnSync(adb, ['-s', serial, 'shell', 'am broadcast -a android.intent.action.WALLPAPER_CHANGED'], { timeout: 5000 });
597
+ console.log('[Android] Wallpaper set via direct copy');
1903
598
  }
1904
599
  }
1905
600
 
1906
- module.exports = {
1907
- AndroidController,
1908
- androidTextEscape,
1909
- chooseConfiguredSystemImage,
1910
- chooseLatestSystemImage,
1911
- configuredSystemImagePackage,
1912
- configuredSystemImagePlatform,
1913
- formatSystemImageError,
1914
- parseResolvedLaunchComponent,
1915
- parseLatestCmdlineToolsUrl,
1916
- parseSystemImages,
1917
- sanitizeUiXml,
1918
- systemImageArchCandidates,
1919
- };
601
+ module.exports = { AndroidController };