feat(open_aot_patch_tools): add offline expiry handling and user configuration for hosted URL
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
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

This commit is contained in:
Tony
2026-06-24 03:01:28 +08:00
parent b1d17f0339
commit c824005c2d
14 changed files with 590 additions and 8 deletions
+98
View File
@@ -0,0 +1,98 @@
# Open Source Replacement Audit
Last verified: 2026-06-22.
This workspace is intended to build and operate without depending on
closed-source Shorebird components. The audit below distinguishes public
upstream Shorebird code from hosted Shorebird services that still need local
open replacements.
## Verified Public Shorebird Components
The public GitHub organization at `https://github.com/shorebirdtech` lists the
following relevant repositories:
- `shorebirdtech/shorebird`: public CLI, CodePush client, protocol models,
artifact proxy, and related tooling.
- `shorebirdtech/updater`: public Rust updater library and patch tooling. The
repository describes `library` as the runtime library linked into Flutter
Engine and `patch` as the developer patch packaging tool.
- `shorebirdtech/flutter`: public Flutter fork.
- `shorebirdtech/engine` and `shorebirdtech/buildroot`: public archived engine
build repositories.
Conclusion: the runtime updater is not a closed-source component. The correct
open-source path is to use the public `shorebirdtech/updater` repository at the
engine location expected by the GN build:
```text
../flutter/engine/src/flutter/third_party/updater
```
Run from this directory:
```powershell
.\sync_open_sources.ps1
```
or manually from the workspace root:
```powershell
git clone https://github.com/shorebirdtech/updater.git `
flutter\engine\src\flutter\third_party\updater
```
## Components Replaced In This Checkout
| Shorebird surface | Public upstream status | Open replacement in this checkout |
| --- | --- | --- |
| Runtime updater linked into Flutter engine | Public: `shorebirdtech/updater` | Use public updater via `sync_open_sources.ps1`; engine wrapper remains at `../flutter/engine/src/flutter/shell/common/shorebird` |
| Patch artifact generator | Public upstream has `shorebirdtech/updater/patch`; this checkout also needs compact encrypted AOT tooling | `packages/open_aot_patch_tools` |
| Hosted CodePush API (`api.shorebird.dev`) | Hosted service, no public server implementation found in the public org audit | `../shorebird-server` |
| Hosted auth service (`auth.shorebird.dev`) | Hosted service | `../shorebird-server` `/auth/*` endpoints |
| Hosted web console (`console.shorebird.dev`) | Hosted service | `../shorebird-server/web` dashboard |
| Artifact proxy for Flutter artifacts | Public in `shorebirdtech/shorebird` | `packages/artifact_proxy` |
| CLI and CodePush protocol/client | Public in `shorebirdtech/shorebird` | `packages/shorebird_cli`, `shorebird_code_push_client`, and `shorebird_code_push_protocol` |
## Self-Hosted Operation
Start the open server:
```powershell
cd ..\shorebird-server
go run ./cmd/server
```
Point the CLI at it:
```powershell
$env:SHOREBIRD_HOSTED_URL = "http://localhost:8080"
$env:AUTH_SERVICE_URL = "http://localhost:8080/auth"
$env:SHOREBIRD_TOKEN = "<jwt-token-from-local-server>"
```
Point devices at it through `shorebird.yaml`:
```yaml
app_id: <app-uuid>
base_url: http://localhost:8080
auto_update: true
```
The local server implements the Shorebird-compatible management and device
patch-check surfaces, including limited offline-license expiry metadata.
## Security Boundary
The public updater, local server, and open patch tools must still enforce:
- patch metadata compatibility: app id, release/build id, platform, arch,
SDK hash, base snapshot hash, flavor id, and license type
- cryptographic integrity: hash pinning or signing
- AES-GCM delivery confidentiality and tamper detection
- limited offline-license expiry through `offline_expires_at`
Do not replace public upstream source with a new implementation unless the
public repository becomes unavailable or its license changes. Replacing a
public runtime updater with an incompatible clone increases risk and makes iOS
and Android behavior diverge from the engine integration expected by Shorebird.
@@ -45,6 +45,7 @@ void _link(Map<String, String> options) {
targetOs: _required(options, 'target-os'),
targetArch: _required(options, 'target-arch'),
obfuscationMapHash: options['obfuscation-map-hash'],
offlineExpiresAt: _optionalIso8601Utc(options['offline-expires-at']),
);
final artifact = linkArtifacts(
baseSnapshot: base,
@@ -85,6 +86,8 @@ void _verify(Map<String, String> options) {
baseLicenseType: options['base-license-type'],
flavorId: _required(options, 'flavor-id'),
licenseType: _required(options, 'license-type'),
now: _optionalDateTime(options['now']),
allowExpired: _boolOption(options, 'allow-expired'),
);
final decrypted = encrypted.decrypt(readKey(_required(options, 'key-hex')));
final basePath = options['base'];
@@ -129,6 +132,7 @@ void _dumpBlobs(Map<String, String> options) {
final format = json['format'];
if (format == artifactFormat) {
final artifact = PatchArtifact.fromJson(json);
_checkOfflineExpiry(artifact.metadata, options);
final reconstructed = _maybeReconstruct(
artifact,
basePath: options['base'],
@@ -167,6 +171,7 @@ void _dumpBlobs(Map<String, String> options) {
final keyHex = options['key-hex'];
if (keyHex != null) {
final decrypted = encrypted.decrypt(readKey(keyHex));
_checkOfflineExpiry(encrypted.metadata, options);
final reconstructed = _maybeReconstruct(
decrypted,
basePath: options['base'],
@@ -190,7 +195,10 @@ void _dumpBlobs(Map<String, String> options) {
} else {
throw const FormatException('not an open AOT patch artifact');
}
} on Object {
} on FormatException {
if (options['key-hex'] != null || options['output'] != null) {
rethrow;
}
report = {
'path': input.path,
'format': 'raw',
@@ -201,6 +209,20 @@ void _dumpBlobs(Map<String, String> options) {
stdout.writeln(const JsonEncoder.withIndent(' ').convert(report));
}
void _checkOfflineExpiry(
PatchMetadata metadata,
Map<String, String> options,
) {
if (_boolOption(options, 'allow-expired')) {
return;
}
if (metadata.isOfflineExpired(now: _optionalDateTime(options['now']))) {
throw StateError(
'Patch offline license expired at ${metadata.offlineExpiresAt}.',
);
}
}
List<int>? _maybeReconstruct(
PatchArtifact artifact, {
required String? basePath,
@@ -312,13 +334,27 @@ bool _boolOption(
return value == 'true' || value == '1' || value == 'yes';
}
DateTime? _optionalDateTime(String? value) {
if (value == null || value.isEmpty) return null;
final parsed = DateTime.tryParse(value);
if (parsed == null) {
throw ArgumentError('Expected ISO-8601 timestamp, got $value');
}
return parsed.toUtc();
}
String? _optionalIso8601Utc(String? value) {
final parsed = _optionalDateTime(value);
return parsed?.toIso8601String();
}
void _usage() {
stdout.writeln('''
Usage:
open_aot_patch_tools dump-blobs --input=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>]
open_aot_patch_tools dump-blobs --input=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>] [--now=<iso8601>] [--allow-expired=true]
open_aot_patch_tools compile-patch --gen-snapshot=<path> --kernel=<dill> --output=<file> [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=<file>] [--obfuscate=true] [--load-obfuscation-map=<file>] [--save-obfuscation-map=<file>]
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--full-snapshot=true]
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--offline-expires-at=<iso8601>] [--full-snapshot=true]
open_aot_patch_tools encrypt --input=<file> --output=<file> --key-id=<id> --key-hex=<64 hex chars> --nonce-hex=<24 hex chars>
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>]
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>] [--now=<iso8601>] [--allow-expired=true]
''');
}
@@ -3,6 +3,12 @@
This package produces compact, flavor-aware AOT patch artifacts without relying
on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter.
The public-source boundary is documented in
`../../../OPEN_SOURCE_REPLACEMENTS.md`. The Shorebird runtime updater itself is
public at `shorebirdtech/updater`; the closed replacement work in this checkout
targets the hosted management/auth/console services and the independent compact
AOT patch generation flow.
## Runtime Model
Patches are AOT snapshot artifacts. The engine selects patched isolate snapshot
@@ -37,6 +43,20 @@ specific transition such as `free -> pro` rather than a generic pro artifact.
delivery artifact, which should be used alongside transport security or a
separate signing layer.
Limited offline licenses should set `offline_expires_at` in the patch metadata.
That timestamp is part of the AES-GCM authenticated metadata. Devices must
refuse to install or reconstruct an expired patch, and an already-installed
patch must be removed at startup once the timestamp passes unless the server
has already supplied a newer valid patch.
The management server remains Shorebird-compatible by keeping the standard
`patch_available`, `patch`, and `rolled_back_patch_numbers` response shape.
When an installed limited-offline patch has expired and no newer valid patch is
available, the server returns `patch_available: false` and includes that patch
number in `rolled_back_patch_numbers`. The open updater can also read the
optional `remove_patch` object, which contains the same patch number,
`reason: "offline_expired"`, and `offline_expires_at`.
## Compact Payloads
By default, `link` chooses the smallest v1 payload representation:
@@ -102,6 +122,7 @@ dart run open_aot_patch_tools link \
--target-os=ios \
--target-arch=arm64 \
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
--offline-expires-at=2030-01-01T00:00:00Z \
--full-snapshot=true
```
@@ -36,6 +36,7 @@ class PatchMetadata {
this.baseFlavorId,
this.baseLicenseType,
this.obfuscationMapHash,
this.offlineExpiresAt,
});
/// Reads metadata from the JSON representation used by patch artifacts.
@@ -52,6 +53,7 @@ class PatchMetadata {
targetOs: _string(json, 'target_os'),
targetArch: _string(json, 'target_arch'),
obfuscationMapHash: json['obfuscation_map_hash'] as String?,
offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'),
);
/// Stable application identifier.
@@ -90,6 +92,23 @@ class PatchMetadata {
/// Optional hash of the obfuscation map used by base and patch builds.
final String? obfuscationMapHash;
/// Optional UTC timestamp after which an installed offline patch must be
/// removed if no newer patch is available.
final String? offlineExpiresAt;
/// Parsed [offlineExpiresAt], or null when this patch has no offline limit.
DateTime? get offlineExpiresAtDateTime => offlineExpiresAt == null
? null
: DateTime.parse(offlineExpiresAt!).toUtc();
/// Returns true when the offline validity window has expired.
bool isOfflineExpired({DateTime? now}) {
final expiresAt = offlineExpiresAtDateTime;
if (expiresAt == null) return false;
final checkedAt = (now ?? DateTime.now().toUtc()).toUtc();
return !checkedAt.isBefore(expiresAt);
}
/// Converts this metadata to the stable JSON map used as AES-GCM AAD.
Map<String, Object?> toJson() => {
'app_id': appId,
@@ -104,6 +123,7 @@ class PatchMetadata {
'target_os': targetOs,
'target_arch': targetArch,
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt,
};
}
@@ -368,6 +388,8 @@ void verifyMetadata(
required String licenseType,
String? baseFlavorId,
String? baseLicenseType,
DateTime? now,
bool allowExpired = false,
}) {
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
throw StateError(
@@ -391,8 +413,20 @@ void verifyMetadata(
'Patch license "${metadata.licenseType}" does not match "$licenseType".',
);
}
if (!allowExpired && metadata.isOfflineExpired(now: now)) {
throw StateError(
'Patch offline license expired at ${metadata.offlineExpiresAt}.',
);
}
}
/// Returns true when a locally installed patch must be removed before startup
/// because its offline license validity has expired.
bool shouldRemoveInstalledPatchOffline(
PatchMetadata metadata, {
DateTime? now,
}) => metadata.isOfflineExpired(now: now);
/// Reconstructs final patch snapshot bytes from a compact payload.
List<int> reconstructPatchSnapshot({
required List<int> baseSnapshot,
@@ -478,6 +512,23 @@ String _string(Map<String, Object?> json, String key) {
return value;
}
String? _optionalIso8601UtcString(Map<String, Object?> json, String key) {
final value = json[key];
if (value == null) return null;
if (value is! String || value.isEmpty) {
throw FormatException('Expected non-empty string field "$key".');
}
return _normalizeIso8601Utc(value, key);
}
String _normalizeIso8601Utc(String value, String name) {
final parsed = DateTime.tryParse(value);
if (parsed == null) {
throw FormatException('Expected ISO-8601 UTC timestamp for "$name".');
}
return parsed.toUtc().toIso8601String();
}
_CompactPayload _compactPayload(
List<int> baseSnapshot,
List<int> patchSnapshot,
@@ -75,7 +75,7 @@ void main() {
];
const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
PatchMetadata metadata() => const PatchMetadata(
PatchMetadata metadata({String? offlineExpiresAt}) => PatchMetadata(
appId: 'app.test',
appBuildId: '1.0.0+1',
baseFlavorId: 'free',
@@ -88,6 +88,7 @@ void main() {
targetOs: 'windows',
targetArch: 'x64',
obfuscationMapHash: 'obfuscated',
offlineExpiresAt: offlineExpiresAt,
);
test('encrypts and decrypts flavor-aware patch payloads', () {
@@ -180,6 +181,51 @@ void main() {
);
});
test('offline expiry is authenticated metadata and enforced', () {
final expiresAt = DateTime.utc(2030).toIso8601String();
final expiredAt = DateTime.utc(2031);
final artifact = linkArtifacts(
baseSnapshot: utf8.encode('free'),
patchSnapshot: utf8.encode('pro'),
metadata: metadata(offlineExpiresAt: expiresAt),
);
final encrypted = encryptArtifact(
artifact: artifact,
keyId: 'test-key',
key: key,
nonce: nonce,
);
expect(encrypted.metadata.toJson()['offline_expires_at'], expiresAt);
expect(
shouldRemoveInstalledPatchOffline(
encrypted.metadata,
now: DateTime.utc(2029),
),
isFalse,
);
expect(
shouldRemoveInstalledPatchOffline(encrypted.metadata, now: expiredAt),
isTrue,
);
expect(
() => verifyMetadata(
encrypted.metadata,
flavorId: 'pro',
licenseType: 'pro',
now: expiredAt,
),
throwsStateError,
);
verifyMetadata(
encrypted.metadata,
flavorId: 'pro',
licenseType: 'pro',
now: expiredAt,
allowExpired: true,
);
});
test('artifact json round trips', () {
final base = utf8.encode('free');
final patch = utf8.encode('pro');
@@ -288,9 +334,14 @@ void main() {
'--target-os=windows',
'--target-arch=x64',
'--full-snapshot=true',
'--offline-expires-at=2030-01-01T00:00:00Z',
]);
final fullArtifactJson = readJsonFile(artifactFile);
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
expect(
(fullArtifactJson['metadata'] as Map)['offline_expires_at'],
'2030-01-01T00:00:00.000Z',
);
await _runTool([
'encrypt',
@@ -311,6 +362,19 @@ void main() {
'--flavor-id=pro',
'--license-type=pro',
'--base=${baseFile.path}',
'--now=2029-01-01T00:00:00Z',
]);
await _runToolExpectFailure([
'verify',
'--input=${encryptedFile.path}',
'--key-hex=$keyHex',
'--base-flavor-id=free',
'--base-license-type=free',
'--flavor-id=pro',
'--license-type=pro',
'--base=${baseFile.path}',
'--now=2031-01-01T00:00:00Z',
]);
await _runToolExpectFailure([
@@ -331,6 +395,7 @@ void main() {
'--key-hex=$keyHex',
'--base=${baseFile.path}',
'--output=${reconstructedFile.path}',
'--now=2029-01-01T00:00:00Z',
]);
final dumpJson = (jsonDecode(dump.stdout as String) as Map)
.cast<String, Object?>();
@@ -340,6 +405,15 @@ void main() {
);
expect(dumpJson['reconstructed_patch_sha256'], sha256Hex(patchBytes));
expect(reconstructedFile.readAsBytesSync(), patchBytes);
await _runToolExpectFailure([
'dump-blobs',
'--input=${encryptedFile.path}',
'--key-hex=$keyHex',
'--base=${baseFile.path}',
'--output=${reconstructedFile.path}',
'--now=2031-01-01T00:00:00Z',
]);
} finally {
tempDir.deleteSync(recursive: true);
}
@@ -177,6 +177,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
final shorebirdYaml = shorebirdEnv.getShorebirdYaml();
final existingFlavors = shorebirdYaml?.flavors;
final hostedBaseUrl = shorebirdEnv.hostedUri?.toString();
Set<String> newFlavors;
if (existingFlavors != null) {
final existingFlavorNames = existingFlavors.keys.toSet();
@@ -224,6 +225,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
projectRoot: projectRoot,
appId: shorebirdYaml.appId,
flavors: flavorsToAppIds,
baseUrl: shorebirdYaml.baseUrl ?? hostedBaseUrl,
);
updateShorebirdYamlProgress.complete('Flavors added to shorebird.yaml');
return ExitCode.success.code;
@@ -315,6 +317,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
projectRoot: projectRoot,
appId: appId,
flavors: flavors,
baseUrl: hostedBaseUrl,
);
if (!shorebirdEnv.pubspecContainsShorebirdYaml) {
@@ -368,6 +371,7 @@ For more information about Shorebird, visit ${link(uri: Uri.parse('https://shore
required String appId,
required Directory projectRoot,
Map<String, String>? flavors,
String? baseUrl,
}) {
const content =
'''
@@ -389,13 +393,14 @@ app_id:
final editor = YamlEditor(content)..update(['app_id'], appId);
if (baseUrl != null) editor.update(['base_url'], baseUrl);
if (flavors != null) editor.update(['flavors'], flavors);
shorebirdEnv
.getShorebirdYamlFile(cwd: projectRoot)
.writeAsStringSync(editor.toString());
return ShorebirdYaml(appId: appId);
return ShorebirdYaml(appId: appId, baseUrl: baseUrl);
}
void _logAvailableOrganizations(
@@ -142,6 +142,8 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
logger.level = Level.verbose;
}
shorebirdEnv.persistHostedUriFromEnvironment();
final process = ShorebirdProcess();
final shorebirdArtifacts = engineConfig.localEngineSrcPath != null
? const ShorebirdLocalEngineArtifacts()
@@ -10,6 +10,7 @@ import 'package:shorebird_cli/src/config/shorebird_yaml.dart';
import 'package:shorebird_cli/src/json_output.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
import 'package:shorebird_cli/src/user_config.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// Exception thrown when the Shorebird cache appears to be corrupted.
@@ -278,13 +279,26 @@ class ShorebirdEnv {
try {
final baseUrl =
platform.environment['SHOREBIRD_HOSTED_URL'] ??
getShorebirdYaml()?.baseUrl;
return baseUrl == null ? null : Uri.tryParse(baseUrl);
getShorebirdYaml()?.baseUrl ??
userConfig.hostedUri?.toString();
return _parseHostedUri(baseUrl);
} on Exception {
return null;
}
}
/// Saves SHOREBIRD_HOSTED_URL into the CLI user config when it is set.
void persistHostedUriFromEnvironment() {
final uri = _parseHostedUri(platform.environment['SHOREBIRD_HOSTED_URL']);
if (uri == null) return;
userConfig.setHostedUri(uri);
}
Uri? _parseHostedUri(String? value) {
if (value == null || value.trim().isEmpty) return null;
return Uri.tryParse(value.trim());
}
/// Whether the CLI can accept user input via stdin.
///
/// Returns `false` when stdin is not a terminal, when running on CI, or
@@ -0,0 +1,61 @@
import 'dart:convert';
import 'dart:io';
import 'package:cli_util/cli_util.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
/// A reference to a [UserConfig] instance.
final userConfigRef = create(UserConfig.new);
/// The [UserConfig] instance available in the current zone.
UserConfig get userConfig => read(userConfigRef);
/// CLI-wide user configuration stored under the Shorebird config directory.
class UserConfig {
/// Creates a [UserConfig].
UserConfig({File? file})
: _file =
file ??
File(
p.join(
BaseDirectories(executableName).configHome,
'config.json',
),
);
final File _file;
/// The persisted code push API base URL, if any.
Uri? get hostedUri {
final value = _read()['base_url'];
if (value is! String || value.trim().isEmpty) return null;
return Uri.tryParse(value.trim());
}
/// Persists the code push API base URL.
void setHostedUri(Uri uri) {
final config = _read();
config['base_url'] = uri.toString();
_file
..createSync(recursive: true)
..writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(config),
flush: true,
);
}
Map<String, Object?> _read() {
if (!_file.existsSync()) return <String, Object?>{};
try {
final decoded = json.decode(_file.readAsStringSync());
if (decoded is Map) {
return decoded.map((key, value) => MapEntry('$key', value));
}
} on Exception {
return <String, Object?>{};
}
return <String, Object?>{};
}
}
@@ -144,6 +144,7 @@ environment:
() => shorebirdEnv.getPubspecYaml(),
).thenReturn(Pubspec.parse(pubspecYamlContent));
when(() => shorebirdEnv.hasShorebirdYaml).thenReturn(false);
when(() => shorebirdEnv.hostedUri).thenReturn(null);
when(() => shorebirdEnv.pubspecContainsShorebirdYaml).thenReturn(false);
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
when(
@@ -785,6 +786,27 @@ flavors:
);
});
test(
'adds base_url to shorebird.yaml when self-hosted url is available',
() async {
when(
() => shorebirdEnv.hostedUri,
).thenReturn(Uri.parse('https://updates.example.com'));
await runWithOverrides(command.run);
verify(
() => shorebirdYamlFile.writeAsStringSync(
any(
that: contains('''
app_id: $appId
base_url: https://updates.example.com'''),
),
),
);
},
);
group('creates shorebird.yaml for an app with flavors', () {
test('android only', () async {
final appIds = [
@@ -99,6 +99,20 @@ void main() {
});
});
test(
'persists hosted uri from environment before running command',
() async {
commandRunner.addCommand(_TestCommand(ExitCode.success));
final result = await runWithOverrides(
() => commandRunner.run(['test']),
);
expect(result, equals(ExitCode.success.code));
verify(() => shorebirdEnv.persistHostedUriFromEnvironment()).called(1);
},
);
test('handles FormatException', () async {
const exception = FormatException('oops!');
var isFirstInvocation = true;
@@ -11,6 +11,7 @@ import 'package:shorebird_cli/src/json_output.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/user_config.dart';
import 'package:test/test.dart';
import 'mocks.dart';
@@ -21,6 +22,7 @@ void main() {
late Platform platform;
late Directory shorebirdRoot;
late Uri platformScript;
late UserConfig userConfig;
late ShorebirdEnv shorebirdEnv;
R runWithOverrides<R>(R Function() body) {
@@ -29,6 +31,7 @@ void main() {
values: {
platformRef.overrideWith(() => platform),
isJsonModeRef.overrideWith(() => false),
userConfigRef.overrideWith(() => userConfig),
},
);
}
@@ -42,6 +45,11 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync(flutterRevision, flush: true);
platform = MockPlatform();
userConfig = UserConfig(
file: File(
p.join(Directory.systemTemp.createTempSync().path, 'config.json'),
),
);
shorebirdEnv = runWithOverrides(ShorebirdEnv.new);
when(() => platform.environment).thenReturn(const {});
@@ -794,6 +802,47 @@ base_url: https://example.com''');
);
});
test('falls back to user config', () {
userConfig.setHostedUri(Uri.parse('https://config.example.com'));
expect(
runWithOverrides(() => shorebirdEnv.hostedUri),
equals(Uri.parse('https://config.example.com')),
);
});
test('prefers env over shorebird.yaml and user config', () {
final directory = Directory.systemTemp.createTempSync();
File(p.join(directory.path, 'shorebird.yaml')).writeAsStringSync('''
app_id: test-id
base_url: https://yaml.example.com''');
userConfig.setHostedUri(Uri.parse('https://config.example.com'));
when(
() => platform.environment,
).thenReturn({'SHOREBIRD_HOSTED_URL': 'https://env.example.com'});
expect(
IOOverrides.runZoned(
() => runWithOverrides(() => shorebirdEnv.hostedUri),
getCurrentDirectory: () => directory,
),
equals(Uri.parse('https://env.example.com')),
);
});
test('persists hosted url from env to user config', () {
when(
() => platform.environment,
).thenReturn({'SHOREBIRD_HOSTED_URL': 'https://env.example.com'});
runWithOverrides(shorebirdEnv.persistHostedUriFromEnvironment);
expect(
userConfig.hostedUri,
equals(Uri.parse('https://env.example.com')),
);
});
test('returns null when there is no env override or shorebird.yaml', () {
expect(runWithOverrides(() => shorebirdEnv.hostedUri), isNull);
});
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/user_config.dart';
import 'package:test/test.dart';
void main() {
group(UserConfig, () {
late File configFile;
late UserConfig userConfig;
setUp(() {
configFile = File(
p.join(Directory.systemTemp.createTempSync().path, 'config.json'),
);
userConfig = UserConfig(file: configFile);
});
test('returns null when config file does not exist', () {
expect(userConfig.hostedUri, isNull);
});
test('persists hostedUri as base_url', () {
final uri = Uri.parse('https://updates.example.com');
userConfig.setHostedUri(uri);
expect(userConfig.hostedUri, equals(uri));
expect(
json.decode(configFile.readAsStringSync()),
equals({'base_url': 'https://updates.example.com'}),
);
});
test('preserves unrelated config values', () {
configFile
..createSync(recursive: true)
..writeAsStringSync(json.encode({'existing': true}));
userConfig.setHostedUri(Uri.parse('https://updates.example.com'));
expect(
json.decode(configFile.readAsStringSync()),
equals({
'existing': true,
'base_url': 'https://updates.example.com',
}),
);
});
test('ignores malformed config files', () {
configFile
..createSync(recursive: true)
..writeAsStringSync('{');
expect(userConfig.hostedUri, isNull);
});
});
}
+75
View File
@@ -0,0 +1,75 @@
[CmdletBinding()]
param(
[string] $UpdaterRevision = "main",
[switch] $PrintPlan
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$workspaceRoot = Split-Path -Parent $repoRoot
$updaterUrl = "https://github.com/shorebirdtech/updater.git"
$updaterPath = Join-Path $workspaceRoot "flutter\engine\src\flutter\third_party\updater"
$updaterHeader = Join-Path $updaterPath "library\include\updater_engine.h"
function Write-Step([string] $message) {
Write-Host "[open-source-sync] $message"
}
Write-Step "Shorebird updater source: $updaterUrl"
Write-Step "Target path: $updaterPath"
Write-Step "Revision: $UpdaterRevision"
if ($PrintPlan) {
Write-Step "PrintPlan only; no files will be changed."
exit 0
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw "git is required to sync public Shorebird sources."
}
$parent = Split-Path -Parent $updaterPath
if (-not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent | Out-Null
}
if (Test-Path -LiteralPath $updaterPath) {
$gitDir = Join-Path $updaterPath ".git"
if (-not (Test-Path -LiteralPath $gitDir)) {
throw "Target exists but is not a git checkout: $updaterPath"
}
Write-Step "Updating existing public updater checkout."
& git -C $updaterPath fetch --tags origin
if ($LASTEXITCODE -ne 0) {
throw "git fetch failed."
}
& git -C $updaterPath checkout $UpdaterRevision
if ($LASTEXITCODE -ne 0) {
throw "git checkout $UpdaterRevision failed."
}
if ($UpdaterRevision -eq "main") {
& git -C $updaterPath pull --ff-only
if ($LASTEXITCODE -ne 0) {
throw "git pull --ff-only failed."
}
}
} else {
Write-Step "Cloning public updater checkout."
& git clone $updaterUrl $updaterPath
if ($LASTEXITCODE -ne 0) {
throw "git clone failed."
}
& git -C $updaterPath checkout $UpdaterRevision
if ($LASTEXITCODE -ne 0) {
throw "git checkout $UpdaterRevision failed."
}
}
if (-not (Test-Path -LiteralPath $updaterHeader)) {
throw "Updater checkout is missing expected C API header: $updaterHeader"
}
Write-Step "Public Shorebird updater is available for the engine build."