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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user