Unverified Commit c4ceea39 authored by Andy Weiss's avatar Andy Weiss Committed by GitHub

[flutter_tools] Support zipped application bundles for macOS (#68854)

* [flutter_tools] Support zipped application bundles for macOS

It is not possible to directly produce a directory (.app) in some build systems
but rather it must be zip'ed before being passed to the tool for
running. This adds support for attempting to extract an application
bundle from a zip file if the bundle is not already a directory. This
uses very similar code from lib/src/application_package.dart which is
used for extracting an ipa for iOS.

This introduces tests for the macos/application_package.dart behavior which did not exist before. These tests cover the changes in the PR and some of the existing behavior, but do not cover everything in that file.
parent 8328b56e
......@@ -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<Directory>()
.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;
}
// 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<Type, Generator> overrides = <Type, Generator>{
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<Type, Generator> noColorTerminalOverride = <Type, Generator>{
Platform: _kNoColorTerminalPlatform,
};
class MockPlistUtils extends Mock implements PlistParser {
@override
Map<String, dynamic> parseFile(String plistFilePath) {
final File file = globals.fs.file(plistFilePath);
if (!file.existsSync()) {
return <String, dynamic>{};
}
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"}
''';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment