Refactor Shorebird CLI to support configurable URLs and improve test coverage
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Deploy Artifact Proxy Dev / ☁️ Artifact Proxy (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled

- Updated AAR releaser test to use ShorebirdProcess.defaultFlutterStorageBaseUrl.
- Changed macOS releaser test to reference a new troubleshooting URL.
- Enhanced shorebird_yaml_test with deserialization for AOT patch metadata.
- Modified network_checker_test to check self-hosted URLs from ShorebirdEnv.
- Added tests for artifact paths in shorebird_artifacts_test.
- Updated shorebird_cli_command_runner_test to reflect new repository URL.
- Improved shorebird_env_test with additional tests for shorebirdRoot and engine revision fallback.
- Adjusted shorebird_flutter_test to use environment variables for Flutter Git URL.
- Updated shorebird_process_test to utilize default Flutter storage URL.
- Enhanced shorebird_validator_test to link to the default hosted URL.
- Updated shorebird_web_console_test to use the configured hosted URL.
- Refactored code_push_client to use default hosted URL for API requests.
- Updated README and generation scripts for the code_push_protocol package to reflect new OpenAPI spec source.
- Modified shared.sh to allow configurable Flutter repository and storage URLs.

Signed-off-by: Tony <tonylu@tony-cloud.com>
This commit is contained in:
Tony
2026-06-25 17:21:56 +08:00
parent 08b8f07bfb
commit d9088aeec6
92 changed files with 2754 additions and 400 deletions
+50 -70
View File
@@ -4,22 +4,30 @@ This is a tool for proxying Flutter artifacts from a derived Flutter engine
revision back to the base Flutter engine revision. This is useful for
when you need to modify _some_ of the Flutter artifacts but not all of them.
This is a development tool which map requests to Google
Storage (either Shorebird's bucket or the official Flutter buckets).
This is a development tool which maps requests to configurable artifact origins.
By default, Flutter artifacts are redirected to the public Flutter storage
origin and Shorebird-specific artifacts are redirected to the local open mirror
root at `http://localhost:8080/artifacts`.
## Usage
Uses `config.dart` to configure the engine revisions and artifact overrides.
Uses `config.dart` to configure recognized artifact URL patterns. Runtime
origins are configured with environment variables:
- `SHOREBIRD_ARTIFACT_BASE_URL`: root for open Shorebird manifests and
artifacts, defaulting to `http://localhost:8080/artifacts`
- `ARTIFACT_PROXY_FLUTTER_BASE_URL`: root for upstream Flutter artifacts,
defaulting to `https://storage.googleapis.com`
```bash
# Run locally with hot-reload enabled.
DEV=true dart --enable-vm-service run bin/server.dart
PORT=8081 DEV=true dart --enable-vm-service run bin/server.dart
```
And then in a separate terminal:
```
FLUTTER_STORAGE_BASE_URL=http://localhost:8080 flutter precache -a
FLUTTER_STORAGE_BASE_URL=http://localhost:8081 flutter precache -a
```
You should use a separate checkout of Flutter when running this, so you don't
@@ -27,84 +35,56 @@ poison the cache of your main Flutter checkout.
## Updating config.dart
If run into 404s when fetching artifacts, ensure that the expected manifest
exists at
https://storage.googleapis.com/download.shorebird.dev/shorebird/$engineRevision/artifacts_manifest.yaml.
If you run into 404s when fetching artifacts, ensure that the expected manifest
exists at:
```text
$SHOREBIRD_ARTIFACT_BASE_URL/shorebird/$engineRevision/artifacts_manifest.yaml
```
If it does, you may need to update the artifact list in `config.dart` and, if
the artifact is one we're providing, add it in `tool/generate_manifest.sh`
the artifact is one we're providing, add it in
`../../../scripts/write_artifact_manifest.py`.
To do so, you will need to determine the artifact URLs. Follow these steps:
To do so, point Flutter at this proxy with `FLUTTER_STORAGE_BASE_URL`, run a
Shorebird/Flutter command that downloads the missing artifact, then add the
observed URL pattern to `packages/artifact_proxy/lib/config.dart`.
- Adjust shorebird_cli to point to http://localhost:8080 instead of https://download.shorebird.dev:
- packages\shorebird_cli\lib\src\shorebird_process.dart
```diff
Map<String, String> _environmentOverrides({
required String executable,
}) {
if (executable == 'flutter') {
// If this ever changes we also need to update the `shorebird` shell
// wrapper which downloads runs Flutter to fetch artifacts the first time.
- return {'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev'};
+ return {'FLUTTER_STORAGE_BASE_URL': 'http://localhost:8080'};
}
return {};
}
```
- Adjust third_party Flutter to point to http://localhost:8080 instead of https://download.shorebird.dev:
- third_party\flutter\bin\internal\shared.sh
```diff
# Either clones or pulls the Shorebird Flutter repository, depending on whether FLUTTER_PATH exists.
function update_flutter {
if [[ -d "$FLUTTER_PATH" ]]; then
git -C "$FLUTTER_PATH" fetch
else
git clone --filter=tree:0 https://github.com/shorebirdtech/flutter.git --no-checkout "$FLUTTER_PATH"
fi
# -c to avoid printing a warning about being in a detached head state.
git -C "$FLUTTER_PATH" -c advice.detachedHead=false checkout "$FLUTTER_VERSION"
SHOREBIRD_ENGINE_VERSION=`cat "$FLUTTER_PATH/bin/internal/engine.version"`
echo "Shorebird Engine • revision $SHOREBIRD_ENGINE_VERSION"
# Install Shorebird Flutter Artifacts
- FLUTTER_STORAGE_BASE_URL=https://download.shorebird.dev $FLUTTER_PATH/bin/flutter --version
+ FLUTTER_STORAGE_BASE_URL=http://localhost:8080 $FLUTTER_PATH/bin/flutter --version
}
```
- Modify flutter_tool used by Shorebird to allow downloads from insecure URLs:
- shorebird\bin\cache\flutter\packages\flutter_tools\gradle\flutter.gradle
```diff
rootProject.allprojects {
repositories {
maven {
url repository
+ allowInsecureProtocol true
}
}
}
```
- Remove the flutter_tools snapshot
If you changed Flutter tool code while debugging, remove the flutter_tools
snapshot:
```bash
cd bin/cache/flutter/bin/cache
rm flutter_tools.s*
```
- Run a shorebird command (`shorebird run` works well)
- For each artifact that 404s, add a line to `packages\artifact_proxy\lib\config.dart`, following the conventions for capturing engine revisions and escaping relevant characters.
- Run a Shorebird command (`shorebird run` works well).
- For each artifact that 404s, add a line to
`packages/artifact_proxy/lib/config.dart`, following the conventions for
capturing engine revisions and escaping relevant characters.
## Generating an `artifact_manifest.yaml`
## Generating an `artifacts_manifest.yaml`
To generate a new `artifact_manifest.yaml` for a specific flutter_revision use the following command:
To generate a new `artifacts_manifest.yaml` for a specific Flutter engine
revision, use the workspace helper:
```
./tools/generate_manifest.sh <flutter_engine_revision> > artifact_manifest.yaml
../../../scripts/write_artifact_manifest.py \
--flutter-engine-revision <flutter_engine_revision> \
--output artifacts_manifest.yaml
```
Then upload the `artifact_manifest.yaml` to `download.shorebird.dev/shorebird/<shorebird_engine_revision>/artifacts_manifest.yaml`
`--flutter-engine-revision` is the upstream Flutter engine revision used for
unchanged artifacts. Upload the generated file under the custom Shorebird engine
revision path shown below.
Then upload the `artifacts_manifest.yaml` to:
```text
$SHOREBIRD_ARTIFACT_BASE_URL/shorebird/<shorebird_engine_revision>/artifacts_manifest.yaml
```
The GitHub CI engine artifacts include a `mirror/` subtree for the override
files listed by this manifest. Copy the contents of that subtree to
`$SHOREBIRD_ARTIFACT_BASE_URL` alongside the manifest and `patch-*.zip`
artifacts.
+35 -4
View File
@@ -5,13 +5,38 @@ import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_hotreload/shelf_hotreload.dart';
Future<void> main() async {
Future<void> main(List<String> args) async {
final isDev = Platform.environment['DEV'] == 'true';
final client = ArtifactManifestClient();
final handler = artifactProxyHandler(client: client);
final ip = InternetAddress.anyIPv6;
final shorebirdArtifactBaseUri = _uriFromEnvironment(
'SHOREBIRD_ARTIFACT_BASE_URL',
ArtifactManifestClient.defaultManifestBaseUri,
);
final flutterArtifactBaseUri = _uriFromEnvironment(
'ARTIFACT_PROXY_FLUTTER_BASE_URL',
defaultFlutterArtifactBaseUri,
);
final port = int.parse(Platform.environment['PORT'] ?? '8080');
if (args.contains('--health-check')) {
stdout.writeln(
'artifact_proxy ok '
'shorebird_artifacts=$shorebirdArtifactBaseUri '
'flutter_artifacts=$flutterArtifactBaseUri '
'port=$port',
);
return;
}
final client = ArtifactManifestClient(
manifestBaseUri: shorebirdArtifactBaseUri,
);
final handler = artifactProxyHandler(
client: client,
flutterArtifactBaseUri: flutterArtifactBaseUri,
shorebirdArtifactBaseUri: shorebirdArtifactBaseUri,
);
final ip = InternetAddress.anyIPv6;
// Hot-reload is enabled when the DEBUG environment variable is set to true.
if (isDev) return withHotreload(() => serve(handler, ip, port));
@@ -25,3 +50,9 @@ Future<HttpServer> serve(Handler proxy, InternetAddress ip, int port) async {
server.autoCompress = true;
return server;
}
Uri _uriFromEnvironment(String name, Uri defaultValue) {
final value = Platform.environment[name];
if (value == null || value.trim().isEmpty) return defaultValue;
return Uri.parse(value.trim());
}
+5 -3
View File
@@ -17,6 +17,7 @@ final engineArtifactPatterns = {
r'flutter_infra_release\/flutter\/(.*)\/linux-x64\/font-subset\.zip',
r'flutter_infra_release\/flutter\/(.*)\/linux-x64\/artifacts\.zip',
r'flutter_infra_release\/flutter\/(.*)\/linux-x64-release\/linux-x64-flutter-gtk\.zip',
r'flutter_infra_release\/flutter\/(.*)\/linux-x64-release\/artifacts\.zip',
r'flutter_infra_release\/flutter\/(.*)\/linux-x64-profile\/linux-x64-flutter-gtk\.zip',
r'flutter_infra_release\/flutter\/(.*)\/linux-x64-debug\/linux-x64-flutter-gtk\.zip',
r'flutter_infra_release\/flutter\/(.*)\/linux-arm64\/linux-arm64-flutter-gtk\.zip',
@@ -38,11 +39,11 @@ final engineArtifactPatterns = {
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/gen_snapshot\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/font-subset\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/FlutterMacOS.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/FlutterMacOS\.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/FlutterMacOS\.framework\.dSYM\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64\/artifacts\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-release\/FlutterMacOS.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-profile\/FlutterMacOS.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-release\/FlutterMacOS\.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-profile\/FlutterMacOS\.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-profile\/artifacts\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-profile\/framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-profile\/gen_snapshot\.zip',
@@ -51,6 +52,7 @@ final engineArtifactPatterns = {
r'flutter_infra_release\/flutter\/(.*)\/darwin-x64-release\/gen_snapshot\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-arm64\/font-subset\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-arm64\/artifacts\.zip',
r'flutter_infra_release\/flutter\/(.*)\/darwin-arm64-release\/FlutterMacOS\.framework\.zip',
r'flutter_infra_release\/flutter\/(.*)\/dart-sdk-windows-x64\.zip',
r'flutter_infra_release\/flutter\/(.*)\/dart-sdk-linux-x64\.zip',
r'flutter_infra_release\/flutter\/(.*)\/dart-sdk-linux-arm64\.zip',
@@ -6,20 +6,26 @@ import 'package:http/http.dart' as http;
import 'package:quiver/collection.dart';
/// {@template artifact_manifest_client}
/// A client that fetches [ArtifactsManifest]s from the shorebird storage bucket
/// and caches them in memory.
/// A client that fetches [ArtifactsManifest]s from an open artifact mirror and
/// caches them in memory.
/// {@endtemplate}
class ArtifactManifestClient {
/// {@macro artifact_manifest_client}
ArtifactManifestClient({http.Client? httpClient})
: _httpClient = httpClient ?? http.Client();
ArtifactManifestClient({http.Client? httpClient, Uri? manifestBaseUri})
: _httpClient = httpClient ?? http.Client(),
_manifestBaseUri = manifestBaseUri ?? defaultManifestBaseUri;
/// Default root for open self-hosted artifact manifests.
static final defaultManifestBaseUri = Uri.parse(
'http://localhost:8080/artifacts',
);
final http.Client _httpClient;
final Uri _manifestBaseUri;
final _cache = LruMap<String, ArtifactsManifest>(maximumSize: 1000);
/// Fetches the [ArtifactsManifest] for the provided [revision] from the
/// shorebird storage bucket.
/// Fetches the [ArtifactsManifest] for the provided [revision].
Future<ArtifactsManifest> getManifest(String revision) async {
if (_cache.containsKey(revision)) return _cache[revision]!;
final manifest = await _fetchManifest(revision);
@@ -28,8 +34,9 @@ class ArtifactManifestClient {
}
Future<ArtifactsManifest> _fetchManifest(String revision) async {
final url = Uri.parse(
'https://storage.googleapis.com/download.shorebird.dev/shorebird/$revision/artifacts_manifest.yaml',
final url = _joinUri(
_manifestBaseUri,
'shorebird/$revision/artifacts_manifest.yaml',
);
final response = await _httpClient.get(url);
if (response.statusCode != HttpStatus.ok) {
@@ -44,3 +51,11 @@ ${response.statusCode} ${response.reasonPhrase}''');
);
}
}
Uri _joinUri(Uri base, String path) {
final basePath = base.path.endsWith('/')
? base.path.substring(0, base.path.length - 1)
: base.path;
final relativePath = path.startsWith('/') ? path.substring(1) : path;
return base.replace(path: '$basePath/$relativePath');
}
@@ -13,31 +13,38 @@ const String _explainerHtml = """
<body>
<p>
This server proxies requests for Flutter artifacts to the correct location,
depending on the engine revision. Most artifacts are served from the standard
`download.flutter.io` location, but a few artifacts are served from Shorebird's
storage bucket to add support for code push.
depending on the engine revision. Most artifacts are served from the configured
Flutter artifact origin, but a few artifacts are served from the configured
open Shorebird artifact mirror to add support for code push.
</p>
<p>
See <a href='https://docs.shorebird.dev/architecture'>
https://docs.shorebird.dev/architecture</a> for more information.
See the workspace README and docs/CI.md for open artifact mirror details.
</p>
<p>
Source code can be found here:
<a
href='https://github.com/shorebirdtech/shorebird/tree/main/packages/artifact_proxy'>
https://github.com/shorebirdtech/shorebird/tree/main/packages/artifact_proxy</a>
href='https://git.tonycloud.org/flutter/shorebird'>
https://git.tonycloud.org/flutter/shorebird</a>
</p>
<p>
If you're seeing problems with your Shorebird install, or are interested in
replicating this proxy, please reach out to us over Discord.
<a href='https://shorebird.dev/contact'>https://shorebird.dev/contact</a>
If you're seeing problems with this open artifact proxy, file an issue against
the repository that hosts this workspace.
</p>
</body>
</html>
""";
/// A [Handler] that proxies artifact requests to the correct location.
Handler artifactProxyHandler({required ArtifactManifestClient client}) {
Handler artifactProxyHandler({
required ArtifactManifestClient client,
Uri? flutterArtifactBaseUri,
Uri? shorebirdArtifactBaseUri,
}) {
final flutterBaseUri =
flutterArtifactBaseUri ?? defaultFlutterArtifactBaseUri;
final shorebirdBaseUri =
shorebirdArtifactBaseUri ?? defaultShorebirdArtifactBaseUri;
return (Request request) async {
final path = request.url.path;
if (path.isEmpty) {
@@ -81,6 +88,7 @@ Handler artifactProxyHandler({required ArtifactManifestClient client}) {
artifactPath: normalizedPath,
engine: shorebirdEngineRevision,
bucket: manifest.storageBucket,
baseUri: shorebirdBaseUri,
);
print('Shorebird engine artifact detected, forwarding to: $location');
return Response.found(location);
@@ -89,6 +97,7 @@ Handler artifactProxyHandler({required ArtifactManifestClient client}) {
final location = getFlutterArtifactLocation(
artifactPath: normalizedPath,
engine: manifest.flutterEngineRevision,
baseUri: flutterBaseUri,
);
print('Flutter artifact detected, forwarding to: $location');
return Response.found(location);
@@ -102,23 +111,40 @@ Handler artifactProxyHandler({required ArtifactManifestClient client}) {
return Response.notFound('Unrecognized artifact path: $path');
}
final location = getFlutterArtifactLocation(artifactPath: path);
final location = getFlutterArtifactLocation(
artifactPath: path,
baseUri: flutterBaseUri,
);
print('Flutter artifact detected, forwarding to: $location');
return Response.found(location);
};
}
/// Default public Flutter artifact origin.
final defaultFlutterArtifactBaseUri = Uri.parse(
'https://storage.googleapis.com',
);
/// Default root for open self-hosted Shorebird artifacts.
final defaultShorebirdArtifactBaseUri = Uri.parse(
'http://localhost:8080/artifacts',
);
/// Returns the location of the artifact at [artifactPath] using the
/// specified [engine] revision for original Flutter artifacts.
String getFlutterArtifactLocation({
required String artifactPath,
String? engine,
Uri? baseUri,
}) {
final adjustedPath = engine != null
? artifactPath.replaceAll(r'$engine', engine)
: artifactPath;
return 'https://storage.googleapis.com/$adjustedPath';
return _joinUri(
baseUri ?? defaultFlutterArtifactBaseUri,
adjustedPath,
).toString();
}
/// Returns the location of the artifact at [artifactPath] using the
@@ -127,7 +153,23 @@ String getShorebirdArtifactLocation({
required String artifactPath,
required String engine,
required String bucket,
Uri? baseUri,
}) {
final adjustedPath = artifactPath.replaceAll(r'$engine', engine);
return 'https://storage.googleapis.com/$bucket/$adjustedPath';
final bucketUri = Uri.tryParse(bucket);
final resolvedBaseUri = bucketUri != null && bucketUri.hasScheme
? bucketUri
: baseUri ?? defaultShorebirdArtifactBaseUri;
final path = bucketUri != null && bucketUri.hasScheme
? adjustedPath
: [if (bucket.isNotEmpty) bucket, adjustedPath].join('/');
return _joinUri(resolvedBaseUri, path).toString();
}
Uri _joinUri(Uri base, String path) {
final basePath = base.path.endsWith('/')
? base.path.substring(0, base.path.length - 1)
: base.path;
final relativePath = path.startsWith('/') ? path.substring(1) : path;
return base.replace(path: '$basePath/$relativePath');
}
@@ -10,7 +10,7 @@ part 'artifacts_manifest.g.dart';
///
/// ```yaml
/// flutter_engine_revision: ec975089acb540fc60752606a3d3ba809dd1528b
/// storage_bucket: download.shorebird.dev
/// storage_bucket: shorebird
/// artifact_overrides:
/// # artifacts.zip
/// - flutter_infra_release/flutter/$engine/android-arm-64-release/artifacts.zip
+1 -1
View File
@@ -1,7 +1,7 @@
name: artifact_proxy
description: Intercept and proxy Flutter artifact requests.
version: 1.0.0
repository: https://github.com/shorebirdtech/shorebird/
repository: https://git.tonycloud.org/flutter/shorebird-workspace
publish_to: "none"
resolution: workspace
@@ -12,10 +12,16 @@ void main() {
const shorebirdEngineRevision = '8b89f8bd9fc6982aa9c4557fd0e5e89db1ff9986';
const manifest = ArtifactsManifest(
flutterEngineRevision: 'ec975089acb540fc60752606a3d3ba809dd1528b',
storageBucket: 'download.shorebird.dev',
storageBucket: 'shorebird',
artifactOverrides: {
r'flutter_infra_release/flutter/$engine/android-arm64-release/artifacts.zip',
r'flutter_infra_release/flutter/$engine/android-arm64-release/symbols.zip',
r'flutter_infra_release/flutter/$engine/linux-x64-release/artifacts.zip',
r'flutter_infra_release/flutter/$engine/linux-x64-release/linux-x64-flutter-gtk.zip',
r'flutter_infra_release/flutter/$engine/ios-release/artifacts.zip',
r'flutter_infra_release/flutter/$engine/flutter_patched_sdk_product.zip',
r'flutter_infra_release/flutter/$engine/flutter-web-sdk.zip',
r'flutter_infra_release/flutter/$engine/darwin-arm64-release/FlutterMacOS.framework.zip',
r'flutter_infra_release/flutter/$engine/android-arm-release/artifacts.zip',
r'flutter_infra_release/flutter/$engine/android-arm-release/symbols.zip',
r'flutter_infra_release/flutter/$engine/android-x64-release/artifacts.zip',
@@ -81,12 +87,73 @@ void main() {
expect(
response,
isRedirectTo(
'https://storage.googleapis.com/${manifest.storageBucket}/$path',
'http://localhost:8080/artifacts/${manifest.storageBucket}/$path',
),
);
verify(() => client.getManifest(shorebirdEngineRevision)).called(1);
});
test('should proxy CI-produced desktop artifacts with overrides', () async {
const paths = [
'flutter_infra_release/flutter/$shorebirdEngineRevision/linux-x64-release/artifacts.zip',
'flutter_infra_release/flutter/$shorebirdEngineRevision/linux-x64-release/linux-x64-flutter-gtk.zip',
'flutter_infra_release/flutter/$shorebirdEngineRevision/flutter_patched_sdk_product.zip',
'flutter_infra_release/flutter/$shorebirdEngineRevision/darwin-arm64-release/FlutterMacOS.framework.zip',
];
for (final path in paths) {
expect(
await handler(buildRequest(path)),
isRedirectTo(
'http://localhost:8080/artifacts/${manifest.storageBucket}/$path',
),
);
}
verify(() => client.getManifest(shorebirdEngineRevision)).called(4);
});
test('should proxy CI-produced iOS and web artifacts with overrides', () async {
const paths = [
'flutter_infra_release/flutter/$shorebirdEngineRevision/ios-release/artifacts.zip',
'flutter_infra_release/flutter/$shorebirdEngineRevision/flutter-web-sdk.zip',
];
for (final path in paths) {
expect(
await handler(buildRequest(path)),
isRedirectTo(
'http://localhost:8080/artifacts/${manifest.storageBucket}/$path',
),
);
}
verify(() => client.getManifest(shorebirdEngineRevision)).called(2);
});
test('can use custom Flutter and Shorebird artifact roots', () async {
handler = artifactProxyHandler(
client: client,
flutterArtifactBaseUri: Uri.parse('https://flutter.example.com'),
shorebirdArtifactBaseUri: Uri.parse('https://shorebird.example.com'),
);
const shorebirdPath =
'flutter_infra_release/flutter/$shorebirdEngineRevision/android-x64-release/artifacts.zip';
const flutterPath =
'flutter_infra_release/flutter/$shorebirdEngineRevision/windows-x64/font-subset.zip';
expect(
await handler(buildRequest(shorebirdPath)),
isRedirectTo(
'https://shorebird.example.com/${manifest.storageBucket}/$shorebirdPath',
),
);
expect(
await handler(buildRequest(flutterPath)),
isRedirectTo(
'https://flutter.example.com/flutter_infra_release/flutter/${manifest.flutterEngineRevision}/windows-x64/font-subset.zip',
),
);
});
test('should proxy to Flutter artifacts '
'when an engine revision is detected with no override', () async {
const path =
@@ -0,0 +1,49 @@
import 'dart:io';
import 'package:test/test.dart';
void main() {
test('health-check exits successfully without binding a server', () async {
final workingDirectory = _shorebirdRoot();
final result = await Process.run(
Platform.resolvedExecutable,
[
'run',
'packages/artifact_proxy/bin/server.dart',
'--health-check',
],
workingDirectory: workingDirectory.path,
environment: {
'SHOREBIRD_ARTIFACT_BASE_URL': 'https://artifacts.example.com/open',
'ARTIFACT_PROXY_FLUTTER_BASE_URL': 'https://flutter.example.com',
'PORT': '18080',
},
);
expect(result.exitCode, 0, reason: result.stderr as String?);
expect(
result.stdout,
contains(
'artifact_proxy ok '
'shorebird_artifacts=https://artifacts.example.com/open '
'flutter_artifacts=https://flutter.example.com '
'port=18080',
),
);
});
}
Directory _shorebirdRoot() {
var directory = Directory.current;
while (true) {
if (File('${directory.path}/pubspec.yaml').existsSync() &&
Directory('${directory.path}/packages/artifact_proxy').existsSync()) {
return directory;
}
final parent = directory.parent;
if (parent.path == directory.path) {
throw StateError('Could not find Shorebird package root.');
}
directory = parent;
}
}
@@ -30,13 +30,30 @@ void main() {
expect(ArtifactManifestClient.new, returnsNormally);
});
test('makes correct http request to storage bucket', () async {
test('makes correct http request to open artifact mirror', () async {
client.getManifest(revision).ignore();
verify(
() => httpClient.get(
Uri.parse(
'https://storage.googleapis.com/download.shorebird.dev/shorebird/$revision/artifacts_manifest.yaml',
'http://localhost:8080/artifacts/shorebird/$revision/artifacts_manifest.yaml',
),
),
).called(1);
});
test('can use a custom artifact manifest root', () async {
client = ArtifactManifestClient(
httpClient: httpClient,
manifestBaseUri: Uri.parse('https://mirror.example.com/root'),
);
client.getManifest(revision).ignore();
verify(
() => httpClient.get(
Uri.parse(
'https://mirror.example.com/root/shorebird/$revision/artifacts_manifest.yaml',
),
),
).called(1);
@@ -68,7 +85,7 @@ void main() {
verify(
() => httpClient.get(
Uri.parse(
'https://storage.googleapis.com/download.shorebird.dev/shorebird/$revision/artifacts_manifest.yaml',
'http://localhost:8080/artifacts/shorebird/$revision/artifacts_manifest.yaml',
),
),
).called(1);
@@ -78,7 +95,7 @@ void main() {
const _testArtifactManifest = r'''
flutter_engine_revision: ec975089acb540fc60752606a3d3ba809dd1528b
storage_bucket: https://download.shorebird.dev
storage_bucket: shorebird
artifact_overrides:
# artifacts.zip
- flutter_infra_release/flutter/$engine/android-arm-64-release/artifacts.zip
@@ -189,6 +189,19 @@ metadata before decrypting. `Dart_InstallAotPatch` rejects iOS native-AOT
artifacts, rejects any dynamic-modules runtime mode, and rejects compact
interpreter payloads before payload installation.
The open CLI now follows that route for `shorebird patch ios`. By default, iOS
patch builds compile the patch target to Dart bytecode with `dart2bytecode`,
write `build/ios_interpreter_patch.bytecode`, wrap that payload as
`open-aot-vmcode-encrypted-v1`, and upload the encrypted interpreter artifact.
The bytecode compile forwards user Dart defines, `FLUTTER_APP_FLAVOR`, and the
standard Flutter version/revision/Dart SDK defines from
`bin/cache/flutter.version.json`. It also mirrors Flutter's
`FLUTTER_ENABLED_FEATURE_FLAGS` define for enabled runtime-id features. User
attempts to override those reserved Flutter defines are rejected.
`aot_patch_bytecode_path` and `SHOREBIRD_IOS_INTERPRETER_PATCH_PATH` are local
override hooks for experiments. The native iOS AOT patch branch is kept only for
development-device validation and requires `SHOREBIRD_IOS_NATIVE_AOT_PATCH=1`.
The open Flutter engine bridge can now pass the encrypted artifact selected by
the updater into `Dart_InstallAotPatch`, then hand the decrypted bytecode to
`Dart_ReloadBytecodePatch`. This bridge is intentionally no-DDM and never adds
@@ -200,12 +213,13 @@ functions to the VM's signed `InterpretCall` stub with attached bytecode. This
does not enable `DART_DYNAMIC_MODULES` and does not map downloaded executable
memory.
For local encrypted interpreter tests, `shorebird.yaml` may provide a
development AES key and expected metadata:
For local encrypted interpreter tests, `shorebird.yaml` may provide expected
metadata:
```yaml
aot_patch_runtime_mode: dart-bytecode-interpreter
aot_patch_key_id: test-key
aot_patch_key_hex: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
aot_patch_app_build_id: 1.2.3+4
aot_patch_base_flavor_id: free
aot_patch_base_license_type: free
aot_patch_flavor_id: pro
@@ -214,10 +228,15 @@ aot_patch_sdk_hash: <flutter-engine-or-dart-sdk-hash>
aot_patch_base_snapshot_hash: <sha256-of-base-bytecode-snapshot>
```
Embedding a symmetric key in `shorebird.yaml` is a development bridge, not the
final production key strategy. The production replacement should supply the AES
key through an app-owned callback or platform key source while keeping the same
VM validation and bytecode reload path.
Add `aot_patch_bytecode_path: build/patch.interp` only when overriding the
generated bytecode payload.
Set `SHOREBIRD_AOT_PATCH_KEY_HEX` when running the CLI so the key is not bundled
with the reviewed app. Embedding `aot_patch_key_hex` in `shorebird.yaml` remains
a development bridge only; strict iOS route checks reject it. The production
replacement should supply the runtime AES key through an app-owned callback or
platform key source while keeping the same VM validation and bytecode reload
path.
The Flutter engine patch cache is prepared for this handoff:
+2 -2
View File
@@ -1,8 +1,8 @@
name: shorebird_redis_client
description: A lightweight Dart client library for communicating with a Redis server. Built by Shorebird.
version: 0.0.13
homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/redis_client
homepage: https://git.tonycloud.org/flutter/shorebird-workspace
repository: https://git.tonycloud.org/flutter/shorebird-workspace
topics: [redis, cache, shorebird]
resolution: workspace
+2 -2
View File
@@ -1,8 +1,8 @@
name: scoped_deps
description: A simple Dart library for managing scoped dependencies built on top of Zones from dart:async.
version: 0.1.0+2
homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/scoped_deps
homepage: https://git.tonycloud.org/flutter/shorebird-workspace
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
environment:
+2 -2
View File
@@ -5,8 +5,8 @@ description: >
Not intended for consumption outside of those projects.
version: 0.1.0
publish_to: none
homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_build_trace
homepage: https://git.tonycloud.org/flutter/shorebird-workspace
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
environment:
+2 -2
View File
@@ -4,8 +4,8 @@ description: >-
workflows, resolves affected packages via dependency graphs, and
verifies path filters stay in sync.
version: 0.2.4
homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_ci
homepage: https://git.tonycloud.org/flutter/shorebird-workspace
repository: https://git.tonycloud.org/flutter/shorebird-workspace
topics: [ci, github-actions, monorepo, shorebird]
resolution: workspace
@@ -37,7 +37,7 @@ class WindowsArchiveDiffer extends ArchiveDiffer {
// Otherwise, this function would return true if the file has a .dll or .exe
// extension.
//
// See https://github.com/shorebirdtech/shorebird/issues/2794
// See https://git.tonycloud.org/flutter/shorebird/issues/2794
return false;
}
}
@@ -112,7 +112,7 @@ ${lightCyan.wrap('shorebird release <platform> --flutter-version=3.29.0')}
• If `flutter build` completes successfully and `shorebird release`
fails when using the same flutter version, please file an issue:
${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/new'))}
${link(uri: Uri.parse(openShorebirdIssueUrl))}
''';
/// Cache of `flutter build <command>` help output checks for
@@ -246,7 +246,7 @@ Reason: Exited with code $exitCode.''',
if (target != null) '--target=$target',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
// TODO(bryanoltman): reintroduce coverage when we can support this.
// See https://github.com/shorebirdtech/shorebird/issues/1141.
// See https://git.tonycloud.org/flutter/shorebird/issues/1141.
// coverage:ignore-start
if (splitPerAbi) '--split-per-abi',
// coverage:ignore-end
@@ -459,7 +459,7 @@ Reason: Exited with code $exitCode.''',
throw ArtifactBuildException(
'Unable to find app.dill file.',
fixRecommendation:
'''Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command.''',
'''Please file a bug at $openShorebirdIssueUrl with the logs for this command.''',
);
}
@@ -531,7 +531,7 @@ Reason: Exited with code $exitCode.''',
throw ArtifactBuildException(
'Unable to find app.dill file.',
fixRecommendation:
'''Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command.''',
'''Please file a bug at $openShorebirdIssueUrl with the logs for this command.''',
);
}
@@ -596,7 +596,7 @@ Reason: Exited with code $exitCode.''',
throw ArtifactBuildException(
'Unable to find app.dill file.',
fixRecommendation:
'''Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command.''',
'''Please file a bug at $openShorebirdIssueUrl with the logs for this command.''',
);
}
@@ -760,7 +760,7 @@ Reason: Exited with code $exitCode.''',
/// Flutter SDK on the user's PATH. This is necessary because Flutter commands
/// run by shorebird update the package_config.json file to point to
/// shorebird's version of Flutter, which confuses VS Code. See
/// https://github.com/shorebirdtech/shorebird/issues/1101 for more info.
/// https://git.tonycloud.org/flutter/shorebird/issues/1101 for more info.
Future<void> _systemFlutterPubGet() async {
const executable = 'flutter';
if (osInterface.which(executable) == null) {
@@ -825,6 +825,51 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
return File(outFilePath);
}
/// Compiles [inputFilePath] to a Dart bytecode snapshot at [outFilePath].
Future<File> buildDartBytecodeSnapshot({
required String inputFilePath,
required String outFilePath,
String? packageConfigPath,
List<String> dartDefines = const [],
List<String> experiments = const [],
}) async {
final arguments = [
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.dart2BytecodeSnapshot,
),
'--platform=${shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.flutterProductPlatformDill,
)}',
'--target=flutter',
if (packageConfigPath != null) '--packages=$packageConfigPath',
for (final define in dartDefines) '-D$define',
'-Ddart.vm.profile=false',
'-Ddart.vm.product=true',
for (final experiment in experiments) '--enable-experiment=$experiment',
'-o',
outFilePath,
inputFilePath,
];
final exitCode = await shorebirdTracer.span<int>(
name: 'dart2bytecode',
category: 'subprocess',
body: () => process.stream(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.dartAotRuntime,
),
arguments,
runInShell: false,
),
);
if (exitCode != ExitCode.success.code) {
throw ArtifactBuildException('Failed to create Dart bytecode snapshot');
}
return File(outFilePath);
}
/// Builds a windows app and returns the x64 Release directory
Future<Directory> buildWindowsApp({
String? target,
@@ -225,7 +225,7 @@ class ArtifactManager {
// "strip{flavor}ReleaseDebugSymbols". We check first for the new
// directory and then fallback to the old one.
//
// See https://github.com/shorebirdtech/shorebird/issues/1798
// See https://git.tonycloud.org/flutter/shorebird/issues/1798
final strippedSymbolsDir = Directory(
p.join(releasePath, stripReleaseDebugSymbolsDirName),
);
@@ -252,7 +252,7 @@ class ArtifactManager {
/// (flutter/flutter#181275) handed `libapp.so` stripping to AGP, and the
/// strip task can emit nothing — or leave stale output from a previous
/// build — while the current library is still bundled into the AAB. See
/// https://github.com/shorebirdtech/shorebird/issues/3388. Reading the AAB
/// https://git.tonycloud.org/flutter/shorebird/issues/3388. Reading the AAB
/// first sidesteps the strip task entirely.
///
/// Reading the unstripped `merged_native_libs` is deliberately NOT used as a
@@ -20,6 +20,7 @@ import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_web_console.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';
@@ -368,7 +369,7 @@ class Auth {
'`shorebird login:ci`. '
'This format is deprecated and will stop working in a future '
'release. '
'Create an API key at https://console.shorebird.dev instead.',
'Create an API key at ${ShorebirdWebConsole.uri('')} instead.',
);
} on FormatException catch (e) {
logger
+73 -8
View File
@@ -52,7 +52,25 @@ class Cache {
Cache() {
registerArtifact(PatchArtifact(cache: this, platform: platform));
registerArtifact(BundleToolArtifact(cache: this, platform: platform));
registerArtifact(AotToolsArtifact(cache: this, platform: platform));
if (legacyAotToolsEnabled) {
registerArtifact(AotToolsArtifact(cache: this, platform: platform));
}
}
/// Environment variable that enables the legacy closed aot-tools download.
static const legacyAotToolsEnvironmentVariable =
'SHOREBIRD_ENABLE_LEGACY_AOT_TOOLS';
/// Whether to download legacy `aot-tools.dill`.
///
/// The open iOS App Store-safe path uses Dart bytecode interpreter artifacts
/// and must not fetch Shorebird's closed native-AOT linker artifact by
/// default. Developers can opt in when validating the old native-AOT route.
bool get legacyAotToolsEnabled {
final value = platform.environment[legacyAotToolsEnvironmentVariable]
?.trim()
.toLowerCase();
return value == '1' || value == 'true' || value == 'yes';
}
/// Register a new [CachedArtifact] with the cache.
@@ -117,11 +135,34 @@ class Cache {
final List<CachedArtifact> _artifacts = [];
/// Default local root for open Shorebird CLI-managed binary artifacts.
static const defaultArtifactBaseUrl = 'http://localhost:8080/artifacts';
/// The storage base url.
String get storageBaseUrl => 'https://storage.googleapis.com';
String get storageBaseUrl =>
platform.environment['SHOREBIRD_STORAGE_BASE_URL'] ??
defaultArtifactBaseUrl;
/// The storage bucket host.
String get storageBucket => 'download.shorebird.dev';
String get storageBucket =>
platform.environment['SHOREBIRD_STORAGE_BUCKET'] ?? '';
/// The root URL for Shorebird CLI-managed binary artifacts.
///
/// Defaults to a local open artifact mirror. Deployments can set
/// `SHOREBIRD_ARTIFACT_BASE_URL` to host the same `/shorebird/<engine
/// revision>/...` layout at a public URL.
String get artifactBaseUrl {
final override = platform.environment['SHOREBIRD_ARTIFACT_BASE_URL'];
if (override != null && override.trim().isNotEmpty) {
return _trimTrailingSlash(override.trim());
}
final bucket = storageBucket.trim();
final base = _trimTrailingSlash(storageBaseUrl.trim());
if (bucket.isEmpty) return base;
return '$base/${_trimSlashes(bucket)}';
}
/// Clear the cache.
Future<void> clear() async {
@@ -130,6 +171,28 @@ class Cache {
await cacheDir.delete(recursive: true);
}
}
/// Returns the full URL for a Shorebird artifact stored under the current
/// engine revision.
String shorebirdArtifactUrl(String fileName) {
return '$artifactBaseUrl/shorebird/${shorebirdEnv.shorebirdEngineRevision}/$fileName';
}
String _trimTrailingSlash(String value) {
var trimmed = value;
while (trimmed.endsWith('/')) {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
return trimmed;
}
String _trimSlashes(String value) {
var trimmed = value;
while (trimmed.startsWith('/')) {
trimmed = trimmed.substring(1);
}
return _trimTrailingSlash(trimmed);
}
}
/// {@template cached_artifact}
@@ -284,8 +347,11 @@ allowed to access $url.''');
}
/// {@template aot_tools_artifact}
/// The aot_tools.dill artifact.
/// Used for linking and generating optimized AOT snapshots.
/// The legacy closed aot_tools.dill artifact.
///
/// This is not part of the default open-source cache. It is available only
/// when [Cache.legacyAotToolsEnvironmentVariable] is set for development-only
/// native-AOT validation.
/// {@endtemplate}
class AotToolsArtifact extends CachedArtifact {
/// {@macro aot_tools_artifact}
@@ -311,8 +377,7 @@ class AotToolsArtifact extends CachedArtifact {
);
@override
Future<String> get storageUrl async =>
'${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/${shorebirdEnv.shorebirdEngineRevision}/$fileName';
Future<String> get storageUrl async => cache.shorebirdArtifactUrl(fileName);
@override
String? get checksum => null;
@@ -358,7 +423,7 @@ class PatchArtifact extends CachedArtifact {
artifactName += 'windows-x64.zip';
}
return '${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/${shorebirdEnv.shorebirdEngineRevision}/$artifactName';
return cache.shorebirdArtifactUrl(artifactName);
}
Future<bool> _supportsArm64Patch() async {
@@ -18,6 +18,7 @@ import 'package:shorebird_cli/src/deployment_track.dart';
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/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_web_console.dart';
@@ -484,11 +485,12 @@ Please create a release using "shorebird release" and try again.
_handleErrorAndExit(
Exception('Cannot find patch build artifacts.'),
progress: createArtifactProgress,
message: '''
message:
'''
Cannot find release build artifacts.
Please run `shorebird cache clean` and try again. If the issue persists, please
file a bug report at https://github.com/shorebirdtech/shorebird/issues/new.
file a bug report at $openShorebirdIssueUrl.
Looked in:
- the libapp.so entries inside the built .aab
@@ -502,7 +504,7 @@ Looked in:
// the project filters it out via `ndk.abiFilters`, `splits.abi`, or
// `jniLibs.excludes`. Iterating only over present files lets a filtered
// release succeed instead of crashing on the first missing arch
// (https://github.com/shorebirdtech/shorebird/issues/3388).
// (https://git.tonycloud.org/flutter/shorebird/issues/3388).
final missingArchPaths = <String>[];
var uploadedArchCount = 0;
for (final arch in architectures) {
@@ -57,7 +57,7 @@ class DoctorCommand extends ShorebirdCommand {
shorebirdFlutterPrefix.write(' $flutterVersion');
}
output.writeln('''
Shorebird $packageVersiongit@github.com:shorebirdtech/shorebird.git
Shorebird $packageVersionhttps://git.tonycloud.org/flutter/shorebird.git
$shorebirdFlutterPrefix • revision ${shorebirdEnv.flutterRevision}
Engine • revision ${shorebirdEnv.shorebirdEngineRevision}''');
@@ -76,7 +76,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
.getOrganizationMemberships();
if (organizationMemberships.isEmpty) {
logger.err(
'''You do not have any organizations. This should never happen. Please contact us on Discord or send us an email at contact@shorebird.dev.''',
'''You do not have any organizations. This should never happen. Please file an issue at $openShorebirdIssueUrl.''',
);
return ExitCode.software.code;
}
@@ -339,7 +339,7 @@ Reference the following commands to get started:
🚀 To push an update use: "${lightCyan.wrap('shorebird patch')}".
👀 To preview a release use: "${lightCyan.wrap('shorebird preview')}".
For more information about Shorebird, visit ${link(uri: Uri.parse('https://shorebird.dev'))}''',
For more information about Shorebird, visit ${link(uri: Uri.parse(docsUrl))}''',
);
await doctor.runValidators(
@@ -1,6 +1,8 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_web_console.dart';
/// {@template login_ci_command}
/// `shorebird login:ci`
@@ -19,9 +21,9 @@ class LoginCiCommand extends ShorebirdCommand {
'''
shorebird login:ci has been replaced by API keys.
Create an API key at ${link(uri: Uri.parse('https://console.shorebird.dev'))} and set it as your ${lightCyan.wrap('SHOREBIRD_TOKEN')} environment variable.
Create an API key at ${link(uri: ShorebirdWebConsole.uri(''))} and set it as your ${lightCyan.wrap('SHOREBIRD_TOKEN')} environment variable.
Learn more: ${link(uri: Uri.parse('https://docs.shorebird.dev/account/api-keys/'))}''',
Learn more: ${link(uri: Uri.parse('$docsUrl/account/api-keys/'))}''',
);
return ExitCode.usage.code;
}
@@ -2,6 +2,7 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/shorebird_web_console.dart';
/// {@template login_command}
/// `shorebird login`
@@ -33,7 +34,7 @@ class LoginCommand extends ShorebirdCommand {
try {
await auth.login(prompt: prompt);
} on UserNotFoundException catch (error) {
final consoleUri = Uri.https('console.shorebird.dev');
final consoleUri = ShorebirdWebConsole.uri('');
logger
..err('''
We could not find a Shorebird account for ${error.email}.''')
@@ -22,7 +22,7 @@ class LogoutCommand extends ShorebirdCommand {
return ExitCode.success.code;
}
final logoutProgress = logger.progress('Logging out of shorebird.dev');
final logoutProgress = logger.progress('Logging out of Shorebird');
await auth.logout();
logoutProgress.complete();
@@ -99,6 +99,7 @@ class AarPatcher extends Patcher {
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
}) async {
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
appId: appId,
@@ -17,6 +17,7 @@ import 'package:shorebird_cli/src/patch_diff_checker.dart';
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_documentation.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
@@ -36,13 +37,13 @@ class AndroidPatcher extends Patcher {
/// Android versions prior to 3.24.2 have a bug that can cause patches to
/// be erroneously uninstalled.
/// https://github.com/shorebirdtech/updater/issues/211 was fixed in 3.24.2
/// Fixed in Flutter 3.24.2.
static final updaterPatchErrorWarning =
'''
Your version of flutter contains a known issue that can cause patches to be erroneously uninstalled in apps that use package:flutter_foreground_task or other plugins that start their own Flutter engines.
This issue was fixed in Flutter 3.24.2. Please upgrade to a newer version of Flutter to avoid this issue.
See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebirdtech/updater/issues/211'), message: 'on Github')}
See more info in the open issue tracker: ${link(uri: Uri.parse(openShorebirdIssueUrl), message: 'open issue tracker')}
''';
/// The `<arch>/libapp.so` directory resolved by [buildPatchArtifact] —
@@ -50,7 +51,7 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir
/// AAB is unreadable, AGP's stripped output. Cached so
/// [createPatchArtifacts] reuses it instead of decoding the AAB a second
/// time.
/// See https://github.com/shorebirdtech/shorebird/issues/3388.
/// See https://git.tonycloud.org/flutter/shorebird/issues/3388.
Directory? _patchArchsBuildDir;
@override
@@ -93,7 +94,7 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir
final flutterVersion = await shorebirdFlutter.getVersion();
// Android versions prior to 3.24.2 have a bug that can cause patches to
// be erroneously uninstalled.
// https://github.com/shorebirdtech/updater/issues/211 was fixed in 3.24.2
// Fixed in Flutter 3.24.2.
if (flutterVersion != null && flutterVersion < Version(3, 24, 2)) {
logger.warn(updaterPatchErrorWarning);
}
@@ -122,7 +123,7 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir
..err('Cannot find patch build artifacts.')
..info('''
Please run `shorebird cache clean` and try again. If the issue persists, please
file a bug report at https://github.com/shorebirdtech/shorebird/issues/new.
file a bug report at $openShorebirdIssueUrl.
Looked in:
- the libapp.so entries inside the built .aab
@@ -140,6 +141,7 @@ Looked in:
required File releaseArtifact,
Directory? supplementDirectory,
Duration downloadMessageTimeout = const Duration(minutes: 1),
String? releaseVersion,
}) async {
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
appId: appId,
@@ -150,7 +152,8 @@ Looked in:
final releaseArtifactPaths = <Arch, String>{};
final numArtifacts = releaseArtifacts.length;
// Direct users to https://github.com/shorebirdtech/shorebird/issues/2532
// Direct users to the troubleshooting docs until we can provide a better
// solution.
// until we can provide a better solution.
var artifactsDownloadCompleted = false;
unawaited(
@@ -161,7 +164,7 @@ Looked in:
logger.info(
'''
It seems like your download is taking longer than expected. If you are on Windows, this is a known issue.
Please refer to ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/2532'))} for potential workarounds.''',
Please refer to ${link(uri: Uri.parse(troubleshootingUrl))} for potential workarounds.''',
);
}),
);
@@ -209,7 +212,7 @@ Please refer to ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebir
// Skip archs the build didn't produce (ndk.abiFilters / splits.abi /
// jniLibs.excludes). Without this, a release built for a subset of
// archs can't be patched without hitting PathNotFoundException
// (https://github.com/shorebirdtech/shorebird/issues/3388).
// (https://git.tonycloud.org/flutter/shorebird/issues/3388).
if (!patchArtifact.existsSync()) {
logger.detail(
'Skipping ${arch.arch}: no libapp.so at $patchArtifactPath. '
@@ -132,6 +132,7 @@ class IosFrameworkPatcher extends Patcher with ApplePatcherMixin {
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
}) async {
final unzipProgress = logger.progress('Extracting release artifact');
late final String releaseXcframeworkPath;
@@ -1,9 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:open_aot_patch_tools/open_aot_patch_tools.dart' as open_patch;
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive/archive.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
@@ -12,6 +15,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch/apple_patcher_mixin.dart';
import 'package:shorebird_cli/src/commands/patch/patcher.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
@@ -50,6 +54,42 @@ class IosPatcher extends Patcher
String get _appDillCopyPath =>
p.join(shorebirdEnv.buildDirectory.path, 'app.dill');
String get _interpreterPatchOutputPath =>
p.join(shorebirdEnv.buildDirectory.path, 'ios_interpreter_patch.vmcode');
String get _interpreterPatchBytecodeOutputPath => p.join(
shorebirdEnv.buildDirectory.path,
'ios_interpreter_patch.bytecode',
);
static const _flutterVersionDefine = 'FLUTTER_VERSION';
static const _flutterChannelDefine = 'FLUTTER_CHANNEL';
static const _flutterGitUrlDefine = 'FLUTTER_GIT_URL';
static const _flutterFrameworkRevisionDefine = 'FLUTTER_FRAMEWORK_REVISION';
static const _flutterEngineRevisionDefine = 'FLUTTER_ENGINE_REVISION';
static const _flutterDartVersionDefine = 'FLUTTER_DART_VERSION';
static const _flutterReservedDefines = [
_flutterVersionDefine,
_flutterChannelDefine,
_flutterGitUrlDefine,
_flutterFrameworkRevisionDefine,
_flutterEngineRevisionDefine,
_flutterDartVersionDefine,
'FLUTTER_ENABLED_FEATURE_FLAGS',
];
static const _runtimeFeatureFlags = [
(
configSetting: 'enable-windowing',
environmentOverride: 'FLUTTER_WINDOWING',
runtimeId: 'windowing',
),
(
configSetting: 'enable-accessibility-evaluations',
environmentOverride: 'FLUTTER_ACCESSIBILITY_EVALUATIONS',
runtimeId: 'accessibility_evaluations',
),
];
/// The last build's link percentage.
@visibleForTesting
double? lastBuildLinkPercentage;
@@ -129,18 +169,35 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
base64PublicKey: argResults.encodedPublicKey,
);
if (splitDebugInfoPath != null) {
Directory(splitDebugInfoPath!).createSync(recursive: true);
final runtimeMode = _iosPatchRuntimeMode;
if (runtimeMode == open_patch.runtimeModeNativeAot) {
if (!_allowsNativeIosAotPatch) {
logger.err(
'''
iOS native AOT patch artifacts are disabled by default because they require mapping downloaded native snapshot text as executable memory.
Set SHOREBIRD_IOS_NATIVE_AOT_PATCH=1 only for development-device validation.''',
);
throw ProcessExit(ExitCode.software.code);
}
if (splitDebugInfoPath != null) {
Directory(splitDebugInfoPath!).createSync(recursive: true);
}
await artifactBuilder.buildElfAotSnapshot(
appDillPath: ipaBuildResult.kernelFile.path,
outFilePath: _aotOutputPath,
genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos,
additionalArgs: [
...ApplePatcherMixin.splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
);
} else if (runtimeMode != open_patch.runtimeModeDartBytecodeInterpreter) {
logger.err('Unsupported iOS patch runtime mode: $runtimeMode');
throw ProcessExit(ExitCode.usage.code);
}
if (runtimeMode == open_patch.runtimeModeDartBytecodeInterpreter) {
await _buildInterpreterBytecodePatch(buildArgs);
}
await artifactBuilder.buildElfAotSnapshot(
appDillPath: ipaBuildResult.kernelFile.path,
outFilePath: _aotOutputPath,
genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos,
additionalArgs: [
...ApplePatcherMixin.splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
);
// Copy the kernel file to the build directory so that it can be used
// to generate a patch.
@@ -155,6 +212,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
}) async {
// Verify that we have built a patch .xcarchive
if (artifactManager.getXcarchiveDirectory()?.path == null) {
@@ -162,6 +220,27 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
throw ProcessExit(ExitCode.software.code);
}
final runtimeMode = _iosPatchRuntimeMode;
if (runtimeMode == open_patch.runtimeModeDartBytecodeInterpreter) {
return _createInterpreterPatchArtifacts(
appId: appId,
releaseVersion: releaseVersion,
);
}
if (runtimeMode != open_patch.runtimeModeNativeAot) {
logger.err('Unsupported iOS patch runtime mode: $runtimeMode');
throw ProcessExit(ExitCode.usage.code);
}
if (!_allowsNativeIosAotPatch) {
logger.err(
'''
iOS native AOT patch artifacts are disabled by default because they require mapping downloaded native snapshot text as executable memory.
Use the open interpreter route (`aot_patch_runtime_mode: dart-bytecode-interpreter`) for App Store-safe patches.
Set SHOREBIRD_IOS_NATIVE_AOT_PATCH=1 only for development-device validation.''',
);
throw ProcessExit(ExitCode.software.code);
}
final unzipProgress = logger.progress('Extracting release artifact');
late final String releaseXcarchivePath;
@@ -271,6 +350,518 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
};
}
String get _iosPatchRuntimeMode {
final environmentMode =
platform.environment['SHOREBIRD_IOS_PATCH_RUNTIME_MODE'];
if (environmentMode != null && environmentMode.trim().isNotEmpty) {
return environmentMode.trim();
}
if (_allowsNativeIosAotPatch) {
return open_patch.runtimeModeNativeAot;
}
return shorebirdEnv.getShorebirdYaml()?.aotPatchRuntimeMode ??
open_patch.runtimeModeDartBytecodeInterpreter;
}
bool get _allowsNativeIosAotPatch {
final value = platform.environment['SHOREBIRD_IOS_NATIVE_AOT_PATCH'];
return value == '1' || value == 'true' || value == 'yes';
}
Future<Map<Arch, PatchArtifactBundle>> _createInterpreterPatchArtifacts({
required String appId,
required String? releaseVersion,
}) async {
final shorebirdYaml = shorebirdEnv.getShorebirdYaml();
if (shorebirdYaml == null) {
logger.err('Unable to find shorebird.yaml for iOS interpreter patch.');
throw ProcessExit(ExitCode.software.code);
}
final bytecodePath = _configuredInterpreterBytecodePath(shorebirdYaml);
final bytecodeFile = bytecodePath == null
? File(_interpreterPatchBytecodeOutputPath)
: _projectRelativeFile(bytecodePath);
if (!bytecodeFile.existsSync()) {
logger.err(
bytecodePath == null
? '''
Unable to find generated iOS interpreter patch bytecode: ${bytecodeFile.path}
Run `shorebird patch ios` so the patch build can generate it, or set aot_patch_bytecode_path/SHOREBIRD_IOS_INTERPRETER_PATCH_PATH to a Dart bytecode payload.'''
: '''
iOS interpreter patch bytecode does not exist: ${bytecodeFile.path}''',
);
throw ProcessExit(ExitCode.software.code);
}
final patchSnapshot = bytecodeFile.readAsBytesSync();
if (_looksLikeNativeExecutable(patchSnapshot)) {
logger.err(
'''
iOS interpreter patch payload must be Dart bytecode data, not a native Mach-O or ELF artifact: ${bytecodeFile.path}''',
);
throw ProcessExit(ExitCode.software.code);
}
final keyId = _requiredPatchValue(
'aot_patch_key_id or SHOREBIRD_AOT_PATCH_KEY_ID',
platform.environment['SHOREBIRD_AOT_PATCH_KEY_ID'] ??
shorebirdYaml.aotPatchKeyId,
);
final keyHex = _requiredPatchValue(
'SHOREBIRD_AOT_PATCH_KEY_HEX',
platform.environment['SHOREBIRD_AOT_PATCH_KEY_HEX'] ??
shorebirdYaml.aotPatchKeyHex,
);
final appBuildId = _requiredPatchValue(
'release version or aot_patch_app_build_id',
releaseVersion ??
platform.environment['SHOREBIRD_AOT_PATCH_APP_BUILD_ID'] ??
shorebirdYaml.aotPatchAppBuildId,
);
final flavorId = _requiredPatchValue(
'aot_patch_flavor_id',
platform.environment['SHOREBIRD_AOT_PATCH_FLAVOR_ID'] ??
shorebirdYaml.aotPatchFlavorId,
);
final licenseType = _requiredPatchValue(
'aot_patch_license_type',
platform.environment['SHOREBIRD_AOT_PATCH_LICENSE_TYPE'] ??
shorebirdYaml.aotPatchLicenseType,
);
final sdkHash = _requiredPatchValue(
'aot_patch_sdk_hash',
platform.environment['SHOREBIRD_AOT_PATCH_SDK_HASH'] ??
shorebirdYaml.aotPatchSdkHash,
);
final baseSnapshotHash = _requiredPatchValue(
'aot_patch_base_snapshot_hash',
platform.environment['SHOREBIRD_AOT_PATCH_BASE_SNAPSHOT_HASH'] ??
shorebirdYaml.aotPatchBaseSnapshotHash,
);
final baseFlavorId =
platform.environment['SHOREBIRD_AOT_PATCH_BASE_FLAVOR_ID'] ??
shorebirdYaml.aotPatchBaseFlavorId;
final baseLicenseType =
platform.environment['SHOREBIRD_AOT_PATCH_BASE_LICENSE_TYPE'] ??
shorebirdYaml.aotPatchBaseLicenseType;
final obfuscationMapHash =
platform.environment['SHOREBIRD_AOT_PATCH_OBFUSCATION_MAP_HASH'] ??
shorebirdYaml.aotPatchObfuscationMapHash;
final offlineExpiresAt = _normalizeOptionalIso8601Utc(
platform.environment['SHOREBIRD_AOT_PATCH_OFFLINE_EXPIRES_AT'] ??
shorebirdYaml.aotPatchOfflineExpiresAt,
);
final metadata = open_patch.PatchMetadata(
appId: appId,
appBuildId: appBuildId,
baseFlavorId: _emptyToNull(baseFlavorId),
baseLicenseType: _emptyToNull(baseLicenseType),
flavorId: flavorId,
licenseType: licenseType,
sdkHash: sdkHash,
baseSnapshotHash: baseSnapshotHash,
patchSnapshotHash: open_patch.sha256Hex(patchSnapshot),
targetOs: 'ios',
targetArch: 'arm64',
runtimeMode: open_patch.runtimeModeDartBytecodeInterpreter,
obfuscationMapHash: _emptyToNull(obfuscationMapHash),
offlineExpiresAt: offlineExpiresAt,
);
final artifact = open_patch.linkArtifacts(
baseSnapshot: const [],
patchSnapshot: patchSnapshot,
metadata: metadata,
forceFullSnapshot: true,
);
final encrypted = open_patch.encryptArtifact(
artifact: artifact,
keyId: keyId,
key: open_patch.readKey(keyHex),
nonce: _readOrCreateNonce(),
);
final outputFile = File(_interpreterPatchOutputPath)
..createSync(recursive: true)
..writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(encrypted.toJson()),
);
final patchFileSize = outputFile.statSync().size;
final hash = sha256.convert(outputFile.readAsBytesSync()).toString();
final hashSignature = await signHash(hash);
logger.info(
'Created iOS interpreter patch artifact at ${outputFile.path}.',
);
return {
Arch.arm64: PatchArtifactBundle(
arch: 'aarch64',
path: outputFile.path,
hash: hash,
size: patchFileSize,
hashSignature: hashSignature,
podfileLockHash: shorebirdEnv.iosPodfileLockHash,
),
};
}
Future<void> _buildInterpreterBytecodePatch(List<String> buildArgs) async {
final shorebirdYaml = shorebirdEnv.getShorebirdYaml();
final configuredBytecodePath = _configuredInterpreterBytecodePath(
shorebirdYaml,
);
if (configuredBytecodePath != null) {
logger.info(
'Using configured iOS interpreter bytecode patch: '
'${_projectRelativeFile(configuredBytecodePath).path}.',
);
return;
}
final inputFile = _projectRelativeFile(
target ?? p.join('lib', 'main.dart'),
);
if (!inputFile.existsSync()) {
logger.err(
'Unable to find Dart entrypoint for iOS interpreter patch: '
'${inputFile.path}',
);
throw ProcessExit(ExitCode.usage.code);
}
final packageConfigFile = File(
p.join(projectRoot.path, '.dart_tool', 'package_config.json'),
);
await artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: inputFile.path,
outFilePath: _interpreterPatchBytecodeOutputPath,
packageConfigPath: packageConfigFile.existsSync()
? packageConfigFile.path
: null,
dartDefines: _dartDefinesForBytecode(buildArgs),
experiments: _buildArgValues(buildArgs, 'enable-experiment'),
);
}
String? _configuredInterpreterBytecodePath(ShorebirdYaml? shorebirdYaml) {
return _emptyToNull(
platform.environment['SHOREBIRD_IOS_INTERPRETER_PATCH_PATH'] ??
shorebirdYaml?.aotPatchBytecodePath,
);
}
File _projectRelativeFile(String path) {
if (p.isAbsolute(path)) {
return File(path);
}
return File(p.join(projectRoot.path, path));
}
String _requiredPatchValue(String name, String? value) {
if (value == null || value.trim().isEmpty) {
logger.err('Missing required iOS interpreter patch value: $name.');
throw ProcessExit(ExitCode.usage.code);
}
return value.trim();
}
List<String> _dartDefinesForBytecode(List<String> buildArgs) {
final defines = <String>[];
for (final defineFilePath in _buildArgValues(
buildArgs,
CommonArguments.dartDefineFromFileArg.name,
)) {
defines.addAll(_readDartDefineFile(_projectRelativeFile(defineFilePath)));
}
defines.addAll(
_buildArgValues(buildArgs, CommonArguments.dartDefineArg.name),
);
if (flavor != null &&
!defines.any((define) => define.startsWith('FLUTTER_APP_FLAVOR='))) {
defines.add('FLUTTER_APP_FLAVOR=$flavor');
}
_assertNoReservedFlutterDefines(defines);
defines.addAll(_flutterVersionDartDefinesForBytecode());
if (_enabledRuntimeFeatureDefinesForBytecode() case final featureDefine?) {
defines.add(featureDefine);
}
return defines;
}
void _assertNoReservedFlutterDefines(List<String> defines) {
for (final reservedDefine in _flutterReservedDefines) {
if (defines.any((define) => define.startsWith(reservedDefine))) {
logger.err(
'$reservedDefine is used by Flutter and cannot be set using --'
'${CommonArguments.dartDefineArg.name} or --'
'${CommonArguments.dartDefineFromFileArg.name}.',
);
throw ProcessExit(ExitCode.usage.code);
}
}
}
List<String> _flutterVersionDartDefinesForBytecode() {
final versionFile = File(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'flutter.version.json',
),
);
if (!versionFile.existsSync()) return const [];
final Json versionJson;
try {
versionJson = (jsonDecode(versionFile.readAsStringSync()) as Map)
.cast<String, Object?>();
} on Object catch (error) {
logger.err('Unable to parse ${versionFile.path}: $error');
throw ProcessExit(ExitCode.software.code);
}
String? stringValue(String key) {
final value = versionJson[key];
return value is String && value.isNotEmpty ? value : null;
}
String? shortRevision(String key) {
final revision = stringValue(key);
if (revision == null) return null;
return revision.length > 10 ? revision.substring(0, 10) : revision;
}
return [
if (stringValue('frameworkVersion') case final value?)
'$_flutterVersionDefine=$value',
if (stringValue('channel') case final value?)
'$_flutterChannelDefine=$value',
if (stringValue('repositoryUrl') case final value?)
'$_flutterGitUrlDefine=$value',
if (shortRevision('frameworkRevision') case final value?)
'$_flutterFrameworkRevisionDefine=$value',
if (shortRevision('engineRevision') case final value?)
'$_flutterEngineRevisionDefine=$value',
if (stringValue('dartSdkVersion') case final value?)
'$_flutterDartVersionDefine=$value',
];
}
String? _enabledRuntimeFeatureDefinesForBytecode() {
final channel = _flutterVersionJsonString('channel');
final runtimeIds = <String>[];
for (final feature in _runtimeFeatureFlags) {
if (_isRuntimeFeatureEnabledForBytecode(
configSetting: feature.configSetting,
environmentOverride: feature.environmentOverride,
channel: channel,
)) {
runtimeIds.add(feature.runtimeId);
}
}
if (runtimeIds.isEmpty) return null;
return 'FLUTTER_ENABLED_FEATURE_FLAGS=${runtimeIds.join(',')}';
}
bool _isRuntimeFeatureEnabledForBytecode({
required String configSetting,
required String environmentOverride,
required String? channel,
}) {
if (channel == 'stable' || channel == 'beta') {
return false;
}
return _projectFeatureConfig(configSetting) ??
_globalFeatureConfig(configSetting) ??
_environmentFeatureConfig(environmentOverride) ??
false;
}
bool? _projectFeatureConfig(String configSetting) {
final config = shorebirdEnv.getPubspecYaml()?.flutter?['config'];
if (config == null) return null;
if (config is! Map) {
logger.err(
'The "config" property of "flutter" in pubspec.yaml must be a map.',
);
throw ProcessExit(ExitCode.usage.code);
}
return _featureConfigBool(
config[configSetting],
name: configSetting,
source: 'flutter: config: in pubspec.yaml',
);
}
bool? _globalFeatureConfig(String configSetting) {
return _featureConfigBool(
shorebirdFlutter.getConfig()[configSetting],
name: configSetting,
source: 'flutter config',
);
}
bool? _environmentFeatureConfig(String environmentOverride) {
final value = platform.environment[environmentOverride];
if (value == null) return null;
return value.toLowerCase() == 'true';
}
bool? _featureConfigBool(
Object? value, {
required String name,
required String source,
}) {
if (value == null || value == '(Not set)') return null;
if (value is bool) return value;
if (value is String) {
if (value.toLowerCase() == 'true') return true;
if (value.toLowerCase() == 'false') return false;
}
logger.err(
'The "$name" property in $source must be a boolean, but got $value.',
);
throw ProcessExit(ExitCode.usage.code);
}
String? _flutterVersionJsonString(String key) {
final versionFile = File(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'flutter.version.json',
),
);
if (!versionFile.existsSync()) return null;
try {
final versionJson = (jsonDecode(versionFile.readAsStringSync()) as Map)
.cast<String, Object?>();
final value = versionJson[key];
return value is String && value.isNotEmpty ? value : null;
} on Object catch (error) {
logger.err('Unable to parse ${versionFile.path}: $error');
throw ProcessExit(ExitCode.software.code);
}
}
List<String> _buildArgValues(List<String> args, String name) {
final values = <String>[];
final longOption = '--$name';
for (var i = 0; i < args.length; i++) {
final arg = args[i];
if (arg == longOption && i + 1 < args.length) {
values.add(args[++i]);
} else if (arg.startsWith('$longOption=')) {
values.add(arg.substring(longOption.length + 1));
} else if (name == CommonArguments.dartDefineArg.name &&
arg.startsWith('-D') &&
arg.length > 2) {
values.add(arg.substring(2));
}
}
return values;
}
List<String> _readDartDefineFile(File file) {
if (!file.existsSync()) {
logger.err(
'Did not find the file passed to --'
'${CommonArguments.dartDefineFromFileArg.name}: ${file.path}',
);
throw ProcessExit(ExitCode.usage.code);
}
final raw = file.readAsStringSync();
if (raw.trimLeft().startsWith('{')) {
try {
final map = (jsonDecode(raw) as Map).cast<String, Object?>();
return [
for (final entry in map.entries) '${entry.key}=${entry.value}',
];
} on FormatException catch (error) {
logger.err(
'Unable to parse Dart define file ${file.path}: $error',
);
throw ProcessExit(ExitCode.usage.code);
}
}
final defines = <String>[];
for (final line in const LineSplitter().convert(raw)) {
final define = _parseDartDefineEnvLine(line);
if (define != null) defines.add(define);
}
return defines;
}
String? _parseDartDefineEnvLine(String line) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) return null;
final separatorIndex = trimmed.indexOf('=');
if (separatorIndex <= 0) {
logger.err(
'Invalid Dart define file line for --'
'${CommonArguments.dartDefineFromFileArg.name}: $line',
);
throw ProcessExit(ExitCode.usage.code);
}
final key = trimmed.substring(0, separatorIndex).trim();
var value = trimmed.substring(separatorIndex + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1);
}
return '$key=$value';
}
String? _emptyToNull(String? value) {
if (value == null || value.trim().isEmpty) return null;
return value.trim();
}
String? _normalizeOptionalIso8601Utc(String? value) {
final trimmed = _emptyToNull(value);
if (trimmed == null) return null;
final parsed = DateTime.tryParse(trimmed);
if (parsed == null) {
logger.err('Invalid aot_patch_offline_expires_at value: $trimmed.');
throw ProcessExit(ExitCode.usage.code);
}
return parsed.toUtc().toIso8601String();
}
List<int> _readOrCreateNonce() {
final nonceHex = platform.environment['SHOREBIRD_AOT_PATCH_NONCE_HEX'];
if (nonceHex != null && nonceHex.trim().isNotEmpty) {
return open_patch.readNonce(nonceHex);
}
final random = Random.secure();
return List<int>.generate(12, (_) => random.nextInt(256));
}
bool _looksLikeNativeExecutable(List<int> bytes) {
if (bytes.length < 4) return false;
if (bytes[0] == 0x7f &&
bytes[1] == 0x45 &&
bytes[2] == 0x4c &&
bytes[3] == 0x46) {
return true;
}
final magic = bytes
.take(4)
.fold<int>(0, (value, byte) => (value << 8) | byte);
return const {
0xfeedface,
0xfeedfacf,
0xcefaedfe,
0xcffaedfe,
0xcafebabe,
0xbebafeca,
}.contains(magic);
}
@override
Future<String> extractReleaseVersionFromArtifact(File artifact) async {
final archivePath = artifactManager.getXcarchiveDirectory()?.path;
@@ -62,6 +62,7 @@ class LinuxPatcher extends Patcher {
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
}) async {
final createDiffProgress = logger.progress('Creating patch artifacts');
final patchArtifactPath = p.join(
@@ -176,6 +176,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
}) async {
final unzipProgress = logger.progress('Extracting release artifact');
final releaseAppDirectory = Directory.systemTemp.createTempSync();
@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_builder/build_trace_session.dart';
@@ -105,7 +105,7 @@ To target the latest release (e.g. the release that was most recently updated) u
[DEPRECATED] Whether to publish the patch to the staging environment. Use --track=staging instead.''',
hide: true,
)
// Added for https://github.com/shorebirdtech/shorebird/issues/3223.
// Added for https://git.tonycloud.org/flutter/shorebird/issues/3223.
// Can be removed fall 2026 or later.
..addFlag(
'confirm',
@@ -458,10 +458,13 @@ Building with Flutter $flutterVersionString to determine the release version...
// calls made by Apple patchers outside the Flutter build.
final extraBuildArgs = <String>[];
if (obfuscationMapFile != null) {
final loadObfuscationMapArg = [
'--extra-gen-snapshot-options=--load-obfuscation-map',
obfuscationMapFile.path,
].join('=');
extraBuildArgs.addAll([
'--obfuscate',
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
loadObfuscationMapArg,
]);
// Gate --strip on the release's Flutter revision (not the user's
@@ -539,6 +542,7 @@ Building patch with Flutter $flutterVersionString
releaseId: release.id,
releaseArtifact: releaseArchive,
supplementDirectory: supplementDirectory,
releaseVersion: release.version,
);
final dryRun = results['dry-run'] == true;
@@ -663,7 +667,7 @@ Please re-run the release command for this version or create a new release.''');
required Patcher patcher,
}) async {
try {
return patcher.assertUnpatchableDiffs(
return await patcher.assertUnpatchableDiffs(
releaseArtifact: releaseArtifact,
releaseArchive: releaseArchive,
patchArchive: patchArchive,
@@ -68,7 +68,7 @@ More info: ${troubleshootingUrl.toLink()}.
List<String> extraBuildArgs = const [];
/// Additional gen_snapshot arguments needed to match the release's
/// obfuscation flags. Used by Apple patchers for [buildElfAotSnapshot]
/// obfuscation flags. Used by Apple patchers for `buildElfAotSnapshot`
/// and linker calls.
List<String> get obfuscationGenSnapshotArgs => [
if (obfuscationMapPath != null) ...[
@@ -125,6 +125,7 @@ More info: ${troubleshootingUrl.toLink()}.
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
});
/// Updates the provided metadata to include patcher-specific fields.
@@ -275,7 +276,7 @@ More info: ${troubleshootingUrl.toLink()}.
/// native changes, even though the user may not have actually changed any
/// code or dependencies.
///
/// Context: https://github.com/shorebirdtech/shorebird/issues/2270
/// Context: https://git.tonycloud.org/flutter/shorebird/issues/2270
List<String> buildNameAndNumberArgsFromReleaseVersion(
String? releaseVersion,
) {
@@ -89,6 +89,7 @@ class WindowsPatcher extends Patcher {
required int releaseId,
required File releaseArtifact,
Directory? supplementDirectory,
String? releaseVersion,
}) async {
final createDiffProgress = logger.progress('Creating patch artifacts');
final patchArtifactPath = p.join(
@@ -8,6 +8,7 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/json_output.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
@@ -111,7 +112,7 @@ class PromoteCommand extends ShorebirdCommand {
'''
No production channel found for app $appId.
This is a bug and should never happen. Please file an issue at https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title=fix%3A+''',
This is a bug and should never happen. Please file an issue at $openShorebirdIssueUrl''',
);
return ExitCode.software.code;
}
@@ -13,6 +13,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_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
@@ -167,7 +168,7 @@ dependencyResolutionManagement {
+ }
+ maven {
- url 'https://storage.googleapis.com/download.flutter.io'
+ url 'https://download.shorebird.dev/download.flutter.io'
+ url '${ShorebirdProcess.defaultFlutterStorageBaseUrl}'
+ }
}
}
@@ -13,6 +13,7 @@ import 'package:shorebird_cli/src/metadata/metadata.dart';
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_documentation.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
@@ -100,7 +101,7 @@ To change the version of this release, change your app's version in your pubspec
Split APKs are each given a different release version than what is specified in the pubspec.yaml.
See ${link(uri: Uri.parse('https://github.com/flutter/flutter/issues/39817'))} for more information about this issue.
Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1141'))} if you would like shorebird to support this.''',
Please file an issue at ${link(uri: Uri.parse(openShorebirdIssueUrl))} if you would like shorebird to support this.''',
);
throw ProcessExit(ExitCode.unavailable.code);
}
@@ -12,6 +12,7 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/shorebird_logger.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
@@ -143,6 +144,6 @@ class MacosReleaser extends Releaser with AppleReleaserMixin {
macOS app created at ${artifactManager.getMacOSAppDirectory(flavor: flavor)!.path}.
${styleBold.wrap('Note:')} If you distribute your app via the Mac App Store using a .pkg installer, the packaging process may modify the binary and cause patch failures. See ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/3223'))} for more information.
${styleBold.wrap('Note:')} If you distribute your app via the Mac App Store using a .pkg installer, the packaging process may modify the binary and cause patch failures. See ${link(uri: Uri.parse(troubleshootingUrl))} for more information.
''';
}
@@ -125,7 +125,7 @@ Defaults to "latest" which builds using the latest stable Flutter version.''',
hide: true,
negatable: false,
)
// Added for https://github.com/shorebirdtech/shorebird/issues/3223.
// Added for https://git.tonycloud.org/flutter/shorebird/issues/3223.
// Can be removed fall 2026 or later.
..addFlag(
'confirm',
@@ -461,9 +461,7 @@ $error''');
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
uri: Uri.parse(openShorebirdIssueUrl),
message: 'open an issue',
);
logger.err('''
@@ -241,7 +241,7 @@ abstract class Releaser {
// 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
// https://git.tonycloud.org/flutter/shorebird/issues/3630
Future<void> uploadSupplementArtifact({
required String appId,
required int releaseId,
@@ -25,6 +25,19 @@ class ShorebirdYaml {
this.baseUrl,
this.autoUpdate,
this.patchVerification,
this.aotPatchRuntimeMode,
this.aotPatchBytecodePath,
this.aotPatchKeyId,
this.aotPatchKeyHex,
this.aotPatchAppBuildId,
this.aotPatchBaseFlavorId,
this.aotPatchBaseLicenseType,
this.aotPatchFlavorId,
this.aotPatchLicenseType,
this.aotPatchSdkHash,
this.aotPatchBaseSnapshotHash,
this.aotPatchObfuscationMapHash,
this.aotPatchOfflineExpiresAt,
});
/// Creates a [ShorebirdYaml] from a JSON map.
@@ -61,6 +74,45 @@ class ShorebirdYaml {
/// The patch verification mode for the app.
final PatchVerification? patchVerification;
/// Runtime mode used for open AOT patch artifacts.
final String? aotPatchRuntimeMode;
/// Path to a full Dart bytecode patch payload for the iOS interpreter route.
final String? aotPatchBytecodePath;
/// Key id used to encrypt open AOT patch artifacts.
final String? aotPatchKeyId;
/// Development-only AES-256 key used to encrypt open AOT patch artifacts.
final String? aotPatchKeyHex;
/// App build id to bind into open AOT patch artifacts.
final String? aotPatchAppBuildId;
/// Optional source flavor id this patch can replace.
final String? aotPatchBaseFlavorId;
/// Optional source license type this patch can replace.
final String? aotPatchBaseLicenseType;
/// Target flavor id after the patch is applied.
final String? aotPatchFlavorId;
/// Target license type after the patch is applied.
final String? aotPatchLicenseType;
/// SDK hash expected by the runtime for open AOT patch artifacts.
final String? aotPatchSdkHash;
/// Base bytecode snapshot hash expected by the runtime.
final String? aotPatchBaseSnapshotHash;
/// Optional obfuscation map hash expected by the runtime.
final String? aotPatchObfuscationMapHash;
/// Optional offline expiration timestamp for the patch artifact.
final String? aotPatchOfflineExpiresAt;
}
/// Extension on [ShorebirdYaml] to get the app id for a specific flavor.
@@ -20,6 +20,19 @@ ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate(
'base_url',
'auto_update',
'patch_verification',
'aot_patch_runtime_mode',
'aot_patch_bytecode_path',
'aot_patch_key_id',
'aot_patch_key_hex',
'aot_patch_app_build_id',
'aot_patch_base_flavor_id',
'aot_patch_base_license_type',
'aot_patch_flavor_id',
'aot_patch_license_type',
'aot_patch_sdk_hash',
'aot_patch_base_snapshot_hash',
'aot_patch_obfuscation_map_hash',
'aot_patch_offline_expires_at',
],
);
final val = ShorebirdYaml(
@@ -34,6 +47,55 @@ ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate(
'patch_verification',
(v) => $enumDecodeNullable(_$PatchVerificationEnumMap, v),
),
aotPatchRuntimeMode: $checkedConvert(
'aot_patch_runtime_mode',
(v) => v as String?,
),
aotPatchBytecodePath: $checkedConvert(
'aot_patch_bytecode_path',
(v) => v as String?,
),
aotPatchKeyId: $checkedConvert('aot_patch_key_id', (v) => v as String?),
aotPatchKeyHex: $checkedConvert(
'aot_patch_key_hex',
(v) => v as String?,
),
aotPatchAppBuildId: $checkedConvert(
'aot_patch_app_build_id',
(v) => v as String?,
),
aotPatchBaseFlavorId: $checkedConvert(
'aot_patch_base_flavor_id',
(v) => v as String?,
),
aotPatchBaseLicenseType: $checkedConvert(
'aot_patch_base_license_type',
(v) => v as String?,
),
aotPatchFlavorId: $checkedConvert(
'aot_patch_flavor_id',
(v) => v as String?,
),
aotPatchLicenseType: $checkedConvert(
'aot_patch_license_type',
(v) => v as String?,
),
aotPatchSdkHash: $checkedConvert(
'aot_patch_sdk_hash',
(v) => v as String?,
),
aotPatchBaseSnapshotHash: $checkedConvert(
'aot_patch_base_snapshot_hash',
(v) => v as String?,
),
aotPatchObfuscationMapHash: $checkedConvert(
'aot_patch_obfuscation_map_hash',
(v) => v as String?,
),
aotPatchOfflineExpiresAt: $checkedConvert(
'aot_patch_offline_expires_at',
(v) => v as String?,
),
);
return val;
},
@@ -42,6 +104,19 @@ ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate(
'baseUrl': 'base_url',
'autoUpdate': 'auto_update',
'patchVerification': 'patch_verification',
'aotPatchRuntimeMode': 'aot_patch_runtime_mode',
'aotPatchBytecodePath': 'aot_patch_bytecode_path',
'aotPatchKeyId': 'aot_patch_key_id',
'aotPatchKeyHex': 'aot_patch_key_hex',
'aotPatchAppBuildId': 'aot_patch_app_build_id',
'aotPatchBaseFlavorId': 'aot_patch_base_flavor_id',
'aotPatchBaseLicenseType': 'aot_patch_base_license_type',
'aotPatchFlavorId': 'aot_patch_flavor_id',
'aotPatchLicenseType': 'aot_patch_license_type',
'aotPatchSdkHash': 'aot_patch_sdk_hash',
'aotPatchBaseSnapshotHash': 'aot_patch_base_snapshot_hash',
'aotPatchObfuscationMapHash': 'aot_patch_obfuscation_map_hash',
'aotPatchOfflineExpiresAt': 'aot_patch_offline_expires_at',
},
);
@@ -53,6 +128,19 @@ Map<String, dynamic> _$ShorebirdYamlToJson(
'base_url': instance.baseUrl,
'auto_update': instance.autoUpdate,
'patch_verification': _$PatchVerificationEnumMap[instance.patchVerification],
'aot_patch_runtime_mode': instance.aotPatchRuntimeMode,
'aot_patch_bytecode_path': instance.aotPatchBytecodePath,
'aot_patch_key_id': instance.aotPatchKeyId,
'aot_patch_key_hex': instance.aotPatchKeyHex,
'aot_patch_app_build_id': instance.aotPatchAppBuildId,
'aot_patch_base_flavor_id': instance.aotPatchBaseFlavorId,
'aot_patch_base_license_type': instance.aotPatchBaseLicenseType,
'aot_patch_flavor_id': instance.aotPatchFlavorId,
'aot_patch_license_type': instance.aotPatchLicenseType,
'aot_patch_sdk_hash': instance.aotPatchSdkHash,
'aot_patch_base_snapshot_hash': instance.aotPatchBaseSnapshotHash,
'aot_patch_obfuscation_map_hash': instance.aotPatchObfuscationMapHash,
'aot_patch_offline_expires_at': instance.aotPatchOfflineExpiresAt,
};
const _$PatchVerificationEnumMap = {
@@ -55,7 +55,7 @@ class PatchExecutable {
// A Windows-specific error code indicating that the Microsoft C++ runtime
// (VCRUNTIME140.dll) could not be found.
// More info: https://github.com/shorebirdtech/shorebird/issues/2329
// More info: https://git.tonycloud.org/flutter/shorebird/issues/2329
const vcRuntimeNotFoundExitCode = -1073741515;
if (result.exitCode == vcRuntimeNotFoundExitCode && platform.isWindows) {
messageDetails =
@@ -11,8 +11,8 @@ part 'build_environment_metadata.g.dart';
/// later failures in their builds.
///
/// We do not collect Personally Identifying Information (e.g. no paths,
/// argument lists, etc.) in accordance with our privacy policy:
/// https://shorebird.dev/privacy/
/// argument lists, etc.) in accordance with the configured server privacy
/// policy.
/// {@endtemplate}
@JsonSerializable()
class BuildEnvironmentMetadata extends Equatable {
@@ -12,8 +12,8 @@ part 'create_patch_metadata.g.dart';
/// later failures in their builds.
///
/// We do not collect Personally Identifying Information (e.g. no paths,
/// argument lists, etc.) in accordance with our privacy policy:
/// https://shorebird.dev/privacy/
/// argument lists, etc.) in accordance with the configured server privacy
/// policy.
/// {@endtemplate}
@JsonSerializable()
class CreatePatchMetadata extends Equatable {
@@ -12,8 +12,8 @@ part 'update_release_metadata.g.dart';
/// later failures in their builds.
///
/// We do not collect Personally Identifying Information (e.g. no paths,
/// argument lists, etc.) in accordance with our privacy policy:
/// https://shorebird.dev/privacy/
/// argument lists, etc.) in accordance with the configured server privacy
/// policy.
/// {@endtemplate}
@JsonSerializable()
class UpdateReleaseMetadata extends Equatable {
@@ -11,6 +11,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/formatters/formatters.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
/// A reference to a [NetworkChecker] instance.
final networkCheckerRef = create(NetworkChecker.new);
@@ -35,13 +36,16 @@ class NetworkCheckerException implements Exception {
/// {@endtemplate}
class NetworkChecker {
/// The URLs to check for network reachability.
static final urlsToCheck = [
'https://api.shorebird.dev',
'https://console.shorebird.dev',
'https://oauth2.googleapis.com',
'https://storage.googleapis.com',
'https://cdn.shorebird.cloud',
].map(Uri.parse).toList();
static List<Uri> get urlsToCheck {
final hostedUri =
shorebirdEnv.hostedUri ?? Uri.parse(ShorebirdEnv.defaultHostedUrl);
final urls = [hostedUri, shorebirdEnv.authServiceUri];
final seen = <String>{};
return [
for (final url in urls)
if (seen.add(url.toString())) url,
];
}
/// Verify that each of [urlsToCheck] responds to an HTTP GET request.
Future<void> checkReachability() async {
@@ -102,7 +102,7 @@ class Apple {
// Ideally, we would use `xcodebuild -list` to detect schemes/flavors.
// Unfortunately, many projects contain schemes that are not flavors, and we
// don't want to create flavors for these schemes. See
// https://github.com/shorebirdtech/shorebird/issues/1703 for an example.
// https://git.tonycloud.org/flutter/shorebird/issues/1703 for an example.
// Instead, we look in `[platform]/Runner.xcodeproj/xcshareddata/xcschemes`
// for xcscheme files (which seem to be 1-to-1 with schemes in Xcode) and
// filter out schemes that are marked as "wasCreatedForAppExtension".
@@ -28,6 +28,15 @@ enum ShorebirdArtifact {
/// The gen_snapshot executable for macOS that creates x64 snapshots.
genSnapshotMacosX64,
/// The host dartaotruntime executable.
dartAotRuntime,
/// The host dart2bytecode snapshot.
dart2BytecodeSnapshot,
/// The Flutter product platform dill used to compile Flutter bytecode.
flutterProductPlatformDill,
}
/// A reference to a [ShorebirdArtifacts] instance.
@@ -68,6 +77,12 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts {
return _genSnapshotMacOsArm64File.path;
case ShorebirdArtifact.genSnapshotMacosX64:
return _genSnapshotMacOsX64File.path;
case ShorebirdArtifact.dartAotRuntime:
return _dartAotRuntimeFile.path;
case ShorebirdArtifact.dart2BytecodeSnapshot:
return _dart2BytecodeSnapshotFile.path;
case ShorebirdArtifact.flutterProductPlatformDill:
return _flutterProductPlatformDillFile.path;
}
}
@@ -164,6 +179,63 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts {
),
);
}
File get _dartAotRuntimeFile {
return File(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'dart-sdk',
'bin',
'dartaotruntime',
),
);
}
File get _dart2BytecodeSnapshotFile {
return File(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'dart-sdk',
'bin',
'snapshots',
'dart2bytecode.dart.snapshot',
),
);
}
File get _flutterProductPlatformDillFile {
final workspaceIosPlatformDill = File(
p.join(
shorebirdEnv.flutterDirectory.path,
'engine',
'src',
'out',
'ios_release',
'flutter_patched_sdk',
'platform_strong.dill',
),
);
if (workspaceIosPlatformDill.existsSync()) {
return workspaceIosPlatformDill;
}
return File(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'artifacts',
'engine',
'common',
'flutter_patched_sdk_product',
'platform_strong.dill',
),
);
}
}
/// {@template shorebird_local_engine_artifacts}
@@ -188,6 +260,12 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts {
return _genSnapshotMacosArm64File.path;
case ShorebirdArtifact.genSnapshotMacosX64:
return _genSnapshotMacosX64File.path;
case ShorebirdArtifact.dartAotRuntime:
return _dartAotRuntimeFile.path;
case ShorebirdArtifact.dart2BytecodeSnapshot:
return _dart2BytecodeSnapshotFile.path;
case ShorebirdArtifact.flutterProductPlatformDill:
return _flutterProductPlatformDillFile.path;
}
}
@@ -265,4 +343,57 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts {
),
);
}
File get _dartAotRuntimeFile {
final localEngineHost = engineConfig.localEngineHost;
if (localEngineHost == null) {
throw StateError(
'localEngineHost is required for local engine artifacts',
);
}
return File(
p.join(
engineConfig.localEngineSrcPath!,
'out',
localEngineHost,
'dartaotruntime',
),
);
}
File get _dart2BytecodeSnapshotFile {
final localEngineHost = engineConfig.localEngineHost;
if (localEngineHost == null) {
throw StateError(
'localEngineHost is required for local engine artifacts',
);
}
return File(
p.join(
engineConfig.localEngineSrcPath!,
'out',
localEngineHost,
'dart-sdk',
'bin',
'snapshots',
'dart2bytecode.dart.snapshot',
),
);
}
File get _flutterProductPlatformDillFile {
final localEngine = engineConfig.localEngine;
if (localEngine == null) {
throw StateError('localEngine is required for local engine artifacts');
}
return File(
p.join(
engineConfig.localEngineSrcPath!,
'out',
localEngine,
'flutter_patched_sdk',
'platform_strong.dill',
),
);
}
}
@@ -12,6 +12,7 @@ import 'package:shorebird_cli/src/json_output.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_documentation.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';
@@ -259,7 +260,7 @@ ${lightCyan.wrap('shorebird release android -- --no-pub lib/main.dart')}''';
shorebirdFlutterPrefix.write(' $flutterVersion');
}
logger.info('''
Shorebird $packageVersiongit@github.com:shorebirdtech/shorebird.git
Shorebird $packageVersionhttps://git.tonycloud.org/flutter/shorebird.git
$shorebirdFlutterPrefix • revision ${shorebirdEnv.flutterRevision}
Engine • revision ${shorebirdEnv.shorebirdEngineRevision}''');
}
@@ -340,9 +341,7 @@ Engine • revision ${shorebirdEnv.shorebirdEngineRevision}''');
exitCode != ExitCode.success.code &&
logger.level != Level.verbose) {
final fileAnIssue = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new/choose',
),
uri: Uri.parse(openShorebirdIssueUrl),
message: 'file an issue',
);
logger.info('''
@@ -1,8 +1,15 @@
// cspell:words swiftobjective ckotlinjava havent
import 'package:mason_logger/mason_logger.dart';
/// Link to the Shorebird documentation page.
const docsUrl = 'https://docs.shorebird.dev';
/// Link to the open Shorebird repository.
const openShorebirdRepositoryUrl =
'https://git.tonycloud.org/flutter/shorebird';
/// Link to the open Shorebird issue tracker.
const openShorebirdIssueUrl = '$openShorebirdRepositoryUrl/issues/new';
/// Link to the open Shorebird documentation page.
const docsUrl = '$openShorebirdRepositoryUrl/src/branch/main/docs';
/// Link to the Flutter version page on the Shorebird documentation.
const flutterVersionUrl = '$docsUrl/getting-started/flutter-version';
@@ -57,6 +57,15 @@ class ShorebirdEnv {
final String? _flutterRevisionOverride;
final String? _flutterProjectRootOverride;
/// Default URL for the open self-hosted Shorebird server.
static const defaultHostedUrl = 'http://localhost:8080';
/// Default auth service URL for the open self-hosted Shorebird server.
static const defaultAuthServiceUrl = '$defaultHostedUrl/auth';
/// Default JWT issuer used by the open self-hosted server.
static const defaultJwtIssuer = 'shorebird-auth';
/// The application config directory for the Shorebird CLI.
Directory get configDirectory {
return Directory(applicationConfigHome(executableName));
@@ -69,21 +78,45 @@ class ShorebirdEnv {
/// The root directory of the Shorebird install.
///
/// Assumes we are running from $ROOT/bin/cache.
/// Historically the CLI was launched from a snapshot under $ROOT/bin/cache.
/// Compiled open-source CLI bundles place the executable under $ROOT/bin, so
/// first look for the install metadata and fall back to the legacy layout.
Directory get shorebirdRoot {
return File(platform.script.toFilePath()).parent.parent.parent;
final scriptDirectory = File(platform.script.toFilePath()).parent;
var candidate = scriptDirectory;
while (true) {
final flutterVersionFile = File(
p.join(candidate.path, 'bin', 'internal', 'flutter.version'),
);
if (flutterVersionFile.existsSync()) return candidate;
final parent = candidate.parent;
if (parent.path == candidate.path) break;
candidate = parent;
}
return scriptDirectory.parent.parent;
}
/// The Shorebird engine revision.
String get shorebirdEngineRevision {
final file = File(
p.join(flutterDirectory.path, 'bin', 'internal', 'engine.version'),
);
try {
return file.readAsStringSync().trim();
} on FileSystemException {
throw CacheCorruptedException('Could not read ${file.path}.');
final files = [
File(p.join(flutterDirectory.path, 'bin', 'internal', 'engine.version')),
File(p.join(shorebirdRoot.path, 'bin', 'internal', 'engine.version')),
];
for (final file in files) {
if (!file.existsSync()) continue;
try {
return file.readAsStringSync().trim();
} on FileSystemException {
break;
}
}
throw CacheCorruptedException(
'Could not read ${files.map((file) => file.path).join(' or ')}.',
);
}
/// Get the Shorebird Flutter revision.
@@ -259,31 +292,29 @@ class ShorebirdEnv {
}
/// The base URL for the Shorebird auth service. Can be overridden with the
/// `AUTH_SERVICE_URL` environment variable. Defaults to
/// `https://auth.shorebird.dev`.
/// `AUTH_SERVICE_URL` environment variable. Defaults to the open self-hosted
/// server auth endpoint.
Uri get authServiceUri => Uri.parse(
platform.environment['AUTH_SERVICE_URL'] ?? 'https://auth.shorebird.dev',
platform.environment['AUTH_SERVICE_URL'] ?? defaultAuthServiceUrl,
);
/// The expected JWT issuer for Shorebird-issued tokens. Can be overridden
/// with the `SHOREBIRD_JWT_ISSUER` environment variable. Defaults to
/// `https://auth.shorebird.dev`.
/// with the `SHOREBIRD_JWT_ISSUER` environment variable. Defaults to the open
/// self-hosted server issuer.
String get jwtIssuer =>
platform.environment['SHOREBIRD_JWT_ISSUER'] ??
'https://auth.shorebird.dev';
platform.environment['SHOREBIRD_JWT_ISSUER'] ?? defaultJwtIssuer;
/// The base URL for the Shorebird code push server that overrides the default
/// used by [CodePushClient]. If none is provided, [CodePushClient] will use
/// its default.
/// The base URL for the Shorebird code push server.
Uri? get hostedUri {
try {
final baseUrl =
platform.environment['SHOREBIRD_HOSTED_URL'] ??
getShorebirdYaml()?.baseUrl ??
userConfig.hostedUri?.toString();
userConfig.hostedUri?.toString() ??
defaultHostedUrl;
return _parseHostedUri(baseUrl);
} on Exception {
return null;
return Uri.parse(defaultHostedUrl);
}
}
@@ -31,8 +31,17 @@ class ShorebirdFlutter {
static const executable = 'flutter';
/// The Shorebird Flutter fork git URL.
static const String flutterGitUrl =
'https://github.com/shorebirdtech/flutter.git';
static const String defaultFlutterGitUrl =
'https://git.tonycloud.org/flutter/flutter.git';
/// The Flutter fork git URL used when installing vended Flutter revisions.
String get flutterGitUrl {
final override = platform.environment['SHOREBIRD_FLUTTER_GIT_URL'];
if (override != null && override.trim().isNotEmpty) {
return override.trim();
}
return defaultFlutterGitUrl;
}
/// Arguments to pass to `flutter precache`.
List<String> get precacheArgs => ['--android', if (platform.isMacOS) '--ios'];
@@ -25,6 +25,10 @@ class ShorebirdProcess {
ProcessWrapper? processWrapper, // For mocking ShorebirdProcess.
}) : processWrapper = processWrapper ?? ProcessWrapper();
/// Default Flutter artifact mirror for the open self-hosted setup.
static const defaultFlutterStorageBaseUrl =
'${ShorebirdEnv.defaultHostedUrl}/download.flutter.io';
/// The underlying process wrapper.
final ProcessWrapper processWrapper;
@@ -38,7 +42,7 @@ class ShorebirdProcess {
/// `bin/shorebird.dart`, so `flutter build` stderr is absent from the
/// shorebird log file — on a build failure users see the real error on
/// screen but the log only has `Failed to build AAB. Exited with code 1`
/// (https://github.com/shorebirdtech/shorebird/issues/3703). Piping
/// (https://git.tonycloud.org/flutter/shorebird/issues/3703). Piping
/// through Dart would capture stderr but turns `stdout.hasTerminal` false
/// on the child side, regressing the interactive UX; a pty or per-fd
/// shell tee would fix both but costs a dependency / POSIX-only path.
@@ -266,7 +270,12 @@ $stderr''');
if (executable == 'flutter') {
// If this ever changes we also need to update the `shorebird` shell
// wrapper which downloads runs Flutter to fetch artifacts the first time.
return {'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev'};
return {
'FLUTTER_STORAGE_BASE_URL':
platform.environment['SHOREBIRD_FLUTTER_STORAGE_BASE_URL'] ??
platform.environment['FLUTTER_STORAGE_BASE_URL'] ??
defaultFlutterStorageBaseUrl,
};
}
return {};
@@ -5,6 +5,7 @@ import 'package:shorebird_cli/src/auth/auth.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_web_console.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
@@ -83,7 +84,7 @@ class ShorebirdValidator {
'''If you already have an account, run ${lightCyan.wrap('shorebird login')} to sign in.''',
)
..info(
'''If you don't have a Shorebird account, go to ${link(uri: Uri.parse('https://console.shorebird.dev'))} to create one.''',
'''If you don't have a Shorebird account, go to ${link(uri: ShorebirdWebConsole.uri(''))} to create one.''',
);
throw UserNotAuthorizedException();
}
@@ -1,8 +1,18 @@
import 'package:shorebird_cli/src/shorebird_env.dart';
/// Shorebird Web Console URLs.
class ShorebirdWebConsole {
/// Returns a [Uri] for the Shorebird Web Console.
static Uri uri(String path) {
return Uri.parse('https://console.shorebird.dev/$path');
final baseUri =
shorebirdEnv.hostedUri ?? Uri.parse(ShorebirdEnv.defaultHostedUrl);
final pathSegments = [
...baseUri.pathSegments.where((segment) => segment.isNotEmpty),
...path.split('/').where((segment) => segment.isNotEmpty),
];
return baseUri.replace(
pathSegments: pathSegments,
);
}
/// Returns a [Uri] for the Shorebird Web Console login page.
@@ -8,7 +8,7 @@ import 'package:xml/xml.dart';
/// Checks that android/app/src/main/AndroidManifest.xml contains the INTERNET
/// permission, which is required for Shorebird to work.
///
/// See https://github.com/shorebirdtech/shorebird/issues/160.
/// See https://git.tonycloud.org/flutter/shorebird/issues/160.
class AndroidInternetPermissionValidator extends Validator {
/// Path to the main AndroidManifest.xml file.
final String _mainAndroidManifestPath = p.join(
+3 -1
View File
@@ -1,7 +1,7 @@
name: shorebird_cli
description: Command-line tool to interact with Shorebird's services.
version: 1.6.108
repository: https://github.com/shorebirdtech/shorebird
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
publish_to: none
@@ -31,6 +31,8 @@ dependencies:
path: ../jwt
mason_logger: ^0.3.5
meta: ^1.16.0
open_aot_patch_tools:
path: ../open_aot_patch_tools
path: ^1.9.1
pem: ^2.0.5
platform: ^3.1.6
@@ -1469,7 +1469,7 @@ Reason: Exited with code 70.'''),
.having(
(e) => e.fixRecommendation,
'fixRecommendation',
'''Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command.''',
'''Please file a bug at https://git.tonycloud.org/flutter/shorebird/issues/new with the logs for this command.''',
),
),
);
@@ -1710,7 +1710,7 @@ Reason: Exited with code 70.'''),
.having(
(e) => e.fixRecommendation,
'fixRecommendation',
'''Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command.''',
'''Please file a bug at https://git.tonycloud.org/flutter/shorebird/issues/new with the logs for this command.''',
),
),
);
@@ -1924,7 +1924,7 @@ Reason: Exited with code 70.'''),
.having(
(e) => e.fixRecommendation,
'fixRecommendation',
'''Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command.''',
'''Please file a bug at https://git.tonycloud.org/flutter/shorebird/issues/new with the logs for this command.''',
),
),
);
@@ -2039,6 +2039,106 @@ Reason: Exited with code 70.'''),
});
});
});
group('buildDartBytecodeSnapshot', () {
setUp(() {
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.dartAotRuntime,
),
).thenReturn('dartaotruntime');
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.dart2BytecodeSnapshot,
),
).thenReturn('dart2bytecode.dart.snapshot');
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.flutterProductPlatformDill,
),
).thenReturn('platform_strong.dill');
});
test('invokes dart2bytecode with release Flutter arguments', () async {
final outFile = await runWithOverrides(
() => builder.buildDartBytecodeSnapshot(
inputFilePath: '/app/lib/main.dart',
outFilePath: '/path/to/out.bytecode',
packageConfigPath: '/app/.dart_tool/package_config.json',
dartDefines: ['FOO=bar'],
experiments: ['records'],
),
);
expect(outFile.path, '/path/to/out.bytecode');
verify(
() => shorebirdProcess.stream(
'dartaotruntime',
[
'dart2bytecode.dart.snapshot',
'--platform=platform_strong.dill',
'--target=flutter',
'--packages=/app/.dart_tool/package_config.json',
'-DFOO=bar',
'-Ddart.vm.profile=false',
'-Ddart.vm.product=true',
'--enable-experiment=records',
'-o',
'/path/to/out.bytecode',
'/app/lib/main.dart',
],
runInShell: false,
),
).called(1);
});
test('omits package config when none is provided', () async {
await runWithOverrides(
() => builder.buildDartBytecodeSnapshot(
inputFilePath: '/app/lib/main.dart',
outFilePath: '/path/to/out.bytecode',
),
);
final captured =
verify(
() => shorebirdProcess.stream(
'dartaotruntime',
captureAny(),
runInShell: false,
),
).captured.single
as List<String>;
expect(
captured,
isNot(contains('--packages=/app/.dart_tool/package_config.json')),
);
});
group('when build fails', () {
setUp(() {
when(
() => shorebirdProcess.stream(
any(),
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => ExitCode.software.code);
});
test('throws ArtifactBuildException', () {
expect(
() => runWithOverrides(
() => builder.buildDartBytecodeSnapshot(
inputFilePath: '/app/lib/main.dart',
outFilePath: '/path/to/out.bytecode',
),
),
throwsA(isA<ArtifactBuildException>()),
);
});
});
});
});
group('buildWindowsApp', () {
@@ -974,7 +974,7 @@ void main() {
'`shorebird login:ci`. '
'This format is deprecated and will stop working in a future '
'release. '
'Create an API key at https://console.shorebird.dev instead.',
'Create an API key at ${ShorebirdEnv.defaultHostedUrl} instead.',
),
).called(1);
verify(
+118 -30
View File
@@ -173,6 +173,52 @@ void main() {
});
});
group('artifactBaseUrl', () {
test('uses the default open artifact mirror root', () {
final url = runWithOverrides(() => cache.artifactBaseUrl);
expect(
url,
equals(Cache.defaultArtifactBaseUrl),
);
});
test('uses SHOREBIRD_ARTIFACT_BASE_URL when set', () {
when(() => platform.environment).thenReturn({
'SHOREBIRD_ARTIFACT_BASE_URL': 'https://artifacts.example.com/open/',
});
final url = runWithOverrides(() => cache.artifactBaseUrl);
expect(url, equals('https://artifacts.example.com/open'));
});
test('can override the storage base URL and bucket separately', () {
when(() => platform.environment).thenReturn({
'SHOREBIRD_STORAGE_BASE_URL': 'https://storage.example.com/',
'SHOREBIRD_STORAGE_BUCKET': '/open-shorebird/',
});
final url = runWithOverrides(() => cache.artifactBaseUrl);
expect(url, equals('https://storage.example.com/open-shorebird'));
});
test('builds per-engine artifact URLs from the configured root', () {
when(() => platform.environment).thenReturn({
'SHOREBIRD_ARTIFACT_BASE_URL': 'https://artifacts.example.com/open/',
});
final url = runWithOverrides(
() => cache.shorebirdArtifactUrl('patch-linux-x64.zip'),
);
expect(
url,
equals(
'https://artifacts.example.com/open/shorebird/'
'$shorebirdEngineRevision/patch-linux-x64.zip',
),
);
});
});
group('clear', () {
test('deletes the cache directory', () async {
final shorebirdCacheDirectory = runWithOverrides(
@@ -357,33 +403,41 @@ void main() {
);
});
test('skips optional artifacts if a 404 is returned', () async {
when(() => httpClient.send(any())).thenAnswer((invocation) async {
final request =
invocation.positionalArguments.first as http.BaseRequest;
final fileName = p.basename(request.url.path);
if (fileName.startsWith('aot-tools')) {
test(
'skips optional legacy aot-tools if enabled and a 404 is returned',
() async {
when(() => platform.environment).thenReturn({
Cache.legacyAotToolsEnvironmentVariable: '1',
});
cache = runWithOverrides(Cache.new);
when(() => httpClient.send(any())).thenAnswer((invocation) async {
final request =
invocation.positionalArguments.first as http.BaseRequest;
final fileName = p.basename(request.url.path);
if (fileName.startsWith('aot-tools')) {
return http.StreamedResponse(
const Stream.empty(),
HttpStatus.notFound,
reasonPhrase: 'Not Found',
);
}
return http.StreamedResponse(
const Stream.empty(),
HttpStatus.notFound,
reasonPhrase: 'Not Found',
Stream.value(ZipEncoder().encode(Archive())),
HttpStatus.ok,
);
}
return http.StreamedResponse(
Stream.value(ZipEncoder().encode(Archive())),
HttpStatus.ok,
});
await expectLater(
runWithOverrides(() => cache.updateAll(Duration.zero)),
completes,
);
});
await expectLater(
runWithOverrides(() => cache.updateAll(Duration.zero)),
completes,
);
verify(
() => logger.detail(
'''[cache] optional artifact: "aot-tools.dill" was not found, skipping...''',
),
).called(1);
});
verify(
() => logger.detail(
'''[cache] optional artifact: "aot-tools.dill" was not found, skipping...''',
),
).called(1);
},
);
test('downloads correct artifacts', () async {
final patchArtifactDirectory = runWithOverrides(
@@ -397,6 +451,22 @@ void main() {
expect(patchArtifactDirectory.existsSync(), isTrue);
});
test('does not download legacy aot-tools by default', () async {
await expectLater(
runWithOverrides(() => cache.updateAll(Duration.zero)),
completes,
);
final requests = verify(
() => httpClient.send(captureAny()),
).captured.cast<http.BaseRequest>().map((r) => r.url).toList();
expect(
requests.map((request) => request.path).join('\n'),
isNot(contains('aot-tools.dill')),
);
});
group('when extraction fails', () {
setUp(() {
when(
@@ -454,12 +524,11 @@ void main() {
).captured.cast<http.BaseRequest>().map((r) => r.url).toList();
String perEngine(String name) =>
'${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/$shorebirdEngineRevision/$name';
'${cache.artifactBaseUrl}/shorebird/$shorebirdEngineRevision/$name';
final expected = [
perEngine('patch-darwin-x64.zip'),
'https://github.com/google/bundletool/releases/download/1.18.1/bundletool-all-1.18.1.jar',
perEngine('aot-tools.dill'),
].map(Uri.parse).toList();
expect(requests, equals(expected));
@@ -478,12 +547,11 @@ void main() {
).captured.cast<http.BaseRequest>().map((r) => r.url).toList();
String perEngine(String name) =>
'${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/$shorebirdEngineRevision/$name';
'${cache.artifactBaseUrl}/shorebird/$shorebirdEngineRevision/$name';
final expected = [
perEngine('patch-windows-x64.zip'),
'https://github.com/google/bundletool/releases/download/1.18.1/bundletool-all-1.18.1.jar',
perEngine('aot-tools.dill'),
].map(Uri.parse).toList();
expect(requests, equals(expected));
@@ -502,16 +570,36 @@ void main() {
).captured.cast<http.BaseRequest>().map((r) => r.url).toList();
String perEngine(String name) =>
'${cache.storageBaseUrl}/${cache.storageBucket}/shorebird/$shorebirdEngineRevision/$name';
'${cache.artifactBaseUrl}/shorebird/$shorebirdEngineRevision/$name';
final expected = [
perEngine('patch-linux-x64.zip'),
'https://github.com/google/bundletool/releases/download/1.18.1/bundletool-all-1.18.1.jar',
perEngine('aot-tools.dill'),
].map(Uri.parse).toList();
expect(requests, equals(expected));
});
test('pulls legacy aot-tools only when explicitly enabled', () async {
when(() => platform.environment).thenReturn({
Cache.legacyAotToolsEnvironmentVariable: 'true',
});
cache = runWithOverrides(Cache.new);
await expectLater(
runWithOverrides(() => cache.updateAll(Duration.zero)),
completes,
);
final requests = verify(
() => httpClient.send(captureAny()),
).captured.cast<http.BaseRequest>().map((r) => r.url).toList();
String perEngine(String name) =>
'${cache.artifactBaseUrl}/shorebird/$shorebirdEngineRevision/$name';
expect(requests, contains(Uri.parse(perEngine('aot-tools.dill'))));
});
});
});
});
@@ -101,6 +101,12 @@ void main() {
() => shorebirdEnv.flutterRevision,
).thenReturn(shorebirdFlutterRevision);
when(() => shorebirdEnv.logsDirectory).thenReturn(logsDirectory);
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse('http://localhost:8080'));
when(
() => shorebirdEnv.authServiceUri,
).thenReturn(Uri.parse('http://localhost:8080/auth'));
when(() => doctor.initAndDoctorValidators).thenReturn([validator]);
when(
() => doctor.runValidators(any(), applyFixes: any(named: 'applyFixes')),
@@ -122,7 +128,7 @@ void main() {
verify(
() => logger.info('''
Shorebird v$packageVersion git@github.com:shorebirdtech/shorebird.git
Shorebird v$packageVersion https://git.tonycloud.org/flutter/shorebird.git
Flutter revision ${shorebirdEnv.flutterRevision}
Engine revision $shorebirdEngineRevision
'''),
@@ -146,7 +152,7 @@ Engine • revision $shorebirdEngineRevision
verify(
() => logger.info('''
Shorebird v$packageVersion git@github.com:shorebirdtech/shorebird.git
Shorebird v$packageVersion https://git.tonycloud.org/flutter/shorebird.git
Flutter $flutterVersion revision ${shorebirdEnv.flutterRevision}
Engine revision $shorebirdEngineRevision
'''),
@@ -178,7 +184,7 @@ Engine • revision $shorebirdEngineRevision
expect(
msg,
equals('''
Shorebird $packageVersion git@github.com:shorebirdtech/shorebird.git
Shorebird $packageVersion https://git.tonycloud.org/flutter/shorebird.git
Flutter revision ${shorebirdEnv.flutterRevision}
Engine revision $shorebirdEngineRevision
@@ -218,7 +224,7 @@ OpenJDK 64-Bit Server VM (build 17.0.9+0-17.0.9b1087.7-11185874, mixed mode)'''
expect(
msg.replaceAll(Platform.lineTerminator, '\n'),
equals('''
Shorebird $packageVersion git@github.com:shorebirdtech/shorebird.git
Shorebird $packageVersion https://git.tonycloud.org/flutter/shorebird.git
Flutter revision ${shorebirdEnv.flutterRevision}
Engine revision $shorebirdEngineRevision
@@ -275,7 +281,7 @@ OpenJDK 64-Bit Server VM (build 17.0.9+0-17.0.9b1087.7-11185874, mixed mode)'''
expect(
msg.replaceAll(Platform.lineTerminator, '\n'),
equals('''
Shorebird $packageVersion git@github.com:shorebirdtech/shorebird.git
Shorebird $packageVersion https://git.tonycloud.org/flutter/shorebird.git
Flutter revision ${shorebirdEnv.flutterRevision}
Engine revision $shorebirdEngineRevision
@@ -16,6 +16,7 @@ import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/apple/apple.dart';
import 'package:shorebird_cli/src/pubspec_editor.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
@@ -393,7 +394,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err(
'''You do not have any organizations. This should never happen. Please contact us on Discord or send us an email at contact@shorebird.dev.''',
'''You do not have any organizations. This should never happen. Please file an issue at $openShorebirdIssueUrl.''',
),
).called(1);
});
@@ -1482,7 +1483,7 @@ flutter:
'''📦 To create a new release use: "${lightCyan.wrap('shorebird release')}".''',
'''🚀 To push an update use: "${lightCyan.wrap('shorebird patch')}".''',
'''👀 To preview a release use: "${lightCyan.wrap('shorebird preview')}".''',
'''For more information about Shorebird, visit ${link(uri: Uri.parse('https://shorebird.dev'))}''',
'''For more information about Shorebird, visit ${link(uri: Uri.parse(docsUrl))}''',
'',
]),
),
@@ -4,6 +4,7 @@ import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:test/test.dart';
import '../mocks.dart';
@@ -12,6 +13,7 @@ void main() {
group(LoginCiCommand, () {
late Auth auth;
late ShorebirdLogger logger;
late ShorebirdEnv shorebirdEnv;
late LoginCiCommand command;
R runWithOverrides<R>(R Function() body) {
@@ -20,6 +22,7 @@ void main() {
values: {
authRef.overrideWith(() => auth),
loggerRef.overrideWith(() => logger),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
},
);
}
@@ -27,6 +30,10 @@ void main() {
setUp(() {
auth = MockAuth();
logger = MockShorebirdLogger();
shorebirdEnv = MockShorebirdEnv();
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse(ShorebirdEnv.defaultHostedUrl));
command = runWithOverrides(LoginCiCommand.new);
});
@@ -50,9 +57,14 @@ void main() {
final message = captured.single as String;
expect(message, contains('shorebird login:ci has been replaced'));
expect(message, contains('console.shorebird.dev'));
expect(message, contains(ShorebirdEnv.defaultHostedUrl));
expect(message, contains('SHOREBIRD_TOKEN'));
expect(message, contains('docs.shorebird.dev/account/api-keys'));
expect(
message,
contains(
'git.tonycloud.org/flutter/shorebird/src/branch/main/docs/account/api-keys',
),
);
});
test('does not trigger any auth flow', () async {
@@ -8,6 +8,7 @@ import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/login_command.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:test/test.dart';
import '../mocks.dart';
@@ -20,6 +21,7 @@ void main() {
late http.Client httpClient;
late Directory applicationConfigHome;
late ShorebirdLogger logger;
late ShorebirdEnv shorebirdEnv;
late LoginCommand command;
R runWithOverrides<R>(R Function() body) {
@@ -28,6 +30,7 @@ void main() {
values: {
authRef.overrideWith(() => auth),
loggerRef.overrideWith(() => logger),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
},
);
}
@@ -37,9 +40,13 @@ void main() {
auth = MockAuth();
httpClient = MockHttpClient();
logger = MockShorebirdLogger();
shorebirdEnv = MockShorebirdEnv();
when(() => auth.isAuthenticated).thenReturn(false);
when(() => auth.client).thenReturn(httpClient);
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse(ShorebirdEnv.defaultHostedUrl));
when(
() => auth.credentialsFilePath,
).thenReturn(p.join(applicationConfigHome.path, 'credentials.json'));
@@ -117,7 +124,7 @@ void main() {
() => logger.err('We could not find a Shorebird account for $email.'),
).called(1);
verify(
() => logger.info(any(that: contains('console.shorebird.dev'))),
() => logger.info(any(that: contains(ShorebirdEnv.defaultHostedUrl))),
).called(1);
});
@@ -56,7 +56,7 @@ void main() {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(() => logger.progress('Logging out of shorebird.dev')).called(1);
verify(() => logger.progress('Logging out of Shorebird')).called(1);
verify(() => auth.logout()).called(1);
});
});
@@ -294,7 +294,7 @@ void main() {
).thenAnswer((_) async => aabFile);
});
// See https://github.com/shorebirdtech/updater/issues/211
// Historical updater bug fixed in Flutter 3.24.2.
group('when flutter version contains updater issue 211', () {
setUp(() {
setUpProjectRootArtifacts();
@@ -366,7 +366,7 @@ void main() {
verify(
() => logger.info('''
Please run `shorebird cache clean` and try again. If the issue persists, please
file a bug report at https://github.com/shorebirdtech/shorebird/issues/new.
file a bug report at https://git.tonycloud.org/flutter/shorebird/issues/new.
Looked in:
- the libapp.so entries inside the built .aab
@@ -834,7 +834,7 @@ Looked in:
() => logger.info(
any(
that: contains(
'https://github.com/shorebirdtech/shorebird/issues/2532',
'git.tonycloud.org/flutter/shorebird/src/branch/main/docs/code-push/troubleshooting',
),
),
),
@@ -1,11 +1,14 @@
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:open_aot_patch_tools/open_aot_patch_tools.dart' as open_patch;
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/archive_analysis/apple_archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
@@ -23,6 +26,7 @@ import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform.dart' as cli_platform;
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
@@ -59,6 +63,7 @@ void main() {
late ShorebirdLogger logger;
late OperatingSystemInterface operatingSystemInterface;
late PatchDiffChecker patchDiffChecker;
late Platform platform;
late Progress progress;
late ShorebirdArtifacts shorebirdArtifacts;
late ShorebirdProcess shorebirdProcess;
@@ -83,6 +88,7 @@ void main() {
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
patchDiffCheckerRef.overrideWith(() => patchDiffChecker),
cli_platform.platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
@@ -117,7 +123,9 @@ void main() {
flavorValidator = MockFlavorValidator();
operatingSystemInterface = MockOperatingSystemInterface();
patchDiffChecker = MockPatchDiffChecker();
platform = MockPlatform();
progress = MockProgress();
flutterDirectory = Directory.systemTemp.createTempSync();
projectRoot = Directory.systemTemp.createTempSync();
logger = MockShorebirdLogger();
shorebirdArtifacts = MockShorebirdArtifacts();
@@ -134,6 +142,9 @@ void main() {
when(() => argResults.wasParsed(any())).thenReturn(false);
when(() => logger.progress(any())).thenReturn(progress);
when(
() => platform.environment,
).thenReturn({'SHOREBIRD_IOS_NATIVE_AOT_PATCH': '1'});
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
@@ -144,9 +155,11 @@ void main() {
when(() => shorebirdEnv.iosSupplementDirectory).thenReturn(
Directory(p.join(projectRoot.path, 'build', 'shorebird', 'ios')),
);
when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory);
when(() => shorebirdEnv.iosPodfileLockHash).thenReturn(null);
when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => false);
when(() => shorebirdFlutter.getConfig()).thenReturn({});
patcher = IosPatcher(
argParser: argParser,
@@ -841,6 +854,251 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
expect(copiedKernelFile.existsSync(), isTrue);
});
group('when using the interpreter patch route', () {
setUp(() {
when(() => platform.environment).thenReturn({});
File(p.join(projectRoot.path, 'lib', 'main.dart'))
..createSync(recursive: true)
..writeAsStringSync('void main() {}');
File(p.join(projectRoot.path, '.dart_tool', 'package_config.json'))
..createSync(recursive: true)
..writeAsStringSync('{"configVersion":2,"packages":[]}');
File(
p.join(
flutterDirectory.path,
'bin',
'cache',
'flutter.version.json',
),
)
..createSync(recursive: true)
..writeAsStringSync(
jsonEncode({
'frameworkVersion': '3.44.0-0.0.pre',
'channel': '[user-branch]',
'repositoryUrl': 'https://example.com/flutter.git',
'frameworkRevision': '1234567890abcdef',
'engineRevision': 'abcdef1234567890',
'dartSdkVersion': '3.13.0',
}),
);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(
const ShorebirdYaml(
appId: 'app-id',
aotPatchRuntimeMode: 'dart-bytecode-interpreter',
),
);
when(
() => artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: any(named: 'inputFilePath'),
outFilePath: any(named: 'outFilePath'),
packageConfigPath: any(named: 'packageConfigPath'),
dartDefines: any(named: 'dartDefines'),
experiments: any(named: 'experiments'),
),
).thenAnswer(
(invocation) async {
final outFilePath =
invocation.namedArguments[#outFilePath] as String;
return File(outFilePath)
..createSync(recursive: true)
..writeAsStringSync('bytecode');
},
);
});
test('generates a bytecode patch and skips native AOT', () async {
await runWithOverrides(patcher.buildPatchArtifact);
verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
args: any(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: any(named: 'base64PublicKey'),
ddMaxBytes: any(named: 'ddMaxBytes'),
),
).called(1);
verify(
() => artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: p.join(projectRoot.path, 'lib', 'main.dart'),
outFilePath: p.join(
projectRoot.path,
'build',
'ios_interpreter_patch.bytecode',
),
packageConfigPath: p.join(
projectRoot.path,
'.dart_tool',
'package_config.json',
),
dartDefines: any(named: 'dartDefines'),
experiments: any(named: 'experiments'),
),
).called(1);
verifyNever(
() => artifactBuilder.buildElfAotSnapshot(
appDillPath: any(named: 'appDillPath'),
outFilePath: any(named: 'outFilePath'),
genSnapshotArtifact: any(named: 'genSnapshotArtifact'),
additionalArgs: any(named: 'additionalArgs'),
),
);
});
test(
'forwards Dart defines and experiments to bytecode compiler',
() async {
File(p.join(projectRoot.path, 'defines.json')).writeAsStringSync(
'{"FROM_FILE":"yes"}',
);
when(() => argResults.rest).thenReturn([
'ios',
'--dart-define=FROM_ARG=yes',
'--dart-define-from-file=defines.json',
'--enable-experiment=records',
]);
await runWithOverrides(patcher.buildPatchArtifact);
final captured =
verify(
() => artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: any(named: 'inputFilePath'),
outFilePath: any(named: 'outFilePath'),
packageConfigPath: any(named: 'packageConfigPath'),
dartDefines: captureAny(named: 'dartDefines'),
experiments: ['records'],
),
).captured.single
as List<String>;
expect(
captured,
containsAllInOrder([
'FROM_FILE=yes',
'FROM_ARG=yes',
'FLUTTER_VERSION=3.44.0-0.0.pre',
'FLUTTER_CHANNEL=[user-branch]',
'FLUTTER_GIT_URL=https://example.com/flutter.git',
'FLUTTER_FRAMEWORK_REVISION=1234567890',
'FLUTTER_ENGINE_REVISION=abcdef1234',
'FLUTTER_DART_VERSION=3.13.0',
]),
);
},
);
test('adds enabled Flutter runtime feature flags', () async {
when(() => shorebirdFlutter.getConfig()).thenReturn({
'enable-windowing': 'true',
});
when(() => platform.environment).thenReturn({
'FLUTTER_ACCESSIBILITY_EVALUATIONS': 'true',
});
await runWithOverrides(patcher.buildPatchArtifact);
final captured =
verify(
() => artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: any(named: 'inputFilePath'),
outFilePath: any(named: 'outFilePath'),
packageConfigPath: any(named: 'packageConfigPath'),
dartDefines: captureAny(named: 'dartDefines'),
experiments: any(named: 'experiments'),
),
).captured.single
as List<String>;
expect(
captured,
contains(
'FLUTTER_ENABLED_FEATURE_FLAGS='
'windowing,accessibility_evaluations',
),
);
});
test(
'respects project feature config before global and environment',
() async {
when(() => shorebirdEnv.getPubspecYaml()).thenReturn(
Pubspec(
'app',
flutter: {
'config': {
'enable-windowing': false,
'enable-accessibility-evaluations': true,
},
},
),
);
when(() => shorebirdFlutter.getConfig()).thenReturn({
'enable-windowing': 'true',
'enable-accessibility-evaluations': 'false',
});
when(() => platform.environment).thenReturn({
'FLUTTER_WINDOWING': 'true',
'FLUTTER_ACCESSIBILITY_EVALUATIONS': 'false',
});
await runWithOverrides(patcher.buildPatchArtifact);
final captured =
verify(
() => artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: any(named: 'inputFilePath'),
outFilePath: any(named: 'outFilePath'),
packageConfigPath: any(named: 'packageConfigPath'),
dartDefines: captureAny(named: 'dartDefines'),
experiments: any(named: 'experiments'),
),
).captured.single
as List<String>;
expect(
captured,
contains(
'FLUTTER_ENABLED_FEATURE_FLAGS=accessibility_evaluations',
),
);
expect(
captured,
isNot(
contains('FLUTTER_ENABLED_FEATURE_FLAGS=windowing'),
),
);
},
);
test('rejects user-provided Flutter version defines', () async {
when(() => argResults.rest).thenReturn([
'ios',
'--dart-define=FLUTTER_VERSION=custom',
]);
await expectLater(
() => runWithOverrides(patcher.buildPatchArtifact),
exitsWithCode(ExitCode.usage),
);
verify(
() => logger.err(
'FLUTTER_VERSION is used by Flutter and cannot be set using '
'--dart-define or --dart-define-from-file.',
),
).called(1);
verifyNever(
() => artifactBuilder.buildDartBytecodeSnapshot(
inputFilePath: any(named: 'inputFilePath'),
outFilePath: any(named: 'outFilePath'),
packageConfigPath: any(named: 'packageConfigPath'),
dartDefines: any(named: 'dartDefines'),
experiments: any(named: 'experiments'),
),
);
});
});
group('when extraBuildArgs has obfuscation flags', () {
late File obfuscationMapFile;
@@ -857,13 +1115,17 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
});
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',
];
final loadObfuscationMapArg = [
'--extra-gen-snapshot-options=--load-obfuscation-map',
obfuscationMapFile.path,
].join('=');
patcher
..obfuscationMapPath = obfuscationMapFile.path
..extraBuildArgs = [
'--obfuscate',
loadObfuscationMapArg,
'--split-debug-info=build/shorebird/symbols',
];
await runWithOverrides(patcher.buildPatchArtifact);
final captured = verify(
@@ -1069,6 +1331,138 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
});
});
group('when using the interpreter patch route', () {
const keyHex =
'000102030405060708090a0b0c0d0e0f'
'101112131415161718191a1b1c1d1e1f';
const nonceHex = '000102030405060708090a0b';
final patchBytes = utf8.encode('dart-bytecode-payload');
setUp(() {
when(() => platform.environment).thenReturn({
'SHOREBIRD_AOT_PATCH_KEY_HEX': keyHex,
'SHOREBIRD_AOT_PATCH_NONCE_HEX': nonceHex,
});
File(p.join(projectRoot.path, 'build', 'patch.interp'))
..createSync(recursive: true)
..writeAsBytesSync(patchBytes);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(
const ShorebirdYaml(
appId: appId,
aotPatchRuntimeMode: 'dart-bytecode-interpreter',
aotPatchBytecodePath: 'build/patch.interp',
aotPatchKeyId: 'test-key',
aotPatchBaseFlavorId: 'free',
aotPatchBaseLicenseType: 'free',
aotPatchFlavorId: 'pro',
aotPatchLicenseType: 'pro',
aotPatchSdkHash: 'sdk-hash',
aotPatchBaseSnapshotHash: 'base-snapshot-hash',
aotPatchOfflineExpiresAt: '2030-01-01T00:00:00Z',
),
);
});
test('returns an encrypted full-snapshot bytecode artifact', () async {
final patchBundle = await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
releaseVersion: '1.2.3+4',
),
);
final bundle = patchBundle[Arch.arm64]!;
expect(bundle.path, endsWith('ios_interpreter_patch.vmcode'));
final encrypted = open_patch.EncryptedPatchArtifact.fromJson(
(jsonDecode(File(bundle.path).readAsStringSync()) as Map)
.cast<String, Object?>(),
);
expect(encrypted.payloadKind, open_patch.payloadKindFullSnapshot);
expect(
encrypted.metadata.runtimeMode,
open_patch.runtimeModeDartBytecodeInterpreter,
);
expect(encrypted.metadata.appId, appId);
expect(encrypted.metadata.appBuildId, '1.2.3+4');
expect(encrypted.metadata.baseFlavorId, 'free');
expect(encrypted.metadata.baseLicenseType, 'free');
expect(encrypted.metadata.flavorId, 'pro');
expect(encrypted.metadata.licenseType, 'pro');
expect(encrypted.metadata.targetOs, 'ios');
expect(encrypted.metadata.targetArch, 'arm64');
expect(
encrypted.metadata.offlineExpiresAt,
'2030-01-01T00:00:00.000Z',
);
expect(
encrypted.decrypt(open_patch.readKey(keyHex)).payload,
equals(patchBytes),
);
verifyNever(
() => apple.runLinker(
kernelFile: any(named: 'kernelFile'),
aotOutputFile: any(named: 'aotOutputFile'),
releaseArtifact: any(named: 'releaseArtifact'),
splitDebugInfoArgs: any(named: 'splitDebugInfoArgs'),
vmCodeFile: any(named: 'vmCodeFile'),
ddMaxBytes: any(named: 'ddMaxBytes'),
),
);
});
test(
'uses generated bytecode when no override path is configured',
() async {
File(
p.join(
projectRoot.path,
'build',
'ios_interpreter_patch.bytecode',
),
)
..createSync(recursive: true)
..writeAsBytesSync(patchBytes);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(
const ShorebirdYaml(
appId: appId,
aotPatchRuntimeMode: 'dart-bytecode-interpreter',
aotPatchKeyId: 'test-key',
aotPatchBaseFlavorId: 'free',
aotPatchBaseLicenseType: 'free',
aotPatchFlavorId: 'pro',
aotPatchLicenseType: 'pro',
aotPatchSdkHash: 'sdk-hash',
aotPatchBaseSnapshotHash: 'base-snapshot-hash',
),
);
final patchBundle = await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
releaseVersion: '1.2.3+4',
),
);
final encrypted = open_patch.EncryptedPatchArtifact.fromJson(
(jsonDecode(
File(patchBundle[Arch.arm64]!.path).readAsStringSync(),
)
as Map)
.cast<String, Object?>(),
);
expect(
encrypted.decrypt(open_patch.readKey(keyHex)).payload,
equals(patchBytes),
);
},
);
});
group('when uses linker', () {
const linkPercentage = 50.0;
late File analyzeSnapshotFile;
@@ -815,7 +815,7 @@ dependencyResolutionManagement {
+ }
+ maven {
- url 'https://storage.googleapis.com/download.flutter.io'
+ url 'https://download.shorebird.dev/download.flutter.io'
+ url '${ShorebirdProcess.defaultFlutterStorageBaseUrl}'
+ }
}
}
@@ -931,7 +931,7 @@ To change the version of this release, change your app's version in your pubspec
expect(
runWithOverrides(() => releaser.postReleaseInstructions),
contains(
'https://github.com/shorebirdtech/shorebird/issues/3223',
'git.tonycloud.org/flutter/shorebird/src/branch/main/docs/code-push/troubleshooting',
),
);
});
@@ -105,6 +105,47 @@ patch_verification: install_only
expect(shorebirdYaml.patchVerification, PatchVerification.installOnly);
});
test('can be deserialized with open AOT patch metadata', () {
const yaml = '''
app_id: test_app_id
aot_patch_runtime_mode: dart-bytecode-interpreter
aot_patch_bytecode_path: build/patch.interp
aot_patch_key_id: test-key
aot_patch_key_hex: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
aot_patch_app_build_id: 1.2.3+4
aot_patch_base_flavor_id: free
aot_patch_base_license_type: free
aot_patch_flavor_id: pro
aot_patch_license_type: pro
aot_patch_sdk_hash: sdk-hash
aot_patch_base_snapshot_hash: base-hash
aot_patch_obfuscation_map_hash: obfuscation-hash
aot_patch_offline_expires_at: 2030-01-01T00:00:00Z
''';
final shorebirdYaml = checkedYamlDecode(
yaml,
(m) => ShorebirdYaml.fromJson(m!),
);
expect(
shorebirdYaml.aotPatchRuntimeMode,
'dart-bytecode-interpreter',
);
expect(shorebirdYaml.aotPatchBytecodePath, 'build/patch.interp');
expect(shorebirdYaml.aotPatchKeyId, 'test-key');
expect(shorebirdYaml.aotPatchAppBuildId, '1.2.3+4');
expect(shorebirdYaml.aotPatchBaseFlavorId, 'free');
expect(shorebirdYaml.aotPatchBaseLicenseType, 'free');
expect(shorebirdYaml.aotPatchFlavorId, 'pro');
expect(shorebirdYaml.aotPatchLicenseType, 'pro');
expect(shorebirdYaml.aotPatchSdkHash, 'sdk-hash');
expect(shorebirdYaml.aotPatchBaseSnapshotHash, 'base-hash');
expect(shorebirdYaml.aotPatchObfuscationMapHash, 'obfuscation-hash');
expect(
shorebirdYaml.aotPatchOfflineExpiresAt,
'2030-01-01T00:00:00Z',
);
});
test('throws when patch_verification has invalid value', () {
const yaml = '''
app_id: test_app_id
@@ -11,6 +11,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/network_checker.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:test/test.dart';
import 'fakes.dart';
@@ -23,6 +24,7 @@ void main() {
late http.Client httpClient;
late ShorebirdLogger logger;
late Progress progress;
late ShorebirdEnv shorebirdEnv;
late NetworkChecker networkChecker;
R runWithOverrides<R>(R Function() body) {
@@ -33,6 +35,7 @@ void main() {
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
httpClientRef.overrideWith(() => httpClient),
loggerRef.overrideWith(() => logger),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
},
);
}
@@ -48,8 +51,15 @@ void main() {
httpClient = MockHttpClient();
logger = MockShorebirdLogger();
progress = MockProgress();
shorebirdEnv = MockShorebirdEnv();
when(() => logger.progress(any())).thenReturn(progress);
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse(ShorebirdEnv.defaultHostedUrl));
when(
() => shorebirdEnv.authServiceUri,
).thenReturn(Uri.parse(ShorebirdEnv.defaultAuthServiceUrl));
networkChecker = NetworkChecker();
});
@@ -63,11 +73,26 @@ void main() {
});
test('logs reachability for each checked url', () async {
await runWithOverrides(networkChecker.checkReachability);
final checkedUrlCount = await runWithOverrides(() async {
await networkChecker.checkReachability();
return NetworkChecker.urlsToCheck.length;
});
verify(
() => progress.complete(any(that: contains('OK'))),
).called(NetworkChecker.urlsToCheck.length);
).called(checkedUrlCount);
});
test('checks self-hosted urls from ShorebirdEnv', () async {
final hostedUri = Uri.parse('https://open.example.com');
final authServiceUri = Uri.parse('https://auth.example.com');
when(() => shorebirdEnv.hostedUri).thenReturn(hostedUri);
when(() => shorebirdEnv.authServiceUri).thenReturn(authServiceUri);
await runWithOverrides(networkChecker.checkReachability);
verify(() => httpClient.get(hostedUri)).called(1);
verify(() => httpClient.get(authServiceUri)).called(1);
});
});
@@ -77,11 +102,14 @@ void main() {
});
test('logs reachability for each checked url', () async {
await runWithOverrides(networkChecker.checkReachability);
final checkedUrlCount = await runWithOverrides(() async {
await networkChecker.checkReachability();
return NetworkChecker.urlsToCheck.length;
});
verify(
() => progress.fail(any(that: contains('unreachable'))),
).called(NetworkChecker.urlsToCheck.length);
).called(checkedUrlCount);
});
});
});
@@ -209,12 +209,99 @@ void main() {
),
);
});
test('returns correct path for dartaotruntime', () {
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.dartAotRuntime,
),
),
equals(
p.join(
flutterDirectory.path,
'bin',
'cache',
'dart-sdk',
'bin',
'dartaotruntime',
),
),
);
});
test('returns correct path for dart2bytecode snapshot', () {
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.dart2BytecodeSnapshot,
),
),
equals(
p.join(
flutterDirectory.path,
'bin',
'cache',
'dart-sdk',
'bin',
'snapshots',
'dart2bytecode.dart.snapshot',
),
),
);
});
test('returns correct path for Flutter product platform dill', () {
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.flutterProductPlatformDill,
),
),
equals(
p.join(
flutterDirectory.path,
'bin',
'cache',
'artifacts',
'engine',
'common',
'flutter_patched_sdk_product',
'platform_strong.dill',
),
),
);
});
test('prefers workspace iOS Flutter platform dill when present', () {
final workspacePlatformDill = File(
p.join(
flutterDirectory.path,
'engine',
'src',
'out',
'ios_release',
'flutter_patched_sdk',
'platform_strong.dill',
),
)..createSync(recursive: true);
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.flutterProductPlatformDill,
),
),
equals(workspacePlatformDill.path),
);
});
});
});
group(ShorebirdLocalEngineArtifacts, () {
late String localEngineSrcPath;
late String localEngine;
late String localEngineHost;
late EngineConfig engineConfig;
late ShorebirdLocalEngineArtifacts artifacts;
@@ -228,6 +315,7 @@ void main() {
setUp(() {
localEngineSrcPath = 'local_engine_src_path';
localEngine = 'local_engine';
localEngineHost = 'local_engine_host';
engineConfig = MockEngineConfig();
artifacts = const ShorebirdLocalEngineArtifacts();
@@ -235,6 +323,7 @@ void main() {
() => engineConfig.localEngineSrcPath,
).thenReturn(localEngineSrcPath);
when(() => engineConfig.localEngine).thenReturn(localEngine);
when(() => engineConfig.localEngineHost).thenReturn(localEngineHost);
});
group('getArtifactPath', () {
@@ -353,6 +442,64 @@ void main() {
),
);
});
test('returns correct path for dartaotruntime', () {
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.dartAotRuntime,
),
),
equals(
p.join(
localEngineSrcPath,
'out',
localEngineHost,
'dartaotruntime',
),
),
);
});
test('returns correct path for dart2bytecode snapshot', () {
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.dart2BytecodeSnapshot,
),
),
equals(
p.join(
localEngineSrcPath,
'out',
localEngineHost,
'dart-sdk',
'bin',
'snapshots',
'dart2bytecode.dart.snapshot',
),
),
);
});
test('returns correct path for Flutter product platform dill', () {
expect(
runWithOverrides(
() => artifacts.getArtifactPath(
artifact: ShorebirdArtifact.flutterProductPlatformDill,
),
),
equals(
p.join(
localEngineSrcPath,
'out',
localEngine,
'flutter_patched_sdk',
'platform_strong.dill',
),
),
);
});
});
});
}
@@ -226,7 +226,7 @@ ${lightCyan.wrap("shorebird release android '--' --no-pub lib/main.dart")}'''),
verify(
() => logger.info('''
Shorebird $packageVersion git@github.com:shorebirdtech/shorebird.git
Shorebird $packageVersion https://git.tonycloud.org/flutter/shorebird.git
Flutter $flutterVersion revision $flutterRevision
Engine revision $shorebirdEngineRevision'''),
).called(1);
@@ -93,6 +93,27 @@ void main() {
});
});
group('shorebirdRoot', () {
test('returns correct directory for legacy snapshot layout', () {
expect(
runWithOverrides(() => shorebirdEnv.shorebirdRoot.path),
equals(shorebirdRoot.path),
);
});
test('returns correct directory for compiled executable layout', () {
platformScript = Uri.file(
p.join(shorebirdRoot.path, 'bin', 'shorebird'),
);
when(() => platform.script).thenReturn(platformScript);
expect(
runWithOverrides(() => shorebirdEnv.shorebirdRoot.path),
equals(shorebirdRoot.path),
);
});
});
group('getShorebirdYamlFile', () {
test('returns correct file', () {
final tempDir = Directory.systemTemp.createTempSync();
@@ -775,6 +796,18 @@ dependencies:
equals(engineRevision),
);
});
test('falls back to packaged engine revision metadata', () {
const engineRevision = 'test-revision';
File(p.join(shorebirdRoot.path, 'bin', 'internal', 'engine.version'))
..createSync(recursive: true)
..writeAsStringSync(engineRevision, flush: true);
expect(
runWithOverrides(() => shorebirdEnv.shorebirdEngineRevision),
equals(engineRevision),
);
});
});
group('hostedUrl', () {
@@ -843,11 +876,14 @@ base_url: https://yaml.example.com''');
);
});
test('returns null when there is no env override or shorebird.yaml', () {
expect(runWithOverrides(() => shorebirdEnv.hostedUri), isNull);
test('defaults to the open self-hosted server URL', () {
expect(
runWithOverrides(() => shorebirdEnv.hostedUri),
equals(Uri.parse(ShorebirdEnv.defaultHostedUrl)),
);
});
test('returns null when unable to read shorebird.yaml', () {
test('falls back to default when unable to read shorebird.yaml', () {
final directory = Directory.systemTemp.createTempSync();
// This is not valid utf8 so readAsString will throw.
File(
@@ -859,7 +895,7 @@ base_url: https://yaml.example.com''');
() => runWithOverrides(() => shorebirdEnv.hostedUri),
getCurrentDirectory: () => directory,
),
isNull,
equals(Uri.parse(ShorebirdEnv.defaultHostedUrl)),
);
});
});
@@ -931,7 +967,7 @@ base_url: https://yaml.example.com''');
when(() => platform.environment).thenReturn({});
expect(
runWithOverrides(() => shorebirdEnv.authServiceUri),
equals(Uri.parse('https://auth.shorebird.dev')),
equals(Uri.parse(ShorebirdEnv.defaultAuthServiceUrl)),
);
});
@@ -951,7 +987,7 @@ base_url: https://yaml.example.com''');
when(() => platform.environment).thenReturn({});
expect(
runWithOverrides(() => shorebirdEnv.jwtIssuer),
equals('https://auth.shorebird.dev'),
equals(ShorebirdEnv.defaultJwtIssuer),
);
});
@@ -96,6 +96,7 @@ void main() {
),
).thenAnswer((_) async => 'origin/flutter_release/3.10.6');
when(() => logger.progress(any())).thenReturn(progress);
when(() => platform.environment).thenReturn(const {});
when(() => platform.isMacOS).thenReturn(false);
when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory);
when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision);
@@ -116,6 +117,27 @@ void main() {
when(() => precacheProcessResult.stderr).thenReturn('');
});
group('flutterGitUrl', () {
test('defaults to the open Flutter fork', () {
expect(
runWithOverrides(() => shorebirdFlutter.flutterGitUrl),
ShorebirdFlutter.defaultFlutterGitUrl,
);
});
test('uses SHOREBIRD_FLUTTER_GIT_URL when set', () {
const flutterGitUrl = 'https://example.com/open/flutter.git';
when(
() => platform.environment,
).thenReturn(const {'SHOREBIRD_FLUTTER_GIT_URL': flutterGitUrl});
expect(
runWithOverrides(() => shorebirdFlutter.flutterGitUrl),
flutterGitUrl,
);
});
});
group('precacheArgs', () {
group('when running on macOS', () {
setUp(() {
@@ -935,7 +957,7 @@ origin/flutter_release/3.10.6''';
verify(
() => git.clone(
url: ShorebirdFlutter.flutterGitUrl,
url: ShorebirdFlutter.defaultFlutterGitUrl,
outputDirectory: p.join(flutterDirectory.parent.path, revision),
args: ['--filter=tree:0', '--no-checkout'],
),
@@ -962,7 +984,7 @@ origin/flutter_release/3.10.6''';
);
verify(
() => git.clone(
url: ShorebirdFlutter.flutterGitUrl,
url: ShorebirdFlutter.defaultFlutterGitUrl,
outputDirectory: p.join(flutterDirectory.parent.path, revision),
args: ['--filter=tree:0', '--no-checkout'],
),
@@ -18,7 +18,7 @@ import 'mocks.dart';
void main() {
group('ShorebirdProcess', () {
const flutterStorageBaseUrlEnv = {
'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev',
'FLUTTER_STORAGE_BASE_URL': ShorebirdProcess.defaultFlutterStorageBaseUrl,
};
late EngineConfig engineConfig;
@@ -64,6 +64,7 @@ void main() {
when(() => logger.level).thenReturn(Level.info);
when(() => platform.environment).thenReturn({});
when(() => platform.isWindows).thenReturn(false);
});
@@ -156,6 +157,31 @@ void main() {
).called(1);
});
test('can use an open Flutter storage mirror', () async {
when(() => platform.environment).thenReturn({
'SHOREBIRD_FLUTTER_STORAGE_BASE_URL':
'https://artifacts.example.com/flutter',
});
await runWithOverrides(
() => shorebirdProcess.run('flutter', [
'--version',
], workingDirectory: '~'),
);
verify(
() => processWrapper.run(
any(),
['--version'],
environment: {
'FLUTTER_STORAGE_BASE_URL':
'https://artifacts.example.com/flutter',
},
workingDirectory: '~',
),
).called(1);
});
test('does not replace flutter with our local flutter if'
' useVendedFlutter is false', () async {
await runWithOverrides(
@@ -42,6 +42,10 @@ void main() {
shorebirdEnv = MockShorebirdEnv();
validator = MockValidator();
shorebirdValidator = runWithOverrides(ShorebirdValidator.new);
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse(ShorebirdEnv.defaultHostedUrl));
});
group('PreconditionFailedException', () {
@@ -93,7 +97,7 @@ void main() {
'''If you already have an account, run ${lightCyan.wrap('shorebird login')} to sign in.''',
),
() => logger.info(
'''If you don't have a Shorebird account, go to ${link(uri: Uri.parse('https://console.shorebird.dev'))} to create one.''',
'''If you don't have a Shorebird account, go to ${link(uri: Uri.parse(ShorebirdEnv.defaultHostedUrl))} to create one.''',
),
]);
});
@@ -1,19 +1,53 @@
import 'package:mocktail/mocktail.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_web_console.dart';
import 'package:test/test.dart';
import 'mocks.dart';
void main() {
group(ShorebirdWebConsole, () {
late ShorebirdEnv shorebirdEnv;
R runWithOverrides<R>(R Function() body) {
return runScoped(
() => body(),
values: {shorebirdEnvRef.overrideWith(() => shorebirdEnv)},
);
}
setUp(() {
shorebirdEnv = MockShorebirdEnv();
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse(ShorebirdEnv.defaultHostedUrl));
});
test('uri returns the correct uri with the received path', () {
expect(
ShorebirdWebConsole.uri('path'),
Uri.parse('https://console.shorebird.dev/path'),
runWithOverrides(() => ShorebirdWebConsole.uri('path')),
Uri.parse('${ShorebirdEnv.defaultHostedUrl}/path'),
);
});
test('appReleaseUri returns the correct uri to an app release', () {
expect(
ShorebirdWebConsole.appReleaseUri('appId', 123),
Uri.parse('https://console.shorebird.dev/apps/appId/releases/123'),
runWithOverrides(
() => ShorebirdWebConsole.appReleaseUri('appId', 123),
),
Uri.parse('${ShorebirdEnv.defaultHostedUrl}/apps/appId/releases/123'),
);
});
test('uses the configured hosted url', () {
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse('https://open.example.com/base'));
expect(
runWithOverrides(() => ShorebirdWebConsole.uri('path')),
Uri.parse('https://open.example.com/base/path'),
);
});
});
@@ -95,7 +95,10 @@ class CodePushClient {
...standardHeaders,
...?customHeaders,
}),
hostedUri = hostedUri ?? Uri.https('api.shorebird.dev');
hostedUri = hostedUri ?? defaultHostedUri;
/// Default URL for the open self-hosted Shorebird server.
static final Uri defaultHostedUri = Uri.parse('http://localhost:8080');
/// The standard headers applied to all requests.
static const standardHeaders = <String, String>{'x-version': packageVersion};
@@ -1,7 +1,7 @@
name: shorebird_code_push_client
description: Library which allows Dart applications to interact with the Shorebird CodePush API
version: 0.9.0+1
repository: https://github.com/shorebirdtech/shorebird
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
publish_to: none
@@ -57,6 +57,10 @@ void main() {
expect(CodePushClient(), isNotNull);
});
test('defaults to the open self-hosted server URL', () {
expect(CodePushClient().hostedUri, CodePushClient.defaultHostedUri);
});
group('CodePushException', () {
test('toString is correct', () {
const exceptionWithDetails = CodePushException(
@@ -4,16 +4,17 @@ The Shorebird CodePush Protocol is a Dart library which contains common interfac
### Regenerating from the OpenAPI spec
Everything under `lib/src/` is generated from the public Shorebird
CodePush OpenAPI spec at [api.shorebird.dev/openapi.json](https://api.shorebird.dev/openapi.json)
(also served as [openapi.yaml](https://api.shorebird.dev/openapi.yaml)
for easier human review) by
[space_gen](https://github.com/eseidel/space_gen). To regenerate
against the latest published spec:
Everything under `lib/src/` is generated from an OpenAPI spec by
[space_gen](https://github.com/eseidel/space_gen). For this open workspace,
regenerate from the checked open spec at
`../shorebird-server/internal/api/handlers/openapi.yaml` or from the
self-hosted server's `http://localhost:8080/openapi.yaml` endpoint. Do not
regenerate from Shorebird's hosted API, or the generated protocol may drift
back toward closed service behavior.
```sh
dart run packages/shorebird_code_push_protocol/tool/gen.dart \
-i https://api.shorebird.dev/openapi.json \
-i http://localhost:8080/openapi.yaml \
-o packages/shorebird_code_push_protocol
```
@@ -1,7 +1,7 @@
name: shorebird_code_push_protocol
description: Library which contains common interfaces used by Shorebird CodePush
version: 0.1.0+1
repository: https://github.com/shorebirdtech/shorebird
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
publish_to: none
@@ -9,9 +9,13 @@
//
// Usage (from the repo root):
// dart run packages/shorebird_code_push_protocol/tool/gen.dart \
// -i https://api.shorebird.dev/openapi.json \
// -i http://localhost:8080/openapi.yaml \
// -o packages/shorebird_code_push_protocol
//
// Do not regenerate this package from Shorebird's hosted API. Use the checked
// open spec at ../shorebird-server/internal/api/handlers/openapi.yaml or the
// self-hosted server's OpenAPI endpoint.
//
// The generator does not touch `lib/extensions/` or
// `lib/shorebird_code_push_protocol.dart`; those are hand-written and
// re-export the generated types.