From b90181cc17b297c4e796fb50705824c249029f42 Mon Sep 17 00:00:00 2001 From: dungngminh Date: Thu, 22 Oct 2026 00:14:04 +0700 Subject: [PATCH 1/3] feat: pick Android emulator camera source (webcam, image, video) Emulator -camera-back/-camera-front from the app's device menu (Camera, next boot), CLI --camera-back/--camera-front, and MCP start_device cameraBack/cameraFront. simutil_adb 1.4.0 adds emulatorCameraArgs and AndroidDeviceService.listWebcams. --- app/CHANGELOG.md | 9 ++ app/lib/src/devices/devices_cubit.dart | 7 +- app/lib/src/mcp/tools/device_tools.dart | 14 +++- .../src/settings/device_settings_cubit.dart | 18 ++++ app/lib/src/ui/shared/device_actions.dart | 23 +++++- .../src/ui/shared/device_context_menu.dart | 82 ++++++++++++++++++- app/pubspec.yaml | 2 +- app/test/desktop_settings_store_test.dart | 2 + packages/simutil/CHANGELOG.md | 4 + packages/simutil/lib/src/cli/cli_catalog.dart | 15 ++++ .../lib/src/cli/cli_device_services.dart | 8 +- .../lib/src/cli/commands/launch_command.dart | 24 ++++++ packages/simutil/pubspec.yaml | 2 +- .../test/cli/cli_device_services_test.dart | 8 ++ packages/simutil_adb/CHANGELOG.md | 8 ++ packages/simutil_adb/lib/simutil_adb.dart | 1 + .../lib/src/android_device_service.dart | 16 ++++ .../lib/src/models/emulator_camera.dart | 35 ++++++++ packages/simutil_adb/pubspec.yaml | 2 +- .../test/models/emulator_camera_test.dart | 55 +++++++++++++ .../services/android_device_service_test.dart | 21 +++++ 21 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 packages/simutil_adb/lib/src/models/emulator_camera.dart create mode 100644 packages/simutil_adb/test/models/emulator_camera_test.dart diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index e3565c6..c7bbd0f 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the SimUtil desktop app will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Pick what an Android emulator's back and front cameras see from the + device menu (Camera): your Mac's webcam, an image or a video file. It + applies the next time the emulator starts, and agents can set it over + MCP too. + ## [0.4.0] ### Added diff --git a/app/lib/src/devices/devices_cubit.dart b/app/lib/src/devices/devices_cubit.dart index 73d7757..502b12c 100644 --- a/app/lib/src/devices/devices_cubit.dart +++ b/app/lib/src/devices/devices_cubit.dart @@ -112,12 +112,14 @@ class DevicesCubit extends CubitSignal { } /// Boots an emulator/simulator; [headless] skips its window, [coldBoot] - /// skips the Android snapshot, [noAudio] boots an Android emulator mute. + /// skips the Android snapshot, [noAudio] boots an Android emulator mute, + /// [cameraArgs] (from `emulatorCameraArgs`) pick its camera sources. Future launch( Device device, { bool headless = false, bool coldBoot = false, bool noAudio = false, + List cameraArgs = const [], }) async { if (device.type.isPhysical || device.state != DeviceState.shutdown) return; _setBusy(device.id, true, 'Starting ${device.name}…'); @@ -139,6 +141,7 @@ class DevicesCubit extends CubitSignal { additionalArgs: [ if (coldBoot && android) '-no-snapshot-load', if (noAudio && android) '-no-audio', + if (android) ...cameraArgs, ], ); unawaited( @@ -165,6 +168,7 @@ class DevicesCubit extends CubitSignal { bool headless = false, bool coldBoot = false, bool noAudio = false, + List cameraArgs = const [], }) async { if (device.type.isPhysical || device.state != DeviceState.booted) return; _setBusy(device.id, true, 'Restarting ${device.name}…'); @@ -182,6 +186,7 @@ class DevicesCubit extends CubitSignal { headless: headless, coldBoot: coldBoot, noAudio: noAudio, + cameraArgs: cameraArgs, ); } diff --git a/app/lib/src/mcp/tools/device_tools.dart b/app/lib/src/mcp/tools/device_tools.dart index 313398f..7f0470d 100644 --- a/app/lib/src/mcp/tools/device_tools.dart +++ b/app/lib/src/mcp/tools/device_tools.dart @@ -73,17 +73,28 @@ List deviceTools(ToolContext c) => [ description: 'Boot an emulator/simulator. headless (default true) skips its own ' 'window and streams it into SimUtil. Android emulators: coldBoot ' - 'skips the snapshot, noAudio boots muted (both default false).', + 'skips the snapshot, noAudio boots muted (both default false); ' + 'cameraBack / cameraFront pick a camera source: webcam (host ' + 'camera), image:, video:, virtualscene, emulated or none ' + '(default: the one picked in the device menu).', properties: { ...deviceArg, 'headless': {'type': 'boolean'}, 'coldBoot': {'type': 'boolean'}, 'noAudio': {'type': 'boolean'}, + 'cameraBack': {'type': 'string'}, + 'cameraFront': {'type': 'string'}, }, required: ['device'], handler: (args) async { final device = c.find(args); final options = _launchOptions(device); + final cameraArgs = device.os == DeviceOs.android + ? emulatorCameraArgs( + back: args['cameraBack'] as String? ?? options.cameraBack, + front: args['cameraFront'] as String? ?? options.cameraFront, + ) + : const []; final headless = args['headless'] as bool? ?? options.headless ?? true; if (headless) c.streams.openWhenBooted(device); await c.devices.launch( @@ -91,6 +102,7 @@ List deviceTools(ToolContext c) => [ headless: headless, coldBoot: args['coldBoot'] as bool? ?? false, noAudio: args['noAudio'] as bool? ?? false, + cameraArgs: cameraArgs, ); return c.text('Starting ${device.name}'); }, diff --git a/app/lib/src/settings/device_settings_cubit.dart b/app/lib/src/settings/device_settings_cubit.dart index 422ebe1..e9dabe2 100644 --- a/app/lib/src/settings/device_settings_cubit.dart +++ b/app/lib/src/settings/device_settings_cubit.dart @@ -15,6 +15,8 @@ final class DeviceSettings extends Equatable { this.stayAwake = true, this.turnScreenOff = false, this.powerOffOnClose = false, + this.cameraBack, + this.cameraFront, }); /// Reads what [toJson] wrote; missing or invalid fields keep defaults. @@ -31,6 +33,8 @@ final class DeviceSettings extends Equatable { turnScreenOff: readField(json, 'turnScreenOff') ?? d.turnScreenOff, powerOffOnClose: readField(json, 'powerOffOnClose') ?? d.powerOffOnClose, + cameraBack: readField(json, 'cameraBack'), + cameraFront: readField(json, 'cameraFront'), ); } @@ -61,6 +65,12 @@ final class DeviceSettings extends Equatable { /// scrcpy: turn the screen off when the stream closes. final bool powerOffOnClose; + /// Android emulator back / front camera source for the next boot (an + /// `emulatorCameraArgs` source such as `webcam0` or `image:`); + /// null keeps the AVD's own setting. + final String? cameraBack; + final String? cameraFront; + DeviceSettings copyWith({ bool? Function()? headless, int? maxSize, @@ -71,6 +81,8 @@ final class DeviceSettings extends Equatable { bool? stayAwake, bool? turnScreenOff, bool? powerOffOnClose, + String? Function()? cameraBack, + String? Function()? cameraFront, }) => DeviceSettings( headless: headless != null ? headless() : this.headless, maxSize: maxSize ?? this.maxSize, @@ -81,6 +93,8 @@ final class DeviceSettings extends Equatable { stayAwake: stayAwake ?? this.stayAwake, turnScreenOff: turnScreenOff ?? this.turnScreenOff, powerOffOnClose: powerOffOnClose ?? this.powerOffOnClose, + cameraBack: cameraBack != null ? cameraBack() : this.cameraBack, + cameraFront: cameraFront != null ? cameraFront() : this.cameraFront, ); Map toJson() => { @@ -93,6 +107,8 @@ final class DeviceSettings extends Equatable { 'stayAwake': stayAwake, 'turnScreenOff': turnScreenOff, 'powerOffOnClose': powerOffOnClose, + 'cameraBack': cameraBack, + 'cameraFront': cameraFront, }; @override @@ -106,6 +122,8 @@ final class DeviceSettings extends Equatable { stayAwake, turnScreenOff, powerOffOnClose, + cameraBack, + cameraFront, ]; } diff --git a/app/lib/src/ui/shared/device_actions.dart b/app/lib/src/ui/shared/device_actions.dart index a4f12c5..9c16067 100644 --- a/app/lib/src/ui/shared/device_actions.dart +++ b/app/lib/src/ui/shared/device_actions.dart @@ -1,5 +1,6 @@ import 'package:bloc_signals_flutter/bloc_signals_flutter.dart'; import 'package:flutter/widgets.dart'; +import 'package:simutil_adb/simutil_adb.dart'; import 'package:simutil_app/src/devices/devices_cubit.dart'; import 'package:simutil_app/src/devices/devices_state.dart'; import 'package:simutil_app/src/settings/device_settings_cubit.dart'; @@ -42,9 +43,25 @@ class DeviceCommands { headless: headless, coldBoot: coldBoot, noAudio: noAudio, + cameraArgs: _cameraArgs(), ); } + /// The emulator camera sources picked in the context menu; a missing file + /// shows an error and boots with the AVD's own cameras. + List _cameraArgs() { + if (device.os != DeviceOs.android) return const []; + try { + return emulatorCameraArgs( + back: settings.cameraBack, + front: settings.cameraFront, + ); + } on FormatException catch (e) { + _devices.notify('Camera: ${e.message}', error: true); + return const []; + } + } + Future stop() async { await _streams.closeStream(device.id); await _devices.shutdown(device); @@ -55,7 +72,11 @@ class DeviceCommands { Future restart() async { final reopen = headless || _streams.stateValue.isOpen(device.id); await _streams.closeStream(device.id); - await _devices.restart(device, headless: headless); + await _devices.restart( + device, + headless: headless, + cameraArgs: _cameraArgs(), + ); // After the restart so the old, still-booted device is not reopened. if (reopen) _streams.openWhenBooted(device); } diff --git a/app/lib/src/ui/shared/device_context_menu.dart b/app/lib/src/ui/shared/device_context_menu.dart index 222230c..59ab8c3 100644 --- a/app/lib/src/ui/shared/device_context_menu.dart +++ b/app/lib/src/ui/shared/device_context_menu.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:bloc_signals_flutter/bloc_signals_flutter.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:simutil_adb/simutil_adb.dart'; @@ -14,9 +17,18 @@ import 'package:simutil_app/src/ui/shared/native_menu.dart'; import 'package:simutil_core/simutil_core.dart'; import 'package:tray_manager/tray_manager.dart' as native; +// ponytail: listed once per run, so a camera plugged in later needs an app +// restart; re-list on every menu open if that matters. +Future>? _webcams; + /// Opens a native context menu for [device] at the cursor: its actions plus /// per-device settings. -void showDeviceContextMenu(BuildContext context, Device device) { +Future showDeviceContextMenu(BuildContext context, Device device) async { + final emulator = device.os == DeviceOs.android && !device.type.isPhysical; + final webcams = emulator + ? await (_webcams ??= getIt().listWebcams()) + : const <({String id, String name})>[]; + if (!context.mounted) return; final settingsCubit = context.read(); void set(DeviceSettings Function(DeviceSettings) change) => settingsCubit.update(device, change); @@ -27,6 +39,7 @@ void showDeviceContextMenu(BuildContext context, Device device) { _actionItems(context, device), if (!busy) _deviceItems(context, device), if (!device.type.isPhysical) _launchItems(context, device, set), + if (emulator) _cameraItems(settingsCubit.of(device), webcams, set), _streamItems(context, device, set), if (!device.type.isPhysical && !busy) _deleteItems(context, device), ], @@ -118,6 +131,73 @@ List _launchItems( ]; } +/// Back / front camera source of an Android emulator, used from its next +/// boot: the AVD default, the emulator's fake camera, a host webcam, an +/// image or a video file. +List _cameraItems( + DeviceSettings settings, + List<({String id, String name})> webcams, + void Function(DeviceSettings Function(DeviceSettings)) set, +) { + native.MenuItem side( + String label, + String? current, { + required bool back, + required void Function(String? source) pick, + }) { + native.MenuItem file(String kind, String title, List extensions) { + final picked = current?.startsWith('$kind:') ?? false; + return menuCheck( + picked + ? '$title: ${current!.split(Platform.pathSeparator).last}' + : '$title…', + picked, + () async { + final file = await openFile( + acceptedTypeGroups: [ + XTypeGroup(label: title, extensions: extensions), + ], + ); + if (file != null) pick('$kind:${file.path}'); + }, + ); + } + + return menuSubmenu(label, [ + menuCheck('AVD default', current == null, () => pick(null)), + menuCheck('Emulated', current == 'emulated', () => pick('emulated')), + if (back) + menuCheck( + 'Virtual scene', + current == 'virtualscene', + () => pick('virtualscene'), + ), + menuCheck('Off', current == 'none', () => pick('none')), + for (final cam in webcams) + menuCheck(cam.name, current == cam.id, () => pick(cam.id)), + file('image', 'Image', const ['png', 'jpg', 'jpeg']), + file('video', 'Video', const ['mp4', 'mov', 'webm']), + ]); + } + + return [ + menuSubmenu('Camera (next boot)', [ + side( + 'Back camera', + settings.cameraBack, + back: true, + pick: (v) => set((s) => s.copyWith(cameraBack: () => v)), + ), + side( + 'Front camera', + settings.cameraFront, + back: false, + pick: (v) => set((s) => s.copyWith(cameraFront: () => v)), + ), + ]), + ]; +} + /// Device frame, and the scrcpy options of an Android stream; they apply /// the next time the stream opens. List _streamItems( diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a04b857..b1905d3 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: bloc_signals_flutter: ^1.4.0 equatable: ^2.0.7 get_it: ^9.3.0 - simutil_adb: ^1.3.0 + simutil_adb: ^1.4.0 simutil_apple: ^1.3.1 simutil_core: ^1.3.0 simutil_h264: ^0.1.0 diff --git a/app/test/desktop_settings_store_test.dart b/app/test/desktop_settings_store_test.dart index 51a6f11..bb90a2b 100644 --- a/app/test/desktop_settings_store_test.dart +++ b/app/test/desktop_settings_store_test.dart @@ -81,6 +81,8 @@ void main() { stayAwake: false, turnScreenOff: true, powerOffOnClose: true, + cameraBack: 'image:/tmp/qr.png', + cameraFront: 'webcam0', ); ViewSettingsCubit(store: store) ..toggleHeadless() diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index c8385bf..7293e19 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `simutil launch --camera-back` / `--camera-front` feed an Android emulator's camera from your Mac's webcam, an image or a video file (`webcam0`, `image:qr.png`, `video:clip.mp4`). + ## [1.2.0] - 2026-10-17 ### Added diff --git a/packages/simutil/lib/src/cli/cli_catalog.dart b/packages/simutil/lib/src/cli/cli_catalog.dart index a9729a3..da78219 100644 --- a/packages/simutil/lib/src/cli/cli_catalog.dart +++ b/packages/simutil/lib/src/cli/cli_catalog.dart @@ -57,6 +57,20 @@ List simutilCliCatalog({required String version}) => [ long: 'no-audio', description: 'Disable emulator audio (Android)', ), + CliFlagSpec( + long: 'camera-back', + kind: CliArgKind.option, + valueName: 'source', + description: + 'Android back camera: webcam, image:, video:, ' + 'virtualscene, emulated, none', + ), + CliFlagSpec( + long: 'camera-front', + kind: CliArgKind.option, + valueName: 'source', + description: 'Android front camera (same sources)', + ), CliFlagSpec( long: 'headless', description: @@ -68,6 +82,7 @@ List simutilCliCatalog({required String version}) => [ 'simutil start emulator-5554 -a', 'simutil launch -c --no-audio', 'simutil launch --headless', + 'simutil launch --camera-back image:qr.png --camera-front webcam0', ], ), CliCommandSpec( diff --git a/packages/simutil/lib/src/cli/cli_device_services.dart b/packages/simutil/lib/src/cli/cli_device_services.dart index 0449859..8be22f2 100644 --- a/packages/simutil/lib/src/cli/cli_device_services.dart +++ b/packages/simutil/lib/src/cli/cli_device_services.dart @@ -62,17 +62,21 @@ class CliDeviceServices { return matches.first; } - /// Launches [device], optionally with Android cold boot / no-audio flags, - /// or [headless] (no emulator window / Simulator app). + /// Launches [device], optionally with Android cold boot / no-audio flags + /// and [cameraArgs] (from `emulatorCameraArgs`), or [headless] (no + /// emulator window / Simulator app). Future launchDevice( Device device, { bool cold = false, bool noAudio = false, bool headless = false, + List cameraArgs = const [], }) async { + final android = device.os == DeviceOs.android; final extra = []; if (cold) extra.add('-no-snapshot-load'); if (noAudio) extra.add('-no-audio'); + if (android) extra.addAll(cameraArgs); await _serviceFor(device).launchDevice( deviceId: device.id, diff --git a/packages/simutil/lib/src/cli/commands/launch_command.dart b/packages/simutil/lib/src/cli/commands/launch_command.dart index 59e07d2..e9f6142 100644 --- a/packages/simutil/lib/src/cli/commands/launch_command.dart +++ b/packages/simutil/lib/src/cli/commands/launch_command.dart @@ -3,6 +3,7 @@ import 'package:args/command_runner.dart'; import 'package:simutil/src/cli/cli_device_services.dart'; import 'package:simutil/src/cli/cli_flags.dart'; import 'package:simutil/src/cli/commands/simutil_command.dart'; +import 'package:simutil_adb/simutil_adb.dart'; /// Boots an emulator or simulator by device id. class LaunchCommand extends SimutilCommand { @@ -36,6 +37,18 @@ class LaunchCommand extends SimutilCommand { help: 'Disable emulator audio (Android).', negatable: false, ) + ..addOption( + 'camera-back', + help: + 'Android back camera: webcam, image:, video:, ' + 'virtualscene, emulated or none.', + valueHelp: 'source', + ) + ..addOption( + 'camera-front', + help: 'Android front camera (same sources as --camera-back).', + valueHelp: 'source', + ) ..addFlag( 'headless', help: 'Start without the emulator window / Simulator app.', @@ -53,6 +66,16 @@ class LaunchCommand extends SimutilCommand { throw UsageException('Expected a single device id.', usage); } + final List cameraArgs; + try { + cameraArgs = emulatorCameraArgs( + back: argResults!['camera-back'] as String?, + front: argResults!['camera-front'] as String?, + ); + } on FormatException catch (e) { + throw UsageException(e.message, usage); + } + final device = await _services.resolveDevice( rest.first, osHint: osHintFromFlags(argResults!), @@ -63,6 +86,7 @@ class LaunchCommand extends SimutilCommand { cold: argResults!['cold'] == true, noAudio: argResults!['no-audio'] == true, headless: argResults!['headless'] == true, + cameraArgs: cameraArgs, ); logger.success('Launched ${device.name} (${device.id})'); diff --git a/packages/simutil/pubspec.yaml b/packages/simutil/pubspec.yaml index 5b47c9d..2c41c9a 100644 --- a/packages/simutil/pubspec.yaml +++ b/packages/simutil/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: ascii_qr: ^1.0.1 mason_logger: ^0.3.6 nocterm: ^0.9.0 - simutil_adb: ^1.3.0 + simutil_adb: ^1.4.0 simutil_apple: ^1.3.0 simutil_core: ^1.3.0 simutil_plugins: ^1.0.0 diff --git a/packages/simutil/test/cli/cli_device_services_test.dart b/packages/simutil/test/cli/cli_device_services_test.dart index d64b02d..df51cea 100644 --- a/packages/simutil/test/cli/cli_device_services_test.dart +++ b/packages/simutil/test/cli/cli_device_services_test.dart @@ -30,6 +30,14 @@ void main() { expect(launch.args, ['-no-snapshot-load', '-no-audio']); }); + test('launchDevice appends camera args for Android', () async { + await services.launchDevice( + emulator, + cameraArgs: ['-camera-back', 'webcam0'], + ); + expect(android.launched.single.args, ['-camera-back', 'webcam0']); + }); + test('shutdownDevice routes to the device OS service', () async { expect(await services.shutdownDevice(emulator), isTrue); expect(android.shutdown, [emulator.id]); diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index bb78ad2..54f2faa 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.4.0 + +- `emulatorCameraArgs(back:, front:)` builds `-camera-back` / + `-camera-front` emulator args from `webcam`, `image:`, + `video:`, `virtualscene`, `emulated` or `none`. +- `AndroidDeviceService.listWebcams()` lists the host cameras the emulator + can use (`emulator -webcam-list`). + ## 1.3.0 - `AndroidAutomation.install` runs `adb install -r -t` and reports the diff --git a/packages/simutil_adb/lib/simutil_adb.dart b/packages/simutil_adb/lib/simutil_adb.dart index 91f8374..b9fa9a8 100644 --- a/packages/simutil_adb/lib/simutil_adb.dart +++ b/packages/simutil_adb/lib/simutil_adb.dart @@ -20,6 +20,7 @@ export 'src/scrcpy/scrcpy_install.dart'; export 'src/scrcpy/scrcpy_session.dart'; export 'src/models/adb_connect_result.dart'; export 'src/models/android_quick_launch_option.dart'; +export 'src/models/emulator_camera.dart'; export 'src/models/wifi_pairing_device.dart'; export 'src/models/wireless_connect_request.dart'; export 'src/models/wireless_pairing_info.dart'; diff --git a/packages/simutil_adb/lib/src/android_device_service.dart b/packages/simutil_adb/lib/src/android_device_service.dart index 02a36cf..5548c06 100644 --- a/packages/simutil_adb/lib/src/android_device_service.dart +++ b/packages/simutil_adb/lib/src/android_device_service.dart @@ -267,6 +267,22 @@ class AndroidDeviceService implements DeviceService { } } + /// Host cameras the emulator can use as `webcam` camera sources, + /// from `emulator -webcam-list`; empty when it fails. + Future> listWebcams() async { + try { + final result = await _exec.run(emulatorPath, arguments: ['-webcam-list']); + return [ + for (final m in RegExp( + r"Camera '(webcam\d+)' is connected to device '([^']*)'", + ).allMatches(result.stdout)) + (id: m.group(1)!, name: m.group(2)!), + ]; + } catch (_) { + return const []; + } + } + /// Reads the device LAN IP via `ip route` / `ifconfig`. Future getDeviceIpAddress(String serial) async { try { diff --git a/packages/simutil_adb/lib/src/models/emulator_camera.dart b/packages/simutil_adb/lib/src/models/emulator_camera.dart new file mode 100644 index 0000000..623425d --- /dev/null +++ b/packages/simutil_adb/lib/src/models/emulator_camera.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +final _plainModes = RegExp(r'^(emulated|none|virtualscene|webcam\d+)$'); + +/// Builds `-camera-back` / `-camera-front` emulator args from camera sources. +/// +/// A source is `emulated`, `none`, `virtualscene`, `webcam` (host camera, +/// see `emulator -webcam-list`), `image:` or `video:`. Paths are +/// made absolute and must exist. Throws [FormatException] otherwise. +List emulatorCameraArgs({String? back, String? front}) => [ + if (back != null) ...['-camera-back', _cameraMode(back)], + if (front != null) ...['-camera-front', _cameraMode(front)], +]; + +String _cameraMode(String source) { + final value = source.trim(); + if (_plainModes.hasMatch(value)) return value; + final colon = value.indexOf(':'); + final kind = colon < 0 ? value : value.substring(0, colon); + final prefix = switch (kind) { + 'image' => 'imagefile', + 'video' => 'videofile', + _ => throw FormatException( + 'Unknown camera source "$source". Use emulated, none, virtualscene, ' + 'webcam, image: or video:.', + ), + }; + final path = colon < 0 ? '' : value.substring(colon + 1); + if (path.isEmpty) throw FormatException('Missing file in "$source".'); + final file = File(path); + if (!file.existsSync()) { + throw FormatException('Camera file not found: $path'); + } + return '$prefix:${file.absolute.path}'; +} diff --git a/packages/simutil_adb/pubspec.yaml b/packages/simutil_adb/pubspec.yaml index dea5aa0..7085dc4 100644 --- a/packages/simutil_adb/pubspec.yaml +++ b/packages/simutil_adb/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil_adb description: Android emulator and device APIs for SimUtil via adb. -version: 1.3.0 +version: 1.4.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_adb issue_tracker: https://github.com/dungngminh/simutil/issues diff --git a/packages/simutil_adb/test/models/emulator_camera_test.dart b/packages/simutil_adb/test/models/emulator_camera_test.dart new file mode 100644 index 0000000..bd72df2 --- /dev/null +++ b/packages/simutil_adb/test/models/emulator_camera_test.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:simutil_adb/src/models/emulator_camera.dart'; +import 'package:test/test.dart'; + +void main() { + group('emulatorCameraArgs', () { + test('is empty without sources', () { + expect(emulatorCameraArgs(), isEmpty); + }); + + test('passes plain modes through', () { + expect(emulatorCameraArgs(back: 'webcam1', front: 'none'), [ + '-camera-back', + 'webcam1', + '-camera-front', + 'none', + ]); + expect(emulatorCameraArgs(back: 'virtualscene'), [ + '-camera-back', + 'virtualscene', + ]); + }); + + test('maps image and video files to absolute paths', () { + final dir = Directory.systemTemp.createTempSync('camera_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + final image = File('${dir.path}/qr.png')..writeAsBytesSync([0]); + final video = File('${dir.path}/clip.mp4')..writeAsBytesSync([0]); + + expect( + emulatorCameraArgs( + back: 'image:${image.path}', + front: 'video:${video.path}', + ), + [ + '-camera-back', + 'imagefile:${image.absolute.path}', + '-camera-front', + 'videofile:${video.absolute.path}', + ], + ); + }); + + test('rejects unknown sources and missing files', () { + expect(() => emulatorCameraArgs(back: 'phone'), throwsFormatException); + expect(() => emulatorCameraArgs(back: 'webcam'), throwsFormatException); + expect(() => emulatorCameraArgs(back: 'image:'), throwsFormatException); + expect( + () => emulatorCameraArgs(front: 'video:/no/such/file.mp4'), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/simutil_adb/test/services/android_device_service_test.dart b/packages/simutil_adb/test/services/android_device_service_test.dart index ff3fb17..811a4fe 100644 --- a/packages/simutil_adb/test/services/android_device_service_test.dart +++ b/packages/simutil_adb/test/services/android_device_service_test.dart @@ -588,4 +588,25 @@ void main() { ); }); }); + + test('listWebcams parses emulator -webcam-list', () async { + final exec = FakeCommandExec( + (_, args) => args.contains('-webcam-list') + ? FakeCommandExec.ok(''' +List of web cameras connected to the computer: + Camera 'webcam0' is connected to device 'FaceTime HD Camera' on channel 0 using pixel format 'YV12' + Camera 'webcam1' is connected to device 'iPhone Camera' on channel 0 using pixel format 'YV12' +''') + : null, + ); + + expect(await service(exec).listWebcams(), [ + (id: 'webcam0', name: 'FaceTime HD Camera'), + (id: 'webcam1', name: 'iPhone Camera'), + ]); + expect( + await service(FakeCommandExec((_, _) => null)).listWebcams(), + isEmpty, + ); + }); } From 13d984eb2bbcee527046f29cdbf72b0320002f21 Mon Sep 17 00:00:00 2001 From: dungngminh Date: Thu, 22 Oct 2026 00:50:10 +0700 Subject: [PATCH 2/3] feat: switch emulator camera from a running stream Add a camera button on a running Android emulator's stream tile that picks the back/front source and restarts the emulator. Ask for macOS Camera access before booting with a host webcam (falls back to the emulator's own cameras when denied), and reject one webcam feeding both cameras, which crashes the emulator's camera app. --- app/CHANGELOG.md | 9 +-- app/lib/src/devices/devices_cubit.dart | 26 ++++++++- .../src/ui/shared/device_context_menu.dart | 57 +++++++++++++------ app/lib/src/ui/streams/stream_tile.dart | 7 +++ app/macos/Runner/DeviceStream.swift | 6 ++ packages/simutil_adb/CHANGELOG.md | 3 +- .../lib/src/models/emulator_camera.dart | 22 +++++-- .../test/models/emulator_camera_test.dart | 4 ++ 8 files changed, 106 insertions(+), 28 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index c7bbd0f..b99f7bb 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Pick what an Android emulator's back and front cameras see from the - device menu (Camera): your Mac's webcam, an image or a video file. It - applies the next time the emulator starts, and agents can set it over - MCP too. +- Pick what an Android emulator's back and front cameras see: your Mac's + webcam, an image or a video file. Set it from the device menu (Camera, + used from the next start) or the camera button on a running emulator's + stream (restarts it). SimUtil asks for Camera access before an emulator + uses your webcam. Agents can set it over MCP too. ## [0.4.0] diff --git a/app/lib/src/devices/devices_cubit.dart b/app/lib/src/devices/devices_cubit.dart index 502b12c..039a14b 100644 --- a/app/lib/src/devices/devices_cubit.dart +++ b/app/lib/src/devices/devices_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:bloc_signals/bloc_signals.dart'; +import 'package:flutter/services.dart'; import 'package:simutil_app/src/devices/devices_state.dart'; import 'package:simutil_core/simutil_core.dart'; import 'package:simutil_shared/simutil_shared.dart'; @@ -30,7 +31,9 @@ class DevicesCubit extends CubitSignal { bool Function()? slimOnLaunch, bool? loadIos, Duration pollInterval = const Duration(seconds: 1), + Future Function()? cameraAccess, }) : _android = android, + _cameraAccess = cameraAccess ?? _requestCameraAccess, _slimOnLaunch = slimOnLaunch, _pollInterval = pollInterval, _ios = ios, @@ -45,6 +48,9 @@ class DevicesCubit extends CubitSignal { final DeviceService _android; final DeviceService _ios; + + /// Asks for Camera access before an emulator boots with a host webcam. + final Future Function() _cameraAccess; final SlimControl? _slim; /// Whether simulators are written slim before they boot (Slim mode). @@ -135,13 +141,23 @@ class DevicesCubit extends CubitSignal { _setMessage('Slim failed for ${device.name}: $e'); } } + // The emulator reads the webcam under SimUtil's Camera permission; + // without it the camera gets no frames and the emulator's screen hangs. + var camera = android ? cameraArgs : const []; + if (camera.any((a) => a.startsWith('webcam')) && !await _cameraAccess()) { + _setMessage( + 'Allow SimUtil in System Settings › Privacy & Security › Camera to ' + 'use a webcam; starting ${device.name} with its own cameras', + ); + camera = const []; + } final launching = _serviceFor(device).launchDevice( deviceId: device.id, headless: headless, additionalArgs: [ if (coldBoot && android) '-no-snapshot-load', if (noAudio && android) '-no-audio', - if (android) ...cameraArgs, + ...camera, ], ); unawaited( @@ -308,3 +324,11 @@ class DevicesCubit extends CubitSignal { await super.close(); } } + +/// macOS asks the user once; other platforms have no camera permission. +Future _requestCameraAccess() async => + !Platform.isMacOS || + (await const MethodChannel( + 'simutil/ios_device_stream', + ).invokeMethod('camera_access') ?? + false); diff --git a/app/lib/src/ui/shared/device_context_menu.dart b/app/lib/src/ui/shared/device_context_menu.dart index 59ab8c3..d2005a7 100644 --- a/app/lib/src/ui/shared/device_context_menu.dart +++ b/app/lib/src/ui/shared/device_context_menu.dart @@ -21,12 +21,31 @@ import 'package:tray_manager/tray_manager.dart' as native; // restart; re-list on every menu open if that matters. Future>? _webcams; +Future> _listWebcams() => + _webcams ??= getIt().listWebcams(); + +/// The camera sources of a running Android emulator as their own menu (from +/// its stream header); a pick restarts it so the camera changes right away. +Future showCameraMenu(BuildContext context, Device device) async { + final webcams = await _listWebcams(); + if (!context.mounted) return; + final settingsCubit = context.read(); + showNativeMenu( + () => [ + _cameraSides(settingsCubit.of(device), webcams, (change) { + settingsCubit.update(device, change); + if (context.mounted) DeviceCommands(context, device).restart(); + }), + ], + ); +} + /// Opens a native context menu for [device] at the cursor: its actions plus /// per-device settings. Future showDeviceContextMenu(BuildContext context, Device device) async { final emulator = device.os == DeviceOs.android && !device.type.isPhysical; final webcams = emulator - ? await (_webcams ??= getIt().listWebcams()) + ? await _listWebcams() : const <({String id, String name})>[]; if (!context.mounted) return; final settingsCubit = context.read(); @@ -39,7 +58,13 @@ Future showDeviceContextMenu(BuildContext context, Device device) async { _actionItems(context, device), if (!busy) _deviceItems(context, device), if (!device.type.isPhysical) _launchItems(context, device, set), - if (emulator) _cameraItems(settingsCubit.of(device), webcams, set), + if (emulator) + [ + menuSubmenu( + 'Camera (next boot)', + _cameraSides(settingsCubit.of(device), webcams, set), + ), + ], _streamItems(context, device, set), if (!device.type.isPhysical && !busy) _deleteItems(context, device), ], @@ -134,7 +159,7 @@ List _launchItems( /// Back / front camera source of an Android emulator, used from its next /// boot: the AVD default, the emulator's fake camera, a host webcam, an /// image or a video file. -List _cameraItems( +List _cameraSides( DeviceSettings settings, List<({String id, String name})> webcams, void Function(DeviceSettings Function(DeviceSettings)) set, @@ -181,20 +206,18 @@ List _cameraItems( } return [ - menuSubmenu('Camera (next boot)', [ - side( - 'Back camera', - settings.cameraBack, - back: true, - pick: (v) => set((s) => s.copyWith(cameraBack: () => v)), - ), - side( - 'Front camera', - settings.cameraFront, - back: false, - pick: (v) => set((s) => s.copyWith(cameraFront: () => v)), - ), - ]), + side( + 'Back camera', + settings.cameraBack, + back: true, + pick: (v) => set((s) => s.copyWith(cameraBack: () => v)), + ), + side( + 'Front camera', + settings.cameraFront, + back: false, + pick: (v) => set((s) => s.copyWith(cameraFront: () => v)), + ), ]; } diff --git a/app/lib/src/ui/streams/stream_tile.dart b/app/lib/src/ui/streams/stream_tile.dart index 41b01d0..d016b72 100644 --- a/app/lib/src/ui/streams/stream_tile.dart +++ b/app/lib/src/ui/streams/stream_tile.dart @@ -355,6 +355,13 @@ List _tileActions( (s) => s.copyWith(showFrame: !s.showFrame), ), ), + if (device.os == DeviceOs.android && !device.type.isPhysical) + TileAction( + icon: LucideIcons.webcam, + label: 'Camera source (restarts the emulator)', + priority: 3, + onPressed: live ? () => showCameraMenu(context, device) : null, + ), if (!device.type.isPhysical) TileAction( icon: LucideIcons.power, diff --git a/app/macos/Runner/DeviceStream.swift b/app/macos/Runner/DeviceStream.swift index 72dc23a..9a92c08 100644 --- a/app/macos/Runner/DeviceStream.swift +++ b/app/macos/Runner/DeviceStream.swift @@ -197,6 +197,12 @@ final class DeviceStreamPlugin: NSObject, FlutterPlugin { } func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // ponytail: rides this channel since it already owns camera access; an + // emulator's webcam runs under SimUtil's Camera permission. + if call.method == "camera_access" { + Task { result(await AVCaptureDevice.requestAccess(for: .video)) } + return + } let args = call.arguments as? [String: Any] ?? [:] guard let udid = args["udid"] as? String else { return result(FlutterError(code: "args", message: "udid required", details: nil)) diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index 54f2faa..25d46df 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -4,7 +4,8 @@ - `emulatorCameraArgs(back:, front:)` builds `-camera-back` / `-camera-front` emulator args from `webcam`, `image:`, - `video:`, `virtualscene`, `emulated` or `none`. + `video:`, `virtualscene`, `emulated` or `none`; one webcam cannot + be both cameras. - `AndroidDeviceService.listWebcams()` lists the host cameras the emulator can use (`emulator -webcam-list`). diff --git a/packages/simutil_adb/lib/src/models/emulator_camera.dart b/packages/simutil_adb/lib/src/models/emulator_camera.dart index 623425d..07bc84f 100644 --- a/packages/simutil_adb/lib/src/models/emulator_camera.dart +++ b/packages/simutil_adb/lib/src/models/emulator_camera.dart @@ -6,11 +6,23 @@ final _plainModes = RegExp(r'^(emulated|none|virtualscene|webcam\d+)$'); /// /// A source is `emulated`, `none`, `virtualscene`, `webcam` (host camera, /// see `emulator -webcam-list`), `image:` or `video:`. Paths are -/// made absolute and must exist. Throws [FormatException] otherwise. -List emulatorCameraArgs({String? back, String? front}) => [ - if (back != null) ...['-camera-back', _cameraMode(back)], - if (front != null) ...['-camera-front', _cameraMode(front)], -]; +/// made absolute and must exist. One webcam cannot feed both cameras (the +/// emulator then crashes the camera app). Throws [FormatException] otherwise. +List emulatorCameraArgs({String? back, String? front}) { + final backMode = back == null ? null : _cameraMode(back); + final frontMode = front == null ? null : _cameraMode(front); + if (backMode != null && + backMode == frontMode && + backMode.startsWith('webcam')) { + throw FormatException( + '$backMode cannot be both the back and front camera.', + ); + } + return [ + if (backMode != null) ...['-camera-back', backMode], + if (frontMode != null) ...['-camera-front', frontMode], + ]; +} String _cameraMode(String source) { final value = source.trim(); diff --git a/packages/simutil_adb/test/models/emulator_camera_test.dart b/packages/simutil_adb/test/models/emulator_camera_test.dart index bd72df2..2231069 100644 --- a/packages/simutil_adb/test/models/emulator_camera_test.dart +++ b/packages/simutil_adb/test/models/emulator_camera_test.dart @@ -46,6 +46,10 @@ void main() { expect(() => emulatorCameraArgs(back: 'phone'), throwsFormatException); expect(() => emulatorCameraArgs(back: 'webcam'), throwsFormatException); expect(() => emulatorCameraArgs(back: 'image:'), throwsFormatException); + expect( + () => emulatorCameraArgs(back: 'webcam0', front: 'webcam0'), + throwsFormatException, + ); expect( () => emulatorCameraArgs(front: 'video:/no/such/file.mp4'), throwsFormatException, From 5f610813f44edb87289f5d1a0e1dc3105b40c644 Mon Sep 17 00:00:00 2001 From: dungngminh Date: Thu, 22 Oct 2026 00:50:11 +0700 Subject: [PATCH 3/3] fix: smooth and reliable headless Android emulator streams Headless emulators (-no-window) fall back to software rendering (lavapipe) under `-gpu auto`, so scrcpy streamed at ~5 fps. Launch them with `-gpu host` unless the caller passes its own `-gpu`: ~55 fps. adb lists an emulator before Android finishes booting, so a stream opened at boot started scrcpy-server too early and it crashed with a null ActivityManager. ScrcpySession now waits for sys.boot_completed (up to 3 min) before starting the server. --- app/CHANGELOG.md | 8 ++++++++ packages/simutil/CHANGELOG.md | 4 ++++ packages/simutil_adb/CHANGELOG.md | 7 +++++++ .../lib/src/android_device_service.dart | 7 +++++++ .../lib/src/scrcpy/scrcpy_session.dart | 19 +++++++++++++++++-- .../test/scrcpy/scrcpy_session_test.dart | 4 +++- .../services/android_device_service_test.dart | 19 +++++++++++++++++++ 7 files changed, 65 insertions(+), 3 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index b99f7bb..7f96ee4 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 stream (restarts it). SimUtil asks for Camera access before an emulator uses your webcam. Agents can set it over MCP too. +### Fixed + +- Android emulators started for streaming (headless) use your computer's + GPU: their stream runs at full speed instead of a few frames a second. +- Streaming an Android emulator right as it starts no longer fails with + "scrcpy server did not start"; the stream waits for Android to finish + booting. + ## [0.4.0] ### Added diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index 7293e19..dbcfdeb 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `simutil launch --camera-back` / `--camera-front` feed an Android emulator's camera from your Mac's webcam, an image or a video file (`webcam0`, `image:qr.png`, `video:clip.mp4`). +### Fixed + +- Headless Android emulators (`simutil launch --headless`) use your computer's GPU, so they run much faster. + ## [1.2.0] - 2026-10-17 ### Added diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index 25d46df..96d7572 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -8,6 +8,13 @@ be both cameras. - `AndroidDeviceService.listWebcams()` lists the host cameras the emulator can use (`emulator -webcam-list`). +- `launchDevice(headless: true)` also adds + `AndroidDeviceService.headlessGpuArgs` (`-gpu host`) unless the caller + passes `-gpu`: headless emulators otherwise render in software (lavapipe) + and stream at ~5 fps instead of ~55. +- `ScrcpySession.start` waits (up to 3 min) for `sys.boot_completed` before + starting the server, which crashed when started on a still-booting + emulator. ## 1.3.0 diff --git a/packages/simutil_adb/lib/src/android_device_service.dart b/packages/simutil_adb/lib/src/android_device_service.dart index 5548c06..a733d61 100644 --- a/packages/simutil_adb/lib/src/android_device_service.dart +++ b/packages/simutil_adb/lib/src/android_device_service.dart @@ -197,6 +197,8 @@ class AndroidDeviceService implements DeviceService { final launchArgs = { '@$deviceId', if (headless) ...headlessArgs, + // The caller's own `-gpu` wins; a set would merge the two values. + if (headless && !additionalArgs.contains('-gpu')) ...headlessGpuArgs, ...additionalArgs, }.toList(); await _exec.run( @@ -210,6 +212,11 @@ class AndroidDeviceService implements DeviceService { /// so many emulators fit at once while something else shows the screen. static const headlessArgs = ['-no-window', '-no-audio', '-no-boot-anim']; + /// GPU flags added by `headless` unless the caller passes `-gpu`: without a + /// window `-gpu auto` picks software rendering (lavapipe), which streams + /// at ~5 fps; the host GPU streams at ~55. + static const headlessGpuArgs = ['-gpu', 'host']; + /// Runs `adb connect [host]`. Future connectDevice(String host) async { try { diff --git a/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart b/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart index 5fd3aa4..7e6fa38 100644 --- a/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart +++ b/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart @@ -195,6 +195,15 @@ class _ScrcpySession implements ScrcpySession { } Future _startServer() async { + // adb lists an emulator while Android is still booting; the server needs + // system services (its cleanup thread crashes on a null ActivityManager). + final boot = await _adb([ + 'shell', + 'while [ "\$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done', + ], timeout: _bootTimeout); + if (!boot.success) { + throw StateError('device did not finish booting: ${boot.stderr.trim()}'); + } final push = await _adb(['push', install.serverPath, _remoteJar]); if (!push.success) { throw StateError('adb push failed: ${push.stderr.trim()}'); @@ -407,10 +416,16 @@ class _ScrcpySession implements ScrcpySession { await _statusController.close(); } - Future _adb(List args) => _exec.run( + /// A cold boot can take minutes after adb first sees the emulator. + static const _bootTimeout = Duration(minutes: 3); + + Future _adb( + List args, { + Duration timeout = const Duration(seconds: 30), + }) => _exec.run( adbPath, arguments: ['-s', serial, ...args], - timeout: const Duration(seconds: 30), + timeout: timeout, priority: CommandPriority.interactive, ); diff --git a/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart b/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart index d2dab82..9b47a5c 100644 --- a/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart +++ b/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart @@ -24,7 +24,9 @@ void main() { expect(await statuses, [ const SessionConnecting(), - const SessionFailed('Bad state: adb push failed: device offline'), + const SessionFailed( + 'Bad state: device did not finish booting: device offline', + ), ]); }, ); diff --git a/packages/simutil_adb/test/services/android_device_service_test.dart b/packages/simutil_adb/test/services/android_device_service_test.dart index 811a4fe..0499b68 100644 --- a/packages/simutil_adb/test/services/android_device_service_test.dart +++ b/packages/simutil_adb/test/services/android_device_service_test.dart @@ -413,6 +413,24 @@ void main() { expect(exec.calls.single.arguments, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, + ...AndroidDeviceService.headlessGpuArgs, + ]); + }); + + test('headless keeps the gpu mode the caller passes', () async { + final exec = FakeCommandExec((_, _) => FakeCommandExec.ok()); + + await service(exec).launchDevice( + deviceId: 'Pixel_7', + headless: true, + additionalArgs: ['-gpu', 'swiftshader_indirect'], + ); + + expect(exec.calls.single.arguments, [ + '@Pixel_7', + ...AndroidDeviceService.headlessArgs, + '-gpu', + 'swiftshader_indirect', ]); }); @@ -428,6 +446,7 @@ void main() { expect(exec.calls.single.arguments, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, + ...AndroidDeviceService.headlessGpuArgs, '-no-snapshot-load', ]); });