Added support for authentication codes for the VM service. (#30857)
* Added support for authentication codes for the VM service. Previously, a valid web socket connection would use the following URI: `ws://127.0.0.1/ws` Now, by default, the VM service requires a connection to be made with a URI similar to the following: `ws://127.0.0.1:8181/Ug_U0QVsqFs=/ws` where `Ug_U0QVsqFs` is an authentication code generated and shared by the service. This behavior can be disabled with the `--disable-service-auth-codes` flag.
This commit is contained in:
parent
086fd993c6
commit
3764cb8515
@ -1 +1 @@
|
|||||||
4b9966f5cb412a73fa50462b3aee9082f436a62a
|
ca31a7c57bada458fa7f5c0d3f36bc1af4ccbc79
|
||||||
|
@ -26,7 +26,7 @@ void main() {
|
|||||||
print('run: starting...');
|
print('run: starting...');
|
||||||
final Process run = await startProcess(
|
final Process run = await startProcess(
|
||||||
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
||||||
<String>['run', '--verbose', '-d', device.deviceId, 'lib/commands.dart'],
|
<String>['run', '--verbose', '--disable-service-auth-codes', '-d', device.deviceId, 'lib/commands.dart'],
|
||||||
);
|
);
|
||||||
final StreamController<String> stdout = StreamController<String>.broadcast();
|
final StreamController<String> stdout = StreamController<String>.broadcast();
|
||||||
run.stdout
|
run.stdout
|
||||||
|
@ -21,7 +21,8 @@ void main() {
|
|||||||
section('Compile and run the tester app');
|
section('Compile and run the tester app');
|
||||||
Completer<void> firstNameFound = Completer<void>();
|
Completer<void> firstNameFound = Completer<void>();
|
||||||
Completer<void> secondNameFound = Completer<void>();
|
Completer<void> secondNameFound = Completer<void>();
|
||||||
final Process runProcess = await _run(device: device, command: <String>['run'], stdoutListener: (String line) {
|
final Process runProcess = await _run(device: device, command:
|
||||||
|
<String>['run', '--disable-service-auth-codes'], stdoutListener: (String line) {
|
||||||
if (line.contains(_kFirstIsolateName)) {
|
if (line.contains(_kFirstIsolateName)) {
|
||||||
firstNameFound.complete();
|
firstNameFound.complete();
|
||||||
} else if (line.contains(_kSecondIsolateName)) {
|
} else if (line.contains(_kSecondIsolateName)) {
|
||||||
|
@ -34,7 +34,7 @@ void main() {
|
|||||||
print('run: starting...');
|
print('run: starting...');
|
||||||
final Process run = await startProcess(
|
final Process run = await startProcess(
|
||||||
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
||||||
<String>['run', '--verbose', '-d', device.deviceId, '--route', '/smuggle-it', 'lib/route.dart'],
|
<String>['run', '--verbose', '--disable-service-auth-codes', '-d', device.deviceId, '--route', '/smuggle-it', 'lib/route.dart'],
|
||||||
);
|
);
|
||||||
run.stdout
|
run.stdout
|
||||||
.transform<String>(utf8.decoder)
|
.transform<String>(utf8.decoder)
|
||||||
|
@ -26,7 +26,7 @@ void main() {
|
|||||||
print('run: starting...');
|
print('run: starting...');
|
||||||
final Process run = await startProcess(
|
final Process run = await startProcess(
|
||||||
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
||||||
<String>['run', '--verbose', '-d', device.deviceId, 'lib/main.dart'],
|
<String>['run', '--verbose', '--disable-service-auth-codes', '-d', device.deviceId, 'lib/main.dart'],
|
||||||
);
|
);
|
||||||
run.stdout
|
run.stdout
|
||||||
.transform<String>(utf8.decoder)
|
.transform<String>(utf8.decoder)
|
||||||
|
@ -41,16 +41,16 @@ Future<Map<String, dynamic>> runTask(String taskName, { bool silent = false }) a
|
|||||||
runnerFinished = true;
|
runnerFinished = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
final Completer<int> port = Completer<int>();
|
final Completer<Uri> uri = Completer<Uri>();
|
||||||
|
|
||||||
final StreamSubscription<String> stdoutSub = runner.stdout
|
final StreamSubscription<String> stdoutSub = runner.stdout
|
||||||
.transform<String>(const Utf8Decoder())
|
.transform<String>(const Utf8Decoder())
|
||||||
.transform<String>(const LineSplitter())
|
.transform<String>(const LineSplitter())
|
||||||
.listen((String line) {
|
.listen((String line) {
|
||||||
if (!port.isCompleted) {
|
if (!uri.isCompleted) {
|
||||||
final int portValue = parseServicePort(line, prefix: 'Observatory listening on ');
|
final Uri serviceUri = parseServiceUri(line, prefix: 'Observatory listening on ');
|
||||||
if (portValue != null)
|
if (serviceUri != null)
|
||||||
port.complete(portValue);
|
uri.complete(serviceUri);
|
||||||
}
|
}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
stdout.writeln('[$taskName] [STDOUT] $line');
|
stdout.writeln('[$taskName] [STDOUT] $line');
|
||||||
@ -66,7 +66,7 @@ Future<Map<String, dynamic>> runTask(String taskName, { bool silent = false }) a
|
|||||||
|
|
||||||
String waitingFor = 'connection';
|
String waitingFor = 'connection';
|
||||||
try {
|
try {
|
||||||
final VMIsolateRef isolate = await _connectToRunnerIsolate(await port.future);
|
final VMIsolateRef isolate = await _connectToRunnerIsolate(await uri.future);
|
||||||
waitingFor = 'task completion';
|
waitingFor = 'task completion';
|
||||||
final Map<String, dynamic> taskResult =
|
final Map<String, dynamic> taskResult =
|
||||||
await isolate.invokeExtension('ext.cocoonRunTask').timeout(taskTimeoutWithGracePeriod);
|
await isolate.invokeExtension('ext.cocoonRunTask').timeout(taskTimeoutWithGracePeriod);
|
||||||
@ -88,8 +88,15 @@ Future<Map<String, dynamic>> runTask(String taskName, { bool silent = false }) a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<VMIsolateRef> _connectToRunnerIsolate(int vmServicePort) async {
|
Future<VMIsolateRef> _connectToRunnerIsolate(Uri vmServiceUri) async {
|
||||||
final String url = 'ws://localhost:$vmServicePort/ws';
|
final List<String> pathSegments = <String>[];
|
||||||
|
if (vmServiceUri.pathSegments.isNotEmpty) {
|
||||||
|
// Add authentication code.
|
||||||
|
pathSegments.add(vmServiceUri.pathSegments[0]);
|
||||||
|
}
|
||||||
|
pathSegments.add('ws');
|
||||||
|
final String url = vmServiceUri.replace(scheme: 'ws', pathSegments:
|
||||||
|
pathSegments).toString();
|
||||||
final DateTime started = DateTime.now();
|
final DateTime started = DateTime.now();
|
||||||
|
|
||||||
// TODO(yjbanov): due to lack of imagination at the moment the handshake with
|
// TODO(yjbanov): due to lack of imagination at the moment the handshake with
|
||||||
|
@ -529,19 +529,43 @@ String extractCloudAuthTokenArg(List<String> rawArgs) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final RegExp _obsRegExp =
|
||||||
|
RegExp('An Observatory debugger .* is available at: ');
|
||||||
|
final RegExp _obsPortRegExp = RegExp('(\\S+:(\\d+)/\\S*)\$');
|
||||||
|
final RegExp _obsUriRegExp = RegExp('((http|\/\/)[a-zA-Z0-9:/=_\\-\.\\[\\]]+)');
|
||||||
|
|
||||||
/// Tries to extract a port from the string.
|
/// Tries to extract a port from the string.
|
||||||
///
|
///
|
||||||
/// The `prefix`, if specified, is a regular expression pattern and must not contain groups.
|
/// The `prefix`, if specified, is a regular expression pattern and must not contain groups.
|
||||||
///
|
/// `prefix` defaults to the RegExp: `An Observatory debugger .* is available at: `.
|
||||||
/// The `multiLine` flag should be set to true if `line` is actually a buffer of many lines.
|
|
||||||
int parseServicePort(String line, {
|
int parseServicePort(String line, {
|
||||||
String prefix = 'An Observatory debugger .* is available at: ',
|
Pattern prefix,
|
||||||
bool multiLine = false,
|
|
||||||
}) {
|
}) {
|
||||||
// e.g. "An Observatory debugger and profiler on ... is available at: http://127.0.0.1:8100/"
|
prefix ??= _obsRegExp;
|
||||||
final RegExp pattern = RegExp('$prefix(\\S+:(\\d+)/\\S*)\$', multiLine: multiLine);
|
final Match prefixMatch = prefix.matchAsPrefix(line);
|
||||||
final Match match = pattern.firstMatch(line);
|
if (prefixMatch == null) {
|
||||||
return match == null ? null : int.parse(match.group(2));
|
return null;
|
||||||
|
}
|
||||||
|
final List<Match> matches =
|
||||||
|
_obsPortRegExp.allMatches(line, prefixMatch.end).toList();
|
||||||
|
return matches.isEmpty ? null : int.parse(matches[0].group(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to extract a Uri from the string.
|
||||||
|
///
|
||||||
|
/// The `prefix`, if specified, is a regular expression pattern and must not contain groups.
|
||||||
|
/// `prefix` defaults to the RegExp: `An Observatory debugger .* is available at: `.
|
||||||
|
Uri parseServiceUri(String line, {
|
||||||
|
Pattern prefix,
|
||||||
|
}) {
|
||||||
|
prefix ??= _obsRegExp;
|
||||||
|
final Match prefixMatch = prefix.matchAsPrefix(line);
|
||||||
|
if (prefixMatch == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final List<Match> matches =
|
||||||
|
_obsUriRegExp.allMatches(line, prefixMatch.end).toList();
|
||||||
|
return matches.isEmpty ? null : Uri.parse(matches[0].group(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If FLUTTER_ENGINE environment variable is set then we need to pass
|
/// If FLUTTER_ENGINE environment variable is set then we need to pass
|
||||||
|
@ -16,4 +16,21 @@ void main() {
|
|||||||
expect(grep(RegExp('^b'), from: 'ab\nba'), <String>['ba']);
|
expect(grep(RegExp('^b'), from: 'ab\nba'), <String>['ba']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('parse service', () {
|
||||||
|
const String badOutput = 'No uri here';
|
||||||
|
const String sampleOutput = 'An Observatory debugger and profiler on '
|
||||||
|
'Pixel 3 XL is available at: http://127.0.0.1:9090/LpjUpsdEjqI=/';
|
||||||
|
|
||||||
|
test('uri', () {
|
||||||
|
expect(parseServiceUri(sampleOutput),
|
||||||
|
Uri.parse('http://127.0.0.1:9090/LpjUpsdEjqI=/'));
|
||||||
|
expect(parseServiceUri(badOutput), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port', () {
|
||||||
|
expect(parseServicePort(sampleOutput), 9090);
|
||||||
|
expect(parseServicePort(badOutput), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -438,6 +438,8 @@ class AndroidDevice extends Device {
|
|||||||
}
|
}
|
||||||
if (debuggingOptions.startPaused)
|
if (debuggingOptions.startPaused)
|
||||||
cmd.addAll(<String>['--ez', 'start-paused', 'true']);
|
cmd.addAll(<String>['--ez', 'start-paused', 'true']);
|
||||||
|
if (debuggingOptions.disableServiceAuthCodes)
|
||||||
|
cmd.addAll(<String>['--ez', 'disable-service-auth-codes', 'true']);
|
||||||
if (debuggingOptions.useTestFonts)
|
if (debuggingOptions.useTestFonts)
|
||||||
cmd.addAll(<String>['--ez', 'use-test-fonts', 'true']);
|
cmd.addAll(<String>['--ez', 'use-test-fonts', 'true']);
|
||||||
if (debuggingOptions.verboseSystemLogs) {
|
if (debuggingOptions.verboseSystemLogs) {
|
||||||
|
@ -31,6 +31,12 @@ import '../runner/flutter_command.dart';
|
|||||||
/// With an application already running, a HotRunner can be attached to it
|
/// With an application already running, a HotRunner can be attached to it
|
||||||
/// with:
|
/// with:
|
||||||
/// ```
|
/// ```
|
||||||
|
/// $ flutter attach --debug-uri http://127.0.0.1:12345/QqL7EFEDNG0=/
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// If `--disable-service-auth-codes` was provided to the application at startup
|
||||||
|
/// time, a HotRunner can be attached with just a port:
|
||||||
|
/// ```
|
||||||
/// $ flutter attach --debug-port 12345
|
/// $ flutter attach --debug-port 12345
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
@ -56,7 +62,14 @@ class AttachCommand extends FlutterCommand {
|
|||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'debug-port',
|
'debug-port',
|
||||||
help: 'Device port where the observatory is listening.',
|
hide: !verboseHelp,
|
||||||
|
help: 'Device port where the observatory is listening. Requires '
|
||||||
|
'--disable-service-auth-codes to also be provided to the Flutter '
|
||||||
|
'application at launch, otherwise this command will fail to connect to '
|
||||||
|
'the application. In general, --debug-uri should be used instead.',
|
||||||
|
)..addOption(
|
||||||
|
'debug-uri',
|
||||||
|
help: 'The URI at which the observatory is listening.',
|
||||||
)..addOption(
|
)..addOption(
|
||||||
'app-id',
|
'app-id',
|
||||||
help: 'The package name (Android) or bundle identifier (iOS) for the application. '
|
help: 'The package name (Android) or bundle identifier (iOS) for the application. '
|
||||||
@ -102,6 +115,17 @@ class AttachCommand extends FlutterCommand {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uri get debugUri {
|
||||||
|
if (argResults['debug-uri'] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Uri uri = Uri.parse(argResults['debug-uri']);
|
||||||
|
if (!uri.hasPort) {
|
||||||
|
throwToolExit('Port not specified for `--debug-uri`: $uri');
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
String get appId {
|
String get appId {
|
||||||
return argResults['app-id'];
|
return argResults['app-id'];
|
||||||
}
|
}
|
||||||
@ -112,24 +136,26 @@ class AttachCommand extends FlutterCommand {
|
|||||||
if (await findTargetDevice() == null)
|
if (await findTargetDevice() == null)
|
||||||
throwToolExit(null);
|
throwToolExit(null);
|
||||||
debugPort;
|
debugPort;
|
||||||
if (debugPort == null && argResults.wasParsed(FlutterCommand.ipv6Flag)) {
|
if (debugPort == null && debugUri == null && argResults.wasParsed(FlutterCommand.ipv6Flag)) {
|
||||||
throwToolExit(
|
throwToolExit(
|
||||||
'When the --debug-port is unknown, this command determines '
|
'When the --debug-port or --debug-uri is unknown, this command determines '
|
||||||
'the value of --ipv6 on its own.',
|
'the value of --ipv6 on its own.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (debugPort == null && argResults.wasParsed(FlutterCommand.observatoryPortOption)) {
|
if (debugPort == null && debugUri == null && argResults.wasParsed(FlutterCommand.observatoryPortOption)) {
|
||||||
throwToolExit(
|
throwToolExit(
|
||||||
'When the --debug-port is unknown, this command does not use '
|
'When the --debug-port or --debug-uri is unknown, this command does not use '
|
||||||
'the value of --observatory-port.',
|
'the value of --observatory-port.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (debugPort != null && debugUri != null) {
|
||||||
|
throwToolExit(
|
||||||
|
'Either --debugPort or --debugUri can be provided, not both.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FlutterCommandResult> runCommand() async {
|
Future<FlutterCommandResult> runCommand() async {
|
||||||
final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
|
|
||||||
final String ipv6Loopback = InternetAddress.loopbackIPv6.address;
|
|
||||||
final FlutterProject flutterProject = await FlutterProject.current();
|
final FlutterProject flutterProject = await FlutterProject.current();
|
||||||
|
|
||||||
Cache.releaseLockEarly();
|
Cache.releaseLockEarly();
|
||||||
@ -147,7 +173,6 @@ class AttachCommand extends FlutterCommand {
|
|||||||
// simulators support it.
|
// simulators support it.
|
||||||
// If/when we do this on Android or other platforms, we can update it here.
|
// If/when we do this on Android or other platforms, we can update it here.
|
||||||
if (device is IOSDevice || device is IOSSimulator) {
|
if (device is IOSDevice || device is IOSSimulator) {
|
||||||
return MDnsObservatoryPortDiscovery().queryForPort(applicationId: appId);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -159,9 +184,13 @@ class AttachCommand extends FlutterCommand {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
Uri observatoryUri;
|
Uri observatoryUri;
|
||||||
bool usesIpv6 = false;
|
bool usesIpv6 = ipv6;
|
||||||
|
final String ipv6Loopback = InternetAddress.loopbackIPv6.address;
|
||||||
|
final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
|
||||||
|
final String hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback;
|
||||||
|
|
||||||
bool attachLogger = false;
|
bool attachLogger = false;
|
||||||
if (devicePort == null) {
|
if (devicePort == null && debugUri == null) {
|
||||||
if (device is FuchsiaDevice) {
|
if (device is FuchsiaDevice) {
|
||||||
attachLogger = true;
|
attachLogger = true;
|
||||||
final String module = argResults['module'];
|
final String module = argResults['module'];
|
||||||
@ -181,7 +210,12 @@ class AttachCommand extends FlutterCommand {
|
|||||||
}
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
} else {
|
} else if ((device is IOSDevice) || (device is IOSSimulator)) {
|
||||||
|
final MDnsObservatoryDiscoveryResult result = await MDnsObservatoryDiscovery().query(applicationId: appId);
|
||||||
|
observatoryUri = await _buildObservatoryUri(device, hostname, result.port, result.authCode);
|
||||||
|
}
|
||||||
|
// If MDNS discovery fails or we're not on iOS, fallback to ProtocolDiscovery.
|
||||||
|
if (observatoryUri == null) {
|
||||||
ProtocolDiscovery observatoryDiscovery;
|
ProtocolDiscovery observatoryDiscovery;
|
||||||
try {
|
try {
|
||||||
observatoryDiscovery = ProtocolDiscovery.observatory(
|
observatoryDiscovery = ProtocolDiscovery.observatory(
|
||||||
@ -198,12 +232,8 @@ class AttachCommand extends FlutterCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
usesIpv6 = ipv6;
|
observatoryUri = await _buildObservatoryUri(device,
|
||||||
final int localPort = observatoryPort
|
debugUri?.host ?? hostname, devicePort, debugUri?.path);
|
||||||
?? await device.portForwarder.forward(devicePort);
|
|
||||||
observatoryUri = usesIpv6
|
|
||||||
? Uri.parse('http://[$ipv6Loopback]:$localPort/')
|
|
||||||
: Uri.parse('http://$ipv4Loopback:$localPort/');
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final bool useHot = getBuildInfo().isDebug;
|
final bool useHot = getBuildInfo().isDebug;
|
||||||
@ -277,6 +307,22 @@ class AttachCommand extends FlutterCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _validateArguments() async { }
|
Future<void> _validateArguments() async { }
|
||||||
|
|
||||||
|
Future<Uri> _buildObservatoryUri(Device device,
|
||||||
|
String host, int devicePort, [String authCode]) async {
|
||||||
|
String path = '/';
|
||||||
|
if (authCode != null) {
|
||||||
|
path = authCode;
|
||||||
|
}
|
||||||
|
// Not having a trailing slash can cause problems in some situations.
|
||||||
|
// Ensure that there's one present.
|
||||||
|
if (!path.endsWith('/')) {
|
||||||
|
path += '/';
|
||||||
|
}
|
||||||
|
final int localPort = observatoryPort
|
||||||
|
?? await device.portForwarder.forward(devicePort);
|
||||||
|
return Uri(scheme: 'http', host: host, port: localPort, path: path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HotRunnerFactory {
|
class HotRunnerFactory {
|
||||||
@ -310,15 +356,21 @@ class HotRunnerFactory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wrapper around [MDnsClient] to find a Dart observatory port.
|
class MDnsObservatoryDiscoveryResult {
|
||||||
class MDnsObservatoryPortDiscovery {
|
MDnsObservatoryDiscoveryResult(this.port, this.authCode);
|
||||||
/// Creates a new [MDnsObservatoryPortDiscovery] object.
|
final int port;
|
||||||
|
final String authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper around [MDnsClient] to find a Dart observatory instance.
|
||||||
|
class MDnsObservatoryDiscovery {
|
||||||
|
/// Creates a new [MDnsObservatoryDiscovery] object.
|
||||||
///
|
///
|
||||||
/// The [client] parameter will be defaulted to a new [MDnsClient] if null.
|
/// The [client] parameter will be defaulted to a new [MDnsClient] if null.
|
||||||
/// The [applicationId] parameter may be null, and can be used to
|
/// The [applicationId] parameter may be null, and can be used to
|
||||||
/// automatically select which application to use if multiple are advertising
|
/// automatically select which application to use if multiple are advertising
|
||||||
/// Dart observatory ports.
|
/// Dart observatory ports.
|
||||||
MDnsObservatoryPortDiscovery({MDnsClient mdnsClient})
|
MDnsObservatoryDiscovery({MDnsClient mdnsClient})
|
||||||
: client = mdnsClient ?? MDnsClient();
|
: client = mdnsClient ?? MDnsClient();
|
||||||
|
|
||||||
/// The [MDnsClient] used to do a lookup.
|
/// The [MDnsClient] used to do a lookup.
|
||||||
@ -326,14 +378,14 @@ class MDnsObservatoryPortDiscovery {
|
|||||||
|
|
||||||
static const String dartObservatoryName = '_dartobservatory._tcp.local';
|
static const String dartObservatoryName = '_dartobservatory._tcp.local';
|
||||||
|
|
||||||
/// Executes an mDNS query for a Dart Observatory port.
|
/// Executes an mDNS query for a Dart Observatory.
|
||||||
///
|
///
|
||||||
/// The [applicationId] parameter may be used to specify which application
|
/// The [applicationId] parameter may be used to specify which application
|
||||||
/// to find. For Android, it refers to the package name; on iOS, it refers to
|
/// to find. For Android, it refers to the package name; on iOS, it refers to
|
||||||
/// the bundle ID.
|
/// the bundle ID.
|
||||||
///
|
///
|
||||||
/// If it is not null, this method will find the port of the
|
/// If it is not null, this method will find the port and authentication code
|
||||||
/// Dart Observatory for that application. If it cannot find a Dart
|
/// of the Dart Observatory for that application. If it cannot find a Dart
|
||||||
/// Observatory matching that application identifier, it will call
|
/// Observatory matching that application identifier, it will call
|
||||||
/// [throwToolExit].
|
/// [throwToolExit].
|
||||||
///
|
///
|
||||||
@ -341,9 +393,10 @@ class MDnsObservatoryPortDiscovery {
|
|||||||
/// prompted with a list of available observatory ports and asked to select
|
/// prompted with a list of available observatory ports and asked to select
|
||||||
/// one.
|
/// one.
|
||||||
///
|
///
|
||||||
/// If it is null and there is only one available port, it will return that
|
/// If it is null and there is only one available instance of Observatory,
|
||||||
/// port regardless of what application the port is for.
|
/// it will return that instance's information regardless of what application
|
||||||
Future<int> queryForPort({String applicationId}) async {
|
/// the Observatory instance is for.
|
||||||
|
Future<MDnsObservatoryDiscoveryResult> query({String applicationId}) async {
|
||||||
printStatus('Checking for advertised Dart observatories...');
|
printStatus('Checking for advertised Dart observatories...');
|
||||||
try {
|
try {
|
||||||
await client.start();
|
await client.start();
|
||||||
@ -399,7 +452,33 @@ class MDnsObservatoryPortDiscovery {
|
|||||||
printError('Unexpectedly found more than one observatory report for $domainName '
|
printError('Unexpectedly found more than one observatory report for $domainName '
|
||||||
'- using first one (${srv.first.port}).');
|
'- using first one (${srv.first.port}).');
|
||||||
}
|
}
|
||||||
return srv.first.port;
|
printStatus('Checking for authentication code for $domainName');
|
||||||
|
final List<TxtResourceRecord> txt = await client
|
||||||
|
.lookup<TxtResourceRecord>(
|
||||||
|
ResourceRecordQuery.text(domainName),
|
||||||
|
)
|
||||||
|
?.toList();
|
||||||
|
if (txt == null || txt.isEmpty) {
|
||||||
|
return MDnsObservatoryDiscoveryResult(srv.first.port, '');
|
||||||
|
}
|
||||||
|
String authCode = '';
|
||||||
|
const String authCodePrefix = 'authCode=';
|
||||||
|
String raw = txt.first.text;
|
||||||
|
// TXT has a format of [<length byte>, text], so if the length is 2,
|
||||||
|
// that means that TXT is empty.
|
||||||
|
if (raw.length > 2) {
|
||||||
|
// Remove length byte from raw txt.
|
||||||
|
raw = raw.substring(1);
|
||||||
|
if (raw.startsWith(authCodePrefix)) {
|
||||||
|
authCode = raw.substring(authCodePrefix.length);
|
||||||
|
// The Observatory currently expects a trailing '/' as part of the
|
||||||
|
// URI, otherwise an invalid authentication code response is given.
|
||||||
|
if (!authCode.endsWith('/')) {
|
||||||
|
authCode += '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MDnsObservatoryDiscoveryResult(srv.first.port, authCode);
|
||||||
} finally {
|
} finally {
|
||||||
client.stop();
|
client.stop();
|
||||||
}
|
}
|
||||||
|
@ -169,6 +169,11 @@ class RunCommand extends RunCommandBase {
|
|||||||
'results out to "refresh_benchmark.json", and exit. This flag is '
|
'results out to "refresh_benchmark.json", and exit. This flag is '
|
||||||
'intended for use in generating automated flutter benchmarks.',
|
'intended for use in generating automated flutter benchmarks.',
|
||||||
)
|
)
|
||||||
|
..addFlag('disable-service-auth-codes',
|
||||||
|
negatable: false,
|
||||||
|
hide: !verboseHelp,
|
||||||
|
help: 'No longer require an authentication code to connect to the VM '
|
||||||
|
'service (not recommended).')
|
||||||
..addOption(FlutterOptions.kExtraFrontEndOptions, hide: true)
|
..addOption(FlutterOptions.kExtraFrontEndOptions, hide: true)
|
||||||
..addOption(FlutterOptions.kExtraGenSnapshotOptions, hide: true)
|
..addOption(FlutterOptions.kExtraGenSnapshotOptions, hide: true)
|
||||||
..addMultiOption(FlutterOptions.kEnableExperiment,
|
..addMultiOption(FlutterOptions.kEnableExperiment,
|
||||||
@ -262,6 +267,7 @@ class RunCommand extends RunCommandBase {
|
|||||||
return DebuggingOptions.enabled(
|
return DebuggingOptions.enabled(
|
||||||
buildInfo,
|
buildInfo,
|
||||||
startPaused: argResults['start-paused'],
|
startPaused: argResults['start-paused'],
|
||||||
|
disableServiceAuthCodes: argResults['disable-service-auth-codes'],
|
||||||
useTestFonts: argResults['use-test-fonts'],
|
useTestFonts: argResults['use-test-fonts'],
|
||||||
enableSoftwareRendering: argResults['enable-software-rendering'],
|
enableSoftwareRendering: argResults['enable-software-rendering'],
|
||||||
skiaDeterministicRendering: argResults['skia-deterministic-rendering'],
|
skiaDeterministicRendering: argResults['skia-deterministic-rendering'],
|
||||||
|
@ -43,6 +43,13 @@ class TestCommand extends FastFlutterCommand {
|
|||||||
'Instructions for connecting with a debugger and printed to the '
|
'Instructions for connecting with a debugger and printed to the '
|
||||||
'console once the test has started.',
|
'console once the test has started.',
|
||||||
)
|
)
|
||||||
|
..addFlag('disable-service-auth-codes',
|
||||||
|
hide: !verboseHelp,
|
||||||
|
defaultsTo: false,
|
||||||
|
negatable: false,
|
||||||
|
help: 'No longer require an authentication code to connect to the VM '
|
||||||
|
'service (not recommended).'
|
||||||
|
)
|
||||||
..addFlag('coverage',
|
..addFlag('coverage',
|
||||||
defaultsTo: false,
|
defaultsTo: false,
|
||||||
negatable: false,
|
negatable: false,
|
||||||
@ -194,6 +201,9 @@ class TestCommand extends FastFlutterCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final bool disableServiceAuthCodes =
|
||||||
|
argResults['disable-service-auth-codes'];
|
||||||
|
|
||||||
final int result = await runTests(
|
final int result = await runTests(
|
||||||
files,
|
files,
|
||||||
workDir: workDir,
|
workDir: workDir,
|
||||||
@ -202,6 +212,7 @@ class TestCommand extends FastFlutterCommand {
|
|||||||
watcher: watcher,
|
watcher: watcher,
|
||||||
enableObservatory: collector != null || startPaused,
|
enableObservatory: collector != null || startPaused,
|
||||||
startPaused: startPaused,
|
startPaused: startPaused,
|
||||||
|
disableServiceAuthCodes: disableServiceAuthCodes,
|
||||||
ipv6: argResults['ipv6'],
|
ipv6: argResults['ipv6'],
|
||||||
machine: machine,
|
machine: machine,
|
||||||
trackWidgetCreation: argResults['track-widget-creation'],
|
trackWidgetCreation: argResults['track-widget-creation'],
|
||||||
|
@ -367,6 +367,7 @@ class DebuggingOptions {
|
|||||||
DebuggingOptions.enabled(
|
DebuggingOptions.enabled(
|
||||||
this.buildInfo, {
|
this.buildInfo, {
|
||||||
this.startPaused = false,
|
this.startPaused = false,
|
||||||
|
this.disableServiceAuthCodes = false,
|
||||||
this.enableSoftwareRendering = false,
|
this.enableSoftwareRendering = false,
|
||||||
this.skiaDeterministicRendering = false,
|
this.skiaDeterministicRendering = false,
|
||||||
this.traceSkia = false,
|
this.traceSkia = false,
|
||||||
@ -381,6 +382,7 @@ class DebuggingOptions {
|
|||||||
: debuggingEnabled = false,
|
: debuggingEnabled = false,
|
||||||
useTestFonts = false,
|
useTestFonts = false,
|
||||||
startPaused = false,
|
startPaused = false,
|
||||||
|
disableServiceAuthCodes = false,
|
||||||
enableSoftwareRendering = false,
|
enableSoftwareRendering = false,
|
||||||
skiaDeterministicRendering = false,
|
skiaDeterministicRendering = false,
|
||||||
traceSkia = false,
|
traceSkia = false,
|
||||||
@ -393,6 +395,7 @@ class DebuggingOptions {
|
|||||||
|
|
||||||
final BuildInfo buildInfo;
|
final BuildInfo buildInfo;
|
||||||
final bool startPaused;
|
final bool startPaused;
|
||||||
|
final bool disableServiceAuthCodes;
|
||||||
final bool enableSoftwareRendering;
|
final bool enableSoftwareRendering;
|
||||||
final bool skiaDeterministicRendering;
|
final bool skiaDeterministicRendering;
|
||||||
final bool traceSkia;
|
final bool traceSkia;
|
||||||
|
@ -279,6 +279,9 @@ class IOSDevice extends Device {
|
|||||||
if (debuggingOptions.startPaused)
|
if (debuggingOptions.startPaused)
|
||||||
launchArguments.add('--start-paused');
|
launchArguments.add('--start-paused');
|
||||||
|
|
||||||
|
if (debuggingOptions.disableServiceAuthCodes)
|
||||||
|
launchArguments.add('--disable-service-auth-codes');
|
||||||
|
|
||||||
if (debuggingOptions.useTestFonts)
|
if (debuggingOptions.useTestFonts)
|
||||||
launchArguments.add('--use-test-fonts');
|
launchArguments.add('--use-test-fonts');
|
||||||
|
|
||||||
|
@ -325,6 +325,8 @@ class IOSSimulator extends Device {
|
|||||||
]);
|
]);
|
||||||
if (debuggingOptions.startPaused)
|
if (debuggingOptions.startPaused)
|
||||||
args.add('--start-paused');
|
args.add('--start-paused');
|
||||||
|
if (debuggingOptions.disableServiceAuthCodes)
|
||||||
|
args.add('--disable-service-auth-codes');
|
||||||
if (debuggingOptions.skiaDeterministicRendering)
|
if (debuggingOptions.skiaDeterministicRendering)
|
||||||
args.add('--skia-deterministic-rendering');
|
args.add('--skia-deterministic-rendering');
|
||||||
if (debuggingOptions.useTestFonts)
|
if (debuggingOptions.useTestFonts)
|
||||||
|
@ -59,8 +59,7 @@ class ProtocolDiscovery {
|
|||||||
|
|
||||||
void _handleLine(String line) {
|
void _handleLine(String line) {
|
||||||
Uri uri;
|
Uri uri;
|
||||||
|
final RegExp r = RegExp('${RegExp.escape(serviceName)} listening on ((http|\/\/)[a-zA-Z0-9:/=_\\-\.\\[\\]]+)');
|
||||||
final RegExp r = RegExp('${RegExp.escape(serviceName)} listening on ((http|\/\/)[a-zA-Z0-9:/=\.\\[\\]]+)');
|
|
||||||
final Match match = r.firstMatch(line);
|
final Match match = r.firstMatch(line);
|
||||||
|
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
|
@ -83,6 +83,7 @@ void installHook({
|
|||||||
bool enableObservatory = false,
|
bool enableObservatory = false,
|
||||||
bool machine = false,
|
bool machine = false,
|
||||||
bool startPaused = false,
|
bool startPaused = false,
|
||||||
|
bool disableServiceAuthCodes = false,
|
||||||
int port = 0,
|
int port = 0,
|
||||||
String precompiledDillPath,
|
String precompiledDillPath,
|
||||||
Map<String, String> precompiledDillFiles,
|
Map<String, String> precompiledDillFiles,
|
||||||
@ -104,6 +105,7 @@ void installHook({
|
|||||||
machine: machine,
|
machine: machine,
|
||||||
enableObservatory: enableObservatory,
|
enableObservatory: enableObservatory,
|
||||||
startPaused: startPaused,
|
startPaused: startPaused,
|
||||||
|
disableServiceAuthCodes: disableServiceAuthCodes,
|
||||||
explicitObservatoryPort: observatoryPort,
|
explicitObservatoryPort: observatoryPort,
|
||||||
host: _kHosts[serverType],
|
host: _kHosts[serverType],
|
||||||
port: port,
|
port: port,
|
||||||
@ -385,6 +387,7 @@ class _FlutterPlatform extends PlatformPlugin {
|
|||||||
this.enableObservatory,
|
this.enableObservatory,
|
||||||
this.machine,
|
this.machine,
|
||||||
this.startPaused,
|
this.startPaused,
|
||||||
|
this.disableServiceAuthCodes,
|
||||||
this.explicitObservatoryPort,
|
this.explicitObservatoryPort,
|
||||||
this.host,
|
this.host,
|
||||||
this.port,
|
this.port,
|
||||||
@ -403,6 +406,7 @@ class _FlutterPlatform extends PlatformPlugin {
|
|||||||
final bool enableObservatory;
|
final bool enableObservatory;
|
||||||
final bool machine;
|
final bool machine;
|
||||||
final bool startPaused;
|
final bool startPaused;
|
||||||
|
final bool disableServiceAuthCodes;
|
||||||
final int explicitObservatoryPort;
|
final int explicitObservatoryPort;
|
||||||
final InternetAddress host;
|
final InternetAddress host;
|
||||||
final int port;
|
final int port;
|
||||||
@ -585,6 +589,7 @@ class _FlutterPlatform extends PlatformPlugin {
|
|||||||
packages: PackageMap.globalPackagesPath,
|
packages: PackageMap.globalPackagesPath,
|
||||||
enableObservatory: enableObservatory,
|
enableObservatory: enableObservatory,
|
||||||
startPaused: startPaused,
|
startPaused: startPaused,
|
||||||
|
disableServiceAuthCodes: disableServiceAuthCodes,
|
||||||
observatoryPort: explicitObservatoryPort,
|
observatoryPort: explicitObservatoryPort,
|
||||||
serverPort: server.port,
|
serverPort: server.port,
|
||||||
);
|
);
|
||||||
@ -932,6 +937,7 @@ class _FlutterPlatform extends PlatformPlugin {
|
|||||||
String packages,
|
String packages,
|
||||||
bool enableObservatory = false,
|
bool enableObservatory = false,
|
||||||
bool startPaused = false,
|
bool startPaused = false,
|
||||||
|
bool disableServiceAuthCodes = false,
|
||||||
int observatoryPort,
|
int observatoryPort,
|
||||||
int serverPort,
|
int serverPort,
|
||||||
}) {
|
}) {
|
||||||
@ -953,6 +959,9 @@ class _FlutterPlatform extends PlatformPlugin {
|
|||||||
if (startPaused) {
|
if (startPaused) {
|
||||||
command.add('--start-paused');
|
command.add('--start-paused');
|
||||||
}
|
}
|
||||||
|
if (disableServiceAuthCodes) {
|
||||||
|
command.add('--disable-service-auth-codes');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
command.add('--disable-observatory');
|
command.add('--disable-observatory');
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ Future<int> runTests(
|
|||||||
List<String> plainNames = const <String>[],
|
List<String> plainNames = const <String>[],
|
||||||
bool enableObservatory = false,
|
bool enableObservatory = false,
|
||||||
bool startPaused = false,
|
bool startPaused = false,
|
||||||
|
bool disableServiceAuthCodes = false,
|
||||||
bool ipv6 = false,
|
bool ipv6 = false,
|
||||||
bool machine = false,
|
bool machine = false,
|
||||||
String precompiledDillPath,
|
String precompiledDillPath,
|
||||||
@ -79,6 +80,7 @@ Future<int> runTests(
|
|||||||
enableObservatory: enableObservatory,
|
enableObservatory: enableObservatory,
|
||||||
machine: machine,
|
machine: machine,
|
||||||
startPaused: startPaused,
|
startPaused: startPaused,
|
||||||
|
disableServiceAuthCodes: disableServiceAuthCodes,
|
||||||
serverType: serverType,
|
serverType: serverType,
|
||||||
precompiledDillPath: precompiledDillPath,
|
precompiledDillPath: precompiledDillPath,
|
||||||
precompiledDillFiles: precompiledDillFiles,
|
precompiledDillFiles: precompiledDillFiles,
|
||||||
|
@ -120,6 +120,8 @@ class FlutterTesterDevice extends Device {
|
|||||||
if (debuggingOptions.debuggingEnabled) {
|
if (debuggingOptions.debuggingEnabled) {
|
||||||
if (debuggingOptions.startPaused)
|
if (debuggingOptions.startPaused)
|
||||||
command.add('--start-paused');
|
command.add('--start-paused');
|
||||||
|
if (debuggingOptions.disableServiceAuthCodes)
|
||||||
|
command.add('--disable-service-auth-codes');
|
||||||
if (debuggingOptions.hasObservatoryPort)
|
if (debuggingOptions.hasObservatoryPort)
|
||||||
command.add('--observatory-port=${debuggingOptions.observatoryPort}');
|
command.add('--observatory-port=${debuggingOptions.observatoryPort}');
|
||||||
}
|
}
|
||||||
|
@ -178,7 +178,7 @@ void main() {
|
|||||||
await expectLater(
|
await expectLater(
|
||||||
createTestCommandRunner(command).run(<String>['attach', '--ipv6']),
|
createTestCommandRunner(command).run(<String>['attach', '--ipv6']),
|
||||||
throwsToolExit(
|
throwsToolExit(
|
||||||
message: 'When the --debug-port is unknown, this command determines '
|
message: 'When the --debug-port or --debug-uri is unknown, this command determines '
|
||||||
'the value of --ipv6 on its own.',
|
'the value of --ipv6 on its own.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -193,7 +193,7 @@ void main() {
|
|||||||
await expectLater(
|
await expectLater(
|
||||||
createTestCommandRunner(command).run(<String>['attach', '--observatory-port', '100']),
|
createTestCommandRunner(command).run(<String>['attach', '--observatory-port', '100']),
|
||||||
throwsToolExit(
|
throwsToolExit(
|
||||||
message: 'When the --debug-port is unknown, this command does not use '
|
message: 'When the --debug-port or --debug-uri is unknown, this command does not use '
|
||||||
'the value of --observatory-port.',
|
'the value of --observatory-port.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -438,7 +438,7 @@ void main() {
|
|||||||
final MDnsClient client = MockMDnsClient();
|
final MDnsClient client = MockMDnsClient();
|
||||||
|
|
||||||
when(client.lookup<PtrResourceRecord>(
|
when(client.lookup<PtrResourceRecord>(
|
||||||
ResourceRecordQuery.serverPointer(MDnsObservatoryPortDiscovery.dartObservatoryName),
|
ResourceRecordQuery.serverPointer(MDnsObservatoryDiscovery.dartObservatoryName),
|
||||||
)).thenAnswer((_) => Stream<PtrResourceRecord>.fromIterable(ptrRecords));
|
)).thenAnswer((_) => Stream<PtrResourceRecord>.fromIterable(ptrRecords));
|
||||||
|
|
||||||
for (final MapEntry<String, List<SrvResourceRecord>> entry in srvResponse.entries) {
|
for (final MapEntry<String, List<SrvResourceRecord>> entry in srvResponse.entries) {
|
||||||
@ -452,8 +452,8 @@ void main() {
|
|||||||
testUsingContext('No ports available', () async {
|
testUsingContext('No ports available', () async {
|
||||||
final MDnsClient client = getMockClient(<PtrResourceRecord>[], <String, List<SrvResourceRecord>>{});
|
final MDnsClient client = getMockClient(<PtrResourceRecord>[], <String, List<SrvResourceRecord>>{});
|
||||||
|
|
||||||
final MDnsObservatoryPortDiscovery portDiscovery = MDnsObservatoryPortDiscovery(mdnsClient: client);
|
final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
|
||||||
final int port = await portDiscovery.queryForPort();
|
final int port = (await portDiscovery.query())?.port;
|
||||||
expect(port, isNull);
|
expect(port, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -469,8 +469,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final MDnsObservatoryPortDiscovery portDiscovery = MDnsObservatoryPortDiscovery(mdnsClient: client);
|
final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
|
||||||
final int port = await portDiscovery.queryForPort();
|
final int port = (await portDiscovery.query())?.port;
|
||||||
expect(port, 123);
|
expect(port, 123);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -490,8 +490,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final MDnsObservatoryPortDiscovery portDiscovery = MDnsObservatoryPortDiscovery(mdnsClient: client);
|
final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
|
||||||
expect(() => portDiscovery.queryForPort(), throwsToolExit());
|
expect(() => portDiscovery.query(), throwsToolExit());
|
||||||
});
|
});
|
||||||
|
|
||||||
testUsingContext('Multiple ports available, with appId', () async {
|
testUsingContext('Multiple ports available, with appId', () async {
|
||||||
@ -510,8 +510,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final MDnsObservatoryPortDiscovery portDiscovery = MDnsObservatoryPortDiscovery(mdnsClient: client);
|
final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
|
||||||
final int port = await portDiscovery.queryForPort(applicationId: 'fiz');
|
final int port = (await portDiscovery.query(applicationId: 'fiz'))?.port;
|
||||||
expect(port, 321);
|
expect(port, 321);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -533,8 +533,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final MDnsObservatoryPortDiscovery portDiscovery = MDnsObservatoryPortDiscovery(mdnsClient: client);
|
final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
|
||||||
final int port = await portDiscovery.queryForPort(applicationId: 'bar');
|
final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
|
||||||
expect(port, 1234);
|
expect(port, 1234);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -417,6 +417,7 @@ class FlutterRunTestDriver extends FlutterTestDriver {
|
|||||||
await _setupProcess(
|
await _setupProcess(
|
||||||
<String>[
|
<String>[
|
||||||
'run',
|
'run',
|
||||||
|
'--disable-service-auth-codes',
|
||||||
'--machine',
|
'--machine',
|
||||||
'-d',
|
'-d',
|
||||||
'flutter-tester',
|
'flutter-tester',
|
||||||
@ -607,6 +608,7 @@ class FlutterTestTestDriver extends FlutterTestDriver {
|
|||||||
}) async {
|
}) async {
|
||||||
await _setupProcess(<String>[
|
await _setupProcess(<String>[
|
||||||
'test',
|
'test',
|
||||||
|
'--disable-service-auth-codes',
|
||||||
'--machine',
|
'--machine',
|
||||||
'-d',
|
'-d',
|
||||||
'flutter-tester',
|
'flutter-tester',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user