diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index 839f7f85..6fdcbff6 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -22f15e658f317f11954ee1933083a8d6a1bd7aae +14f2a1545b43ddf6ba5d825a4a79310c74806a90 diff --git a/cspell.config.yaml b/cspell.config.yaml index 2448477c..69c923aa 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -17,6 +17,7 @@ words: - armeabi - Azul - backboardd + - bdero - bintools - bitcode - bryanoltman @@ -105,10 +106,12 @@ words: - sideload - sideloadable - sideloaded + - sideloading - signup - SIGSTOP - storepass # From .github/workflows/e2e.yaml - storyboardc + - symbolication - syskeys # From adb.dart - subosito # From .github dir, doesn't show up in "**" check? - swiftshader # From .github dir, doesn't show up in "**" check? @@ -119,6 +122,7 @@ words: - udid # Unique Device Identifier - unawaited - unmockable + - unobfuscated - unpadded - Unpatchable - unsets diff --git a/packages/shorebird_cli/lib/src/artifact_manager.dart b/packages/shorebird_cli/lib/src/artifact_manager.dart index 0836012f..2e955902 100644 --- a/packages/shorebird_cli/lib/src/artifact_manager.dart +++ b/packages/shorebird_cli/lib/src/artifact_manager.dart @@ -368,24 +368,41 @@ class ArtifactManager { return ipaFiles.single; } + /// Returns the shorebird release supplement directory for the given + /// [platformSubdir] (e.g. 'ios', 'macos', 'android'). + /// + /// If [create] is true, the directory is created if it doesn't exist. + /// Returns null if the directory doesn't exist and [create] is false. + Directory? getReleaseSupplementDirectory({ + required String platformSubdir, + bool create = false, + }) { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot(); + if (projectRoot == null) return null; + final releaseSupplementDir = Directory( + p.join(projectRoot.path, 'build', platformSubdir, 'shorebird'), + ); + + if (!releaseSupplementDir.existsSync()) { + if (create) { + releaseSupplementDir.createSync(recursive: true); + } else { + logger.detail( + 'No release supplements found at ${releaseSupplementDir.path}', + ); + return null; + } + } + + return releaseSupplementDir; + } + /// Returns the path to the shorebird release supplement directory for iOS. /// /// Returns null if there is no supplement directory /// (e.g. when using older Flutter revisions). Directory? getIosReleaseSupplementDirectory() { - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - final releaseSupplementDir = Directory( - p.join(projectRoot.path, 'build', 'ios', 'shorebird'), - ); - - if (!releaseSupplementDir.existsSync()) { - logger.detail( - 'No iOS release supplements found at ${releaseSupplementDir.path}', - ); - return null; - } - - return releaseSupplementDir; + return getReleaseSupplementDirectory(platformSubdir: 'ios'); } /// Returns the path to the shorebird release supplement directory for macOS. @@ -393,19 +410,7 @@ class ArtifactManager { /// Returns null if there is no supplement directory /// (e.g. when using older Flutter revisions). Directory? getMacosReleaseSupplementDirectory() { - final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; - final releaseSupplementDir = Directory( - p.join(projectRoot.path, 'build', 'macos', 'shorebird'), - ); - - if (!releaseSupplementDir.existsSync()) { - logger.detail( - 'No macOS release supplements found at ${releaseSupplementDir.path}', - ); - return null; - } - - return releaseSupplementDir; + return getReleaseSupplementDirectory(platformSubdir: 'macos'); } /// Name of the App.xcframework generated by `shorebird release ios-framework` diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index 3fdcbab3..d2fea973 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -744,7 +744,6 @@ aar artifact already exists, continuing...'''); required String runnerPath, required bool isCodesigned, required String? podfileLockHash, - required String? supplementPath, }) async { final createArtifactProgress = logger.progress('Uploading artifacts'); final thinnedArchiveDirectory = await _thinXcarchive( @@ -791,30 +790,6 @@ aar artifact already exists, continuing...'''); ); } - if (supplementPath != null) { - final zippedSupplement = await Directory( - supplementPath, - ).zipToTempFile(name: 'ios_supplement'); - try { - await codePushClient.createReleaseArtifact( - appId: appId, - releaseId: releaseId, - artifactPath: zippedSupplement.path, - arch: 'ios_supplement', - platform: ReleasePlatform.ios, - hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(), - canSideload: false, - podfileLockHash: podfileLockHash, - ); - } catch (error) { - _handleErrorAndExit( - error, - progress: createArtifactProgress, - message: 'Error uploading release supplements: $error', - ); - } - } - createArtifactProgress.complete(); } @@ -824,7 +799,6 @@ aar artifact already exists, continuing...'''); required String appId, required int releaseId, required String appFrameworkPath, - required String? supplementPath, }) async { final createArtifactProgress = logger.progress('Uploading artifacts'); final appFrameworkDirectory = Directory(appFrameworkPath); @@ -850,33 +824,50 @@ aar artifact already exists, continuing...'''); ); } - if (supplementPath != null) { - final zippedSupplement = await Directory( - supplementPath, - ).zipToTempFile(name: 'ios_framework_supplement'); - try { - await codePushClient.createReleaseArtifact( - appId: appId, - releaseId: releaseId, - artifactPath: zippedSupplement.path, - arch: 'ios_framework_supplement', - platform: ReleasePlatform.ios, - hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(), - canSideload: false, - podfileLockHash: null, - ); - } catch (error) { - _handleErrorAndExit( - error, - progress: createArtifactProgress, - message: 'Error uploading release supplements: $error', - ); - } - } - createArtifactProgress.complete(); } + /// Zips and uploads a supplement directory as a release artifact. + Future createSupplementReleaseArtifact({ + required String appId, + required int releaseId, + required ReleasePlatform platform, + required String supplementDirectoryPath, + required String arch, + }) async { + final createSupplementProgress = logger.progress( + 'Uploading supplement artifacts', + ); + final zippedSupplement = await Directory( + supplementDirectoryPath, + ).zipToTempFile(name: arch); + try { + await codePushClient.createReleaseArtifact( + appId: appId, + releaseId: releaseId, + artifactPath: zippedSupplement.path, + arch: arch, + platform: platform, + hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(), + // Supplements are auxiliary snapshot metadata used during patching and + // can't produce a working app on their own, so sideloading isn't + // applicable. + canSideload: false, + // Supplement artifacts contain only Dart snapshot metadata (e.g. class + // tables, dispatch tables) and have no dependency on native pods, so + // the podfile lock hash is not applicable here. + podfileLockHash: null, + ); + } catch (error) { + _handleErrorAndExit( + error, + progress: createSupplementProgress, + message: 'Error uploading supplement artifacts: $error', + ); + } + createSupplementProgress.complete(); + } + /// Creates a patch for the given [appId], [releaseId], and [metadata]. @visibleForTesting Future createPatch({ diff --git a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart index f192e96f..53064e7f 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart @@ -40,6 +40,9 @@ class AarPatcher extends Patcher { @override String get primaryReleaseArtifactArch => 'aar'; + @override + String? get supplementaryReleaseArtifactArch => 'aar_supplement'; + @override ReleaseType get releaseType => ReleaseType.aar; @@ -75,9 +78,10 @@ class AarPatcher extends Patcher { @override Future buildPatchArtifact({String? releaseVersion}) async { + final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs]; await artifactBuilder.buildAar( buildNumber: buildNumber, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: argResults.encodedPublicKey, ); @@ -94,7 +98,7 @@ class AarPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) async { final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( appId: appId, diff --git a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart index aed1e968..669c5301 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart @@ -51,6 +51,9 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir @override String get primaryReleaseArtifactArch => 'aab'; + @override + String? get supplementaryReleaseArtifactArch => 'android_supplement'; + @override Future assertUnpatchableDiffs({ required ReleaseArtifact releaseArtifact, @@ -87,12 +90,15 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir logger.warn(updaterPatchErrorWarning); } + final buildArgs = [ + ...argResults.forwardedArgs, + ...extraBuildArgs, + ...buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + ]; final aabFile = await artifactBuilder.buildAppBundle( flavor: flavor, target: target, - args: - argResults.forwardedArgs + - buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + args: buildArgs, base64PublicKey: argResults.encodedPublicKey, ); @@ -123,7 +129,7 @@ Looked in: required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, Duration downloadMessageTimeout = const Duration(minutes: 1), }) async { final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart index 383c7f11..5caf9dd0 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart @@ -104,8 +104,9 @@ class IosFrameworkPatcher extends Patcher { @override Future buildPatchArtifact({String? releaseVersion}) async { + final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs]; final buildResult = await artifactBuilder.buildIosFramework( - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: argResults.encodedPublicKey, ); @@ -116,7 +117,10 @@ class IosFrameworkPatcher extends Patcher { appDillPath: buildResult.kernelFile.path, outFilePath: _aotOutputPath, genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, - additionalArgs: IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), + additionalArgs: [ + ...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), + ...obfuscationGenSnapshotArgs, + ], ); // Copy the kernel file to the build directory so that it can be used @@ -136,7 +140,7 @@ class IosFrameworkPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) async { final unzipProgress = logger.progress('Extracting release artifact'); late final String releaseXcframeworkPath; @@ -149,13 +153,8 @@ class IosFrameworkPatcher extends Patcher { releaseXcframeworkPath = tempDir.path; } - final releaseSupplementDir = Directory.systemTemp.createTempSync(); - if (supplementArtifact != null) { - await artifactManager.extractZip( - zipFile: supplementArtifact, - outputDirectory: releaseSupplementDir, - ); - } + final releaseSupplementDir = + supplementDirectory ?? Directory.systemTemp.createTempSync(); unzipProgress.complete( 'Extracted release artifact to $releaseXcframeworkPath', @@ -180,7 +179,10 @@ class IosFrameworkPatcher extends Patcher { final result = await apple.runLinker( kernelFile: File(_appDillCopyPath), releaseArtifact: releaseArtifactFile, - splitDebugInfoArgs: IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), + splitDebugInfoArgs: [ + ...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), + ...obfuscationGenSnapshotArgs, + ], aotOutputFile: File(_aotOutputPath), vmCodeFile: File(_vmcodeOutputPath), ); diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart index 2fc7b82d..3659e9e6 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart @@ -167,15 +167,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); throw ProcessExit(ExitCode.software.code); } + final buildArgs = [ + ...argResults.forwardedArgs, + ...extraBuildArgs, + ...buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + ]; + // If buildIpa is called with a different codesign value than the // release was, we will erroneously report native diffs. final ipaBuildResult = await artifactBuilder.buildIpa( codesign: shouldCodesign, flavor: flavor, target: target, - args: - argResults.forwardedArgs + - buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + args: buildArgs, base64PublicKey: argResults.encodedPublicKey, ); @@ -186,7 +190,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); appDillPath: ipaBuildResult.kernelFile.path, outFilePath: _aotOutputPath, genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, - additionalArgs: splitDebugInfoArgs(splitDebugInfoPath), + additionalArgs: [ + ...splitDebugInfoArgs(splitDebugInfoPath), + ...obfuscationGenSnapshotArgs, + ], ); // Copy the kernel file to the build directory so that it can be used @@ -201,7 +208,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) async { // Verify that we have built a patch .xcarchive if (artifactManager.getXcarchiveDirectory()?.path == null) { @@ -221,13 +228,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); releaseXcarchivePath = tempDir.path; } - final releaseSupplementDir = Directory.systemTemp.createTempSync(); - if (supplementArtifact != null) { - await artifactManager.extractZip( - zipFile: supplementArtifact, - outputDirectory: releaseSupplementDir, - ); - } + final releaseSupplementDir = + supplementDirectory ?? Directory.systemTemp.createTempSync(); unzipProgress.complete(); final appDirectory = artifactManager.getIosAppDirectory( @@ -253,7 +255,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); final result = await apple.runLinker( kernelFile: File(_appDillCopyPath), releaseArtifact: releaseArtifactFile, - splitDebugInfoArgs: splitDebugInfoArgs(splitDebugInfoPath), + splitDebugInfoArgs: [ + ...splitDebugInfoArgs(splitDebugInfoPath), + ...obfuscationGenSnapshotArgs, + ], aotOutputFile: File(_aotOutputPath), vmCodeFile: File(_vmcodeOutputPath), ); diff --git a/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart index e1b3c8bb..8173777b 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/linux_patcher.dart @@ -51,6 +51,7 @@ class LinuxPatcher extends Patcher { Future buildPatchArtifact({String? releaseVersion}) async { await artifactBuilder.buildLinuxApp( base64PublicKey: argResults.encodedPublicKey, + args: extraBuildArgs, ); return artifactManager.linuxBundleDirectory.zipToTempFile(); } @@ -60,7 +61,7 @@ class LinuxPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) async { final createDiffProgress = logger.progress('Creating patch artifacts'); final patchArtifactPath = p.join( @@ -122,6 +123,9 @@ class LinuxPatcher extends Patcher { @override String get primaryReleaseArtifactArch => 'bundle'; + @override + String? get supplementaryReleaseArtifactArch => 'linux_supplement'; + @override ReleaseType get releaseType => ReleaseType.linux; } diff --git a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart index 047a0e6c..91787e28 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart @@ -57,6 +57,9 @@ class MacosPatcher extends Patcher { @override String get primaryReleaseArtifactArch => 'app'; + @override + String? get supplementaryReleaseArtifactArch => 'macos_supplement'; + @override Future assertPreconditions() async { try { @@ -135,15 +138,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); throw ProcessExit(ExitCode.software.code); } + final buildArgs = [ + ...argResults.forwardedArgs, + ...extraBuildArgs, + ...buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + ]; + // If buildMacos is called with a different codesign value than the // release was, we will erroneously report native diffs. final macosBuildResult = await artifactBuilder.buildMacos( codesign: codesign, flavor: flavor, target: target, - args: - argResults.forwardedArgs + - buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + args: buildArgs, base64PublicKey: argResults.encodedPublicKey, ); @@ -154,6 +161,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); appDillPath: macosBuildResult.kernelFile.path, outFilePath: _arm64AotOutputPath, genSnapshotArtifact: ShorebirdArtifact.genSnapshotMacosArm64, + additionalArgs: [ + ...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), + ...obfuscationGenSnapshotArgs, + ], ); if (!File(_arm64AotOutputPath).existsSync()) { @@ -164,6 +175,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); appDillPath: macosBuildResult.kernelFile.path, outFilePath: _x64AotOutputPath, genSnapshotArtifact: ShorebirdArtifact.genSnapshotMacosX64, + additionalArgs: [ + ...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), + ...obfuscationGenSnapshotArgs, + ], ); if (!File(_x64AotOutputPath).existsSync()) { @@ -217,7 +232,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''); required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) async { final unzipProgress = logger.progress('Extracting release artifact'); final releaseAppDirectory = Directory.systemTemp.createTempSync(); diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index 8c3090d3..e8fa6548 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; import 'package:meta/meta.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; @@ -153,6 +154,12 @@ To target the latest release (e.g. the release that was most recently updated) u CommonArguments.splitDebugInfoArg.name, help: CommonArguments.splitDebugInfoArg.description, ) + ..addFlag( + CommonArguments.obfuscateArg.name, + help: CommonArguments.obfuscateArg.description, + negatable: false, + hide: true, + ) ..addOption( CommonArguments.minLinkPercentage.name, help: CommonArguments.minLinkPercentage.description, @@ -401,6 +408,83 @@ Building with Flutter $flutterVersionString to determine the release version... ? await downloadReleaseArtifact(releaseArtifact: supplementalArtifact) : null; + // Download and extract the supplement archive (if present). + Directory? supplementDirectory; + File? obfuscationMapFile; + if (supplementArchive != null) { + supplementDirectory = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: supplementArchive, + outputDirectory: supplementDirectory, + ); + final candidateMapFile = File( + p.join(supplementDirectory.path, 'obfuscation_map.json'), + ); + if (candidateMapFile.existsSync()) { + obfuscationMapFile = candidateMapFile; + logger.info( + 'Release was built with obfuscation. ' + 'Applying obfuscation map to patch build.', + ); + } + } + + // If the user explicitly passed --obfuscate but the release has no + // obfuscation map, the patch would be obfuscated against a non-obfuscated + // release, producing a broken patch. + // Also check rest for `-- --obfuscate`, which bypasses the parser but + // still flows through forwardedArgs to the Flutter build command. + final userPassedObfuscate = + (results.wasParsed('obfuscate') && results['obfuscate'] == true) || + results.rest.any((a) => a == '--obfuscate'); + if (userPassedObfuscate && obfuscationMapFile == null) { + logger.err( + '--obfuscate was passed, but the release was not built with ' + 'obfuscation. A patch cannot change the obfuscation mode of a ' + 'release.', + ); + throw ProcessExit(ExitCode.software.code); + } + if (userPassedObfuscate && obfuscationMapFile != null) { + logger.info( + '--obfuscate is not needed for patching. Obfuscation is applied ' + 'automatically when the release was built with --obfuscate.', + ); + } + + patcher.obfuscationMapPath = obfuscationMapFile?.path; + + // Build extra args to inject into the Flutter build command. These use + // --extra-gen-snapshot-options= because they're passed through Flutter's + // build system, which forwards them to gen_snapshot. This is distinct from + // patcher.obfuscationGenSnapshotArgs, which produces bare gen_snapshot + // flags (e.g. --load-obfuscation-map=...) for direct gen_snapshot/linker + // calls made by Apple patchers outside the Flutter build. + final extraBuildArgs = []; + if (obfuscationMapFile != null) { + extraBuildArgs.addAll([ + '--obfuscate', + '--extra-gen-snapshot-options=' + '--load-obfuscation-map=${obfuscationMapFile.path}', + // Strip unobfuscated DWARF debug info from the compiled snapshot so + // it doesn't leak identifiers that obfuscation was meant to hide. + '--extra-gen-snapshot-options=--strip', + ]); + } + // Flutter requires --split-debug-info with --obfuscate. Auto-add it + // if --obfuscate will be in the build args (from the user or from + // the obfuscation map injection above) but --split-debug-info is not. + final hasObfuscate = + (results.wasParsed('obfuscate') && results['obfuscate'] == true) || + extraBuildArgs.contains('--obfuscate'); + final hasSplitDebugInfo = results.wasParsed('split-debug-info'); + if (hasObfuscate && !hasSplitDebugInfo) { + extraBuildArgs.add( + '--split-debug-info=${p.join('build', 'shorebird', 'symbols')}', + ); + } + patcher.extraBuildArgs = extraBuildArgs; + final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith( flutterRevisionOverride: release.flutterRevision, ); @@ -417,7 +501,9 @@ Building with Flutter $flutterVersionString to determine the release version... Building patch with Flutter $flutterVersionString '''); patchArtifactFile = await _tryBuildingArtifact( - () => patcher.buildPatchArtifact(releaseVersion: release.version), + () => patcher.buildPatchArtifact( + releaseVersion: release.version, + ), ); } @@ -431,7 +517,7 @@ Building patch with Flutter $flutterVersionString appId: appId, releaseId: release.id, releaseArtifact: releaseArchive, - supplementArtifact: supplementArchive, + supplementDirectory: supplementDirectory, ); final dryRun = results['dry-run'] == true; diff --git a/packages/shorebird_cli/lib/src/commands/patch/patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart index ae6044ba..16b2922a 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patcher.dart @@ -57,6 +57,34 @@ More info: ${troubleshootingUrl.toLink()}. /// The target script to run, if any. final String? target; + /// The path to the obfuscation map downloaded from the release, if any. + /// Set by the patch command after downloading the map. Used by Apple + /// patchers to pass obfuscation flags to gen_snapshot and the linker. + String? obfuscationMapPath; + + /// Extra build arguments injected by the patch command. These are included + /// in the Flutter build command args by patchers. Currently used to inject + /// obfuscation flags when the release was built with obfuscation. + List extraBuildArgs = const []; + + /// Additional gen_snapshot arguments needed to match the release's + /// obfuscation flags. Used by Apple patchers for [buildElfAotSnapshot] + /// and linker calls. + List get obfuscationGenSnapshotArgs => [ + if (obfuscationMapPath != null) ...[ + '--obfuscate', + '--load-obfuscation-map=$obfuscationMapPath', + // --dwarf-stack-traces must match the release build so the patch + // produces the same stack trace format for correct symbolication. + // --split-debug-info already implies --dwarf-stack-traces, so we only + // need to pass it as a standalone flag when split debug info isn't used. + if (splitDebugInfoPath == null) '--dwarf-stack-traces', + // Strip DWARF debug info so obfuscated snapshots don't leak the + // identifiers that obfuscation was meant to hide. + '--strip', + ], + ]; + /// The type of artifact we are creating a release for. ReleaseType get releaseType; @@ -96,7 +124,7 @@ More info: ${troubleshootingUrl.toLink()}. required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }); /// Updates the provided metadata to include patcher-specific fields. diff --git a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart index 0dda88c9..9ba8f8f1 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart @@ -37,6 +37,9 @@ class WindowsPatcher extends Patcher { @override String get primaryReleaseArtifactArch => primaryWindowsReleaseArtifactArch; + @override + String? get supplementaryReleaseArtifactArch => 'windows_supplement'; + @override ReleaseType get releaseType => ReleaseType.windows; @@ -71,9 +74,10 @@ class WindowsPatcher extends Patcher { @override Future buildPatchArtifact({String? releaseVersion}) async { + final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs]; final releaseDir = await artifactBuilder.buildWindowsApp( target: target, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: argResults.encodedPublicKey, ); return releaseDir.zipToTempFile(); @@ -84,7 +88,7 @@ class WindowsPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) async { final createDiffProgress = logger.progress('Creating patch artifacts'); final patchArtifactPath = p.join( diff --git a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart index 96967850..db822f62 100644 --- a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart @@ -44,6 +44,12 @@ class AarReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.aar; + @override + String get supplementPlatformSubdir => 'android'; + + @override + String get supplementArtifactArch => 'aar_supplement'; + @override String get artifactDisplayName => 'Android archive'; @@ -70,17 +76,23 @@ class AarReleaser extends Releaser { logger.err('Missing required argument: --release-version'); throw ProcessExit(ExitCode.usage.code); } + + await assertObfuscationIsSupported(); } @override Future buildReleaseArtifacts() async { final base64PublicKey = await getEncodedPublicKey(); + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); await artifactBuilder.buildAar( buildNumber: buildNumber, targetPlatforms: architectures, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); // Copy release AAR to a new directory to avoid overwriting with // subsequent patch builds. @@ -126,6 +138,8 @@ class AarReleaser extends Releaser { extractedAarDir: extractedAarDir.path, architectures: architectures, ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart index b9ed5ed7..7ae170cc 100644 --- a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart @@ -31,6 +31,12 @@ class AndroidReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.android; + @override + String get supplementPlatformSubdir => 'android'; + + @override + String get supplementArtifactArch => 'android_supplement'; + @override String get artifactDisplayName => 'Android app bundle'; @@ -98,26 +104,33 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec ); throw ProcessExit(ExitCode.unavailable.code); } + + await assertObfuscationIsSupported(); } @override Future buildReleaseArtifacts() async { final base64PublicKey = await getEncodedPublicKey(); + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); final aab = await artifactBuilder.buildAppBundle( flavor: flavor, target: target, targetPlatforms: architectures, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); + if (generateApk) { logger.info('Building APK'); await artifactBuilder.buildApk( flavor: flavor, target: target, targetPlatforms: architectures, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); } @@ -150,12 +163,12 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec Future uploadReleaseArtifacts({ required String appId, required Release release, - }) { + }) async { final aabFile = shorebirdAndroidArtifacts.findAab( project: projectRoot, flavor: flavor, ); - return codePushClientWrapper.createAndroidReleaseArtifacts( + await codePushClientWrapper.createAndroidReleaseArtifacts( appId: appId, releaseId: release.id, projectRoot: projectRoot.path, @@ -164,6 +177,8 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec architectures: architectures, flavor: flavor, ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart index f2b30dee..540405e6 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart @@ -42,12 +42,20 @@ class IosFrameworkReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.iosFramework; + @override + String get supplementPlatformSubdir => 'ios'; + + @override + String get supplementArtifactArch => 'ios_framework_supplement'; + @override Future assertArgsAreValid() async { if (!argResults.wasParsed('release-version')) { logger.err('Missing required argument: --release-version'); throw ProcessExit(ExitCode.usage.code); } + + await assertObfuscationIsSupported(); } @override @@ -79,10 +87,14 @@ class IosFrameworkReleaser extends Releaser { } final base64PublicKey = await getEncodedPublicKey(); + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); await artifactBuilder.buildIosFramework( - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); // Copy release xcframework to a new directory to avoid overwriting with // subsequent patch builds. @@ -117,13 +129,14 @@ class IosFrameworkReleaser extends Releaser { Future uploadReleaseArtifacts({ required Release release, required String appId, - }) { - return codePushClientWrapper.createIosFrameworkReleaseArtifacts( + }) async { + await codePushClientWrapper.createIosFrameworkReleaseArtifacts( appId: appId, releaseId: release.id, appFrameworkPath: p.join(releaseDirectory.path, 'App.xcframework'), - supplementPath: artifactManager.getIosReleaseSupplementDirectory()?.path, ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index 70b3eca5..ccaf9cda 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -37,6 +37,12 @@ class IosReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.ios; + @override + String get supplementPlatformSubdir => 'ios'; + + @override + String get supplementArtifactArch => 'ios_supplement'; + @override String get artifactDisplayName => 'iOS app'; @@ -46,22 +52,13 @@ class IosReleaser extends Releaser { logger.err( ''' The "--release-version" flag is only supported for aar and ios-framework releases. - + To change the version of this release, change your app's version in your pubspec.yaml.''', ); throw ProcessExit(ExitCode.usage.code); } - if (argResults.rest.contains('--obfuscate')) { - // Obfuscated releases break patching, so we don't support them. - // See https://github.com/shorebirdtech/shorebird/issues/1619 - logger - ..err('Shorebird does not currently support obfuscation on iOS.') - ..info( - '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', - ); - throw ProcessExit(ExitCode.unavailable.code); - } + await assertObfuscationIsSupported(); } @override @@ -103,14 +100,21 @@ To change the version of this release, change your app's version in your pubspec } final base64PublicKey = await getEncodedPublicKey(); + + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); + await artifactBuilder.buildIpa( codesign: codesign, flavor: flavor, target: target, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); + final xcarchiveDirectory = artifactManager.getXcarchiveDirectory(); if (xcarchiveDirectory == null) { logger.err('Unable to find .xcarchive directory'); @@ -172,8 +176,9 @@ To change the version of this release, change your app's version in your pubspec .path, isCodesigned: codesign, podfileLockHash: podfileLockHash, - supplementPath: artifactManager.getIosReleaseSupplementDirectory()?.path, ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart index a168c423..dd162640 100644 --- a/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart @@ -29,6 +29,12 @@ class LinuxReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.linux; + @override + String get supplementPlatformSubdir => 'linux'; + + @override + String get supplementArtifactArch => 'linux_supplement'; + @override String get artifactDisplayName => 'Linux app'; @@ -43,6 +49,8 @@ To change the version of this release, change your app's version in your pubspec ); throw ProcessExit(ExitCode.usage.code); } + + await assertObfuscationIsSupported(); } @override @@ -65,11 +73,15 @@ To change the version of this release, change your app's version in your pubspec @override Future buildReleaseArtifacts() async { final base64PublicKey = await getEncodedPublicKey(); + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); await artifactBuilder.buildLinuxApp( target: target, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); return artifactManager.linuxBundleDirectory; } @@ -92,9 +104,13 @@ Linux release created at ${artifactManager.linuxBundleDirectory.path}. Future uploadReleaseArtifacts({ required Release release, required String appId, - }) => codePushClientWrapper.createLinuxReleaseArtifacts( - appId: appId, - releaseId: release.id, - bundle: artifactManager.linuxBundleDirectory, - ); + }) async { + await codePushClientWrapper.createLinuxReleaseArtifacts( + appId: appId, + releaseId: release.id, + bundle: artifactManager.linuxBundleDirectory, + ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); + } } diff --git a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart index 67c3257b..806ca418 100644 --- a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart @@ -37,6 +37,12 @@ class MacosReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.macos; + @override + String get supplementPlatformSubdir => 'macos'; + + @override + String get supplementArtifactArch => 'macos_supplement'; + @override String get artifactDisplayName => 'macOS app'; @@ -46,22 +52,13 @@ class MacosReleaser extends Releaser { logger.err( ''' The "--release-version" flag is only supported for aar and ios-framework releases. - + To change the version of this release, change your app's version in your pubspec.yaml.''', ); throw ProcessExit(ExitCode.usage.code); } - if (argResults.rest.contains('--obfuscate')) { - // Obfuscated releases break patching, so we don't support them. - // See https://github.com/shorebirdtech/shorebird/issues/1619 - logger - ..err('Shorebird does not currently support obfuscation on macOS.') - ..info( - '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', - ); - throw ProcessExit(ExitCode.unavailable.code); - } + await assertObfuscationIsSupported(); } @override @@ -94,14 +91,21 @@ To change the version of this release, change your app's version in your pubspec } final base64PublicKey = await getEncodedPublicKey(); + + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); + await artifactBuilder.buildMacos( codesign: codesign, flavor: flavor, target: target, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); + final appDirectory = artifactManager.getMacOSAppDirectory(flavor: flavor); if (appDirectory == null) { logger.err('Unable to find .app directory'); @@ -160,6 +164,8 @@ To change the version of this release, change your app's version in your pubspec isCodesigned: codesign, podfileLockHash: podfileLockHash, ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index 5e881d66..5aa90de7 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -153,6 +153,11 @@ of the iOS app that is using this module. (aar and ios-framework only)''', ..addOption( CommonArguments.splitDebugInfoArg.name, help: CommonArguments.splitDebugInfoArg.description, + ) + ..addFlag( + CommonArguments.obfuscateArg.name, + help: CommonArguments.obfuscateArg.description, + negatable: false, ); } diff --git a/packages/shorebird_cli/lib/src/commands/release/releaser.dart b/packages/shorebird_cli/lib/src/commands/release/releaser.dart index d114bffd..2fc4b3b6 100644 --- a/packages/shorebird_cli/lib/src/commands/release/releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/releaser.dart @@ -1,16 +1,26 @@ import 'dart:io'; import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; export 'package:pub_semver/pub_semver.dart'; +/// Minimum Flutter version for obfuscation support across all platforms. +final _minimumObfuscationFlutterVersion = Version(3, 41, 2); + /// {@template releaser} /// Executes platform-specific functionality to create a release. /// {@endtemplate} @@ -87,4 +97,123 @@ abstract class Releaser { /// /// Returns null if no public key is configured. Future getEncodedPublicKey() => argResults.getEncodedPublicKey(); + + /// Whether the user is building with obfuscation. + bool get useObfuscation => argResults['obfuscate'] == true; + + /// Path where the obfuscation map is saved during obfuscated builds. + String get obfuscationMapPath => p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + + /// Auto-adds --split-debug-info when --obfuscate is used without it. + void addSplitDebugInfoDefault(List buildArgs) { + if (useObfuscation && + !buildArgs.any((a) => a.startsWith('--split-debug-info'))) { + buildArgs.add( + '--split-debug-info=${p.join('build', 'shorebird', 'symbols')}', + ); + } + } + + /// Adds obfuscation-related gen_snapshot options to [buildArgs]. + /// + /// When obfuscation is enabled, passes --save-obfuscation-map to capture the + /// mapping and --strip to remove unobfuscated DWARF debugging information + /// from the compiled snapshot (the DWARF sections would otherwise leak + /// identifiers that obfuscation was meant to hide). + void addObfuscationMapArgs(List buildArgs) { + if (!useObfuscation) return; + final mapDir = Directory(p.dirname(obfuscationMapPath)); + if (!mapDir.existsSync()) mapDir.createSync(recursive: true); + buildArgs.addAll([ + '--extra-gen-snapshot-options=--save-obfuscation-map=$obfuscationMapPath', + '--extra-gen-snapshot-options=--strip', + ]); + } + + /// Platform subdirectory for the supplement directory (e.g. 'android', + /// 'ios'). Used to construct `build//shorebird/`. + String get supplementPlatformSubdir; + + /// Arch string for the supplement artifact on the server (e.g. + /// 'android_supplement'). + String get supplementArtifactArch; + + /// Assembles the supplement directory: copies the obfuscation map (if + /// present) into the platform supplement dir. Returns the directory, or null + /// if empty. + Directory? assembleSupplementDirectory() { + final obfuscationMapFile = File(obfuscationMapPath); + final hasObfuscationMap = useObfuscation && obfuscationMapFile.existsSync(); + final supplementDir = artifactManager.getReleaseSupplementDirectory( + platformSubdir: supplementPlatformSubdir, + create: hasObfuscationMap, + ); + if (hasObfuscationMap && supplementDir != null) { + obfuscationMapFile.copySync( + p.join(supplementDir.path, 'obfuscation_map.json'), + ); + } + return supplementDir; + } + + /// Uploads the supplement artifact (e.g. obfuscation map) if one was + /// assembled. Call this at the end of [uploadReleaseArtifacts]. + /// + // TODO(bdero): This is a separate network call from the primary artifact + // upload, so an interruption between the two leaves the release without its + // supplement. Now that the supplement drives patching decisions (e.g. + // obfuscation), we should make this atomic or recoverable. + // https://github.com/shorebirdtech/shorebird/issues/3630 + Future uploadSupplementArtifact({ + required String appId, + required int releaseId, + }) async { + final supplementDir = assembleSupplementDirectory(); + if (supplementDir != null) { + await codePushClientWrapper.createSupplementReleaseArtifact( + appId: appId, + releaseId: releaseId, + platform: releaseType.releasePlatform, + supplementDirectoryPath: supplementDir.path, + arch: supplementArtifactArch, + ); + } + } + + /// Asserts that the current Flutter version supports obfuscation, if + /// obfuscation is enabled. + Future assertObfuscationIsSupported() async { + if (!useObfuscation) return; + final flutterVersion = await shorebirdFlutter.resolveFlutterVersion( + shorebirdEnv.flutterRevision, + ); + if (flutterVersion != null && + flutterVersion < _minimumObfuscationFlutterVersion) { + logger.err( + 'Obfuscation on ${releaseType.releasePlatform.displayName} requires Flutter ' + '$_minimumObfuscationFlutterVersion or later ' + '(current: $flutterVersion).', + ); + throw ProcessExit(ExitCode.unavailable.code); + } + } + + /// Verifies the obfuscation map was generated after build. + void verifyObfuscationMap() { + if (!useObfuscation) return; + final mapFile = File(obfuscationMapPath); + if (!mapFile.existsSync()) { + logger.err( + 'Obfuscation was enabled but the obfuscation map was not ' + 'generated at $obfuscationMapPath', + ); + throw ProcessExit(ExitCode.software.code); + } + logger.detail('Obfuscation map saved to $obfuscationMapPath'); + } } diff --git a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart index 3edc8cb1..c59be0f0 100644 --- a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart @@ -32,6 +32,12 @@ class WindowsReleaser extends Releaser { @override ReleaseType get releaseType => ReleaseType.windows; + @override + String get supplementPlatformSubdir => 'windows'; + + @override + String get supplementArtifactArch => 'windows_supplement'; + @override String get artifactDisplayName => 'Windows app'; @@ -41,11 +47,13 @@ class WindowsReleaser extends Releaser { logger.err( ''' The "--release-version" flag is only supported for aar and ios-framework releases. - + To change the version of this release, change your app's version in your pubspec.yaml.''', ); throw ProcessExit(ExitCode.usage.code); } + + await assertObfuscationIsSupported(); } @override @@ -68,11 +76,16 @@ To change the version of this release, change your app's version in your pubspec @override Future buildReleaseArtifacts() async { final base64PublicKey = await getEncodedPublicKey(); - return artifactBuilder.buildWindowsApp( + final buildArgs = [...argResults.forwardedArgs]; + addSplitDebugInfoDefault(buildArgs); + addObfuscationMapArgs(buildArgs); + final result = await artifactBuilder.buildWindowsApp( target: target, - args: argResults.forwardedArgs, + args: buildArgs, base64PublicKey: base64PublicKey, ); + verifyObfuscationMap(); + return result; } @override @@ -107,6 +120,8 @@ To change the version of this release, change your app's version in your pubspec projectRoot: projectRoot.path, releaseZipPath: zippedRelease.path, ); + + await uploadSupplementArtifact(appId: appId, releaseId: release.id); } @override diff --git a/packages/shorebird_cli/lib/src/common_arguments.dart b/packages/shorebird_cli/lib/src/common_arguments.dart index 8ddbe392..1e71ef70 100644 --- a/packages/shorebird_cli/lib/src/common_arguments.dart +++ b/packages/shorebird_cli/lib/src/common_arguments.dart @@ -137,6 +137,16 @@ Command that reads data from stdin and outputs a base64 signature to stdout. description: 'The version of the release (e.g. "1.0.0").', ); + /// The Flutter --obfuscate argument. + static const obfuscateArg = ArgumentDescriber( + name: 'obfuscate', + description: + 'In a release build, this flag removes identifiers and replaces ' + 'them with randomized values for the purposes of source code ' + 'obfuscation. This flag must always be combined with ' + '"--split-debug-info" command, to not break crash reports.', + ); + /// An argument that allows the user to specify a directory where program /// symbols are stored. static const splitDebugInfoArg = ArgumentDescriber( diff --git a/packages/shorebird_cli/lib/src/extensions/arg_results.dart b/packages/shorebird_cli/lib/src/extensions/arg_results.dart index e69df105..24d5d65e 100644 --- a/packages/shorebird_cli/lib/src/extensions/arg_results.dart +++ b/packages/shorebird_cli/lib/src/extensions/arg_results.dart @@ -251,6 +251,16 @@ extension ForwardedArgs on ArgResults { } } + /// Returns `['--$name']` when the boolean flag [name] was parsed and is + /// `true`, or an empty iterable otherwise. + Iterable _flagNamed(String name) { + if (!wasParsed(name)) { + return []; + } + final value = this[name] as bool; + return value ? ['--$name'] : []; + } + /// A list of arguments parsed by Shorebird commands that will be forwarded /// to the underlying Flutter commands (that is, placed after `--`). List get forwardedArgs { @@ -269,6 +279,7 @@ extension ForwardedArgs on ArgResults { ..._argsNamed(CommonArguments.splitDebugInfoArg.name), ..._argsNamed(CommonArguments.exportMethodArg.name), ..._argsNamed(CommonArguments.exportOptionsPlistArg.name), + ..._flagNamed(CommonArguments.obfuscateArg.name), ]); return forwarded; diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index d316fe90..52169f2f 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -1836,7 +1836,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' const podfileLockHash = 'podfile-lock-hash'; final xcarchivePath = p.join('path', 'to', 'app.xcarchive'); final runnerPath = p.join('path', 'to', 'runner.app'); - final releaseSupplementPath = p.join('path', 'to', 'supplement'); void setUpProjectRoot({String? flavor}) { Directory( @@ -1845,9 +1844,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' Directory( p.join(projectRoot.path, runnerPath), ).createSync(recursive: true); - Directory( - p.join(projectRoot.path, releaseSupplementPath), - ).createSync(recursive: true); } setUp(() { @@ -1895,7 +1891,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: true, podfileLockHash: podfileLockHash, - supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ), exitsWithCode(ExitCode.software), @@ -1935,7 +1930,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: false, podfileLockHash: podfileLockHash, - supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ), exitsWithCode(ExitCode.software), @@ -1975,47 +1969,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: false, podfileLockHash: podfileLockHash, - supplementPath: p.join(projectRoot.path, releaseSupplementPath), - ), - ), - exitsWithCode(ExitCode.software), - ); - - verify(() => progress.fail(any(that: contains(error)))).called(1); - }, - ); - - test( - 'exits with code 70 when supplement artifact creation fails', - () async { - const error = 'something went wrong'; - when( - () => codePushClient.createReleaseArtifact( - appId: any(named: 'appId'), - artifactPath: any( - named: 'artifactPath', - that: endsWith('ios_supplement.zip'), - ), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - hash: any(named: 'hash'), - canSideload: any(named: 'canSideload'), - podfileLockHash: any(named: 'podfileLockHash'), - ), - ).thenThrow(error); - setUpProjectRoot(); - - await expectLater( - () async => runWithOverrides( - () async => codePushClientWrapper.createIosReleaseArtifacts( - appId: app.appId, - releaseId: releaseId, - xcarchivePath: p.join(projectRoot.path, xcarchivePath), - runnerPath: p.join(projectRoot.path, runnerPath), - isCodesigned: false, - podfileLockHash: podfileLockHash, - supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ), exitsWithCode(ExitCode.software), @@ -2048,7 +2001,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' runnerPath: p.join(projectRoot.path, runnerPath), isCodesigned: true, podfileLockHash: podfileLockHash, - supplementPath: p.join(projectRoot.path, releaseSupplementPath), ), ); @@ -2158,15 +2110,11 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' group('createMacosReleaseArtifacts', () { final appPath = p.join('path', 'to', 'Runner.app'); - final releaseSupplementPath = p.join('path', 'to', 'supplement'); void setUpProjectRoot({String? flavor}) { Directory( p.join(projectRoot.path, appPath), ).createSync(recursive: true); - Directory( - p.join(projectRoot.path, releaseSupplementPath), - ).createSync(recursive: true); } setUp(() { @@ -2240,15 +2188,11 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' group('createIosFrameworkReleaseArtifacts', () { final frameworkPath = p.join('path', 'to', 'App.xcframework'); - final releaseSupplementPath = p.join('path', 'to', 'supplement'); void setUpProjectRoot({String? flavor}) { Directory( p.join(projectRoot.path, frameworkPath), ).createSync(recursive: true); - Directory( - p.join(projectRoot.path, releaseSupplementPath), - ).createSync(recursive: true); } setUp(() { @@ -2289,7 +2233,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' appId: app.appId, releaseId: releaseId, appFrameworkPath: p.join(projectRoot.path, frameworkPath), - supplementPath: null, ), ), exitsWithCode(ExitCode.software), @@ -2297,46 +2240,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' }, ); - test( - 'exits with code 70 when supplement artifact creation fails', - () async { - const error = 'something went wrong'; - when( - () => codePushClient.createReleaseArtifact( - appId: any(named: 'appId'), - artifactPath: any( - named: 'artifactPath', - that: endsWith('ios_framework_supplement.zip'), - ), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - hash: any(named: 'hash'), - canSideload: any(named: 'canSideload'), - podfileLockHash: any(named: 'podfileLockHash'), - ), - ).thenThrow(error); - - await expectLater( - () async => runWithOverrides( - () async => - codePushClientWrapper.createIosFrameworkReleaseArtifacts( - appId: app.appId, - releaseId: releaseId, - appFrameworkPath: p.join(projectRoot.path, frameworkPath), - supplementPath: p.join( - projectRoot.path, - releaseSupplementPath, - ), - ), - ), - exitsWithCode(ExitCode.software), - ); - - verify(() => progress.fail(any(that: contains(error)))).called(1); - }, - ); - test('completes successfully when release artifact is created', () async { await expectLater( runWithOverrides( @@ -2344,7 +2247,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' appId: app.appId, releaseId: releaseId, appFrameworkPath: p.join(projectRoot.path, frameworkPath), - supplementPath: null, ), ), completes, diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart index 49986270..3a370370 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart @@ -449,6 +449,48 @@ void main() { }); }); + group('when obfuscationMapPath is set', () { + late File obfuscationMapFile; + + setUp(() { + obfuscationMapFile = + File( + p.join( + Directory.systemTemp.createTempSync().path, + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); + }); + + test( + 'passes obfuscationGenSnapshotArgs to buildElfAotSnapshot', + () async { + patcher.obfuscationMapPath = obfuscationMapFile.path; + await runWithOverrides(patcher.buildPatchArtifact); + + final captured = verify( + () => artifactBuilder.buildElfAotSnapshot( + appDillPath: any(named: 'appDillPath'), + outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), + additionalArgs: captureAny(named: 'additionalArgs'), + ), + ).captured; + + final args = captured.last as List; + expect(args, contains('--obfuscate')); + expect( + args.any( + (a) => a.startsWith('--load-obfuscation-map='), + ), + isTrue, + ); + }, + ); + }); + group('when platform was specified via arg results rest', () { setUp(() { when(() => argResults.rest).thenReturn(['ios', '--verbose']); @@ -539,7 +581,7 @@ void main() { canSideload: true, ); late File releaseArtifactFile; - late File supplementArtifactFile; + late Directory supplementDirectory; void setUpProjectRootArtifacts() { File( @@ -582,12 +624,7 @@ void main() { 'release.xcframework', ), )..createSync(recursive: true); - supplementArtifactFile = File( - p.join( - Directory.systemTemp.createTempSync().path, - 'ios_framework_supplement.zip', - ), - )..createSync(recursive: true); + supplementDirectory = Directory.systemTemp.createTempSync(); when( () => codePushClientWrapper.getReleaseArtifact( @@ -784,6 +821,57 @@ void main() { ).called(1); }); + group('when obfuscationMapPath is set', () { + late File obfuscationMapFile; + + setUp(() { + obfuscationMapFile = + File( + p.join( + Directory.systemTemp.createTempSync().path, + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); + }); + + test( + 'passes obfuscationGenSnapshotArgs to runLinker', + () async { + patcher.obfuscationMapPath = obfuscationMapFile.path; + await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ); + + final captured = verify( + () => apple.runLinker( + kernelFile: any(named: 'kernelFile'), + aotOutputFile: any(named: 'aotOutputFile'), + releaseArtifact: any(named: 'releaseArtifact'), + vmCodeFile: any(named: 'vmCodeFile'), + splitDebugInfoArgs: captureAny( + named: 'splitDebugInfoArgs', + ), + ), + ).captured; + + final args = captured.last as List; + expect(args, contains('--obfuscate')); + expect( + args.any( + (a) => a.startsWith('--load-obfuscation-map='), + ), + isTrue, + ); + }, + ); + }); + test('returns linked patch artifact in patch bundle', () async { final patchBundle = await runWithOverrides( () => patcher.createPatchArtifacts( @@ -818,21 +906,12 @@ void main() { p.join(outDir.path, 'ios-arm64', 'App.framework', 'App'), ).createSync(recursive: true); }); - when( - () => artifactManager.extractZip( - zipFile: supplementArtifactFile, - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((invocation) async { - final outDir = - invocation.namedArguments[#outputDirectory] as Directory; - File( - p.join(outDir.path, 'App.ct.link'), - ).createSync(recursive: true); - File( - p.join(outDir.path, 'App.class_table.json'), - ).createSync(recursive: true); - }); + File( + p.join(supplementDirectory.path, 'App.ct.link'), + ).createSync(recursive: true); + File( + p.join(supplementDirectory.path, 'App.class_table.json'), + ).createSync(recursive: true); }); test('returns linked patch artifact in patch bundle', () async { @@ -841,7 +920,7 @@ void main() { appId: appId, releaseId: releaseId, releaseArtifact: releaseArtifactFile, - supplementArtifact: supplementArtifactFile, + supplementDirectory: supplementDirectory, ), ); @@ -864,7 +943,7 @@ void main() { appId: appId, releaseId: releaseId, releaseArtifact: releaseArtifactFile, - supplementArtifact: supplementArtifactFile, + supplementDirectory: supplementDirectory, ), ); expect(patcher.linkPercentage, isNotNull); diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart index dd78febb..ac6e64a1 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart @@ -758,6 +758,84 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), await runWithOverrides(patcher.buildPatchArtifact); expect(copiedKernelFile.existsSync(), isTrue); }); + + group('when extraBuildArgs has obfuscation flags', () { + late File obfuscationMapFile; + + setUp(() { + obfuscationMapFile = + File( + p.join( + Directory.systemTemp.createTempSync().path, + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); + }); + + test('includes obfuscation flags in build args', () async { + patcher.obfuscationMapPath = obfuscationMapFile.path; + patcher.extraBuildArgs = [ + '--obfuscate', + '--extra-gen-snapshot-options=' + '--load-obfuscation-map=${obfuscationMapFile.path}', + '--split-debug-info=build/shorebird/symbols', + ]; + await runWithOverrides(patcher.buildPatchArtifact); + + final captured = verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + args: captureAny(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect(args, contains('--obfuscate')); + expect( + args.any((a) => a.startsWith('--split-debug-info=')), + isTrue, + ); + expect( + args, + contains( + '--extra-gen-snapshot-options=' + '--load-obfuscation-map=${obfuscationMapFile.path}', + ), + ); + }); + }); + + group('when extraBuildArgs is empty', () { + test('does not inject obfuscation flags', () async { + await runWithOverrides(patcher.buildPatchArtifact); + + final captured = verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + args: captureAny(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect(args, isNot(contains('--obfuscate'))); + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--load-obfuscation-map', + ), + ), + isFalse, + ); + }); + }); }); }); @@ -783,7 +861,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), canSideload: true, ); late File releaseArtifactFile; - late File supplementArtifactFile; + late Directory supplementDirectory; void setUpProjectRootArtifacts() { File( @@ -839,12 +917,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), 'release.xcarchive', ), )..createSync(recursive: true); - supplementArtifactFile = File( - p.join( - Directory.systemTemp.createTempSync().path, - 'ios_supplement.zip', - ), - )..createSync(recursive: true); + supplementDirectory = Directory.systemTemp.createTempSync(); when( () => codePushClientWrapper.getReleaseArtifact( @@ -1115,21 +1188,12 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), group('when class table link info & debug info are present', () { setUp(() { - when( - () => artifactManager.extractZip( - zipFile: supplementArtifactFile, - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((invocation) async { - final outDir = - invocation.namedArguments[#outputDirectory] as Directory; - File( - p.join(outDir.path, 'App.ct.link'), - ).createSync(recursive: true); - File( - p.join(outDir.path, 'App.class_table.json'), - ).createSync(recursive: true); - }); + File( + p.join(supplementDirectory.path, 'App.ct.link'), + ).createSync(recursive: true); + File( + p.join(supplementDirectory.path, 'App.class_table.json'), + ).createSync(recursive: true); }); test('returns linked patch artifact in patch bundle', () async { @@ -1138,7 +1202,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), appId: appId, releaseId: releaseId, releaseArtifact: releaseArtifactFile, - supplementArtifact: supplementArtifactFile, + supplementDirectory: supplementDirectory, ), ); @@ -1160,7 +1224,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), appId: appId, releaseId: releaseId, releaseArtifact: releaseArtifactFile, - supplementArtifact: supplementArtifactFile, + supplementDirectory: supplementDirectory, ), ); expect(patcher.linkPercentage, isNotNull); diff --git a/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart index cdec281f..09c569f8 100644 --- a/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/linux_patcher_test.dart @@ -252,6 +252,52 @@ void main() { ); }); }); + + group('when extraBuildArgs has obfuscation flags', () { + setUp(() { + final releaseDir = Directory( + p.join( + projectRoot.path, + 'build', + 'linux', + 'x64', + 'release', + 'bundle', + ), + )..createSync(recursive: true); + when( + () => artifactManager.linuxBundleDirectory, + ).thenReturn(releaseDir); + when( + () => artifactBuilder.buildLinuxApp( + base64PublicKey: any(named: 'base64PublicKey'), + args: any(named: 'args'), + ), + ).thenAnswer((_) async => {}); + }); + + test('passes extraBuildArgs to buildLinuxApp', () async { + patcher.extraBuildArgs = [ + '--obfuscate', + '--split-debug-info=build/shorebird/symbols', + ]; + await runWithOverrides(() => patcher.buildPatchArtifact()); + + final captured = verify( + () => artifactBuilder.buildLinuxApp( + base64PublicKey: any(named: 'base64PublicKey'), + args: captureAny(named: 'args'), + ), + ).captured; + + final args = captured.last as List; + expect(args, contains('--obfuscate')); + expect( + args.any((a) => a.startsWith('--split-debug-info=')), + isTrue, + ); + }); + }); }); group('createPatchArtifacts', () { @@ -342,7 +388,7 @@ void main() { appId: 'com.example.app', releaseId: 1, releaseArtifact: releaseArtifact, - supplementArtifact: File('supplement.zip'), + supplementDirectory: Directory('supplement'), ), ); diff --git a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart index 20bc3c4b..7b943b3a 100644 --- a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart @@ -183,6 +183,15 @@ void main() { }); }); + group('supplementaryReleaseArtifactArch', () { + test('is "macos_supplement"', () { + expect( + patcher.supplementaryReleaseArtifactArch, + 'macos_supplement', + ); + }); + }); + group('releaseType', () { test('is ReleaseType.macos', () { expect(patcher.releaseType, ReleaseType.macos); @@ -866,6 +875,84 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''), await runWithOverrides(patcher.buildPatchArtifact); expect(copiedKernelFile.existsSync(), isTrue); }); + + group('when extraBuildArgs has obfuscation flags', () { + late File obfuscationMapFile; + + setUp(() { + obfuscationMapFile = + File( + p.join( + Directory.systemTemp.createTempSync().path, + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); + }); + + test('includes obfuscation flags in build args', () async { + patcher.obfuscationMapPath = obfuscationMapFile.path; + patcher.extraBuildArgs = [ + '--obfuscate', + '--extra-gen-snapshot-options=' + '--load-obfuscation-map=${obfuscationMapFile.path}', + '--split-debug-info=build/shorebird/symbols', + ]; + await runWithOverrides(patcher.buildPatchArtifact); + + final captured = verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: captureAny(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect(args, contains('--obfuscate')); + expect( + args.any((a) => a.startsWith('--split-debug-info=')), + isTrue, + ); + expect( + args, + contains( + '--extra-gen-snapshot-options=' + '--load-obfuscation-map=${obfuscationMapFile.path}', + ), + ); + }); + }); + + group('when extraBuildArgs is empty', () { + test('does not inject obfuscation flags', () async { + await runWithOverrides(patcher.buildPatchArtifact); + + final captured = verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: captureAny(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect(args, isNot(contains('--obfuscate'))); + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--load-obfuscation-map', + ), + ), + isFalse, + ); + }); + }); }); }); diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 87c9a5e3..a5a5db60 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -200,6 +200,7 @@ void main() { when( () => argResults.wasParsed(CommonArguments.signCmd.name), ).thenReturn(false); + when(() => argResults.rest).thenReturn([]); when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => true); @@ -209,6 +210,12 @@ void main() { message: any(named: 'message'), ), ).thenAnswer((_) async => File('')); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((_) async {}); when(() => cache.updateAll()).thenAnswer((_) async => {}); @@ -291,7 +298,7 @@ void main() { appId: any(named: 'appId'), releaseId: any(named: 'releaseId'), releaseArtifact: any(named: 'releaseArtifact'), - supplementArtifact: any(named: 'supplementArtifact'), + supplementDirectory: any(named: 'supplementDirectory'), ), ).thenAnswer((_) async => patchArtifactBundles); when( @@ -541,7 +548,7 @@ void main() { appId: appId, releaseId: release.id, releaseArtifact: any(named: 'releaseArtifact'), - supplementArtifact: any(named: 'supplementArtifact'), + supplementDirectory: any(named: 'supplementDirectory'), ), ).called(1); }); @@ -565,12 +572,36 @@ void main() { appId: appId, releaseId: release.id, releaseArtifact: any(named: 'releaseArtifact'), - supplementArtifact: any(named: 'supplementArtifact'), + supplementDirectory: any(named: 'supplementDirectory'), ), ).called(1); }); }); }); + + group( + 'when --obfuscate is passed but release has no obfuscation map', + () { + setUp(() { + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => argResults['obfuscate']).thenReturn(true); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(() => command.createPatch(patcher)), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.err( + '--obfuscate was passed, but the release was not built with ' + 'obfuscation. A patch cannot change the obfuscation mode of ' + 'a release.', + ), + ).called(1); + }); + }, + ); }); }); diff --git a/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart index 664da30e..6326fd21 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patcher_test.dart @@ -633,7 +633,7 @@ class _TestPatcher extends Patcher { required String appId, required int releaseId, required File releaseArtifact, - File? supplementArtifact, + Directory? supplementDirectory, }) { throw UnimplementedError(); } diff --git a/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart index c6496967..d2ff73f6 100644 --- a/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart @@ -443,7 +443,7 @@ void main() { appId: 'com.example.app', releaseId: 1, releaseArtifact: releaseArtifact, - supplementArtifact: File('supplement.zip'), + supplementDirectory: Directory('supplement'), ), ); diff --git a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart index 01186da6..2867e377 100644 --- a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart @@ -5,7 +5,9 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:shorebird_cli/src/artifact_builder/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/code_signer.dart'; import 'package:shorebird_cli/src/commands/release/aar_releaser.dart'; @@ -18,6 +20,7 @@ import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -33,6 +36,7 @@ void main() { late ArgResults argResults; late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; late CodePushClientWrapper codePushClientWrapper; late CodeSigner codeSigner; late Directory projectRoot; @@ -41,6 +45,7 @@ void main() { late Progress progress; late ShorebirdProcess shorebirdProcess; late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; late ShorebirdValidator shorebirdValidator; late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts; late AarReleaser aarReleaser; @@ -50,6 +55,7 @@ void main() { body, values: { artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), codeSignerRef.overrideWith(() => codeSigner), engineConfigRef.overrideWith(() => const EngineConfig.empty()), @@ -57,6 +63,7 @@ void main() { osInterfaceRef.overrideWith(() => operatingSystemInterface), processRef.overrideWith(() => shorebirdProcess), shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), shorebirdAndroidArtifactsRef.overrideWith( () => shorebirdAndroidArtifacts, @@ -74,6 +81,7 @@ void main() { setUp(() { argResults = MockArgResults(); artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); codePushClientWrapper = MockCodePushClientWrapper(); codeSigner = MockCodeSigner(); operatingSystemInterface = MockOperatingSystemInterface(); @@ -82,6 +90,7 @@ void main() { logger = MockShorebirdLogger(); shorebirdProcess = MockShorebirdProcess(); shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); shorebirdValidator = MockShorebirdValidator(); shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts(); @@ -233,6 +242,58 @@ void main() { ); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(aarReleaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(aarReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); + }); + }); + + group('when --obfuscate is not passed', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(aarReleaser.assertArgsAreValid), + completes, + ); + }); + }); }); group('buildReleaseArtifacts', () { @@ -411,6 +472,93 @@ void main() { }, ); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + // Simulate the build creating the obfuscation map. + when( + () => artifactBuilder.buildAar( + buildNumber: any(named: 'buildNumber'), + targetPlatforms: any(named: 'targetPlatforms'), + args: any(named: 'args'), + ), + ).thenAnswer((_) async { + final mapPath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + File(mapPath) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + }); + }); + + test('injects --save-obfuscation-map into build args', () async { + await runWithOverrides(aarReleaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildAar( + buildNumber: any(named: 'buildNumber'), + targetPlatforms: any(named: 'targetPlatforms'), + args: captureAny(named: 'args'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--save-obfuscation-map=', + ), + ), + isTrue, + ); + }); + + test('logs detail about map location', () async { + await runWithOverrides(aarReleaser.buildReleaseArtifacts); + + verify( + () => logger.detail( + any(that: startsWith('Obfuscation map saved to')), + ), + ).called(1); + }); + + group('when obfuscation map is not generated', () { + setUp(() { + // Override to NOT create the map file. + when( + () => artifactBuilder.buildAar( + buildNumber: any(named: 'buildNumber'), + targetPlatforms: any(named: 'targetPlatforms'), + args: any(named: 'args'), + ), + ).thenAnswer((_) async {}); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(aarReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: contains( + 'Obfuscation was enabled but the obfuscation map was not', + ), + ), + ), + ).called(1); + }); + }); + }); }); }); diff --git a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart index 262922e0..6b73b2ad 100644 --- a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart @@ -5,7 +5,9 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:shorebird_cli/src/artifact_builder/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/code_signer.dart'; import 'package:shorebird_cli/src/commands/release/android_releaser.dart'; @@ -20,6 +22,7 @@ import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_android_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; @@ -34,6 +37,7 @@ void main() { group(AndroidReleaser, () { late ArgResults argResults; late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; late CodePushClientWrapper codePushClientWrapper; late CodeSigner codeSigner; late Doctor doctor; @@ -44,6 +48,7 @@ void main() { late Progress progress; late ShorebirdProcess shorebirdProcess; late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; late ShorebirdValidator shorebirdValidator; late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts; late AndroidReleaser androidReleaser; @@ -53,6 +58,7 @@ void main() { body, values: { artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), codeSignerRef.overrideWith(() => codeSigner), doctorRef.overrideWith(() => doctor), @@ -61,6 +67,7 @@ void main() { osInterfaceRef.overrideWith(() => operatingSystemInterface), processRef.overrideWith(() => shorebirdProcess), shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), shorebirdAndroidArtifactsRef.overrideWith( () => shorebirdAndroidArtifacts, @@ -78,6 +85,7 @@ void main() { setUp(() { argResults = MockArgResults(); artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); codePushClientWrapper = MockCodePushClientWrapper(); codeSigner = MockCodeSigner(); doctor = MockDoctor(); @@ -88,6 +96,7 @@ void main() { logger = MockShorebirdLogger(); shorebirdProcess = MockShorebirdProcess(); shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); shorebirdValidator = MockShorebirdValidator(); shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts(); @@ -277,6 +286,60 @@ To change the version of this release, change your app's version in your pubspec ); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['artifact']).thenReturn('aab'); + when(() => argResults['split-per-abi']).thenReturn(false); + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(androidReleaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(androidReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); + }); + }); + + group('when --obfuscate is not passed', () { + setUp(() { + when(() => argResults['artifact']).thenReturn('aab'); + when(() => argResults['split-per-abi']).thenReturn(false); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(androidReleaser.assertArgsAreValid), + completes, + ); + }); + }); }); group('buildReleaseArtifacts', () { diff --git a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart index a84682d6..49ddadab 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart @@ -157,6 +157,58 @@ void main() { ); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(iosFrameworkReleaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(iosFrameworkReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); + }); + }); + + group('when --obfuscate is not passed', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(iosFrameworkReleaser.assertArgsAreValid), + completes, + ); + }); + }); }); group('assertPreconditions', () { @@ -426,6 +478,93 @@ void main() { expect(xcframework.path, p.join(projectRoot.path, 'release')); verify(() => artifactBuilder.buildIosFramework(args: [])).called(1); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + // Simulate the build creating the obfuscation map. + when( + () => artifactBuilder.buildIosFramework( + args: any(named: 'args'), + ), + ).thenAnswer((_) async { + final mapPath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + File(mapPath) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + return AppleBuildResult(kernelFile: File('/path/to/app.dill')); + }); + }); + + test('injects --save-obfuscation-map into build args', () async { + await runWithOverrides(iosFrameworkReleaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildIosFramework( + args: captureAny(named: 'args'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--save-obfuscation-map=', + ), + ), + isTrue, + ); + }); + + test('logs detail about map location', () async { + await runWithOverrides(iosFrameworkReleaser.buildReleaseArtifacts); + + verify( + () => logger.detail( + any(that: startsWith('Obfuscation map saved to')), + ), + ).called(1); + }); + + group('when obfuscation map is not generated', () { + setUp(() { + // Override to NOT create the map file. + when( + () => artifactBuilder.buildIosFramework( + args: any(named: 'args'), + ), + ).thenAnswer( + (_) async => + AppleBuildResult(kernelFile: File('/path/to/app.dill')), + ); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides( + iosFrameworkReleaser.buildReleaseArtifacts, + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: contains( + 'Obfuscation was enabled but the obfuscation map was not', + ), + ), + ), + ).called(1); + }); + }); + }); }); group('getReleaseVersion', () { @@ -468,7 +607,6 @@ void main() { appId: any(named: 'appId'), releaseId: any(named: 'releaseId'), appFrameworkPath: any(named: 'appFrameworkPath'), - supplementPath: any(named: 'supplementPath'), ), ).thenAnswer((_) async {}); }); @@ -490,7 +628,6 @@ void main() { 'release', ArtifactManager.appXcframeworkName, ), - supplementPath: null, ), ).called(1); }); diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index ddc31ac0..a8d03d23 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -237,7 +237,7 @@ void main() { () => logger.err( ''' The "--release-version" flag is only supported for aar and ios-framework releases. - + To change the version of this release, change your app's version in your pubspec.yaml.''', ), ).called(1); @@ -246,25 +246,39 @@ To change the version of this release, change your app's version in your pubspec group('when --obfuscate is passed', () { setUp(() { - when(() => argResults.rest).thenReturn(['--obfuscate']); + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); }); - test('logs error and exits', () async { - await expectLater( - runWithOverrides(iosReleaser.assertArgsAreValid), - exitsWithCode(ExitCode.unavailable), - ); + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); - verify( - () => logger.err( - 'Shorebird does not currently support obfuscation on iOS.', - ), - ).called(1); - verify( - () => logger.info( - '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', - ), - ).called(1); + test('returns normally', () async { + await expectLater( + runWithOverrides(iosReleaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(iosReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); }); }); @@ -532,6 +546,158 @@ To change the version of this release, change your app's version in your pubspec verify(() => logger.err('Unable to find .app directory')).called(1); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + // By default, simulate the build creating the obfuscation map. + when( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + ), + ).thenAnswer((_) async { + final mapPath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + File(mapPath) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + return AppleBuildResult( + kernelFile: File('/path/to/app.dill'), + ); + }); + }); + + test('injects --save-obfuscation-map into build args', () async { + await runWithOverrides(iosReleaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: captureAny(named: 'args'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--save-obfuscation-map=', + ), + ), + isTrue, + ); + }); + + test('auto-defaults --split-debug-info when not provided', () async { + await runWithOverrides(iosReleaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: captureAny(named: 'args'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith('--split-debug-info='), + ), + isTrue, + ); + }); + + group('when --split-debug-info is also provided', () { + setUp(() { + when( + () => argResults.wasParsed('split-debug-info'), + ).thenReturn(true); + when( + () => argResults['split-debug-info'], + ).thenReturn('custom/symbols'); + }); + + test('preserves user-provided --split-debug-info', () async { + await runWithOverrides(iosReleaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: captureAny(named: 'args'), + ), + ).captured; + + final args = captured.last as List; + expect( + args, + contains('--split-debug-info=custom/symbols'), + ); + // Should not contain a second auto-defaulted one. + expect( + args.where((a) => a.startsWith('--split-debug-info=')).length, + equals(1), + ); + }); + }); + + test('logs detail about map location', () async { + await runWithOverrides(iosReleaser.buildReleaseArtifacts); + + verify( + () => logger.detail( + any(that: startsWith('Obfuscation map saved to')), + ), + ).called(1); + }); + + group('when obfuscation map is not generated', () { + setUp(() { + // Override to NOT create the map file. + when( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + ), + ).thenAnswer( + (_) async => + AppleBuildResult(kernelFile: File('/path/to/app.dill')), + ); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: contains( + 'Obfuscation was enabled but the obfuscation map was not', + ), + ), + ), + ).called(1); + }); + }); + }); }); group('getReleaseVersion', () { @@ -674,6 +840,9 @@ To change the version of this release, change your app's version in your pubspec setUp(() { when(() => argResults['codesign']).thenReturn(codesign); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); xcarchiveDirectory = Directory.systemTemp.createTempSync(); iosAppDirectory = Directory.systemTemp.createTempSync(); @@ -706,7 +875,6 @@ To change the version of this release, change your app's version in your pubspec runnerPath: any(named: 'runnerPath'), isCodesigned: any(named: 'isCodesigned'), podfileLockHash: any(named: 'podfileLockHash'), - supplementPath: any(named: 'supplementPath'), ), ).thenAnswer((_) async => {}); when(() => shorebirdEnv.iosPodfileLockFile).thenReturn(podfileLockFile); @@ -729,10 +897,46 @@ To change the version of this release, change your app's version in your pubspec isCodesigned: codesign, podfileLockHash: '${sha256.convert(utf8.encode(podfileLockContent))}', - supplementPath: supplementDirectory.path, ), ).called(1); }); + + group('when obfuscation map exists', () { + setUp(() { + // Create the obfuscation map file at the expected path. + File( + p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); + }); + + test('passes obfuscation map path to wrapper', () async { + await runWithOverrides( + () => iosReleaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ); + + verify( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: xcarchiveDirectory.path, + runnerPath: iosAppDirectory.path, + isCodesigned: codesign, + podfileLockHash: + '${sha256.convert(utf8.encode(podfileLockContent))}', + ), + ).called(1); + }); + }); }); group('updatedReleaseMetadata', () { diff --git a/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart index a11f9bc2..fe25f2b8 100644 --- a/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart @@ -16,6 +16,7 @@ import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; @@ -154,6 +155,53 @@ To change the version of this release, change your app's version in your pubspec ).called(1); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(releaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); + }); + }); + + group('when --obfuscate is not passed', () { + test('returns normally', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + completes, + ); + }); + }); }); group('assertPreconditions', () { @@ -356,6 +404,93 @@ To change the version of this release, change your app's version in your pubspec ).called(1); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + // Simulate the build creating the obfuscation map. + when( + () => artifactBuilder.buildLinuxApp( + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async { + final mapPath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + File(mapPath) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + }); + }); + + test('injects --save-obfuscation-map into build args', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildLinuxApp( + target: any(named: 'target'), + args: captureAny(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--save-obfuscation-map=', + ), + ), + isTrue, + ); + }); + + test('logs detail about map location', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + verify( + () => logger.detail( + any(that: startsWith('Obfuscation map saved to')), + ), + ).called(1); + }); + + group('when obfuscation map is not generated', () { + setUp(() { + // Override to NOT create the map file. + when( + () => artifactBuilder.buildLinuxApp( + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async {}); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: contains( + 'Obfuscation was enabled but the obfuscation map was not', + ), + ), + ), + ).called(1); + }); + }); + }); }); group('getReleaseVersion', () { diff --git a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart index 51d3c9f5..c9684ecb 100644 --- a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart @@ -219,7 +219,7 @@ void main() { () => logger.err( ''' The "--release-version" flag is only supported for aar and ios-framework releases. - + To change the version of this release, change your app's version in your pubspec.yaml.''', ), ).called(1); @@ -228,25 +228,39 @@ To change the version of this release, change your app's version in your pubspec group('when --obfuscate is passed', () { setUp(() { - when(() => argResults.rest).thenReturn(['--obfuscate']); + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); }); - test('logs error and exits', () async { - await expectLater( - runWithOverrides(releaser.assertArgsAreValid), - exitsWithCode(ExitCode.unavailable), - ); + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); - verify( - () => logger.err( - 'Shorebird does not currently support obfuscation on macOS.', - ), - ).called(1); - verify( - () => logger.info( - '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', - ), - ).called(1); + test('returns normally', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(releaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); }); }); @@ -435,6 +449,163 @@ To change the version of this release, change your app's version in your pubspec verify(() => logger.err('Unable to find .app directory')).called(1); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + // By default, simulate the build creating the obfuscation map. + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async { + final mapPath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + File(mapPath) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + return AppleBuildResult( + kernelFile: File('/path/to/app.dill'), + ); + }); + }); + + test('injects --save-obfuscation-map into build args', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: captureAny(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--save-obfuscation-map=', + ), + ), + isTrue, + ); + }); + + test('auto-defaults --split-debug-info when not provided', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: captureAny(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith('--split-debug-info='), + ), + isTrue, + ); + }); + + group('when --split-debug-info is also provided', () { + setUp(() { + when( + () => argResults.wasParsed('split-debug-info'), + ).thenReturn(true); + when( + () => argResults['split-debug-info'], + ).thenReturn('custom/symbols'); + }); + + test('preserves user-provided --split-debug-info', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: captureAny(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect( + args, + contains('--split-debug-info=custom/symbols'), + ); + // Should not contain a second auto-defaulted one. + expect( + args.where((a) => a.startsWith('--split-debug-info=')).length, + equals(1), + ); + }); + }); + + test('logs detail about map location', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + verify( + () => logger.detail( + any(that: startsWith('Obfuscation map saved to')), + ), + ).called(1); + }); + + group('when obfuscation map is not generated', () { + setUp(() { + // Override to NOT create the map file. + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer( + (_) async => + AppleBuildResult(kernelFile: File('/path/to/app.dill')), + ); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: contains( + 'Obfuscation was enabled but the obfuscation map was not', + ), + ), + ), + ).called(1); + }); + }); + }); }); group('getReleaseVersion', () { @@ -577,6 +748,9 @@ To change the version of this release, change your app's version in your pubspec setUp(() { when(() => argResults['codesign']).thenReturn(codesign); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); appDirectory = Directory.systemTemp.createTempSync(); @@ -644,6 +818,42 @@ To change the version of this release, change your app's version in your pubspec ), ).called(1); }); + + group('when obfuscation map exists', () { + setUp(() { + // Create the obfuscation map file at the expected path. + File( + p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); + }); + + test('passes obfuscation map path to wrapper', () async { + await runWithOverrides( + () => releaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ); + + verify( + () => codePushClientWrapper.createMacosReleaseArtifacts( + appId: appId, + releaseId: release.id, + appPath: appDirectory.path, + isCodesigned: codesign, + podfileLockHash: + '${sha256.convert(utf8.encode(podfileLockContent))}', + ), + ).called(1); + }); + }); }); group('updatedReleaseMetadata', () { diff --git a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart index e594b795..818efe82 100644 --- a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart @@ -18,6 +18,7 @@ import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; @@ -160,12 +161,59 @@ void main() { () => logger.err( ''' The "--release-version" flag is only supported for aar and ios-framework releases. - + To change the version of this release, change your app's version in your pubspec.yaml.''', ), ).called(1); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('when Flutter version supports obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); + + test('returns normally', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version does not support obfuscation', () { + setUp(() { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 27, 4)); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(releaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); + }); + }); + + group('when --obfuscate is not passed', () { + test('returns normally', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + completes, + ); + }); + }); }); group('assertPreconditions', () { @@ -371,6 +419,94 @@ To change the version of this release, change your app's version in your pubspec ).called(1); }); }); + + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults['obfuscate']).thenReturn(true); + when(() => argResults.wasParsed('obfuscate')).thenReturn(true); + // Simulate the build creating the obfuscation map. + when( + () => artifactBuilder.buildWindowsApp( + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async { + final mapPath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ); + File(mapPath) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + return projectRoot; + }); + }); + + test('injects --save-obfuscation-map into build args', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + final captured = verify( + () => artifactBuilder.buildWindowsApp( + target: any(named: 'target'), + args: captureAny(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).captured; + + final args = captured.last as List; + expect( + args.any( + (a) => a.startsWith( + '--extra-gen-snapshot-options=--save-obfuscation-map=', + ), + ), + isTrue, + ); + }); + + test('logs detail about map location', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + verify( + () => logger.detail( + any(that: startsWith('Obfuscation map saved to')), + ), + ).called(1); + }); + + group('when obfuscation map is not generated', () { + setUp(() { + // Override to NOT create the map file. + when( + () => artifactBuilder.buildWindowsApp( + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async => projectRoot); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: contains( + 'Obfuscation was enabled but the obfuscation map was not', + ), + ), + ), + ).called(1); + }); + }); + }); }); group('getReleaseVersion', () { diff --git a/packages/shorebird_cli/test/src/extensions/arg_results_test.dart b/packages/shorebird_cli/test/src/extensions/arg_results_test.dart index a3027ca2..4c6761ef 100644 --- a/packages/shorebird_cli/test/src/extensions/arg_results_test.dart +++ b/packages/shorebird_cli/test/src/extensions/arg_results_test.dart @@ -138,6 +138,10 @@ void main() { 'platforms', allowed: ReleaseType.values.map((e) => e.cliName), ) + ..addFlag( + CommonArguments.obfuscateArg.name, + negatable: false, + ) ..addFlag('verbose', abbr: 'v'); }); @@ -317,6 +321,23 @@ void main() { ); }); }); + + group('when --obfuscate flag is provided', () { + test('forwards it', () { + final args = ['--verbose', '--obfuscate']; + final result = parser.parse(args); + expect(result.forwardedArgs, hasLength(1)); + expect(result.forwardedArgs, contains('--obfuscate')); + }); + }); + + group('when --obfuscate flag is not provided', () { + test('does not forward it', () { + final args = ['--verbose']; + final result = parser.parse(args); + expect(result.forwardedArgs, isNot(contains('--obfuscate'))); + }); + }); }); group('CodeSign', () {