Unverified Commit c2f5bf99 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add macos project auto migration code for FlutterApplication (#122336)

Add macos project auto migration code for FlutterApplication
parent 2ba0d0d7
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -29,6 +29,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -30,6 +30,9 @@ class PlistParser {
static const String kCFBundleVersionKey = 'CFBundleVersion';
static const String kCFBundleDisplayNameKey = 'CFBundleDisplayName';
static const String kMinimumOSVersionKey = 'MinimumOSVersion';
static const String kNSPrincipalClassKey = 'NSPrincipalClass';
static const String _plutilExecutable = '/usr/bin/plutil';
/// Returns the content, converted to XML, of the plist file located at
/// [plistFilePath].
......@@ -39,12 +42,11 @@ class PlistParser {
///
/// The [plistFilePath] argument must not be null.
String? plistXmlContent(String plistFilePath) {
const String executable = '/usr/bin/plutil';
if (!_fileSystem.isFileSync(executable)) {
throw const FileNotFoundException(executable);
if (!_fileSystem.isFileSync(_plutilExecutable)) {
throw const FileNotFoundException(_plutilExecutable);
}
final List<String> args = <String>[
executable, '-convert', 'xml1', '-o', '-', plistFilePath,
_plutilExecutable, '-convert', 'xml1', '-o', '-', plistFilePath,
];
try {
final String xmlContent = _processUtils.runSync(
......@@ -53,11 +55,42 @@ class PlistParser {
).stdout.trim();
return xmlContent;
} on ProcessException catch (error) {
_logger.printTrace('$error');
_logger.printError('$error');
return null;
}
}
/// Replaces the string key in the given plist file with the given value.
///
/// If the value is null, then the key will be removed.
///
/// Returns true if successful.
bool replaceKey(String plistFilePath, {required String key, String? value }) {
if (!_fileSystem.isFileSync(_plutilExecutable)) {
throw const FileNotFoundException(_plutilExecutable);
}
final List<String> args;
if (value == null) {
args = <String>[
_plutilExecutable, '-remove', key, plistFilePath,
];
} else {
args = <String>[
_plutilExecutable, '-replace', key, '-string', value, plistFilePath,
];
}
try {
_processUtils.runSync(
args,
throwOnError: true,
);
} on ProcessException catch (error) {
_logger.printError('$error');
return false;
}
return true;
}
/// Parses the plist file located at [plistFilePath] and returns the
/// associated map of key/value property list pairs.
///
......
......@@ -17,6 +17,7 @@ import '../migrations/xcode_script_build_phase_migration.dart';
import '../migrations/xcode_thin_binary_build_phase_input_paths_migration.dart';
import '../project.dart';
import 'cocoapod_utils.dart';
import 'migrations/flutter_application_migration.dart';
import 'migrations/macos_deployment_target_migration.dart';
import 'migrations/remove_macos_framework_link_and_embedding_migration.dart';
......@@ -57,6 +58,7 @@ Future<void> buildMacOS({
XcodeProjectObjectVersionMigration(flutterProject.macos, globals.logger),
XcodeScriptBuildPhaseMigration(flutterProject.macos, globals.logger),
XcodeThinBinaryBuildPhaseInputPathsMigration(flutterProject.macos, globals.logger),
FlutterApplicationMigration(flutterProject.macos, globals.logger),
];
final ProjectMigration migration = ProjectMigration(migrators);
......
// 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 '../../base/file_system.dart';
import '../../base/project_migrator.dart';
import '../../globals.dart' as globals;
import '../../ios/plist_parser.dart';
import '../../xcode_project.dart';
/// Update the minimum macOS deployment version to the minimum allowed by Xcode without causing a warning.
class FlutterApplicationMigration extends ProjectMigrator {
FlutterApplicationMigration(
MacOSProject project,
super.logger,
) : _infoPlistFile = project.defaultHostInfoPlist;
final File _infoPlistFile;
@override
void migrate() {
if (_infoPlistFile.existsSync()) {
final String? principleClass =
globals.plistParser.getStringValueFromFile(_infoPlistFile.path, PlistParser.kNSPrincipalClassKey);
if (principleClass == null || principleClass == 'FlutterApplication') {
// No NSPrincipalClass defined, or already converted, so no migration
// needed.
return;
}
if (principleClass != 'NSApplication') {
// Only replace NSApplication values, since we don't know why they might
// have substituted something else.
logger.printTrace('${_infoPlistFile.basename} has an '
'${PlistParser.kNSPrincipalClassKey} of $principleClass, not '
'NSApplication, skipping FlutterApplication migration.\nYou will need '
'to modify your application class to derive from FlutterApplication.');
return;
}
logger.printStatus('Updating ${_infoPlistFile.basename} to use FlutterApplication instead of NSApplication.');
final bool success = globals.plistParser.replaceKey(_infoPlistFile.path, key: PlistParser.kNSPrincipalClassKey, value: 'FlutterApplication');
if (!success) {
logger.printError('Updating ${_infoPlistFile.basename} failed.');
}
} else {
logger.printTrace('${_infoPlistFile.basename} not found, skipping FlutterApplication migration.');
}
}
}
......@@ -27,6 +27,6 @@
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<string>FlutterApplication</string>
</dict>
</plist>
......@@ -5,13 +5,17 @@
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/ios/plist_parser.dart';
import 'package:flutter_tools/src/macos/migrations/flutter_application_migration.dart';
import 'package:flutter_tools/src/macos/migrations/macos_deployment_target_migration.dart';
import 'package:flutter_tools/src/macos/migrations/remove_macos_framework_link_and_embedding_migration.dart';
import 'package:flutter_tools/src/project.dart';
import 'package:flutter_tools/src/reporting/reporting.dart';
import 'package:flutter_tools/src/xcode_project.dart';
import 'package:test/fake.dart';
import '../../src/common.dart';
import '../../src/context.dart';
import '../../src/fakes.dart';
void main() {
group('remove link and embed migration', () {
......@@ -275,12 +279,107 @@ platform :osx, '10.14'
expect('Updating minimum macOS deployment target to 10.14'.allMatches(testLogger.statusText).length, 1);
});
});
group('update NSPrincipalClass to FlutterApplication', () {
late MemoryFileSystem memoryFileSystem;
late BufferLogger testLogger;
late FakeMacOSProject project;
late File infoPlistFile;
late FakePlistParser fakePlistParser;
late FlutterProjectFactory flutterProjectFactory;
setUp(() {
memoryFileSystem = MemoryFileSystem();
fakePlistParser = FakePlistParser();
testLogger = BufferLogger.test();
project = FakeMacOSProject();
infoPlistFile = memoryFileSystem.file('Info.plist');
project.defaultHostInfoPlist = infoPlistFile;
flutterProjectFactory = FlutterProjectFactory(
fileSystem: memoryFileSystem,
logger: testLogger,
);
});
void testWithMocks(String description, Future<void> Function() testMethod) {
testUsingContext(description, testMethod, overrides: <Type, Generator>{
FileSystem: () => memoryFileSystem,
ProcessManager: () => FakeProcessManager.any(),
PlistParser: () => fakePlistParser,
FlutterProjectFactory: () => flutterProjectFactory,
});
}
testWithMocks('skipped if files are missing', () async {
final FlutterApplicationMigration macOSProjectMigration = FlutterApplicationMigration(
project,
testLogger,
);
macOSProjectMigration.migrate();
expect(infoPlistFile.existsSync(), isFalse);
expect(testLogger.traceText, contains('${infoPlistFile.basename} not found, skipping FlutterApplication migration.'));
expect(testLogger.statusText, isEmpty);
});
testWithMocks('skipped if no NSPrincipalClass key exists to upgrade', () async {
final FlutterApplicationMigration macOSProjectMigration = FlutterApplicationMigration(
project,
testLogger,
);
infoPlistFile.writeAsStringSync('contents'); // Just so it exists: parser is a fake.
macOSProjectMigration.migrate();
expect(fakePlistParser.getStringValueFromFile(infoPlistFile.path, PlistParser.kNSPrincipalClassKey), isNull);
expect(testLogger.statusText, isEmpty);
});
testWithMocks('skipped if already upgraded', () async {
fakePlistParser.setProperty(PlistParser.kNSPrincipalClassKey, 'FlutterApplication');
final FlutterApplicationMigration macOSProjectMigration = FlutterApplicationMigration(
project,
testLogger,
);
infoPlistFile.writeAsStringSync('contents'); // Just so it exists: parser is a fake.
macOSProjectMigration.migrate();
expect(fakePlistParser.getStringValueFromFile(infoPlistFile.path, PlistParser.kNSPrincipalClassKey), 'FlutterApplication');
expect(testLogger.statusText, isEmpty);
});
testWithMocks('Info.plist migrated to use FlutterApplication', () async {
fakePlistParser.setProperty(PlistParser.kNSPrincipalClassKey, 'NSApplication');
final FlutterApplicationMigration macOSProjectMigration = FlutterApplicationMigration(
project,
testLogger,
);
infoPlistFile.writeAsStringSync('contents'); // Just so it exists: parser is a fake.
macOSProjectMigration.migrate();
expect(fakePlistParser.getStringValueFromFile(infoPlistFile.path, PlistParser.kNSPrincipalClassKey), 'FlutterApplication');
// Only print once.
expect('Updating ${infoPlistFile.basename} to use FlutterApplication instead of NSApplication.'.allMatches(testLogger.statusText).length, 1);
});
testWithMocks('Skip if NSPrincipalClass is not NSApplication', () async {
const String differentApp = 'DIFFERENTApplication';
fakePlistParser.setProperty(PlistParser.kNSPrincipalClassKey, differentApp);
final FlutterApplicationMigration macOSProjectMigration = FlutterApplicationMigration(
project,
testLogger,
);
infoPlistFile.writeAsStringSync('contents'); // Just so it exists: parser is a fake.
macOSProjectMigration.migrate();
expect(fakePlistParser.getStringValueFromFile(infoPlistFile.path, PlistParser.kNSPrincipalClassKey), differentApp);
expect(testLogger.traceText, contains('${infoPlistFile.basename} has an ${PlistParser.kNSPrincipalClassKey} of $differentApp, not NSApplication, skipping FlutterApplication migration'));
});
});
}
class FakeMacOSProject extends Fake implements MacOSProject {
@override
File xcodeProjectInfoFile = MemoryFileSystem.test().file('xcodeProjectInfoFile');
@override
File defaultHostInfoPlist = MemoryFileSystem.test().file('InfoplistFile');
@override
File podfile = MemoryFileSystem.test().file('Podfile');
}
......@@ -74,7 +74,7 @@ void main() {
file.deleteSync();
});
testWithoutContext('PlistParser.getStringValueFromFile works with xml file', () {
testWithoutContext('PlistParser.getStringValueFromFile works with an XML file', () {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
......@@ -83,7 +83,7 @@ void main() {
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getStringValueFromFile works with binary file', () {
testWithoutContext('PlistParser.getStringValueFromFile works with a binary file', () {
file.writeAsBytesSync(base64.decode(base64PlistBinary));
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
......@@ -92,7 +92,7 @@ void main() {
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getStringValueFromFile works with json file', () {
testWithoutContext('PlistParser.getStringValueFromFile works with a JSON file', () {
file.writeAsBytesSync(base64.decode(base64PlistJson));
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
......@@ -101,13 +101,13 @@ void main() {
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getStringValueFromFile returns null for non-existent plist file', () {
testWithoutContext('PlistParser.getStringValueFromFile returns null for a non-existent plist file', () {
expect(parser.getStringValueFromFile('missing.plist', 'CFBundleIdentifier'), null);
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getStringValueFromFile returns null for non-existent key within plist', () {
testWithoutContext('PlistParser.getStringValueFromFile returns null for a non-existent key within a plist', () {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.getStringValueFromFile(file.path, 'BadKey'), null);
......@@ -116,12 +116,15 @@ void main() {
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getStringValueFromFile returns null for malformed plist file', () {
testWithoutContext('PlistParser.getStringValueFromFile returns null for a malformed plist file', () {
file.writeAsBytesSync(const <int>[1, 2, 3, 4, 5, 6]);
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), null);
expect(logger.statusText, isNotEmpty);
expect(logger.errorText, isEmpty);
expect(logger.statusText, contains('Property List error: Unexpected character \x01 at line 1 / '
'JSON error: JSON text did not start with array or object and option to allow fragments not '
'set. around line 1, column 0.\n'));
expect(logger.errorText, 'ProcessException: The command failed\n'
' Command: /usr/bin/plutil -convert xml1 -o - ${file.absolute.path}\n');
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getStringValueFromFile throws when /usr/bin/plutil is not found', () async {
......@@ -133,7 +136,68 @@ void main() {
);
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
}, skip: platform.isMacOS); // [intended] requires macos tool chain.
}, skip: platform.isMacOS); // [intended] requires absence of macos tool chain.
testWithoutContext('PlistParser.replaceKey can replace a key', () async {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(parser.replaceKey(file.path, key: 'CFBundleIdentifier', value: 'dev.flutter.fake'), isTrue);
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), equals('dev.flutter.fake'));
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.replaceKey can create a new key', () async {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.getStringValueFromFile(file.path, 'CFNewKey'), isNull);
expect(parser.replaceKey(file.path, key: 'CFNewKey', value: 'dev.flutter.fake'), isTrue);
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
expect(parser.getStringValueFromFile(file.path, 'CFNewKey'), equals('dev.flutter.fake'));
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.replaceKey can delete a key', () async {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.replaceKey(file.path, key: 'CFBundleIdentifier'), isTrue);
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), isNull);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.replaceKey throws when /usr/bin/plutil is not found', () async {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(
() => parser.replaceKey(file.path, key: 'CFBundleIdentifier', value: 'dev.flutter.fake'),
throwsA(isA<FileNotFoundException>()),
);
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
}, skip: platform.isMacOS); // [intended] requires absence of macos tool chain.
testWithoutContext('PlistParser.replaceKey returns false for a malformed plist file', () {
file.writeAsBytesSync(const <int>[1, 2, 3, 4, 5, 6]);
expect(parser.replaceKey(file.path, key: 'CFBundleIdentifier', value: 'dev.flutter.fake'), isFalse);
expect(logger.statusText, contains('foo.plist: Property List error: Unexpected character \x01 '
'at line 1 / JSON error: JSON text did not start with array or object and option to allow '
'fragments not set. around line 1, column 0.\n'));
expect(logger.errorText, equals('ProcessException: The command failed\n'
' Command: /usr/bin/plutil -replace CFBundleIdentifier -string dev.flutter.fake foo.plist\n'));
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.replaceKey works with a JSON file', () {
file.writeAsBytesSync(base64.decode(base64PlistJson));
expect(parser.getStringValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(parser.replaceKey(file.path, key:'CFBundleIdentifier', value: 'dev.flutter.fake'), isTrue);
expect(parser.getStringValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'dev.flutter.fake');
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.parseFile can handle different datatypes', () async {
file.writeAsBytesSync(base64.decode(base64PlistXmlWithComplexDatatypes));
......
......@@ -298,6 +298,16 @@ class FakePlistParser implements PlistParser {
String? getStringValueFromFile(String plistFilePath, String key) {
return _underlyingValues[key] as String?;
}
@override
bool replaceKey(String plistFilePath, {required String key, String? value}) {
if (value == null) {
_underlyingValues.remove(key);
return true;
}
setProperty(key, value);
return true;
}
}
class FakeBotDetector implements BotDetector {
......
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