[flutter_tool] add a vmservice API for hot ui requests (#45649)
This commit is contained in:
parent
523ac7b6f5
commit
81aa2710d2
@ -326,6 +326,27 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
|
||||
},
|
||||
);
|
||||
|
||||
// Register the ability to quickly mark elements as dirty.
|
||||
// The performance of this method may be improved with additional
|
||||
// information from https://github.com/flutter/flutter/issues/46195.
|
||||
registerServiceExtension(
|
||||
name: 'fastReassemble',
|
||||
callback: (Map<String, Object> params) async {
|
||||
final String className = params['class'];
|
||||
void markElementsDirty(Element element) {
|
||||
if (element == null) {
|
||||
return;
|
||||
}
|
||||
if (element.widget?.runtimeType?.toString()?.startsWith(className) ?? false) {
|
||||
element.markNeedsBuild();
|
||||
}
|
||||
element.visitChildElements(markElementsDirty);
|
||||
}
|
||||
markElementsDirty(renderViewElement);
|
||||
return <String, String>{'Success': 'true'};
|
||||
},
|
||||
);
|
||||
|
||||
// Expose the ability to send Widget rebuilds as [Timeline] events.
|
||||
registerBoolServiceExtension(
|
||||
name: 'profileWidgetBuilds',
|
||||
|
@ -170,7 +170,7 @@ void main() {
|
||||
const int disabledExtensions = kIsWeb ? 3 : 0;
|
||||
// If you add a service extension... TEST IT! :-)
|
||||
// ...then increment this number.
|
||||
expect(binding.extensions.length, 27 + widgetInspectorExtensionCount - disabledExtensions);
|
||||
expect(binding.extensions.length, 28 + widgetInspectorExtensionCount - disabledExtensions);
|
||||
|
||||
expect(console, isEmpty);
|
||||
debugPrint = debugPrintThrottled;
|
||||
@ -692,4 +692,11 @@ void main() {
|
||||
expect(trace, contains('package:test_api/test_api.dart,::,test\n'));
|
||||
expect(trace, contains('service_extensions_test.dart,::,main\n'));
|
||||
}, skip: isBrowser);
|
||||
|
||||
test('Service extensions - fastReassemble', () async {
|
||||
Map<String, dynamic> result;
|
||||
result = await binding.testExtension('fastReassemble', <String, String>{'class': 'Foo'});
|
||||
|
||||
expect(result, containsPair('Success', 'true'));
|
||||
}, skip: isBrowser);
|
||||
}
|
||||
|
@ -107,6 +107,15 @@ The `restart()` restarts the given application. It returns a Map of `{ int code,
|
||||
- `reason`: optional; the reason for the full restart (eg. `save`, `manual`) for reporting purposes
|
||||
- `pause`: optional; when doing a hot restart the isolate should enter a paused mode
|
||||
|
||||
#### app.reloadMethod
|
||||
|
||||
Performs a limited hot restart which does not sync assets and only marks elements as dirty, instead of reassembling the full application. A `code` of `0` indicates success, and non-zero indicates a failure.
|
||||
|
||||
- `appId`: the id of a previously started app; this is required.
|
||||
- `library`: the absolute file URI of the library to be updated; this is required.
|
||||
- `class`: the name of the StatelessWidget that was updated, or the StatefulWidget
|
||||
corresponding to the updated State class; this is required.
|
||||
|
||||
#### app.callServiceExtension
|
||||
|
||||
The `callServiceExtension()` allows clients to make arbitrary calls to service protocol extensions. It returns a `Map` - the result returned by the service protocol method.
|
||||
|
@ -392,6 +392,7 @@ typedef _RunOrAttach = Future<void> Function({
|
||||
class AppDomain extends Domain {
|
||||
AppDomain(Daemon daemon) : super(daemon, 'app') {
|
||||
registerHandler('restart', restart);
|
||||
registerHandler('reloadMethod', reloadMethod);
|
||||
registerHandler('callServiceExtension', callServiceExtension);
|
||||
registerHandler('stop', stop);
|
||||
registerHandler('detach', detach);
|
||||
@ -584,6 +585,28 @@ class AppDomain extends Domain {
|
||||
});
|
||||
}
|
||||
|
||||
Future<OperationResult> reloadMethod(Map<String, dynamic> args) async {
|
||||
final String appId = _getStringArg(args, 'appId', required: true);
|
||||
final String classId = _getStringArg(args, 'class', required: true);
|
||||
final String libraryId = _getStringArg(args, 'library', required: true);
|
||||
|
||||
final AppInstance app = _getApp(appId);
|
||||
if (app == null) {
|
||||
throw "app '$appId' not found";
|
||||
}
|
||||
|
||||
if (_inProgressHotReload != null) {
|
||||
throw 'hot restart already in progress';
|
||||
}
|
||||
|
||||
_inProgressHotReload = app._runInZone<OperationResult>(this, () {
|
||||
return app.reloadMethod(classId: classId, libraryId: libraryId);
|
||||
});
|
||||
return _inProgressHotReload.whenComplete(() {
|
||||
_inProgressHotReload = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns an error, or the service extension result (a map with two fixed
|
||||
/// keys, `type` and `method`). The result may have one or more additional keys,
|
||||
/// depending on the specific service extension end-point. For example:
|
||||
@ -926,6 +949,10 @@ class AppInstance {
|
||||
return runner.restart(fullRestart: fullRestart, pauseAfterRestart: pauseAfterRestart, reason: reason);
|
||||
}
|
||||
|
||||
Future<OperationResult> reloadMethod({ String classId, String libraryId }) {
|
||||
return runner.reloadMethod(classId: classId, libraryId: libraryId);
|
||||
}
|
||||
|
||||
Future<void> stop() => runner.exit();
|
||||
Future<void> detach() => runner.detach();
|
||||
|
||||
|
@ -613,6 +613,7 @@ class DefaultResidentCompiler implements ResidentCompiler {
|
||||
printTrace('<- recompile $mainUri$inputKey');
|
||||
for (Uri fileUri in request.invalidatedFiles) {
|
||||
_server.stdin.writeln(_mapFileUri(fileUri.toString(), packageUriMapper));
|
||||
printTrace('${_mapFileUri(fileUri.toString(), packageUriMapper)}');
|
||||
}
|
||||
_server.stdin.writeln(inputKey);
|
||||
printTrace('<- $inputKey');
|
||||
|
@ -453,6 +453,7 @@ class DevFS {
|
||||
String projectRootPath,
|
||||
@required String pathToReload,
|
||||
@required List<Uri> invalidatedFiles,
|
||||
bool skipAssets = false,
|
||||
}) async {
|
||||
assert(trackWidgetCreation != null);
|
||||
assert(generator != null);
|
||||
@ -463,7 +464,7 @@ class DevFS {
|
||||
final Map<Uri, DevFSContent> dirtyEntries = <Uri, DevFSContent>{};
|
||||
|
||||
int syncedBytes = 0;
|
||||
if (bundle != null) {
|
||||
if (bundle != null && !skipAssets) {
|
||||
printTrace('Scanning asset files');
|
||||
// We write the assets into the AssetBundle working dir so that they
|
||||
// are in the same location in DevFS and the iOS simulator.
|
||||
|
@ -160,6 +160,7 @@ class FlutterDevice {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
}) {
|
||||
final Completer<void> completer = Completer<void>();
|
||||
StreamSubscription<void> subscription;
|
||||
@ -177,6 +178,7 @@ class FlutterDevice {
|
||||
reloadSources: reloadSources,
|
||||
restart: restart,
|
||||
compileExpression: compileExpression,
|
||||
reloadMethod: reloadMethod,
|
||||
device: device,
|
||||
);
|
||||
} on Exception catch (exception) {
|
||||
@ -718,6 +720,22 @@ abstract class ResidentRunner {
|
||||
throw '${fullRestart ? 'Restart' : 'Reload'} is not supported in $mode mode';
|
||||
}
|
||||
|
||||
/// The resident runner API for interaction with the reloadMethod vmservice
|
||||
/// request.
|
||||
///
|
||||
/// This API should only be called for UI only-changes spanning a single
|
||||
/// library/Widget.
|
||||
///
|
||||
/// The value [classId] should be the identifier of the StatelessWidget that
|
||||
/// was invalidated, or the StatefulWidget for the corresponding State class
|
||||
/// that was invalidated. This must be provided.
|
||||
///
|
||||
/// The value [libraryId] should be the absolute file URI for the containing
|
||||
/// library of the widget that was invalidated. This must be provided.
|
||||
Future<OperationResult> reloadMethod({ String classId, String libraryId }) {
|
||||
throw UnsupportedError('Method is not supported.');
|
||||
}
|
||||
|
||||
@protected
|
||||
void writeVmserviceFile() {
|
||||
if (debuggingOptions.vmserviceOutFile != null) {
|
||||
@ -896,6 +914,7 @@ abstract class ResidentRunner {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
}) async {
|
||||
if (!debuggingOptions.debuggingEnabled) {
|
||||
throw 'The service protocol is not enabled.';
|
||||
@ -909,6 +928,7 @@ abstract class ResidentRunner {
|
||||
reloadSources: reloadSources,
|
||||
restart: restart,
|
||||
compileExpression: compileExpression,
|
||||
reloadMethod: reloadMethod,
|
||||
);
|
||||
await device.getVMs();
|
||||
await device.refreshViews();
|
||||
|
@ -145,6 +145,57 @@ class HotRunner extends ResidentRunner {
|
||||
throw 'Failed to compile $expression';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OperationResult> reloadMethod({String libraryId, String classId}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
final UpdateFSReport results = UpdateFSReport(success: true);
|
||||
final List<Uri> invalidated = <Uri>[Uri.parse(libraryId)];
|
||||
for (FlutterDevice device in flutterDevices) {
|
||||
results.incorporateResults(await device.updateDevFS(
|
||||
mainPath: mainPath,
|
||||
target: target,
|
||||
bundle: assetBundle,
|
||||
firstBuildTime: firstBuildTime,
|
||||
bundleFirstUpload: false,
|
||||
bundleDirty: false,
|
||||
fullRestart: false,
|
||||
projectRootPath: projectRootPath,
|
||||
pathToReload: getReloadPath(fullRestart: false),
|
||||
invalidatedFiles: invalidated,
|
||||
dillOutputPath: dillOutputPath,
|
||||
));
|
||||
}
|
||||
if (!results.success) {
|
||||
return OperationResult(1, 'Failed to compile');
|
||||
}
|
||||
try {
|
||||
final String entryPath = fs.path.relative(
|
||||
getReloadPath(fullRestart: false),
|
||||
from: projectRootPath,
|
||||
);
|
||||
for (FlutterDevice device in flutterDevices) {
|
||||
final List<Future<Map<String, dynamic>>> reportFutures = device.reloadSources(
|
||||
entryPath, pause: false,
|
||||
);
|
||||
final List<Map<String, dynamic>> reports = await Future.wait(reportFutures);
|
||||
final Map<String, dynamic> firstReport = reports.first;
|
||||
await device.updateReloadStatus(validateReloadReport(firstReport, printErrors: false));
|
||||
}
|
||||
} catch (error) {
|
||||
return OperationResult(1, error.toString());
|
||||
}
|
||||
|
||||
for (FlutterDevice device in flutterDevices) {
|
||||
for (FlutterView view in device.views) {
|
||||
await view.uiIsolate.flutterFastReassemble(classId);
|
||||
}
|
||||
}
|
||||
|
||||
printStatus('reloadMethod took ${stopwatch.elapsedMilliseconds}');
|
||||
flutterUsage.sendTiming('hot', 'ui', stopwatch.elapsed);
|
||||
return OperationResult.ok;
|
||||
}
|
||||
|
||||
// Returns the exit code of the flutter tool process, like [run].
|
||||
@override
|
||||
Future<int> attach({
|
||||
@ -157,6 +208,7 @@ class HotRunner extends ResidentRunner {
|
||||
reloadSources: _reloadSourcesService,
|
||||
restart: _restartService,
|
||||
compileExpression: _compileExpressionService,
|
||||
reloadMethod: reloadMethod,
|
||||
);
|
||||
} catch (error) {
|
||||
printError('Error connecting to the service protocol: $error');
|
||||
|
@ -60,6 +60,11 @@ typedef CompileExpression = Future<String> Function(
|
||||
bool isStatic,
|
||||
);
|
||||
|
||||
typedef ReloadMethod = Future<void> Function({
|
||||
String classId,
|
||||
String libraryId,
|
||||
});
|
||||
|
||||
Future<StreamChannel<String>> _defaultOpenChannel(Uri uri, {io.CompressionOptions compression = io.CompressionOptions.compressionDefault}) async {
|
||||
Duration delay = const Duration(milliseconds: 100);
|
||||
int attempts = 0;
|
||||
@ -102,6 +107,7 @@ typedef VMServiceConnector = Future<VMService> Function(Uri httpUri, {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
io.CompressionOptions compression,
|
||||
Device device,
|
||||
});
|
||||
@ -117,6 +123,7 @@ class VMService {
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
Device device,
|
||||
ReloadMethod reloadMethod,
|
||||
) {
|
||||
_vm = VM._empty(this);
|
||||
_peer.listen().catchError(_connectionError.completeError);
|
||||
@ -150,15 +157,21 @@ class VMService {
|
||||
'alias': 'Flutter Tools',
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
if (reloadMethod != null) {
|
||||
// Register a special method for hot UI. while this is implemented
|
||||
// currently in the same way as hot reload, it leaves the tool free
|
||||
// to change to a more efficient implementation in the future.
|
||||
//
|
||||
// `library` should be the file URI of the updated code.
|
||||
// `class` should be the name of the Widget subclass to be marked dirty. For example,
|
||||
// if the build method of a StatelessWidget is updated, this is the name of class.
|
||||
// If the build method of a StatefulWidget is updated, then this is the name
|
||||
// of the Widget class that created the State object.
|
||||
_peer.registerMethod('reloadMethod', (rpc.Parameters params) async {
|
||||
final String isolateId = params['isolateId'].value as String;
|
||||
final String libraryId = params['library'].value as String;
|
||||
final String classId = params['class'].value as String;
|
||||
final String methodId = params['method'].value as String;
|
||||
final String methodBody = params['methodBody'].value as String;
|
||||
|
||||
if (libraryId.isEmpty) {
|
||||
throw rpc.RpcException.invalidParams('Invalid \'libraryId\': $libraryId');
|
||||
@ -166,17 +179,14 @@ class VMService {
|
||||
if (classId.isEmpty) {
|
||||
throw rpc.RpcException.invalidParams('Invalid \'classId\': $classId');
|
||||
}
|
||||
if (methodId.isEmpty) {
|
||||
throw rpc.RpcException.invalidParams('Invalid \'methodId\': $methodId');
|
||||
}
|
||||
if (methodBody.isEmpty) {
|
||||
throw rpc.RpcException.invalidParams('Invalid \'methodBody\': $methodBody');
|
||||
}
|
||||
|
||||
printTrace('reloadMethod not yet supported, falling back to hot reload');
|
||||
|
||||
try {
|
||||
await reloadSources(isolateId);
|
||||
await reloadMethod(
|
||||
libraryId: libraryId,
|
||||
classId: classId,
|
||||
);
|
||||
return <String, String>{'type': 'Success'};
|
||||
} on rpc.RpcException {
|
||||
rethrow;
|
||||
@ -298,6 +308,7 @@ class VMService {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
io.CompressionOptions compression = io.CompressionOptions.compressionDefault,
|
||||
Device device,
|
||||
}) async {
|
||||
@ -308,6 +319,7 @@ class VMService {
|
||||
compileExpression: compileExpression,
|
||||
compression: compression,
|
||||
device: device,
|
||||
reloadMethod: reloadMethod,
|
||||
);
|
||||
}
|
||||
|
||||
@ -316,13 +328,23 @@ class VMService {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
io.CompressionOptions compression = io.CompressionOptions.compressionDefault,
|
||||
Device device,
|
||||
}) async {
|
||||
final Uri wsUri = httpUri.replace(scheme: 'ws', path: fs.path.join(httpUri.path, 'ws'));
|
||||
final StreamChannel<String> channel = await _openChannel(wsUri, compression: compression);
|
||||
final rpc.Peer peer = rpc.Peer.withoutJson(jsonDocument.bind(channel), onUnhandledError: _unhandledError);
|
||||
final VMService service = VMService(peer, httpUri, wsUri, reloadSources, restart, compileExpression, device);
|
||||
final VMService service = VMService(
|
||||
peer,
|
||||
httpUri,
|
||||
wsUri,
|
||||
reloadSources,
|
||||
restart,
|
||||
compileExpression,
|
||||
device,
|
||||
reloadMethod,
|
||||
);
|
||||
// This call is to ensure we are able to establish a connection instead of
|
||||
// keeping on trucking and failing farther down the process.
|
||||
await service._sendRequest('getVersion', const <String, dynamic>{});
|
||||
@ -1336,6 +1358,12 @@ class Isolate extends ServiceObjectOwner {
|
||||
return invokeFlutterExtensionRpcRaw('ext.flutter.reassemble');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> flutterFastReassemble(String classId) {
|
||||
return invokeFlutterExtensionRpcRaw('ext.flutter.fastReassemble', params: <String, Object>{
|
||||
'class': classId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> flutterAlreadyPaintedFirstUsefulFrame() async {
|
||||
final Map<String, dynamic> result = await invokeFlutterExtensionRpcRaw('ext.flutter.didSendFirstFrameRasterizedEvent');
|
||||
// result might be null when the service extension is not initialized
|
||||
|
@ -280,6 +280,7 @@ class WebDevFS implements DevFS {
|
||||
String projectRootPath,
|
||||
String pathToReload,
|
||||
List<Uri> invalidatedFiles,
|
||||
bool skipAssets = false,
|
||||
}) async {
|
||||
assert(trackWidgetCreation != null);
|
||||
assert(generator != null);
|
||||
|
@ -747,6 +747,7 @@ VMServiceConnector getFakeVmServiceFactory({
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
CompressionOptions compression,
|
||||
Device device,
|
||||
}) async {
|
||||
|
@ -183,6 +183,7 @@ class TestFlutterDevice extends FlutterDevice {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
}) async {
|
||||
throw exception;
|
||||
}
|
||||
|
@ -397,6 +397,7 @@ class TestFlutterDevice extends FlutterDevice {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
}) async {
|
||||
throw exception;
|
||||
}
|
||||
|
@ -664,6 +664,7 @@ void main() {
|
||||
ReloadSources reloadSources,
|
||||
Restart restart,
|
||||
CompileExpression compileExpression,
|
||||
ReloadMethod reloadMethod,
|
||||
io.CompressionOptions compression,
|
||||
Device device,
|
||||
}) async => mockVMService,
|
||||
|
@ -198,7 +198,7 @@ void main() {
|
||||
bool done = false;
|
||||
final MockPeer mockPeer = MockPeer();
|
||||
expect(mockPeer.returnedFromSendRequest, 0);
|
||||
final VMService vmService = VMService(mockPeer, null, null, null, null, null, null);
|
||||
final VMService vmService = VMService(mockPeer, null, null, null, null, null, null, null);
|
||||
expect(mockPeer.sentNotifications, contains('registerService'));
|
||||
final List<String> registeredServices =
|
||||
mockPeer.sentNotifications['registerService']
|
||||
@ -270,8 +270,8 @@ void main() {
|
||||
testUsingContext('registers hot UI method', () {
|
||||
FakeAsync().run((FakeAsync time) {
|
||||
final MockPeer mockPeer = MockPeer();
|
||||
Future<void> reloadSources(String isolateId, { bool pause, bool force}) async {}
|
||||
VMService(mockPeer, null, null, reloadSources, null, null, null);
|
||||
Future<void> reloadMethod({ String classId, String libraryId }) async {}
|
||||
VMService(mockPeer, null, null, null, null, null, null, reloadMethod);
|
||||
|
||||
expect(mockPeer.registeredMethods, contains('reloadMethod'));
|
||||
});
|
||||
@ -285,7 +285,7 @@ void main() {
|
||||
final MockDevice mockDevice = MockDevice();
|
||||
final MockPeer mockPeer = MockPeer();
|
||||
Future<void> reloadSources(String isolateId, { bool pause, bool force}) async {}
|
||||
VMService(mockPeer, null, null, reloadSources, null, null, mockDevice);
|
||||
VMService(mockPeer, null, null, reloadSources, null, null, mockDevice, null);
|
||||
|
||||
expect(mockPeer.registeredMethods, contains('flutterMemoryInfo'));
|
||||
});
|
||||
|
@ -48,6 +48,23 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('reloadMethod triggers hot reload behavior', () async {
|
||||
await _flutter.run();
|
||||
_project.uncommentHotReloadPrint();
|
||||
final StringBuffer stdout = StringBuffer();
|
||||
final StreamSubscription<String> subscription = _flutter.stdout.listen(stdout.writeln);
|
||||
try {
|
||||
final String libraryId = _project.buildBreakpointUri.toString();
|
||||
await _flutter.reloadMethod(libraryId: libraryId, classId: 'MyApp');
|
||||
// reloadMethod does not wait for the next frame, to allow scheduling a new
|
||||
// update while the previous update was pending.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
expect(stdout.toString(), contains('(((((RELOAD WORKED)))))'));
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test('hot restart works without error', () async {
|
||||
await _flutter.run();
|
||||
await _flutter.hotRestart();
|
||||
|
@ -538,6 +538,29 @@ class FlutterRunTestDriver extends FlutterTestDriver {
|
||||
Future<void> hotRestart({ bool pause = false }) => _restart(fullRestart: true, pause: pause);
|
||||
Future<void> hotReload() => _restart(fullRestart: false);
|
||||
|
||||
Future<void> scheduleFrame() async {
|
||||
if (_currentRunningAppId == null) {
|
||||
throw Exception('App has not started yet');
|
||||
}
|
||||
await _sendRequest(
|
||||
'app.callServiceExtension',
|
||||
<String, dynamic>{'appId': _currentRunningAppId, 'methodName': 'ext.ui.window.scheduleFrame'},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reloadMethod({ String libraryId, String classId }) async {
|
||||
if (_currentRunningAppId == null) {
|
||||
throw Exception('App has not started yet');
|
||||
}
|
||||
final dynamic reloadMethodResponse = await _sendRequest(
|
||||
'app.reloadMethod',
|
||||
<String, dynamic>{'appId': _currentRunningAppId, 'class': classId, 'library': libraryId},
|
||||
);
|
||||
if (reloadMethodResponse == null || reloadMethodResponse['code'] != 0) {
|
||||
_throwErrorResponse('reloadMethodResponse request failed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restart({ bool fullRestart = false, bool pause = false }) async {
|
||||
if (_currentRunningAppId == null) {
|
||||
throw Exception('App has not started yet');
|
||||
|
Loading…
x
Reference in New Issue
Block a user