From 1b21d69de45212e1dedd81d0a44c39740ee6a3b0 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Wed, 23 Jan 2019 16:32:50 -0800 Subject: [PATCH] Support running macOS prebuilt application (#26593) --- .../lib/src/application_package.dart | 4 + .../lib/src/ios/plist_utils.dart | 1 + .../lib/src/macos/application_package.dart | 91 +++++++++++++++ .../lib/src/macos/macos_device.dart | 108 ++++++++++++++---- .../lib/src/macos/macos_workflow.dart | 2 +- .../test/macos/macos_device_test.dart | 100 ++++++++++++++-- .../test/macos/macos_workflow_test.dart | 8 ++ 7 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 packages/flutter_tools/lib/src/macos/application_package.dart diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart index 8c89e5df4a3..9379e781c79 100644 --- a/packages/flutter_tools/lib/src/application_package.dart +++ b/packages/flutter_tools/lib/src/application_package.dart @@ -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: diff --git a/packages/flutter_tools/lib/src/ios/plist_utils.dart b/packages/flutter_tools/lib/src/ios/plist_utils.dart index 0c75ad29b4e..7a2e085d1d5 100644 --- a/packages/flutter_tools/lib/src/ios/plist_utils.dart +++ b/packages/flutter_tools/lib/src/ios/plist_utils.dart @@ -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) { diff --git a/packages/flutter_tools/lib/src/macos/application_package.dart b/packages/flutter_tools/lib/src/macos/application_package.dart new file mode 100644 index 00000000000..71c26aac068 --- /dev/null +++ b/packages/flutter_tools/lib/src/macos/application_package.dart @@ -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; +} diff --git a/packages/flutter_tools/lib/src/macos/macos_device.dart b/packages/flutter_tools/lib/src/macos/macos_device.dart index 656145b0057..529226f0caa 100644 --- a/packages/flutter_tools/lib/src/macos/macos_device.dart +++ b/packages/flutter_tools/lib/src/macos/macos_device.dart @@ -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 installApp(ApplicationPackage app) { - throw UnimplementedError(); - } + Future 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 isAppInstalled(ApplicationPackage app) { - throw UnimplementedError(); - } + Future 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 isLatestBuildInstalled(ApplicationPackage app) { - throw UnimplementedError(); - } + Future isLatestBuildInstalled(ApplicationPackage app) async => true; @override Future 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 get sdkNameAndVersion async => os.name; @override - Future startApp(ApplicationPackage package, { + Future 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([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 stopApp(ApplicationPackage app) { - throw UnimplementedError(); + Future stopApp(covariant MacOSApp app) async { + final RegExp whitespace = RegExp(r'\s+'); + bool succeeded = true; + try { + final ProcessResult result = await processManager.run([ + 'ps', 'aux', + ]); + if (result.exitCode != 0) { + return false; + } + final List lines = result.stdout.split('\n'); + for (String line in lines) { + if (!line.contains(app.executable)) { + continue; + } + final List values = line.split(whitespace); + if (values.length < 2) { + continue; + } + final String pid = values[1]; + final ProcessResult killResult = await processManager.run([ + 'kill', pid + ]); + succeeded &= killResult.exitCode == 0; + } + return true; + } on ArgumentError { + succeeded = false; + } + return succeeded; } @override Future 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 uninstallApp(ApplicationPackage app) { - throw UnimplementedError(); - } + Future 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> getDiagnostics() async => const []; } + +class MacOSLogReader extends DeviceLogReader { + MacOSLogReader(this.macOSApp, this.process); + + final MacOSApp macOSApp; + final Process process; + + @override + Stream get logLines { + return process.stdout.transform(utf8.decoder); + } + + @override + String get name => macOSApp.displayName; +} diff --git a/packages/flutter_tools/lib/src/macos/macos_workflow.dart b/packages/flutter_tools/lib/src/macos/macos_workflow.dart index 48f29e93fcc..62154219ff0 100644 --- a/packages/flutter_tools/lib/src/macos/macos_workflow.dart +++ b/packages/flutter_tools/lib/src/macos/macos_workflow.dart @@ -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. diff --git a/packages/flutter_tools/test/macos/macos_device_test.dart b/packages/flutter_tools/test/macos/macos_device_test.dart index ba5e19d02c0..e379eddfdb9 100644 --- a/packages/flutter_tools/test/macos/macos_device_test.dart +++ b/packages/flutter_tools/test/macos/macos_device_test.dart @@ -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 {}); - - 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())); - expect(() => device.uninstallApp(null), throwsA(isInstanceOf())); - expect(() => device.isLatestBuildInstalled(null), throwsA(isInstanceOf())); - expect(() => device.startApp(null), throwsA(isInstanceOf())); - expect(() => device.stopApp(null), throwsA(isInstanceOf())); - expect(() => device.isAppInstalled(null), throwsA(isInstanceOf())); + 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: { + 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(['ps', 'aux'])).thenAnswer((Invocation invocation) async { + return ProcessResult(1, 0, psOut, ''); + }); + when(mockProcessManager.run(['kill', '17193'])).thenAnswer((Invocation invocation) async { + return ProcessResult(2, 0, '', ''); + }); + expect(await device.stopApp(mockMacOSApp), true); + verify(mockProcessManager.run(['kill', '17193'])); + }, overrides: { + 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(['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>.fromIterable(>[ + 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: { + 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 {} diff --git a/packages/flutter_tools/test/macos/macos_workflow_test.dart b/packages/flutter_tools/test/macos/macos_workflow_test.dart index 9352ef1ba4c..0cc0ba37452 100644 --- a/packages/flutter_tools/test/macos/macos_workflow_test.dart +++ b/packages/flutter_tools/test/macos/macos_workflow_test.dart @@ -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: { @@ -45,3 +51,5 @@ class MockPlatform extends Mock implements Platform { @override Map environment = {}; } + +class MockProcessManager extends Mock implements ProcessManager {}