diff --git a/OPEN_SOURCE_REPLACEMENTS.md b/OPEN_SOURCE_REPLACEMENTS.md new file mode 100644 index 00000000..347741a2 --- /dev/null +++ b/OPEN_SOURCE_REPLACEMENTS.md @@ -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 = "" +``` + +Point devices at it through `shorebird.yaml`: + +```yaml +app_id: +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. diff --git a/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart b/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart index ad334036..e2321aa8 100644 --- a/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart +++ b/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart @@ -45,6 +45,7 @@ void _link(Map 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 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 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 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 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 options) { stdout.writeln(const JsonEncoder.withIndent(' ').convert(report)); } +void _checkOfflineExpiry( + PatchMetadata metadata, + Map options, +) { + if (_boolOption(options, 'allow-expired')) { + return; + } + if (metadata.isOfflineExpired(now: _optionalDateTime(options['now']))) { + throw StateError( + 'Patch offline license expired at ${metadata.offlineExpiresAt}.', + ); + } +} + List? _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= [--key-hex=<64 hex chars>] [--base=] [--output=] + open_aot_patch_tools dump-blobs --input= [--key-hex=<64 hex chars>] [--base=] [--output=] [--now=] [--allow-expired=true] open_aot_patch_tools compile-patch --gen-snapshot= --kernel= --output= [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=] [--obfuscate=true] [--load-obfuscation-map=] [--save-obfuscation-map=] - open_aot_patch_tools link --base= --patch= --output= --app-id= --app-build-id= --flavor-id= --license-type= --sdk-hash= --target-os= --target-arch= [--base-flavor-id=] [--base-license-type=] [--obfuscation-map-hash=] [--full-snapshot=true] + open_aot_patch_tools link --base= --patch= --output= --app-id= --app-build-id= --flavor-id= --license-type= --sdk-hash= --target-os= --target-arch= [--base-flavor-id=] [--base-license-type=] [--obfuscation-map-hash=] [--offline-expires-at=] [--full-snapshot=true] open_aot_patch_tools encrypt --input= --output= --key-id= --key-hex=<64 hex chars> --nonce-hex=<24 hex chars> - open_aot_patch_tools verify --input= --key-hex=<64 hex chars> --flavor-id= --license-type= [--base=] [--artifact-sha256=] [--base-flavor-id=] [--base-license-type=] + open_aot_patch_tools verify --input= --key-hex=<64 hex chars> --flavor-id= --license-type= [--base=] [--artifact-sha256=] [--base-flavor-id=] [--base-license-type=] [--now=] [--allow-expired=true] '''); } diff --git a/packages/open_aot_patch_tools/doc/open_aot_patch_design.md b/packages/open_aot_patch_tools/doc/open_aot_patch_design.md index 65ef82a6..f9702a85 100644 --- a/packages/open_aot_patch_tools/doc/open_aot_patch_design.md +++ b/packages/open_aot_patch_tools/doc/open_aot_patch_design.md @@ -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= \ + --offline-expires-at=2030-01-01T00:00:00Z \ --full-snapshot=true ``` diff --git a/packages/open_aot_patch_tools/lib/src/patch_artifact.dart b/packages/open_aot_patch_tools/lib/src/patch_artifact.dart index 12bc586c..c71a90d3 100644 --- a/packages/open_aot_patch_tools/lib/src/patch_artifact.dart +++ b/packages/open_aot_patch_tools/lib/src/patch_artifact.dart @@ -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 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 reconstructPatchSnapshot({ required List baseSnapshot, @@ -478,6 +512,23 @@ String _string(Map json, String key) { return value; } +String? _optionalIso8601UtcString(Map 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 baseSnapshot, List patchSnapshot, diff --git a/packages/open_aot_patch_tools/test/patch_artifact_test.dart b/packages/open_aot_patch_tools/test/patch_artifact_test.dart index 6cd45608..1ea137bc 100644 --- a/packages/open_aot_patch_tools/test/patch_artifact_test.dart +++ b/packages/open_aot_patch_tools/test/patch_artifact_test.dart @@ -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(); @@ -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); } diff --git a/packages/shorebird_cli/lib/src/commands/init_command.dart b/packages/shorebird_cli/lib/src/commands/init_command.dart index cbde5410..0e50c84d 100644 --- a/packages/shorebird_cli/lib/src/commands/init_command.dart +++ b/packages/shorebird_cli/lib/src/commands/init_command.dart @@ -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 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? 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( diff --git a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart index a7ad2f89..f2ccb661 100644 --- a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart +++ b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart @@ -142,6 +142,8 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { logger.level = Level.verbose; } + shorebirdEnv.persistHostedUriFromEnvironment(); + final process = ShorebirdProcess(); final shorebirdArtifacts = engineConfig.localEngineSrcPath != null ? const ShorebirdLocalEngineArtifacts() diff --git a/packages/shorebird_cli/lib/src/shorebird_env.dart b/packages/shorebird_cli/lib/src/shorebird_env.dart index 86c65d48..3b828d66 100644 --- a/packages/shorebird_cli/lib/src/shorebird_env.dart +++ b/packages/shorebird_cli/lib/src/shorebird_env.dart @@ -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 diff --git a/packages/shorebird_cli/lib/src/user_config.dart b/packages/shorebird_cli/lib/src/user_config.dart new file mode 100644 index 00000000..5a9983dd --- /dev/null +++ b/packages/shorebird_cli/lib/src/user_config.dart @@ -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 _read() { + if (!_file.existsSync()) return {}; + try { + final decoded = json.decode(_file.readAsStringSync()); + if (decoded is Map) { + return decoded.map((key, value) => MapEntry('$key', value)); + } + } on Exception { + return {}; + } + return {}; + } +} diff --git a/packages/shorebird_cli/test/src/commands/init_command_test.dart b/packages/shorebird_cli/test/src/commands/init_command_test.dart index aa52008f..928fcff5 100644 --- a/packages/shorebird_cli/test/src/commands/init_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/init_command_test.dart @@ -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 = [ diff --git a/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart b/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart index e184245a..89c085e7 100644 --- a/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart @@ -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; diff --git a/packages/shorebird_cli/test/src/shorebird_env_test.dart b/packages/shorebird_cli/test/src/shorebird_env_test.dart index 64762c64..6220066e 100644 --- a/packages/shorebird_cli/test/src/shorebird_env_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_env_test.dart @@ -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 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); }); diff --git a/packages/shorebird_cli/test/src/user_config_test.dart b/packages/shorebird_cli/test/src/user_config_test.dart new file mode 100644 index 00000000..032fbbe1 --- /dev/null +++ b/packages/shorebird_cli/test/src/user_config_test.dart @@ -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); + }); + }); +} diff --git a/sync_open_sources.ps1 b/sync_open_sources.ps1 new file mode 100644 index 00000000..9f0aa899 --- /dev/null +++ b/sync_open_sources.ps1 @@ -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."