Support running macOS prebuilt application (#26593)

This commit is contained in:
Jonah Williams 2019-01-23 16:32:50 -08:00 committed by GitHub
parent 65a70bc7d3
commit 1b21d69de4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 281 additions and 33 deletions

View file

@ -18,6 +18,7 @@ import 'build_info.dart';
import 'globals.dart';
import 'ios/ios_workflow.dart';
import 'ios/plist_utils.dart' as plist;
import 'macos/application_package.dart';
import 'project.dart';
import 'tester/flutter_tester.dart';
@ -42,6 +43,9 @@ class ApplicationPackageFactory {
case TargetPlatform.tester:
return FlutterTesterApp.fromCurrentDirectory();
case TargetPlatform.darwin_x64:
return applicationBinary != null
? MacOSApp.fromPrebuiltApp(applicationBinary)
: null;
case TargetPlatform.linux_x64:
case TargetPlatform.windows_x64:
case TargetPlatform.fuchsia:

View file

@ -7,6 +7,7 @@ import '../base/process.dart';
const String kCFBundleIdentifierKey = 'CFBundleIdentifier';
const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString';
const String kCFBundleExecutable = 'CFBundleExecutable';
// Prefer using [iosWorkflow.getPlistValueFromFile] to enable mocking.
String getValueFromFile(String plistFilePath, String key) {

View file

@ -0,0 +1,91 @@
// Copyright 2019 The Chromium 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 'package:meta/meta.dart';
import '../application_package.dart';
import '../base/file_system.dart';
import '../globals.dart';
import '../ios/plist_utils.dart' as plist;
/// Tests whether a [FileSystemEntity] is an macOS bundle directory
bool _isBundleDirectory(FileSystemEntity entity) =>
entity is Directory && entity.path.endsWith('.app');
abstract class MacOSApp extends ApplicationPackage {
MacOSApp({@required String projectBundleId}) : super(id: projectBundleId);
/// Creates a new [MacOSApp] from an existing app bundle.
///
/// `applicationBinary` is the path to the framework directory created by an
/// Xcode build. By default, this is located under
/// "~/Library/Developer/Xcode/DerivedData/" and contains an executable
/// which is expected to start the application and send the observatory
/// port over stdout.
factory MacOSApp.fromPrebuiltApp(FileSystemEntity applicationBinary) {
final FileSystemEntityType entityType = fs.typeSync(applicationBinary.path);
if (entityType == FileSystemEntityType.notFound) {
printError('File "${applicationBinary.path}" does not exist.');
return null;
}
Directory bundleDir;
if (entityType == FileSystemEntityType.directory) {
final Directory directory = fs.directory(applicationBinary);
if (!_isBundleDirectory(directory)) {
printError('Folder "${applicationBinary.path}" is not an app bundle.');
return null;
}
bundleDir = fs.directory(applicationBinary);
} else {
printError('Folder "${applicationBinary.path}" is not an app bundle.');
return null;
}
final String plistPath = fs.path.join(bundleDir.path, 'Contents', 'Info.plist');
if (!fs.file(plistPath).existsSync()) {
printError('Invalid prebuilt macOS app. Does not contain Info.plist.');
return null;
}
final String id = plist.getValueFromFile(plistPath, plist.kCFBundleIdentifierKey);
final String executableName = plist.getValueFromFile(plistPath, plist.kCFBundleExecutable);
if (id == null) {
printError('Invalid prebuilt macOS app. Info.plist does not contain bundle identifier');
return null;
}
final String executable = fs.path.join(bundleDir.path, 'Contents', 'MacOS', executableName);
if (!fs.file(executable).existsSync()) {
printError('Could not find macOS binary at $executable');
return null;
}
return PrebuiltMacOSApp(
bundleDir: bundleDir,
bundleName: fs.path.basename(bundleDir.path),
projectBundleId: id,
executable: executable,
);
}
@override
String get displayName => id;
String get executable;
}
class PrebuiltMacOSApp extends MacOSApp {
PrebuiltMacOSApp({
@required this.bundleDir,
@required this.bundleName,
@required this.projectBundleId,
@required this.executable,
}) : super(projectBundleId: projectBundleId);
final Directory bundleDir;
final String bundleName;
final String projectBundleId;
@override
final String executable;
@override
String get name => bundleName;
}

View file

@ -2,16 +2,23 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import '../application_package.dart';
import '../base/io.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../base/process_manager.dart';
import '../build_info.dart';
import '../device.dart';
import '../globals.dart';
import '../macos/application_package.dart';
import '../protocol_discovery.dart';
import 'macos_workflow.dart';
/// A device that represents a desktop MacOS target.
class MacOSDevice extends Device {
MacOSDevice() : super('MacOS');
MacOSDevice() : super('macOS');
@override
void clearLogs() {}
@ -19,20 +26,20 @@ class MacOSDevice extends Device {
@override
DeviceLogReader getLogReader({ApplicationPackage app}) => NoOpDeviceLogReader('macos');
// Since the host and target devices are the same, no work needs to be done
// to install the application.
@override
Future<bool> installApp(ApplicationPackage app) {
throw UnimplementedError();
}
Future<bool> installApp(ApplicationPackage app) async => true;
// Since the host and target devices are the same, no work needs to be done
// to install the application.
@override
Future<bool> isAppInstalled(ApplicationPackage app) {
throw UnimplementedError();
}
Future<bool> isAppInstalled(ApplicationPackage app) async => true;
// Since the host and target devices are the same, no work needs to be done
// to install the application.
@override
Future<bool> isLatestBuildInstalled(ApplicationPackage app) {
throw UnimplementedError();
}
Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => true;
@override
Future<bool> get isLocalEmulator async => false;
@ -41,7 +48,7 @@ class MacOSDevice extends Device {
bool isSupported() => true;
@override
String get name => 'MacOS';
String get name => 'macOS';
@override
DevicePortForwarder get portForwarder => const NoOpDevicePortForwarder();
@ -50,7 +57,7 @@ class MacOSDevice extends Device {
Future<String> get sdkNameAndVersion async => os.name;
@override
Future<LaunchResult> startApp(ApplicationPackage package, {
Future<LaunchResult> startApp(covariant MacOSApp package, {
String mainPath,
String route,
DebuggingOptions debuggingOptions,
@ -59,26 +66,72 @@ class MacOSDevice extends Device {
bool applicationNeedsRebuild = false,
bool usesTerminalUi = true,
bool ipv6 = false,
}) {
throw UnimplementedError();
}) async {
if (!prebuiltApplication) {
return LaunchResult.failed();
}
// Stop any running applications with the same executable.
await stopApp(package);
final Process process = await processManager.start(<String>[package.executable]);
final MacOSLogReader logReader = MacOSLogReader(package, process);
final ProtocolDiscovery observatoryDiscovery = ProtocolDiscovery.observatory(logReader);
try {
final Uri observatoryUri = await observatoryDiscovery.uri;
return LaunchResult.succeeded(observatoryUri: observatoryUri);
} catch (error) {
printError('Error waiting for a debug connection: $error');
return LaunchResult.failed();
} finally {
await observatoryDiscovery.cancel();
}
}
// TODO(jonahwilliams): implement using process manager.
// currently we rely on killing the isolate taking down the application.
@override
Future<bool> stopApp(ApplicationPackage app) {
throw UnimplementedError();
Future<bool> stopApp(covariant MacOSApp app) async {
final RegExp whitespace = RegExp(r'\s+');
bool succeeded = true;
try {
final ProcessResult result = await processManager.run(<String>[
'ps', 'aux',
]);
if (result.exitCode != 0) {
return false;
}
final List<String> lines = result.stdout.split('\n');
for (String line in lines) {
if (!line.contains(app.executable)) {
continue;
}
final List<String> values = line.split(whitespace);
if (values.length < 2) {
continue;
}
final String pid = values[1];
final ProcessResult killResult = await processManager.run(<String>[
'kill', pid
]);
succeeded &= killResult.exitCode == 0;
}
return true;
} on ArgumentError {
succeeded = false;
}
return succeeded;
}
@override
Future<TargetPlatform> get targetPlatform async => TargetPlatform.darwin_x64;
// Since the host and target devices are the same, no work needs to be done
// to uninstall the application.
@override
Future<bool> uninstallApp(ApplicationPackage app) {
throw UnimplementedError();
}
Future<bool> uninstallApp(ApplicationPackage app) async => true;
}
class MacOSDevices extends PollingDeviceDiscovery {
MacOSDevices() : super('macos devices');
MacOSDevices() : super('macOS devices');
@override
bool get supportsPlatform => platform.isMacOS;
@ -99,3 +152,18 @@ class MacOSDevices extends PollingDeviceDiscovery {
@override
Future<List<String>> getDiagnostics() async => const <String>[];
}
class MacOSLogReader extends DeviceLogReader {
MacOSLogReader(this.macOSApp, this.process);
final MacOSApp macOSApp;
final Process process;
@override
Stream<String> get logLines {
return process.stdout.transform(utf8.decoder);
}
@override
String get name => macOSApp.displayName;
}

View file

@ -10,7 +10,7 @@ import '../doctor.dart';
/// The [MacOSWorkflow] instance.
MacOSWorkflow get macOSWorkflow => context[MacOSWorkflow];
/// The macos-specific implementation of a [Workflow].
/// The macOS-specific implementation of a [Workflow].
///
/// This workflow requires the flutter-desktop-embedding as a sibling
/// repository to the flutter repo.

View file

@ -2,11 +2,19 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import 'package:flutter_tools/src/base/context.dart';
import 'package:mockito/mockito.dart';
import 'package:process/process.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/device.dart';
import 'package:flutter_tools/src/macos/application_package.dart';
import 'package:flutter_tools/src/macos/macos_device.dart';
import 'package:mockito/mockito.dart';
import '../src/common.dart';
import '../src/context.dart';
@ -15,21 +23,79 @@ void main() {
group(MacOSDevice, () {
final MockPlatform notMac = MockPlatform();
final MacOSDevice device = MacOSDevice();
final MockProcessManager mockProcessManager = MockProcessManager();
when(notMac.isMacOS).thenReturn(false);
when(notMac.environment).thenReturn(const <String, String>{});
test('defaults', () async {
expect(await device.targetPlatform, TargetPlatform.darwin_x64);
expect(device.name, 'MacOS');
when(mockProcessManager.run(any)).thenAnswer((Invocation invocation) async {
return ProcessResult(0, 1, '', '');
});
test('unimplemented methods', () {
expect(() => device.installApp(null), throwsA(isInstanceOf<UnimplementedError>()));
expect(() => device.uninstallApp(null), throwsA(isInstanceOf<UnimplementedError>()));
expect(() => device.isLatestBuildInstalled(null), throwsA(isInstanceOf<UnimplementedError>()));
expect(() => device.startApp(null), throwsA(isInstanceOf<UnimplementedError>()));
expect(() => device.stopApp(null), throwsA(isInstanceOf<UnimplementedError>()));
expect(() => device.isAppInstalled(null), throwsA(isInstanceOf<UnimplementedError>()));
testUsingContext('defaults', () async {
final MockMacOSApp mockMacOSApp = MockMacOSApp();
when(mockMacOSApp.executable).thenReturn('foo');
expect(await device.targetPlatform, TargetPlatform.darwin_x64);
expect(device.name, 'macOS');
expect(await device.installApp(mockMacOSApp), true);
expect(await device.uninstallApp(mockMacOSApp), true);
expect(await device.isLatestBuildInstalled(mockMacOSApp), true);
expect(await device.isAppInstalled(mockMacOSApp), true);
expect(await device.stopApp(mockMacOSApp), false);
}, overrides: <Type, Generator>{
ProcessManager: () => mockProcessManager,
});
testUsingContext('stopApp', () async {
const String psOut = r'''
tester 17193 0.0 0.2 4791128 37820 ?? S 2:27PM 0:00.09 /Applications/foo
''';
final MockMacOSApp mockMacOSApp = MockMacOSApp();
when(mockMacOSApp.executable).thenReturn('/Applications/foo');
when(mockProcessManager.run(<String>['ps', 'aux'])).thenAnswer((Invocation invocation) async {
return ProcessResult(1, 0, psOut, '');
});
when(mockProcessManager.run(<String>['kill', '17193'])).thenAnswer((Invocation invocation) async {
return ProcessResult(2, 0, '', '');
});
expect(await device.stopApp(mockMacOSApp), true);
verify(mockProcessManager.run(<String>['kill', '17193']));
}, overrides: <Type, Generator>{
ProcessManager: () => mockProcessManager,
});
group('startApp', () {
final MockMacOSApp macOSApp = MockMacOSApp();
final MockFileSystem mockFileSystem = MockFileSystem();
final MockProcessManager mockProcessManager = MockProcessManager();
final MockFile mockFile = MockFile();
final MockProcess mockProcess = MockProcess();
when(macOSApp.executable).thenReturn('test');
when(mockFileSystem.file('test')).thenReturn(mockFile);
when(mockFile.existsSync()).thenReturn(true);
when(mockProcessManager.start(<String>['test'])).thenAnswer((Invocation invocation) async {
return mockProcess;
});
when(mockProcessManager.run(any)).thenAnswer((Invocation invocation) async {
return ProcessResult(0, 1, '', '');
});
when(mockProcess.stdout).thenAnswer((Invocation invocation) {
return Stream<List<int>>.fromIterable(<List<int>>[
utf8.encode('Observatory listening on http://127.0.0.1/0'),
]);
});
test('fails without a prebuilt application', () async {
final LaunchResult result = await device.startApp(macOSApp, prebuiltApplication: false);
expect(result.started, false);
});
testUsingContext('Can run from prebuilt application', () async {
final LaunchResult result = await device.startApp(macOSApp, prebuiltApplication: true);
expect(result.started, true);
expect(result.observatoryUri, Uri.parse('http://127.0.0.1/0'));
}, overrides: <Type, Generator>{
FileSystem: () => mockFileSystem,
ProcessManager: () => mockProcessManager,
});
});
test('noop port forwarding', () async {
@ -49,3 +115,13 @@ void main() {
}
class MockPlatform extends Mock implements Platform {}
class MockMacOSApp extends Mock implements MacOSApp {}
class MockFileSystem extends Mock implements FileSystem {}
class MockFile extends Mock implements File {}
class MockProcessManager extends Mock implements ProcessManager {}
class MockProcess extends Mock implements Process {}

View file

@ -2,10 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_tools/src/base/io.dart';
import 'package:mockito/mockito.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/macos/macos_workflow.dart';
import 'package:process/process.dart';
import '../src/common.dart';
import '../src/context.dart';
@ -20,6 +22,10 @@ void main() {
when(macWithFde.isMacOS).thenReturn(true);
when(notMac.isMacOS).thenReturn(false);
final MockProcessManager mockProcessManager = MockProcessManager();
when(mockProcessManager.run(any)).thenAnswer((Invocation invocation) async {
return ProcessResult(0, 1, '', '');
});
testUsingContext('Applies to mac platform', () {
expect(macOSWorkflow.appliesToHostPlatform, true);
}, overrides: <Type, Generator>{
@ -45,3 +51,5 @@ class MockPlatform extends Mock implements Platform {
@override
Map<String, String> environment = <String, String>{};
}
class MockProcessManager extends Mock implements ProcessManager {}