From f8f4354fc323a5556618d076309c5ee8f7abe472 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Sat, 2 May 2026 15:11:43 -0700 Subject: [PATCH] fix: download arm64 patch binary on Apple Silicon (#3713) Co-authored-by: Eric Seidel --- packages/shorebird_cli/lib/src/cache.dart | 34 ++++-- .../lib/src/flutter_version_constraints.dart | 11 ++ .../shorebird_cli/test/src/cache_test.dart | 105 +++++++++++++++++- 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/packages/shorebird_cli/lib/src/cache.dart b/packages/shorebird_cli/lib/src/cache.dart index 6f3060d2..fd68f84e 100644 --- a/packages/shorebird_cli/lib/src/cache.dart +++ b/packages/shorebird_cli/lib/src/cache.dart @@ -1,3 +1,4 @@ +import 'dart:ffi' show Abi; import 'dart:io' hide Platform; import 'package:http/http.dart' as http; @@ -5,13 +6,16 @@ import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:retry/retry.dart'; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/abi.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/checksum_checker.dart'; +import 'package:shorebird_cli/src/flutter_version_constraints.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/shorebird_artifacts.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; /// {@template cache_update_failure} @@ -147,8 +151,10 @@ abstract class CachedArtifact { /// Should the artifact be marked executable. bool get isExecutable; - /// The URL from which the artifact can be downloaded. - String get storageUrl; + /// The URL from which the artifact can be downloaded. Returned as a + /// future so subclasses can resolve runtime context (e.g. the active + /// Flutter version) before deciding which artifact to fetch. + Future get storageUrl; /// Whether the artifact is required for Shorebird to function. /// If we fail to fetch it we will exit with an error. @@ -197,7 +203,8 @@ abstract class CachedArtifact { final updateProgress = logger.progress('Downloading $fileName...'); - final request = http.Request('GET', Uri.parse(storageUrl)); + final url = await storageUrl; + final request = http.Request('GET', Uri.parse(url)); final http.StreamedResponse response; try { response = await httpClient.send(request); @@ -205,7 +212,7 @@ abstract class CachedArtifact { throw CacheUpdateFailure(''' Failed to download $fileName: $error If you're behind a firewall/proxy, please, make sure shorebird_cli is -allowed to access $storageUrl.'''); +allowed to access $url.'''); } if (response.statusCode != HttpStatus.ok) { @@ -304,7 +311,7 @@ class AotToolsArtifact extends CachedArtifact { ); @override - String get storageUrl => + Future get storageUrl async => '${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/${shorebirdEnv.shorebirdEngineRevision}/$fileName'; @override @@ -339,10 +346,12 @@ class PatchArtifact extends CachedArtifact { } @override - String get storageUrl { + Future get storageUrl async { var artifactName = 'patch-'; if (platform.isMacOS) { - artifactName += 'darwin-x64.zip'; + final useArm64 = + abi.current == Abi.macosArm64 && await _supportsArm64Patch(); + artifactName += useArm64 ? 'darwin-arm64.zip' : 'darwin-x64.zip'; } else if (platform.isLinux) { artifactName += 'linux-x64.zip'; } else if (platform.isWindows) { @@ -352,6 +361,15 @@ class PatchArtifact extends CachedArtifact { return '${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/${shorebirdEnv.shorebirdEngineRevision}/$artifactName'; } + Future _supportsArm64Patch() async { + final revision = shorebirdEnv.flutterRevision; + final version = await shorebirdFlutter.resolveFlutterVersion(revision); + return arm64PatchSupportConstraint.isSatisfiedBy( + version: version ?? arm64PatchSupportConstraint.minVersion, + revision: revision, + ); + } + @override String? get checksum => null; } @@ -371,7 +389,7 @@ class BundleToolArtifact extends CachedArtifact { bool get isExecutable => false; @override - String get storageUrl { + Future get storageUrl async { return 'https://github.com/google/bundletool/releases/download/1.18.1/bundletool-all-1.18.1.jar'; } diff --git a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart index 7d46bdd8..5a44b6a3 100644 --- a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart +++ b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart @@ -65,6 +65,17 @@ class FlutterSupportConstraint { version >= minVersion || allowedRevisions.contains(revision); } +/// Flutter support for downloading the `patch-darwin-arm64.zip` artifact +/// on Apple Silicon. The arm64 upload was added in shorebirdtech/flutter#129 +/// (merged 2026-04-09); the tip of `flutter_release/3.41.7` and every +/// subsequent release branch ships with it. +/// +/// Pre-floor pins fall back to `patch-darwin-x64.zip`, which works on +/// Apple Silicon when Rosetta is installed. +final arm64PatchSupportConstraint = FlutterSupportConstraint( + minVersion: Version(3, 41, 7), +); + /// Flutter support for `flutter build --shorebird-trace=` for emitting /// Chrome Trace Event Format build traces. /// diff --git a/packages/shorebird_cli/test/src/cache_test.dart b/packages/shorebird_cli/test/src/cache_test.dart index eebc770c..cbe65256 100644 --- a/packages/shorebird_cli/test/src/cache_test.dart +++ b/packages/shorebird_cli/test/src/cache_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ffi' show Abi; import 'dart:io'; import 'package:archive/archive_io.dart'; @@ -7,14 +8,18 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/abi.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/checksum_checker.dart'; +import 'package:shorebird_cli/src/flutter_version_constraints.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform.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:test/test.dart'; @@ -25,6 +30,7 @@ void main() { group(Cache, () { const shorebirdEngineRevision = 'test-revision'; + late LocalAbi mockAbi; late ArtifactManager artifactManager; late Cache cache; late ChecksumChecker checksumChecker; @@ -35,12 +41,14 @@ void main() { late Process chmodProcess; late Progress progress; late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; late ShorebirdProcess shorebirdProcess; R runWithOverrides(R Function() body) { return runScoped( () => body(), values: { + abiRef.overrideWith(() => mockAbi), artifactManagerRef.overrideWith(() => artifactManager), cacheRef.overrideWith(() => cache), checksumCheckerRef.overrideWith(() => checksumChecker), @@ -49,6 +57,7 @@ void main() { platformRef.overrideWith(() => platform), processRef.overrideWith(() => shorebirdProcess), shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), }, ); } @@ -73,6 +82,7 @@ void main() { }); setUp(() { + mockAbi = MockAbi(); artifactManager = MockArtifactManager(); chmodProcess = MockProcess(); checksumChecker = MockChecksumChecker(); @@ -81,8 +91,14 @@ void main() { platform = MockPlatform(); progress = MockProgress(); shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); shorebirdProcess = MockShorebirdProcess(); + when(() => mockAbi.current).thenReturn(Abi.macosX64); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => null); + shorebirdRoot = Directory.systemTemp.createTempSync(); when( () => artifactManager.extractZip( @@ -98,6 +114,9 @@ void main() { when( () => shorebirdEnv.shorebirdEngineRevision, ).thenReturn(shorebirdEngineRevision); + when( + () => shorebirdEnv.flutterRevision, + ).thenReturn('test-flutter-revision'); when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); when(() => platform.environment).thenReturn({}); @@ -204,6 +223,89 @@ void main() { }); }); + group('storageUrl', () { + group('when on macOS', () { + setUp(() { + setMockPlatform(Platform.macOS); + }); + + test( + 'uses darwin-arm64 on Apple Silicon when the Flutter version ' + 'satisfies the arm64 patch constraint', + () async { + when(() => mockAbi.current).thenReturn(Abi.macosArm64); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer( + (_) async => arm64PatchSupportConstraint.minVersion, + ); + final url = await runWithOverrides( + () => PatchArtifact( + cache: cache, + platform: platform, + ).storageUrl, + ); + expect(url, contains('patch-darwin-arm64.zip')); + }, + ); + + test( + 'uses darwin-x64 on Apple Silicon when the Flutter version ' + 'is below the arm64 patch constraint floor', + () async { + when(() => mockAbi.current).thenReturn(Abi.macosArm64); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 6)); + final url = await runWithOverrides( + () => PatchArtifact( + cache: cache, + platform: platform, + ).storageUrl, + ); + expect(url, contains('patch-darwin-x64.zip')); + }, + ); + + test('uses darwin-x64 on Intel', () async { + when(() => mockAbi.current).thenReturn(Abi.macosX64); + final url = await runWithOverrides( + () => + PatchArtifact(cache: cache, platform: platform).storageUrl, + ); + expect(url, contains('patch-darwin-x64.zip')); + }); + }); + + group('when on Linux', () { + setUp(() { + setMockPlatform(Platform.linux); + }); + + test('uses linux-x64', () async { + final url = await runWithOverrides( + () => + PatchArtifact(cache: cache, platform: platform).storageUrl, + ); + expect(url, contains('patch-linux-x64.zip')); + }); + }); + + group('when on Windows', () { + setUp(() { + setMockPlatform(Platform.windows); + }); + + test('uses windows-x64', () async { + final url = await runWithOverrides( + () => + PatchArtifact(cache: cache, platform: platform).storageUrl, + ); + expect(url, contains('patch-windows-x64.zip')); + }); + }); + }); + group('when an exception happens', () { test('throws CacheUpdateFailure', () async { const exception = SocketException('test'); @@ -571,5 +673,6 @@ class _TestCachedArtifact extends CachedArtifact { File get file => File(p.join(_location.path, fileName)); @override - String get storageUrl => 'https://example.com/test_artifact.exe'; + Future get storageUrl async => + 'https://example.com/test_artifact.exe'; }