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.
- package/.env.example +63 -5
- package/README.md +16 -5
- package/com.neoagent.plist +1 -1
- package/docs/automation.md +82 -41
- package/docs/capabilities.md +85 -89
- package/docs/configuration.md +89 -87
- package/docs/getting-started.md +46 -27
- package/docs/hardware.md +1 -1
- package/docs/index.md +24 -30
- package/docs/integrations.md +46 -60
- package/docs/migration.md +41 -146
- package/docs/operations.md +23 -20
- package/docs/skills.md +20 -20
- package/docs/why-neoagent.md +15 -13
- package/extensions/chrome-browser/background.mjs +10 -2
- package/flutter_app/.metadata +42 -0
- package/flutter_app/DESIGN_SYSTEM.md +166 -0
- package/flutter_app/README.md +21 -0
- package/flutter_app/analysis_options.yaml +32 -0
- package/flutter_app/android/app/build.gradle.kts +111 -0
- package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +185 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +888 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/auto/NeoAgentCarAppService.kt +114 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnection.kt +118 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnectionService.kt +86 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
- package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
- package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
- package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/values/arrays.xml +8 -0
- package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
- package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/xml/automotive_app_desc.xml +4 -0
- package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
- package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
- package/flutter_app/android/build.gradle.kts +24 -0
- package/flutter_app/android/ci-release.keystore +0 -0
- package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
- package/flutter_app/android/gradle.properties +3 -0
- package/flutter_app/android/key.properties +4 -0
- package/flutter_app/android/settings.gradle.kts +26 -0
- package/flutter_app/assets/branding/app_icon_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_64.png +0 -0
- package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
- package/flutter_app/assets/branding/tray_icon_template.png +0 -0
- package/flutter_app/lib/features/location/location_service.dart +117 -0
- package/flutter_app/lib/features/notifications/notification_interceptor.dart +73 -0
- package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +571 -0
- package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +275 -0
- package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +366 -0
- package/flutter_app/lib/features/onboarding/onboarding_shell.dart +57 -0
- package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +218 -0
- package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +46 -0
- package/flutter_app/lib/main.dart +107 -0
- package/flutter_app/lib/main_account_settings.dart +1340 -0
- package/flutter_app/lib/main_admin.dart +900 -0
- package/flutter_app/lib/main_app_shell.dart +2162 -0
- package/flutter_app/lib/main_chat.dart +3902 -0
- package/flutter_app/lib/main_controller.dart +7229 -0
- package/flutter_app/lib/main_devices.dart +1738 -0
- package/flutter_app/lib/main_integrations.dart +917 -0
- package/flutter_app/lib/main_launcher.dart +959 -0
- package/flutter_app/lib/main_launcher_entry.dart +5 -0
- package/flutter_app/lib/main_models.dart +3807 -0
- package/flutter_app/lib/main_navigation.dart +206 -0
- package/flutter_app/lib/main_operations.dart +5249 -0
- package/flutter_app/lib/main_recordings.dart +920 -0
- package/flutter_app/lib/main_runtime.dart +869 -0
- package/flutter_app/lib/main_settings.dart +2288 -0
- package/flutter_app/lib/main_shared.dart +3393 -0
- package/flutter_app/lib/main_spacing.dart +33 -0
- package/flutter_app/lib/main_theme.dart +276 -0
- package/flutter_app/lib/main_unified.dart +388 -0
- package/flutter_app/lib/main_voice_assistant.dart +957 -0
- package/flutter_app/lib/src/analytics_service.dart +294 -0
- package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
- package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
- package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
- package/flutter_app/lib/src/android_app_installer.dart +22 -0
- package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
- package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
- package/flutter_app/lib/src/android_auto_bridge.dart +59 -0
- package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
- package/flutter_app/lib/src/app_launch_bridge.dart +58 -0
- package/flutter_app/lib/src/app_release_updater.dart +511 -0
- package/flutter_app/lib/src/backend_client.dart +1870 -0
- package/flutter_app/lib/src/desktop_companion.dart +2 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
- package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
- package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
- package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
- package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
- package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
- package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
- package/flutter_app/lib/src/health_bridge.dart +136 -0
- package/flutter_app/lib/src/live_voice_capture.dart +85 -0
- package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
- package/flutter_app/lib/src/network/app_http_client.dart +53 -0
- package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
- package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
- package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
- package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
- package/flutter_app/lib/src/oauth_launcher.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_io.dart +95 -0
- package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
- package/flutter_app/lib/src/recording_bridge.dart +232 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
- package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
- package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
- package/flutter_app/lib/src/recording_payloads.dart +86 -0
- package/flutter_app/lib/src/theme/palette.dart +81 -0
- package/flutter_app/lib/src/web_app_update_monitor.dart +17 -0
- package/flutter_app/lib/src/web_app_update_monitor_stub.dart +24 -0
- package/flutter_app/lib/src/web_app_update_monitor_web.dart +123 -0
- package/flutter_app/lib/src/widget_bridge.dart +49 -0
- package/flutter_app/linux/CMakeLists.txt +128 -0
- package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.cc +47 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/linux/flutter/generated_plugins.cmake +32 -0
- package/flutter_app/linux/runner/CMakeLists.txt +26 -0
- package/flutter_app/linux/runner/main.cc +6 -0
- package/flutter_app/linux/runner/my_application.cc +144 -0
- package/flutter_app/linux/runner/my_application.h +18 -0
- package/flutter_app/linux/runner/resources/app_icon.png +0 -0
- package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
- package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +48 -0
- package/flutter_app/macos/Podfile +42 -0
- package/flutter_app/macos/Podfile.lock +87 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
- package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
- package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
- package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
- package/flutter_app/macos/Runner/Info.plist +36 -0
- package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
- package/flutter_app/macos/Runner/Release.entitlements +12 -0
- package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
- package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
- package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
- package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
- package/flutter_app/patch_strings.py +12 -0
- package/flutter_app/pubspec.lock +1256 -0
- package/flutter_app/pubspec.yaml +59 -0
- package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
- package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
- package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
- package/flutter_app/tool/generate_desktop_branding.py +219 -0
- package/flutter_app/web/favicon.png +0 -0
- package/flutter_app/web/favicon.svg +12 -0
- package/flutter_app/web/icons/Icon-192.png +0 -0
- package/flutter_app/web/icons/Icon-512.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
- package/flutter_app/web/index.html +39 -0
- package/flutter_app/web/manifest.json +35 -0
- package/flutter_app/windows/CMakeLists.txt +108 -0
- package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.cc +50 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/windows/flutter/generated_plugins.cmake +36 -0
- package/flutter_app/windows/runner/CMakeLists.txt +41 -0
- package/flutter_app/windows/runner/Runner.rc +121 -0
- package/flutter_app/windows/runner/flutter_window.cpp +533 -0
- package/flutter_app/windows/runner/flutter_window.h +37 -0
- package/flutter_app/windows/runner/main.cpp +53 -0
- package/flutter_app/windows/runner/resource.h +16 -0
- package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
- package/flutter_app/windows/runner/runner.exe.manifest +14 -0
- package/flutter_app/windows/runner/utils.cpp +65 -0
- package/flutter_app/windows/runner/utils.h +19 -0
- package/flutter_app/windows/runner/win32_window.cpp +299 -0
- package/flutter_app/windows/runner/win32_window.h +102 -0
- package/lib/install_helpers.js +31 -0
- package/lib/manager.js +465 -43
- package/package.json +11 -3
- package/runtime/paths.js +137 -0
- package/server/config/analytics.js +30 -0
- package/server/db/database.js +363 -21
- package/server/guest-agent.android.package.json +13 -0
- package/server/guest-agent.browser.package.json +14 -0
- package/server/guest_agent.js +98 -52
- package/server/http/middleware.js +55 -2
- package/server/http/routes.js +6 -1
- package/server/http/static.js +41 -1
- package/server/index.js +16 -16
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/AssetManifest.bin +1 -1
- package/server/public/assets/AssetManifest.bin.json +1 -1
- package/server/public/assets/AssetManifest.json +1 -1
- package/server/public/assets/NOTICES +679 -129
- package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/assets/packages/mixpanel_flutter/assets/mixpanel.js +3 -0
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +95866 -87333
- package/server/routes/account.js +26 -1
- package/server/routes/agents.js +6 -2
- package/server/routes/android.js +22 -6
- package/server/routes/auth.js +25 -5
- package/server/routes/browser.js +47 -13
- package/server/routes/integrations.js +119 -9
- package/server/routes/memory.js +179 -8
- package/server/routes/runtime.js +14 -0
- package/server/routes/screenHistory.js +46 -0
- package/server/routes/settings.js +102 -24
- package/server/routes/skills.js +12 -0
- package/server/routes/social_video.js +66 -0
- package/server/routes/tasks.js +22 -2
- package/server/routes/triggers.js +90 -0
- package/server/routes/wearable.js +67 -0
- package/server/routes/widgets.js +4 -4
- package/server/services/ai/capabilityHealth.js +23 -51
- package/server/services/ai/compaction.js +1 -1
- package/server/services/ai/deliverables/artifact_helpers.js +190 -0
- package/server/services/ai/deliverables/contracts.js +113 -0
- package/server/services/ai/deliverables/index.js +20 -0
- package/server/services/ai/deliverables/selector.js +94 -0
- package/server/services/ai/deliverables/validator.js +63 -0
- package/server/services/ai/deliverables/workflows.js +207 -0
- package/server/services/ai/engine.js +372 -27
- package/server/services/ai/hooks.js +127 -0
- package/server/services/ai/integrated_tools/index.js +98 -0
- package/server/services/ai/integrated_tools/remotion.js +373 -0
- package/server/services/ai/integrated_tools/shared.js +204 -0
- package/server/services/ai/integrated_tools/slidev.js +207 -0
- package/server/services/ai/loopPolicy.js +146 -0
- package/server/services/ai/models.js +36 -0
- package/server/services/ai/providers/githubCopilot.js +97 -0
- package/server/services/ai/providers/openai.js +2 -1
- package/server/services/ai/providers/openaiCodex.js +384 -0
- package/server/services/ai/recordingInsights.js +11 -13
- package/server/services/ai/runEvents.js +100 -0
- package/server/services/ai/settings.js +20 -0
- package/server/services/ai/systemPrompt.js +16 -2
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/toolResult.js +20 -0
- package/server/services/ai/toolRunner.js +46 -6
- package/server/services/ai/toolSelector.js +54 -1
- package/server/services/ai/tools.js +360 -127
- package/server/services/android/android_bootstrap_worker.js +63 -0
- package/server/services/android/controller.js +442 -1760
- package/server/services/bootstrap_helpers.js +0 -34
- package/server/services/browser/controller.js +326 -52
- package/server/services/cli/executor.js +40 -1
- package/server/services/desktop/screenRecorder.js +182 -0
- package/server/services/integrations/env.js +10 -0
- package/server/services/integrations/figma/provider.js +1 -0
- package/server/services/integrations/github/common.js +106 -0
- package/server/services/integrations/github/provider.js +499 -0
- package/server/services/integrations/github/repos.js +1124 -0
- package/server/services/integrations/google/provider.js +1 -0
- package/server/services/integrations/manager.js +88 -12
- package/server/services/integrations/microsoft/provider.js +1 -0
- package/server/services/integrations/oauth_provider.js +25 -8
- package/server/services/integrations/provider_config_store.js +85 -0
- package/server/services/integrations/registry.js +8 -2
- package/server/services/integrations/spotify/provider.js +488 -0
- package/server/services/integrations/trello/provider.js +842 -0
- package/server/services/integrations/weather/provider.js +559 -0
- package/server/services/integrations/whatsapp/provider.js +6 -2
- package/server/services/manager.js +109 -154
- package/server/services/mcp/client.js +120 -23
- package/server/services/memory/llm_transfer.js +218 -0
- package/server/services/memory/manager.js +299 -35
- package/server/services/messaging/access_policy.js +10 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/manager.js +93 -8
- package/server/services/messaging/meshtastic.js +277 -0
- package/server/services/messaging/meshtastic_env.js +40 -0
- package/server/services/messaging/meshtastic_protocol.js +530 -0
- package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
- package/server/services/runtime/backends/local-vm.js +221 -158
- package/server/services/runtime/docker-vm-manager.js +392 -0
- package/server/services/runtime/guest_bootstrap.js +654 -0
- package/server/services/runtime/manager.js +85 -29
- package/server/services/runtime/settings.js +22 -31
- package/server/services/runtime/validation.js +16 -31
- package/server/services/skills/base_catalog.js +33 -0
- package/server/services/social_video/adapters/base.js +26 -0
- package/server/services/social_video/adapters/index.js +27 -0
- package/server/services/social_video/adapters/instagram.js +17 -0
- package/server/services/social_video/adapters/tiktok.js +17 -0
- package/server/services/social_video/adapters/x.js +17 -0
- package/server/services/social_video/adapters/youtube.js +17 -0
- package/server/services/social_video/captions.js +187 -0
- package/server/services/social_video/frame.js +42 -0
- package/server/services/social_video/index.js +7 -0
- package/server/services/social_video/metadata.js +63 -0
- package/server/services/social_video/result.js +63 -0
- package/server/services/social_video/service.js +720 -0
- package/server/services/social_video/url.js +83 -0
- package/server/services/tasks/adapters/index.js +2 -0
- package/server/services/tasks/adapters/manual.js +12 -0
- package/server/services/tasks/adapters/schedule.js +33 -5
- package/server/services/tasks/adapters/weather_event.js +84 -0
- package/server/services/tasks/integration_runtime.js +86 -1
- package/server/services/tasks/runtime.js +3 -3
- package/server/services/tasks/task_repository.js +3 -6
- package/server/services/voice/agentBridge.js +20 -4
- package/server/services/voice/message.js +3 -0
- package/server/services/voice/openaiClient.js +4 -1
- package/server/services/voice/openaiSpeech.js +6 -1
- package/server/services/voice/providers.js +144 -15
- package/server/services/voice/runtime.js +8 -8
- package/server/services/voice/runtimeManager.js +292 -20
- package/server/services/voice/turnRunner.js +29 -9
- package/server/services/wearable/firmware_manifest.js +353 -0
- package/server/services/wearable/gateway.js +350 -0
- package/server/services/wearable/protocol.js +45 -0
- package/server/services/wearable/service.js +240 -0
- package/server/services/websocket.js +3 -1
- package/server/services/widgets/focus_widget.js +137 -0
- package/server/services/widgets/service.js +182 -6
- package/server/services/workspace/manager.js +301 -0
- package/server/utils/deployment.js +16 -9
- package/server/utils/local_secrets.js +56 -0
- package/server/utils/logger.js +37 -9
- package/server/services/integrations/home_assistant/provider.js +0 -350
- package/server/services/messaging/access_policy.test.js +0 -228
- package/server/services/runtime/backends/host.js +0 -37
- 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
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
146
|
-
}
|
|
28
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
147
29
|
|
|
148
|
-
|
|
149
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
150
|
-
}
|
|
30
|
+
// ─── State persistence ───────────────────────────────────────────────────────
|
|
151
31
|
|
|
152
|
-
function
|
|
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
|
|
165
|
-
|
|
166
|
-
return
|
|
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
|
|
170
|
-
const
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
return String(process.env.ANDROID_SYSTEM_IMAGE_PLATFORM || '').trim() || null;
|
|
237
|
-
}
|
|
74
|
+
// ─── System image selection ──────────────────────────────────────────────────
|
|
238
75
|
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
|
|
76
|
+
function pickSystemImage(sdkDir) {
|
|
77
|
+
const siRoot = path.join(sdkDir, 'system-images');
|
|
78
|
+
if (!fs.existsSync(siRoot)) return null;
|
|
242
79
|
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
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
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
https.get(
|
|
344
|
-
if (res.statusCode
|
|
345
|
-
|
|
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.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
425
|
-
const
|
|
426
|
-
const
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
597
|
-
const
|
|
598
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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.
|
|
683
|
-
this.
|
|
684
|
-
|
|
685
|
-
this.
|
|
686
|
-
this.
|
|
687
|
-
this.
|
|
688
|
-
this.
|
|
689
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
writeOwnershipUnlocked(owners);
|
|
727
|
-
});
|
|
728
|
-
}
|
|
241
|
+
getStatusSync() { return readState(this.userId); }
|
|
729
242
|
|
|
730
|
-
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
817
|
-
const state = this.#readState();
|
|
818
|
-
const serial = state.serial;
|
|
271
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
819
272
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
|
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
|
-
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
950
|
-
const
|
|
951
|
-
|
|
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
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
|
1114
|
-
this
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
|
1309
|
-
|
|
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
|
|
1380
|
-
this
|
|
1381
|
-
return this.#
|
|
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
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (
|
|
1390
|
-
|
|
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
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
|
1503
|
-
const
|
|
1504
|
-
const
|
|
1505
|
-
|
|
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
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
|
1538
|
-
|
|
1539
|
-
let
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
|
1554
|
-
|
|
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
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
-
|
|
1621
|
-
if (
|
|
1622
|
-
|
|
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
|
-
|
|
1634
|
-
const
|
|
1635
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
const
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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 (
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
|
1704
|
-
const
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
513
|
+
progress('Downloading Android SDK…');
|
|
514
|
+
await ensureSdk(this.sdkDir, progress);
|
|
515
|
+
await ensurePackages(this.sdkDir, progress);
|
|
1722
516
|
}
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
|
1737
|
-
|
|
1738
|
-
const
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
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
|
-
|
|
1779
|
-
|
|
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
|
-
|
|
1785
|
-
|
|
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
|
-
|
|
1789
|
-
|
|
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
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
|
1818
|
-
|
|
1819
|
-
const
|
|
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
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
const
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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 };
|