Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ 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: 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.

### 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
Expand Down
31 changes: 30 additions & 1 deletion app/lib/src/devices/devices_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -30,7 +31,9 @@ class DevicesCubit extends CubitSignal<DevicesState> {
bool Function()? slimOnLaunch,
bool? loadIos,
Duration pollInterval = const Duration(seconds: 1),
Future<bool> Function()? cameraAccess,
}) : _android = android,
_cameraAccess = cameraAccess ?? _requestCameraAccess,
_slimOnLaunch = slimOnLaunch,
_pollInterval = pollInterval,
_ios = ios,
Expand All @@ -45,6 +48,9 @@ class DevicesCubit extends CubitSignal<DevicesState> {

final DeviceService _android;
final DeviceService _ios;

/// Asks for Camera access before an emulator boots with a host webcam.
final Future<bool> Function() _cameraAccess;
final SlimControl? _slim;

/// Whether simulators are written slim before they boot (Slim mode).
Expand Down Expand Up @@ -112,12 +118,14 @@ class DevicesCubit extends CubitSignal<DevicesState> {
}

/// 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<void> launch(
Device device, {
bool headless = false,
bool coldBoot = false,
bool noAudio = false,
List<String> cameraArgs = const [],
}) async {
if (device.type.isPhysical || device.state != DeviceState.shutdown) return;
_setBusy(device.id, true, 'Starting ${device.name}…');
Expand All @@ -133,12 +141,23 @@ class DevicesCubit extends CubitSignal<DevicesState> {
_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 <String>[];
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',
...camera,
],
);
unawaited(
Expand All @@ -165,6 +184,7 @@ class DevicesCubit extends CubitSignal<DevicesState> {
bool headless = false,
bool coldBoot = false,
bool noAudio = false,
List<String> cameraArgs = const [],
}) async {
if (device.type.isPhysical || device.state != DeviceState.booted) return;
_setBusy(device.id, true, 'Restarting ${device.name}…');
Expand All @@ -182,6 +202,7 @@ class DevicesCubit extends CubitSignal<DevicesState> {
headless: headless,
coldBoot: coldBoot,
noAudio: noAudio,
cameraArgs: cameraArgs,
);
}

Expand Down Expand Up @@ -303,3 +324,11 @@ class DevicesCubit extends CubitSignal<DevicesState> {
await super.close();
}
}

/// macOS asks the user once; other platforms have no camera permission.
Future<bool> _requestCameraAccess() async =>
!Platform.isMacOS ||
(await const MethodChannel(
'simutil/ios_device_stream',
).invokeMethod<bool>('camera_access') ??
false);
14 changes: 13 additions & 1 deletion app/lib/src/mcp/tools/device_tools.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,24 +73,36 @@ List<McpTool> 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<N> (host '
'camera), image:<path>, video:<path>, 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 <String>[];
final headless = args['headless'] as bool? ?? options.headless ?? true;
if (headless) c.streams.openWhenBooted(device);
await c.devices.launch(
device,
headless: headless,
coldBoot: args['coldBoot'] as bool? ?? false,
noAudio: args['noAudio'] as bool? ?? false,
cameraArgs: cameraArgs,
);
return c.text('Starting ${device.name}');
},
Expand Down
18 changes: 18 additions & 0 deletions app/lib/src/settings/device_settings_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -31,6 +33,8 @@ final class DeviceSettings extends Equatable {
turnScreenOff: readField<bool>(json, 'turnScreenOff') ?? d.turnScreenOff,
powerOffOnClose:
readField<bool>(json, 'powerOffOnClose') ?? d.powerOffOnClose,
cameraBack: readField<String>(json, 'cameraBack'),
cameraFront: readField<String>(json, 'cameraFront'),
);
}

Expand Down Expand Up @@ -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:<path>`);
/// null keeps the AVD's own setting.
final String? cameraBack;
final String? cameraFront;

DeviceSettings copyWith({
bool? Function()? headless,
int? maxSize,
Expand All @@ -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,
Expand All @@ -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<String, Object?> toJson() => {
Expand All @@ -93,6 +107,8 @@ final class DeviceSettings extends Equatable {
'stayAwake': stayAwake,
'turnScreenOff': turnScreenOff,
'powerOffOnClose': powerOffOnClose,
'cameraBack': cameraBack,
'cameraFront': cameraFront,
};

@override
Expand All @@ -106,6 +122,8 @@ final class DeviceSettings extends Equatable {
stayAwake,
turnScreenOff,
powerOffOnClose,
cameraBack,
cameraFront,
];
}

Expand Down
23 changes: 22 additions & 1 deletion app/lib/src/ui/shared/device_actions.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<String> _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<void> stop() async {
await _streams.closeStream(device.id);
await _devices.shutdown(device);
Expand All @@ -55,7 +72,11 @@ class DeviceCommands {
Future<void> 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);
}
Expand Down
Loading
Loading