diff --git a/packages/flutter_tools/lib/src/macos/application_package.dart b/packages/flutter_tools/lib/src/macos/application_package.dart index 57f15ade722..27e93f8fc16 100644 --- a/packages/flutter_tools/lib/src/macos/application_package.dart +++ b/packages/flutter_tools/lib/src/macos/application_package.dart @@ -6,6 +6,7 @@ import 'package:meta/meta.dart'; import '../application_package.dart'; import '../base/file_system.dart'; +import '../base/io.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../globals.dart' as globals; @@ -32,18 +33,21 @@ abstract class MacOSApp extends ApplicationPackage { /// which is expected to start the application and send the observatory /// port over stdout. factory MacOSApp.fromPrebuiltApp(FileSystemEntity applicationBinary) { - final _ExecutableAndId executableAndId = _executableFromBundle(applicationBinary); - final Directory applicationBundle = globals.fs.directory(applicationBinary); + final _BundleInfo bundleInfo = _executableFromBundle(applicationBinary); + if (bundleInfo == null) { + return null; + } + return PrebuiltMacOSApp( - bundleDir: applicationBundle, - bundleName: applicationBundle.path, - projectBundleId: executableAndId.id, - executable: executableAndId.executable, + bundleDir: bundleInfo.bundle, + bundleName: bundleInfo.bundle.path, + projectBundleId: bundleInfo.id, + executable: bundleInfo.executable, ); } /// Look up the executable name for a macOS application bundle. - static _ExecutableAndId _executableFromBundle(FileSystemEntity applicationBundle) { + static _BundleInfo _executableFromBundle(FileSystemEntity applicationBundle) { final FileSystemEntityType entityType = globals.fs.typeSync(applicationBundle.path); if (entityType == FileSystemEntityType.notFound) { globals.printError('File "${applicationBundle.path}" does not exist.'); @@ -58,8 +62,23 @@ abstract class MacOSApp extends ApplicationPackage { } bundleDir = globals.fs.directory(applicationBundle); } else { - globals.printError('Folder "${applicationBundle.path}" is not an app bundle.'); - return null; + // Try to unpack as a zip. + final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_app.'); + try { + globals.os.unzip(globals.fs.file(applicationBundle), tempDir); + } on ProcessException { + globals.printError('Invalid prebuilt macOS app. Unable to extract bundle from archive.'); + return null; + } + try { + bundleDir = tempDir + .listSync() + .whereType() + .singleWhere(_isBundleDirectory); + } on StateError { + globals.printError('Archive "${applicationBundle.path}" does not contain a single app bundle.'); + return null; + } } final String plistPath = globals.fs.path.join(bundleDir.path, 'Contents', 'Info.plist'); if (!globals.fs.file(plistPath).existsSync()) { @@ -73,11 +92,15 @@ abstract class MacOSApp extends ApplicationPackage { globals.printError('Invalid prebuilt macOS app. Info.plist does not contain bundle identifier'); return null; } + if (executableName == null) { + globals.printError('Invalid prebuilt macOS app. Info.plist does not contain bundle executable'); + return null; + } final String executable = globals.fs.path.join(bundleDir.path, 'Contents', 'MacOS', executableName); if (!globals.fs.file(executable).existsSync()) { globals.printError('Could not find macOS binary at $executable'); } - return _ExecutableAndId(executable, id); + return _BundleInfo(executable, id, bundleDir); } @override @@ -142,14 +165,15 @@ class BuildableMacOSApp extends MacOSApp { if (directory == null) { return null; } - final _ExecutableAndId executableAndId = MacOSApp._executableFromBundle(globals.fs.directory(directory)); - return executableAndId?.executable; + final _BundleInfo bundleInfo = MacOSApp._executableFromBundle(globals.fs.directory(directory)); + return bundleInfo?.executable; } } -class _ExecutableAndId { - _ExecutableAndId(this.executable, this.id); +class _BundleInfo { + _BundleInfo(this.executable, this.id, this.bundle); + final Directory bundle; final String executable; final String id; } diff --git a/packages/flutter_tools/test/general.shard/macos/application_package_test.dart b/packages/flutter_tools/test/general.shard/macos/application_package_test.dart new file mode 100644 index 00000000000..36848b5ad9e --- /dev/null +++ b/packages/flutter_tools/test/general.shard/macos/application_package_test.dart @@ -0,0 +1,230 @@ +// 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:convert'; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/os.dart'; +import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/utils.dart'; +import 'package:flutter_tools/src/ios/plist_parser.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/macos/application_package.dart'; +import 'package:mockito/mockito.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +void main() { + group('PrebuiltMacOSApp', () { + MockOperatingSystemUtils os; + final Map overrides = { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + PlistParser: () => MockPlistUtils(), + Platform: _kNoColorTerminalPlatform, + OperatingSystemUtils: () => os, + }; + + setUp(() { + os = MockOperatingSystemUtils(); + }); + + testUsingContext('Error on non-existing file', () { + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('not_existing.app')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect( + testLogger.errorText, + 'File "not_existing.app" does not exist.\n', + ); + }, overrides: overrides); + + testUsingContext('Error on non-app-bundle folder', () { + globals.fs.directory('regular_folder').createSync(); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('regular_folder')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect(testLogger.errorText, + 'Folder "regular_folder" is not an app bundle.\n'); + }, overrides: overrides); + + testUsingContext('Error on no info.plist', () { + globals.fs.directory('bundle.app').createSync(); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('bundle.app')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect( + testLogger.errorText, + 'Invalid prebuilt macOS app. Does not contain Info.plist.\n', + ); + }, overrides: overrides); + + testUsingContext('Error on info.plist missing bundle identifier', () { + final String contentsDirectory = + globals.fs.path.join('bundle.app', 'Contents'); + globals.fs.directory(contentsDirectory).createSync(recursive: true); + globals.fs + .file(globals.fs.path.join('bundle.app', 'Contents', 'Info.plist')) + .writeAsStringSync(badPlistData); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('bundle.app')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect( + testLogger.errorText, + contains( + 'Invalid prebuilt macOS app. Info.plist does not contain bundle identifier\n'), + ); + }, overrides: overrides); + + testUsingContext('Error on info.plist missing executable', () { + final String contentsDirectory = + globals.fs.path.join('bundle.app', 'Contents'); + globals.fs.directory(contentsDirectory).createSync(recursive: true); + globals.fs + .file(globals.fs.path.join('bundle.app', 'Contents', 'Info.plist')) + .writeAsStringSync(badPlistDataNoExecutable); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('bundle.app')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect( + testLogger.errorText, + contains( + 'Invalid prebuilt macOS app. Info.plist does not contain bundle executable\n'), + ); + }, overrides: overrides); + + testUsingContext('Success with app bundle', () { + final String appDirectory = + globals.fs.path.join('bundle.app', 'Contents', 'MacOS'); + globals.fs.directory(appDirectory).createSync(recursive: true); + globals.fs + .file(globals.fs.path.join('bundle.app', 'Contents', 'Info.plist')) + .writeAsStringSync(plistData); + globals.fs + .file(globals.fs.path.join(appDirectory, executableName)) + .createSync(); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('bundle.app')) + as PrebuiltMacOSApp; + expect(testLogger.errorText, isEmpty); + expect(macosApp.bundleDir.path, 'bundle.app'); + expect(macosApp.id, 'fooBundleId'); + expect(macosApp.bundleName, 'bundle.app'); + }, overrides: overrides); + + testUsingContext('Bad zipped app, no payload dir', () { + globals.fs.file('app.zip').createSync(); + when(os.unzip(globals.fs.file('app.zip'), any)) + .thenAnswer((Invocation _) {}); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('app.zip')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect( + testLogger.errorText, + 'Archive "app.zip" does not contain a single app bundle.\n', + ); + }, overrides: overrides); + + testUsingContext('Bad zipped app, two app bundles', () { + globals.fs.file('app.zip').createSync(); + when(os.unzip(any, any)).thenAnswer((Invocation invocation) { + final File zipFile = invocation.positionalArguments[0] as File; + if (zipFile.path != 'app.zip') { + return; + } + final Directory targetDirectory = + invocation.positionalArguments[1] as Directory; + final String bundlePath1 = + globals.fs.path.join(targetDirectory.path, 'bundle1.app'); + final String bundlePath2 = + globals.fs.path.join(targetDirectory.path, 'bundle2.app'); + globals.fs.directory(bundlePath1).createSync(recursive: true); + globals.fs.directory(bundlePath2).createSync(recursive: true); + }); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('app.zip')) + as PrebuiltMacOSApp; + expect(macosApp, isNull); + expect(testLogger.errorText, + 'Archive "app.zip" does not contain a single app bundle.\n'); + }, overrides: overrides); + + testUsingContext('Success with zipped app', () { + globals.fs.file('app.zip').createSync(); + when(os.unzip(any, any)).thenAnswer((Invocation invocation) { + final File zipFile = invocation.positionalArguments[0] as File; + if (zipFile.path != 'app.zip') { + return; + } + final Directory targetDirectory = + invocation.positionalArguments[1] as Directory; + final Directory bundleAppContentsDir = globals.fs.directory(globals + .fs.path + .join(targetDirectory.path, 'bundle.app', 'Contents')); + bundleAppContentsDir.createSync(recursive: true); + globals.fs + .file(globals.fs.path.join(bundleAppContentsDir.path, 'Info.plist')) + .writeAsStringSync(plistData); + globals.fs + .directory(globals.fs.path.join(bundleAppContentsDir.path, 'MacOS')) + .createSync(); + globals.fs + .file(globals.fs.path + .join(bundleAppContentsDir.path, 'MacOS', executableName)) + .createSync(); + }); + final PrebuiltMacOSApp macosApp = + MacOSApp.fromPrebuiltApp(globals.fs.file('app.zip')) + as PrebuiltMacOSApp; + expect(testLogger.errorText, isEmpty); + expect(macosApp.bundleDir.path, endsWith('bundle.app')); + expect(macosApp.id, 'fooBundleId'); + expect(macosApp.bundleName, endsWith('bundle.app')); + }, overrides: overrides); + }); +} + +class MockOperatingSystemUtils extends Mock implements OperatingSystemUtils {} + +final Generator _kNoColorTerminalPlatform = + () => FakePlatform(stdoutSupportsAnsi: false); +final Map noColorTerminalOverride = { + Platform: _kNoColorTerminalPlatform, +}; + +class MockPlistUtils extends Mock implements PlistParser { + @override + Map parseFile(String plistFilePath) { + final File file = globals.fs.file(plistFilePath); + if (!file.existsSync()) { + return {}; + } + return castStringKeyedMap(json.decode(file.readAsStringSync())); + } +} + +// Contains no bundle identifier. +const String badPlistData = ''' +{} +'''; + +// Contains no bundle executable. +const String badPlistDataNoExecutable = ''' +{"CFBundleIdentifier": "fooBundleId"} +'''; + +const String executableName = 'foo'; + +const String plistData = ''' +{"CFBundleIdentifier": "fooBundleId", "CFBundleExecutable": "$executableName"} +''';