feat(shorebird_cli): create new IosPatcher (#2046)

This commit is contained in:
Felix Angelov
2024-05-09 12:42:39 -05:00
committed by GitHub
parent 92a81adf36
commit a06de49490
7 changed files with 1640 additions and 32 deletions
@@ -0,0 +1,375 @@
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:shorebird_cli/src/archive/archive.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch_new/patcher.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
typedef _LinkResult = ({int exitCode, double? linkPercentage});
/// {@template ios_patcher}
/// Functions to create an iOS patch.
/// {@endtemplate}
class IosPatcher extends Patcher {
/// {@macro ios_patcher}
IosPatcher({
required super.argResults,
required super.flavor,
required super.target,
});
// Link percentage that is considered the minimum before a user might notice.
// Our early testing has shown that about:
// - 1/3rd of patches link at 99%
// - 1/3rd of patches link between 20% and 99%
// - 1/3rd of patches link below 20%
// Most lowering is likely due to:
// https://github.com/shorebirdtech/shorebird/issues/1825
static const double minLinkPercentage = 75;
static String lowLinkPercentageWarning(double linkPercentage) {
return '''
${lightCyan.wrap('shorebird patch')} was only able to share ${linkPercentage.toStringAsFixed(1)}% of Dart code with the released app.
This means the patched code may execute slower than expected.
https://docs.shorebird.dev/status#link-percentage-ios
''';
}
String get _buildDirectory => p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
);
String get _aotOutputPath => p.join(
_buildDirectory,
'out.aot',
);
String get _vmcodeOutputPath => p.join(
_buildDirectory,
'out.vmcode',
);
static const _linkDebugInfoFileName = 'linker_diagnostic.zip';
String get _debugInfoOutputPath => p.join(
_buildDirectory,
_linkDebugInfoFileName,
);
@visibleForTesting
double? lastBuildLinkPercentage;
@override
ReleaseType get releaseType => ReleaseType.ios;
@override
String get primaryReleaseArtifactArch => 'ipa';
@override
ArchiveDiffer get archiveDiffer => IosArchiveDiffer();
@override
Future<void> assertPreconditions() async {
try {
await shorebirdValidator.validatePreconditions(
checkShorebirdInitialized: true,
checkUserIsAuthenticated: true,
validators: doctor.iosCommandValidators,
supportedOperatingSystems: {Platform.macOS},
);
} on PreconditionFailedException catch (error) {
exit(error.exitCode.code);
}
}
@override
Future<File> buildPatchArtifact() async {
final File exportOptionsPlist;
try {
exportOptionsPlist = ios.exportOptionsPlistFromArgs(argResults);
} catch (error) {
logger.err('$error');
return exit(ExitCode.usage.code);
}
try {
final shouldCodesign = argResults['codesign'] == true;
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building patch with Flutter $flutterVersionString',
);
try {
// If buildIpa is called with a different codesign value than the
// release was, we will erroneously report native diffs.
await artifactBuilder.buildIpa(
codesign: shouldCodesign,
exportOptionsPlist: exportOptionsPlist,
flavor: flavor,
target: target,
);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
rethrow;
} on ArtifactBuildException catch (error) {
buildProgress.fail('Failed to build IPA');
logger.err(error.message);
rethrow;
}
try {
final newestDillFile = artifactManager.newestAppDill();
await artifactBuilder.buildElfAotSnapshot(
appDillPath: newestDillFile.path,
outFilePath: _aotOutputPath,
);
} catch (error) {
buildProgress.fail('$error');
rethrow;
}
buildProgress.complete();
} catch (_) {
return exit(ExitCode.software.code);
}
return artifactManager.getXcarchiveDirectory()!.zipToTempFile();
}
@override
Future<Map<Arch, PatchArtifactBundle>> createPatchArtifacts({
required String appId,
required int releaseId,
}) async {
final archivePath = artifactManager.getXcarchiveDirectory()?.path;
if (archivePath == null) {
logger.err('Unable to find .xcarchive directory');
return exit(ExitCode.software.code);
}
final releaseArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: releaseId,
arch: 'xcarchive',
platform: ReleasePlatform.ios,
);
final downloadProgress = logger.progress('Downloading release artifact');
final File releaseArtifactZipFile;
try {
releaseArtifactZipFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.url),
);
if (!releaseArtifactZipFile.existsSync()) {
throw Exception('Failed to download release artifact');
}
} catch (error) {
downloadProgress.fail('$error');
return exit(ExitCode.software.code);
}
downloadProgress.complete();
final unzipProgress = logger.progress('Extracting release artifact');
final tempDir = Directory.systemTemp.createTempSync();
await artifactManager.extractZip(
zipFile: releaseArtifactZipFile,
outputDirectory: tempDir,
);
final releaseXcarchivePath = tempDir.path;
unzipProgress.complete();
final appDirectory = artifactManager.getIosAppDirectory(
xcarchiveDirectory: Directory(releaseXcarchivePath),
);
if (appDirectory == null) {
logger.err('Unable to find release artifact .app directory');
return exit(ExitCode.software.code);
}
final releaseArtifactFile = File(
p.join(
appDirectory.path,
'Frameworks',
'App.framework',
'App',
),
);
final useLinker = AotTools.usesLinker(shorebirdEnv.flutterRevision);
if (useLinker) {
final (:exitCode, :linkPercentage) = await _runLinker(
releaseArtifact: releaseArtifactFile,
);
if (exitCode != ExitCode.success.code) return exit(exitCode);
if (linkPercentage != null && linkPercentage < minLinkPercentage) {
logger.warn(lowLinkPercentageWarning(linkPercentage));
}
lastBuildLinkPercentage = linkPercentage;
}
final patchBuildFile = File(useLinker ? _vmcodeOutputPath : _aotOutputPath);
final File patchFile;
if (useLinker && await aotTools.isGeneratePatchDiffBaseSupported()) {
final patchBaseProgress = logger.progress('Generating patch diff base');
final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
);
final File patchBaseFile;
try {
// If the aot_tools executable supports the dump_blobs command, we
// can generate a stable diff base and use that to create a patch.
patchBaseFile = await aotTools.generatePatchDiffBase(
analyzeSnapshotPath: analyzeSnapshotPath,
releaseSnapshot: releaseArtifactFile,
);
patchBaseProgress.complete();
} catch (error) {
patchBaseProgress.fail('$error');
return exit(ExitCode.software.code);
}
patchFile = File(
await artifactManager.createDiff(
releaseArtifactPath: patchBaseFile.path,
patchArtifactPath: patchBuildFile.path,
),
);
} else {
patchFile = patchBuildFile;
}
final patchFileSize = patchFile.statSync().size;
return {
Arch.arm64: PatchArtifactBundle(
arch: 'aarch64',
path: patchFile.path,
hash: sha256.convert(patchBuildFile.readAsBytesSync()).toString(),
size: patchFileSize,
),
};
}
@override
Future<String> extractReleaseVersionFromArtifact(File artifact) async {
final archivePath = artifactManager.getXcarchiveDirectory()?.path;
if (archivePath == null) {
logger.err('Unable to find .xcarchive directory');
exit(ExitCode.software.code);
}
final plistFile = File(p.join(archivePath, 'Info.plist'));
if (!plistFile.existsSync()) {
logger.err('No Info.plist file found at ${plistFile.path}.');
exit(ExitCode.software.code);
}
final plist = Plist(file: plistFile);
try {
return plist.versionNumber;
} catch (error) {
logger.err(
'Failed to determine release version from ${plistFile.path}: $error',
);
exit(ExitCode.software.code);
}
}
@override
Future<CreatePatchMetadata> createPatchMetadata(DiffStatus diffStatus) async {
return CreatePatchMetadata(
releasePlatform: releaseType.releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: lastBuildLinkPercentage,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
);
}
Future<_LinkResult> _runLinker({
required File releaseArtifact,
}) async {
final patch = File(_aotOutputPath);
final dumpDebugInfo = argResults['debug-linker'] == true &&
(await aotTools.isLinkDebugInfoSupported());
if (!patch.existsSync()) {
logger.err('Unable to find patch AOT file at ${patch.path}');
return (exitCode: ExitCode.software.code, linkPercentage: null);
}
final analyzeSnapshot = File(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
),
);
if (!analyzeSnapshot.existsSync()) {
logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}');
return (exitCode: ExitCode.software.code, linkPercentage: null);
}
final genSnapshot = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
);
final linkProgress = logger.progress('Linking AOT files');
double? linkPercentage;
try {
final dumpDebugInfoDir =
dumpDebugInfo ? Directory.systemTemp.createTempSync() : null;
linkPercentage = await aotTools.link(
base: releaseArtifact.path,
patch: patch.path,
analyzeSnapshot: analyzeSnapshot.path,
genSnapshot: genSnapshot,
outputPath: _vmcodeOutputPath,
workingDirectory: _buildDirectory,
kernel: artifactManager.newestAppDill().path,
dumpDebugInfoPath: dumpDebugInfoDir?.path,
);
if (dumpDebugInfo && dumpDebugInfoDir != null) {
final debugInfoZip = await dumpDebugInfoDir.zipToTempFile();
debugInfoZip.copySync(
p.join(
'build',
_debugInfoOutputPath,
),
);
}
} catch (error) {
linkProgress.fail('Failed to link AOT files: $error');
return (exitCode: ExitCode.software.code, linkPercentage: null);
}
linkProgress.complete();
return (exitCode: ExitCode.success.code, linkPercentage: linkPercentage);
}
}
@@ -1,5 +1,6 @@
export 'aar_patcher.dart';
export 'android_patcher.dart';
export 'ios_framework_patcher.dart';
export 'ios_patcher.dart';
export 'patch_new_command.dart';
export 'patcher.dart';
@@ -136,7 +136,11 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
target: target,
);
case ReleaseType.ios:
throw UnimplementedError();
return IosPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.iosFramework:
return IosFrameworkPatcher(
argResults: results,
@@ -148,21 +148,25 @@ void main() {
group('assertPreconditions', () {
setUp(() {
when(() => doctor.iosCommandValidators)
.thenReturn([flutterValidator]);
when(
() => doctor.iosCommandValidators,
).thenReturn([flutterValidator]);
});
group('when validation succeeds', () {
setUp(() {
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated:
any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
checkUserIsAuthenticated: any(
named: 'checkUserIsAuthenticated',
),
checkShorebirdInitialized: any(
named: 'checkShorebirdInitialized',
),
validators: any(named: 'validators'),
supportedOperatingSystems:
any(named: 'supportedOperatingSystems'),
supportedOperatingSystems: any(
named: 'supportedOperatingSystems',
),
),
).thenAnswer((_) async {});
});
@@ -180,10 +184,12 @@ void main() {
final exception = ValidationFailedException();
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated:
any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
checkUserIsAuthenticated: any(
named: 'checkUserIsAuthenticated',
),
checkShorebirdInitialized: any(
named: 'checkShorebirdInitialized',
),
validators: any(named: 'validators'),
),
).thenThrow(exception);
@@ -520,7 +526,8 @@ void main() {
setUp(() {
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot),
artifact: ShorebirdArtifact.analyzeSnapshot,
),
).thenReturn('');
setUpProjectRootArtifacts();
});
@@ -581,8 +588,9 @@ void main() {
group('when generate patch diff base is supported', () {
setUp(() {
when(() => aotTools.isGeneratePatchDiffBaseSupported())
.thenAnswer((_) async => true);
when(
() => aotTools.isGeneratePatchDiffBaseSupported(),
).thenAnswer((_) async => true);
when(
() => aotTools.generatePatchDiffBase(
analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'),
@@ -642,8 +650,11 @@ void main() {
expect(patchBundle, hasLength(1));
expect(
patchBundle[Arch.arm64],
isA<PatchArtifactBundle>()
.having((b) => b.path, 'path', endsWith(diffPath)),
isA<PatchArtifactBundle>().having(
(b) => b.path,
'path',
endsWith(diffPath),
),
);
});
});
@@ -651,8 +662,9 @@ void main() {
group('when generate patch diff base is not supported', () {
setUp(() {
when(aotTools.isGeneratePatchDiffBaseSupported)
.thenAnswer((_) async => false);
when(
aotTools.isGeneratePatchDiffBaseSupported,
).thenAnswer((_) async => false);
setUpProjectRootArtifacts();
});
@@ -667,8 +679,11 @@ void main() {
expect(patchBundle, hasLength(1));
expect(
patchBundle[Arch.arm64],
isA<PatchArtifactBundle>()
.having((b) => b.path, 'path', endsWith('out.vmcode')),
isA<PatchArtifactBundle>().having(
(b) => b.path,
'path',
endsWith('out.vmcode'),
),
);
});
});
@@ -676,10 +691,12 @@ void main() {
group('when does not use linker', () {
setUp(() {
when(() => shorebirdEnv.flutterRevision)
.thenReturn(preLinkerFlutterRevision);
when(() => aotTools.isGeneratePatchDiffBaseSupported())
.thenAnswer((_) async => false);
when(
() => shorebirdEnv.flutterRevision,
).thenReturn(preLinkerFlutterRevision);
when(
() => aotTools.isGeneratePatchDiffBaseSupported(),
).thenAnswer((_) async => false);
setUpProjectRootArtifacts();
});
@@ -724,8 +741,9 @@ void main() {
const xcodeVersion = '11';
setUp(() {
when(() => argResults['allow-asset-diffs'])
.thenReturn(allowAssetDiffs);
when(
() => argResults['allow-asset-diffs'],
).thenReturn(allowAssetDiffs);
when(
() => argResults['allow-native-diffs'],
).thenReturn(allowNativeDiffs);
@@ -734,8 +752,9 @@ void main() {
() => platform.operatingSystemVersion,
).thenReturn(operatingSystemVersion);
when(() => xcodeBuild.version())
.thenAnswer((_) async => xcodeVersion);
when(
() => xcodeBuild.version(),
).thenAnswer((_) async => xcodeVersion);
});
group('when linker is not enabled', () {
File diff suppressed because it is too large Load Diff
@@ -280,8 +280,8 @@ void main() {
isA<AarPatcher>(),
);
expect(
() => command.getPatcher(ReleaseType.ios),
throwsA(isA<UnimplementedError>()),
command.getPatcher(ReleaseType.ios),
isA<IosPatcher>(),
);
expect(
command.getPatcher(ReleaseType.iosFramework),
@@ -1,9 +1,12 @@
import 'package:args/args.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
class FakeArgResults extends Fake implements ArgResults {}
class FakeBaseRequest extends Fake implements http.BaseRequest {}
class FakeDiffStatus extends Fake implements DiffStatus {}