blob: 182ac77045ac66b7b0b033dd9924209293fdb482 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'package:meta/meta.dart';
import 'package:multicast_dns/multicast_dns.dart';
import '../base/logger.dart';
import '../base/time.dart';
import '../base/utils.dart';
import '../convert.dart';
import '../mdns_device_discovery.dart';
import '../runner/flutter_command.dart';
import '../runner/flutter_command_runner.dart';
import 'run.dart';
const String _noRunningAppsFoundMessage =
'No running Flutter apps found.\n'
'Note: Flutter running-apps only detects apps running with the '
'"--${RunCommand.kEnableLocalDiscovery}" flag (debug/profile mode only).';
/// Command to list running Flutter applications.
class RunningAppsCommand extends FlutterCommand {
RunningAppsCommand({
this.hidden = false,
@visibleForTesting MDnsClient? mdnsClient,
required SystemClock systemClock,
required Logger logger,
}) : _mdnsClient = mdnsClient ?? MDnsClient(),
_systemClock = systemClock,
_logger = logger {
argParser.addFlag(
FlutterGlobalOptions.kMachineFlag,
negatable: false,
help: 'Print output in JSON format.',
);
}
final MDnsClient _mdnsClient;
final SystemClock _systemClock;
final Logger _logger;
@override
final name = 'running-apps';
@override
final bool hidden;
@override
final description = 'List running applications.';
@override
final String category = FlutterCommandCategory.tools;
@override
Future<FlutterCommandResult> runCommand() async {
_logger.printStatus('Searching for running Flutter apps...');
final apps = <MDNSObservation>[];
final seenUris = <String>{};
try {
await _mdnsClient.start();
final pendingLookups = <Future<void>>[];
// Listen for pointer records (PTR) to find services
await for (final PtrResourceRecord ptr
in _mdnsClient
.lookup<PtrResourceRecord>(
ResourceRecordQuery.serverPointer(MDNSDeviceDiscovery.kFlutterDevicesService),
)
.timeout(
const Duration(seconds: 5),
onTimeout: (EventSink<PtrResourceRecord> sink) => sink.close(),
)) {
pendingLookups.add(_resolveAppMetadata(ptr, seenUris, apps));
}
await pendingLookups.wait;
} finally {
_mdnsClient.stop();
}
if (boolArg(FlutterGlobalOptions.kMachineFlag)) {
_logger.printStatus(json.encode(apps));
return FlutterCommandResult.success();
}
if (apps.isEmpty) {
_logger.printStatus(_noRunningAppsFoundMessage);
return FlutterCommandResult.success();
}
// Sort by epoch descending (newest/shortest duration first).
apps.sort((MDNSObservation a, MDNSObservation b) {
final int epochA = a.epoch;
final int epochB = b.epoch;
return epochB.compareTo(epochA);
});
_logger.printStatus('Found ${apps.length} running Flutter ${pluralize('app', apps.length)}:');
final table = <List<String>>[];
for (final app in apps) {
final String projectName = app.projectName;
final String mode = app.mode;
final String deviceName = app.deviceName;
final String deviceId = app.deviceId;
final String platform = app.targetPlatform;
final String vmServiceUri = app.wsUri;
final String age = processAge(app.epoch, _systemClock);
// If the device name and ID are effectively the same (e.g., "macos" and "macos"),
// only show the name to avoid redundancy like "macos (macos)".
final deviceString = (deviceName.toLowerCase() == deviceId.toLowerCase())
? deviceName
: '$deviceName ($deviceId)';
table.add(<String>['$projectName ($mode)', deviceString, platform, vmServiceUri, age]);
}
// TODO(jwren): consider combining this logic with the logic in `flutter devices`,
// see https://github.com/flutter/flutter/issues/180949
// Calculate column widths
final indices = List<int>.generate(table[0].length - 1, (int i) => i);
List<int> widths = indices.map<int>((int i) => 0).toList();
for (final row in table) {
widths = indices.map<int>((int i) => math.max(widths[i], row[i].length)).toList();
}
// Join columns into lines of text
for (final row in table) {
final String rowString = indices
.map<String>((int i) => row[i].padRight(widths[i]))
.followedBy(<String>[row.last])
.join(' • ');
_logger.printStatus(' $rowString');
}
return FlutterCommandResult.success();
}
/// Resolves the app's metadata (e.g., project name, device, observatory URI)
/// from the TXT records.
///
/// The [apps] list is populated with the metadata found.
/// The [seenUris] set is used to avoid duplicate entries for the same app.
Future<void> _resolveAppMetadata(
PtrResourceRecord ptr,
Set<String> seenUris,
List<MDNSObservation> apps,
) async {
try {
// For each PTR, look up the TXT records
await for (final TxtResourceRecord txt in _mdnsClient.lookup<TxtResourceRecord>(
ResourceRecordQuery.text(ptr.domainName),
)) {
final MDNSObservation? observation = MDNSObservation.parse(txt.text);
if (observation != null) {
final String uri = observation.wsUri;
if (!seenUris.add(uri)) {
continue;
}
apps.add(observation);
}
}
} on Exception {
// Ignore errors for individual lookups
}
}
}
/// Formats the elapsed time since the given epoch.
@visibleForTesting
String processAge(int? epoch, SystemClock systemClock) {
// TODO(jwren): Consider using [DurationAgo] from `lib/src/base/utils.dart`.
// We need to decide on the width and precision, possibly modifying the utility
// to support a shorter form (e.g. "5m" versus "5 minutes ago").
if (epoch == null) {
return 'unknown age';
}
final Duration elapsed = systemClock.now().difference(DateTime.fromMillisecondsSinceEpoch(epoch));
if (elapsed.inDays > 0) {
return '${elapsed.inDays}d';
} else if (elapsed.inHours > 0) {
return '${elapsed.inHours}h';
} else if (elapsed.inMinutes > 0) {
return '${elapsed.inMinutes}m';
} else {
return '${elapsed.inSeconds}s';
}
}