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'),
|
targetOs: _required(options, 'target-os'),
|
||||||
targetArch: _required(options, 'target-arch'),
|
targetArch: _required(options, 'target-arch'),
|
||||||
obfuscationMapHash: options['obfuscation-map-hash'],
|
obfuscationMapHash: options['obfuscation-map-hash'],
|
||||||
|
offlineExpiresAt: _optionalIso8601Utc(options['offline-expires-at']),
|
||||||
);
|
);
|
||||||
final artifact = linkArtifacts(
|
final artifact = linkArtifacts(
|
||||||
baseSnapshot: base,
|
baseSnapshot: base,
|
||||||
@@ -85,6 +86,8 @@ void _verify(Map<String, String> options) {
|
|||||||
baseLicenseType: options['base-license-type'],
|
baseLicenseType: options['base-license-type'],
|
||||||
flavorId: _required(options, 'flavor-id'),
|
flavorId: _required(options, 'flavor-id'),
|
||||||
licenseType: _required(options, 'license-type'),
|
licenseType: _required(options, 'license-type'),
|
||||||
|
now: _optionalDateTime(options['now']),
|
||||||
|
allowExpired: _boolOption(options, 'allow-expired'),
|
||||||
);
|
);
|
||||||
final decrypted = encrypted.decrypt(readKey(_required(options, 'key-hex')));
|
final decrypted = encrypted.decrypt(readKey(_required(options, 'key-hex')));
|
||||||
final basePath = options['base'];
|
final basePath = options['base'];
|
||||||
@@ -129,6 +132,7 @@ void _dumpBlobs(Map<String, String> options) {
|
|||||||
final format = json['format'];
|
final format = json['format'];
|
||||||
if (format == artifactFormat) {
|
if (format == artifactFormat) {
|
||||||
final artifact = PatchArtifact.fromJson(json);
|
final artifact = PatchArtifact.fromJson(json);
|
||||||
|
_checkOfflineExpiry(artifact.metadata, options);
|
||||||
final reconstructed = _maybeReconstruct(
|
final reconstructed = _maybeReconstruct(
|
||||||
artifact,
|
artifact,
|
||||||
basePath: options['base'],
|
basePath: options['base'],
|
||||||
@@ -167,6 +171,7 @@ void _dumpBlobs(Map<String, String> options) {
|
|||||||
final keyHex = options['key-hex'];
|
final keyHex = options['key-hex'];
|
||||||
if (keyHex != null) {
|
if (keyHex != null) {
|
||||||
final decrypted = encrypted.decrypt(readKey(keyHex));
|
final decrypted = encrypted.decrypt(readKey(keyHex));
|
||||||
|
_checkOfflineExpiry(encrypted.metadata, options);
|
||||||
final reconstructed = _maybeReconstruct(
|
final reconstructed = _maybeReconstruct(
|
||||||
decrypted,
|
decrypted,
|
||||||
basePath: options['base'],
|
basePath: options['base'],
|
||||||
@@ -190,7 +195,10 @@ void _dumpBlobs(Map<String, String> options) {
|
|||||||
} else {
|
} else {
|
||||||
throw const FormatException('not an open AOT patch artifact');
|
throw const FormatException('not an open AOT patch artifact');
|
||||||
}
|
}
|
||||||
} on Object {
|
} on FormatException {
|
||||||
|
if (options['key-hex'] != null || options['output'] != null) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
report = {
|
report = {
|
||||||
'path': input.path,
|
'path': input.path,
|
||||||
'format': 'raw',
|
'format': 'raw',
|
||||||
@@ -201,6 +209,20 @@ void _dumpBlobs(Map<String, String> options) {
|
|||||||
stdout.writeln(const JsonEncoder.withIndent(' ').convert(report));
|
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(
|
List<int>? _maybeReconstruct(
|
||||||
PatchArtifact artifact, {
|
PatchArtifact artifact, {
|
||||||
required String? basePath,
|
required String? basePath,
|
||||||
@@ -312,13 +334,27 @@ bool _boolOption(
|
|||||||
return value == 'true' || value == '1' || value == 'yes';
|
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() {
|
void _usage() {
|
||||||
stdout.writeln('''
|
stdout.writeln('''
|
||||||
Usage:
|
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 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 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
|
This package produces compact, flavor-aware AOT patch artifacts without relying
|
||||||
on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter.
|
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
|
## Runtime Model
|
||||||
|
|
||||||
Patches are AOT snapshot artifacts. The engine selects patched isolate snapshot
|
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
|
delivery artifact, which should be used alongside transport security or a
|
||||||
separate signing layer.
|
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
|
## Compact Payloads
|
||||||
|
|
||||||
By default, `link` chooses the smallest v1 payload representation:
|
By default, `link` chooses the smallest v1 payload representation:
|
||||||
@@ -102,6 +122,7 @@ dart run open_aot_patch_tools link \
|
|||||||
--target-os=ios \
|
--target-os=ios \
|
||||||
--target-arch=arm64 \
|
--target-arch=arm64 \
|
||||||
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
||||||
|
--offline-expires-at=2030-01-01T00:00:00Z \
|
||||||
--full-snapshot=true
|
--full-snapshot=true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PatchMetadata {
|
|||||||
this.baseFlavorId,
|
this.baseFlavorId,
|
||||||
this.baseLicenseType,
|
this.baseLicenseType,
|
||||||
this.obfuscationMapHash,
|
this.obfuscationMapHash,
|
||||||
|
this.offlineExpiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Reads metadata from the JSON representation used by patch artifacts.
|
/// Reads metadata from the JSON representation used by patch artifacts.
|
||||||
@@ -52,6 +53,7 @@ class PatchMetadata {
|
|||||||
targetOs: _string(json, 'target_os'),
|
targetOs: _string(json, 'target_os'),
|
||||||
targetArch: _string(json, 'target_arch'),
|
targetArch: _string(json, 'target_arch'),
|
||||||
obfuscationMapHash: json['obfuscation_map_hash'] as String?,
|
obfuscationMapHash: json['obfuscation_map_hash'] as String?,
|
||||||
|
offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Stable application identifier.
|
/// Stable application identifier.
|
||||||
@@ -90,6 +92,23 @@ class PatchMetadata {
|
|||||||
/// Optional hash of the obfuscation map used by base and patch builds.
|
/// Optional hash of the obfuscation map used by base and patch builds.
|
||||||
final String? obfuscationMapHash;
|
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.
|
/// Converts this metadata to the stable JSON map used as AES-GCM AAD.
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'app_id': appId,
|
'app_id': appId,
|
||||||
@@ -104,6 +123,7 @@ class PatchMetadata {
|
|||||||
'target_os': targetOs,
|
'target_os': targetOs,
|
||||||
'target_arch': targetArch,
|
'target_arch': targetArch,
|
||||||
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
|
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
|
||||||
|
if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +388,8 @@ void verifyMetadata(
|
|||||||
required String licenseType,
|
required String licenseType,
|
||||||
String? baseFlavorId,
|
String? baseFlavorId,
|
||||||
String? baseLicenseType,
|
String? baseLicenseType,
|
||||||
|
DateTime? now,
|
||||||
|
bool allowExpired = false,
|
||||||
}) {
|
}) {
|
||||||
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
|
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
|
||||||
throw StateError(
|
throw StateError(
|
||||||
@@ -391,8 +413,20 @@ void verifyMetadata(
|
|||||||
'Patch license "${metadata.licenseType}" does not match "$licenseType".',
|
'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.
|
/// Reconstructs final patch snapshot bytes from a compact payload.
|
||||||
List<int> reconstructPatchSnapshot({
|
List<int> reconstructPatchSnapshot({
|
||||||
required List<int> baseSnapshot,
|
required List<int> baseSnapshot,
|
||||||
@@ -478,6 +512,23 @@ String _string(Map<String, Object?> json, String key) {
|
|||||||
return value;
|
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(
|
_CompactPayload _compactPayload(
|
||||||
List<int> baseSnapshot,
|
List<int> baseSnapshot,
|
||||||
List<int> patchSnapshot,
|
List<int> patchSnapshot,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
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',
|
appId: 'app.test',
|
||||||
appBuildId: '1.0.0+1',
|
appBuildId: '1.0.0+1',
|
||||||
baseFlavorId: 'free',
|
baseFlavorId: 'free',
|
||||||
@@ -88,6 +88,7 @@ void main() {
|
|||||||
targetOs: 'windows',
|
targetOs: 'windows',
|
||||||
targetArch: 'x64',
|
targetArch: 'x64',
|
||||||
obfuscationMapHash: 'obfuscated',
|
obfuscationMapHash: 'obfuscated',
|
||||||
|
offlineExpiresAt: offlineExpiresAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
test('encrypts and decrypts flavor-aware patch payloads', () {
|
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', () {
|
test('artifact json round trips', () {
|
||||||
final base = utf8.encode('free');
|
final base = utf8.encode('free');
|
||||||
final patch = utf8.encode('pro');
|
final patch = utf8.encode('pro');
|
||||||
@@ -288,9 +334,14 @@ void main() {
|
|||||||
'--target-os=windows',
|
'--target-os=windows',
|
||||||
'--target-arch=x64',
|
'--target-arch=x64',
|
||||||
'--full-snapshot=true',
|
'--full-snapshot=true',
|
||||||
|
'--offline-expires-at=2030-01-01T00:00:00Z',
|
||||||
]);
|
]);
|
||||||
final fullArtifactJson = readJsonFile(artifactFile);
|
final fullArtifactJson = readJsonFile(artifactFile);
|
||||||
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
|
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
|
||||||
|
expect(
|
||||||
|
(fullArtifactJson['metadata'] as Map)['offline_expires_at'],
|
||||||
|
'2030-01-01T00:00:00.000Z',
|
||||||
|
);
|
||||||
|
|
||||||
await _runTool([
|
await _runTool([
|
||||||
'encrypt',
|
'encrypt',
|
||||||
@@ -311,6 +362,19 @@ void main() {
|
|||||||
'--flavor-id=pro',
|
'--flavor-id=pro',
|
||||||
'--license-type=pro',
|
'--license-type=pro',
|
||||||
'--base=${baseFile.path}',
|
'--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([
|
await _runToolExpectFailure([
|
||||||
@@ -331,6 +395,7 @@ void main() {
|
|||||||
'--key-hex=$keyHex',
|
'--key-hex=$keyHex',
|
||||||
'--base=${baseFile.path}',
|
'--base=${baseFile.path}',
|
||||||
'--output=${reconstructedFile.path}',
|
'--output=${reconstructedFile.path}',
|
||||||
|
'--now=2029-01-01T00:00:00Z',
|
||||||
]);
|
]);
|
||||||
final dumpJson = (jsonDecode(dump.stdout as String) as Map)
|
final dumpJson = (jsonDecode(dump.stdout as String) as Map)
|
||||||
.cast<String, Object?>();
|
.cast<String, Object?>();
|
||||||
@@ -340,6 +405,15 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(dumpJson['reconstructed_patch_sha256'], sha256Hex(patchBytes));
|
expect(dumpJson['reconstructed_patch_sha256'], sha256Hex(patchBytes));
|
||||||
expect(reconstructedFile.readAsBytesSync(), 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 {
|
} finally {
|
||||||
tempDir.deleteSync(recursive: true);
|
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 shorebirdYaml = shorebirdEnv.getShorebirdYaml();
|
||||||
final existingFlavors = shorebirdYaml?.flavors;
|
final existingFlavors = shorebirdYaml?.flavors;
|
||||||
|
final hostedBaseUrl = shorebirdEnv.hostedUri?.toString();
|
||||||
Set<String> newFlavors;
|
Set<String> newFlavors;
|
||||||
if (existingFlavors != null) {
|
if (existingFlavors != null) {
|
||||||
final existingFlavorNames = existingFlavors.keys.toSet();
|
final existingFlavorNames = existingFlavors.keys.toSet();
|
||||||
@@ -224,6 +225,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
|
|||||||
projectRoot: projectRoot,
|
projectRoot: projectRoot,
|
||||||
appId: shorebirdYaml.appId,
|
appId: shorebirdYaml.appId,
|
||||||
flavors: flavorsToAppIds,
|
flavors: flavorsToAppIds,
|
||||||
|
baseUrl: shorebirdYaml.baseUrl ?? hostedBaseUrl,
|
||||||
);
|
);
|
||||||
updateShorebirdYamlProgress.complete('Flavors added to shorebird.yaml');
|
updateShorebirdYamlProgress.complete('Flavors added to shorebird.yaml');
|
||||||
return ExitCode.success.code;
|
return ExitCode.success.code;
|
||||||
@@ -315,6 +317,7 @@ Please make sure you are running "shorebird init" from within your Flutter proje
|
|||||||
projectRoot: projectRoot,
|
projectRoot: projectRoot,
|
||||||
appId: appId,
|
appId: appId,
|
||||||
flavors: flavors,
|
flavors: flavors,
|
||||||
|
baseUrl: hostedBaseUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!shorebirdEnv.pubspecContainsShorebirdYaml) {
|
if (!shorebirdEnv.pubspecContainsShorebirdYaml) {
|
||||||
@@ -368,6 +371,7 @@ For more information about Shorebird, visit ${link(uri: Uri.parse('https://shore
|
|||||||
required String appId,
|
required String appId,
|
||||||
required Directory projectRoot,
|
required Directory projectRoot,
|
||||||
Map<String, String>? flavors,
|
Map<String, String>? flavors,
|
||||||
|
String? baseUrl,
|
||||||
}) {
|
}) {
|
||||||
const content =
|
const content =
|
||||||
'''
|
'''
|
||||||
@@ -389,13 +393,14 @@ app_id:
|
|||||||
|
|
||||||
final editor = YamlEditor(content)..update(['app_id'], appId);
|
final editor = YamlEditor(content)..update(['app_id'], appId);
|
||||||
|
|
||||||
|
if (baseUrl != null) editor.update(['base_url'], baseUrl);
|
||||||
if (flavors != null) editor.update(['flavors'], flavors);
|
if (flavors != null) editor.update(['flavors'], flavors);
|
||||||
|
|
||||||
shorebirdEnv
|
shorebirdEnv
|
||||||
.getShorebirdYamlFile(cwd: projectRoot)
|
.getShorebirdYamlFile(cwd: projectRoot)
|
||||||
.writeAsStringSync(editor.toString());
|
.writeAsStringSync(editor.toString());
|
||||||
|
|
||||||
return ShorebirdYaml(appId: appId);
|
return ShorebirdYaml(appId: appId, baseUrl: baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _logAvailableOrganizations(
|
void _logAvailableOrganizations(
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
|
|||||||
logger.level = Level.verbose;
|
logger.level = Level.verbose;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shorebirdEnv.persistHostedUriFromEnvironment();
|
||||||
|
|
||||||
final process = ShorebirdProcess();
|
final process = ShorebirdProcess();
|
||||||
final shorebirdArtifacts = engineConfig.localEngineSrcPath != null
|
final shorebirdArtifacts = engineConfig.localEngineSrcPath != null
|
||||||
? const ShorebirdLocalEngineArtifacts()
|
? 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/json_output.dart';
|
||||||
import 'package:shorebird_cli/src/platform.dart';
|
import 'package:shorebird_cli/src/platform.dart';
|
||||||
import 'package:shorebird_cli/src/shorebird_cli_command_runner.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';
|
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||||
|
|
||||||
/// Exception thrown when the Shorebird cache appears to be corrupted.
|
/// Exception thrown when the Shorebird cache appears to be corrupted.
|
||||||
@@ -278,13 +279,26 @@ class ShorebirdEnv {
|
|||||||
try {
|
try {
|
||||||
final baseUrl =
|
final baseUrl =
|
||||||
platform.environment['SHOREBIRD_HOSTED_URL'] ??
|
platform.environment['SHOREBIRD_HOSTED_URL'] ??
|
||||||
getShorebirdYaml()?.baseUrl;
|
getShorebirdYaml()?.baseUrl ??
|
||||||
return baseUrl == null ? null : Uri.tryParse(baseUrl);
|
userConfig.hostedUri?.toString();
|
||||||
|
return _parseHostedUri(baseUrl);
|
||||||
} on Exception {
|
} on Exception {
|
||||||
return null;
|
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.
|
/// Whether the CLI can accept user input via stdin.
|
||||||
///
|
///
|
||||||
/// Returns `false` when stdin is not a terminal, when running on CI, or
|
/// 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(),
|
() => shorebirdEnv.getPubspecYaml(),
|
||||||
).thenReturn(Pubspec.parse(pubspecYamlContent));
|
).thenReturn(Pubspec.parse(pubspecYamlContent));
|
||||||
when(() => shorebirdEnv.hasShorebirdYaml).thenReturn(false);
|
when(() => shorebirdEnv.hasShorebirdYaml).thenReturn(false);
|
||||||
|
when(() => shorebirdEnv.hostedUri).thenReturn(null);
|
||||||
when(() => shorebirdEnv.pubspecContainsShorebirdYaml).thenReturn(false);
|
when(() => shorebirdEnv.pubspecContainsShorebirdYaml).thenReturn(false);
|
||||||
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
|
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
|
||||||
when(
|
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', () {
|
group('creates shorebird.yaml for an app with flavors', () {
|
||||||
test('android only', () async {
|
test('android only', () async {
|
||||||
final appIds = [
|
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 {
|
test('handles FormatException', () async {
|
||||||
const exception = FormatException('oops!');
|
const exception = FormatException('oops!');
|
||||||
var isFirstInvocation = true;
|
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/platform.dart';
|
||||||
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
|
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
|
||||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||||
|
import 'package:shorebird_cli/src/user_config.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
import 'mocks.dart';
|
import 'mocks.dart';
|
||||||
@@ -21,6 +22,7 @@ void main() {
|
|||||||
late Platform platform;
|
late Platform platform;
|
||||||
late Directory shorebirdRoot;
|
late Directory shorebirdRoot;
|
||||||
late Uri platformScript;
|
late Uri platformScript;
|
||||||
|
late UserConfig userConfig;
|
||||||
late ShorebirdEnv shorebirdEnv;
|
late ShorebirdEnv shorebirdEnv;
|
||||||
|
|
||||||
R runWithOverrides<R>(R Function() body) {
|
R runWithOverrides<R>(R Function() body) {
|
||||||
@@ -29,6 +31,7 @@ void main() {
|
|||||||
values: {
|
values: {
|
||||||
platformRef.overrideWith(() => platform),
|
platformRef.overrideWith(() => platform),
|
||||||
isJsonModeRef.overrideWith(() => false),
|
isJsonModeRef.overrideWith(() => false),
|
||||||
|
userConfigRef.overrideWith(() => userConfig),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,11 @@ void main() {
|
|||||||
..createSync(recursive: true)
|
..createSync(recursive: true)
|
||||||
..writeAsStringSync(flutterRevision, flush: true);
|
..writeAsStringSync(flutterRevision, flush: true);
|
||||||
platform = MockPlatform();
|
platform = MockPlatform();
|
||||||
|
userConfig = UserConfig(
|
||||||
|
file: File(
|
||||||
|
p.join(Directory.systemTemp.createTempSync().path, 'config.json'),
|
||||||
|
),
|
||||||
|
);
|
||||||
shorebirdEnv = runWithOverrides(ShorebirdEnv.new);
|
shorebirdEnv = runWithOverrides(ShorebirdEnv.new);
|
||||||
|
|
||||||
when(() => platform.environment).thenReturn(const {});
|
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', () {
|
test('returns null when there is no env override or shorebird.yaml', () {
|
||||||
expect(runWithOverrides(() => shorebirdEnv.hostedUri), isNull);
|
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