From 3b583094fd871d8358e72327a686d67b42f92424 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Fri, 3 Jul 2026 11:25:03 +0530 Subject: [PATCH 1/2] feat(bundles): client bundle mode and atomic CLI bundle deploy - StacBundle/StacBundleConfig models; SharedPreferences bundle store with schema-version guard - StacBundleService: conditional sync (ETag/since), seed-asset hydration (highest version wins), updates stream, offline fallback - StacBundleUpdater: sync on app-resume + optional foreground polling - StacCloud serves screens/themes from the bundle when enabled (opt-in; falls through to legacy per-artifact fetch) - CLI: single atomic POST /bundles with checksum + payload size log, seed asset emission with pubspec warning, --legacy fallback flag; build now cleans stale .build outputs --- packages/stac/lib/src/framework/stac.dart | 8 + .../stac/lib/src/framework/stac_service.dart | 17 + packages/stac/lib/src/models/models.dart | 2 + packages/stac/lib/src/models/stac_bundle.dart | 99 +++ .../stac/lib/src/models/stac_bundle.g.dart | 28 + .../lib/src/models/stac_bundle_config.dart | 126 ++++ packages/stac/lib/src/services/services.dart | 2 + .../lib/src/services/stac_bundle_service.dart | 313 ++++++++++ .../lib/src/services/stac_bundle_store.dart | 100 +++ .../lib/src/services/stac_bundle_updater.dart | 83 +++ .../stac/lib/src/services/stac_cloud.dart | 47 ++ .../stac/test/models/stac_bundle_test.dart | 79 +++ .../services/stac_bundle_service_test.dart | 589 ++++++++++++++++++ packages/stac_cli/CHANGELOG.md | 7 + .../lib/src/commands/deploy_command.dart | 9 +- .../lib/src/services/build_service.dart | 7 +- .../lib/src/services/deploy_service.dart | 292 ++++++++- packages/stac_cli/pubspec.lock | 2 +- packages/stac_cli/pubspec.yaml | 1 + .../test/services/deploy_service_test.dart | 300 +++++++++ 20 files changed, 2102 insertions(+), 9 deletions(-) create mode 100644 packages/stac/lib/src/models/stac_bundle.dart create mode 100644 packages/stac/lib/src/models/stac_bundle.g.dart create mode 100644 packages/stac/lib/src/models/stac_bundle_config.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_service.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_store.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_updater.dart create mode 100644 packages/stac/test/models/stac_bundle_test.dart create mode 100644 packages/stac/test/services/stac_bundle_service_test.dart create mode 100644 packages/stac_cli/test/services/deploy_service_test.dart diff --git a/packages/stac/lib/src/framework/stac.dart b/packages/stac/lib/src/framework/stac.dart index a761594b4..e2fd4ebf1 100644 --- a/packages/stac/lib/src/framework/stac.dart +++ b/packages/stac/lib/src/framework/stac.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:stac/src/framework/stac_error.dart'; import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/services/stac_cloud.dart'; import 'package:stac_core/actions/network_request/stac_network_request.dart'; @@ -165,6 +166,11 @@ class Stac extends StatelessWidget { /// - [cacheConfig]: Global cache configuration for all Stac widgets and /// StacCloud calls. Defaults to networkFirst strategy if not provided. /// + /// - [bundleConfig]: Configuration for bundle mode, where all screens and + /// themes are downloaded as a single version-gated bundle. Disabled by + /// default (opt-in); when not provided, legacy per-screen fetching is + /// used. + /// /// ## Example /// /// ```dart @@ -188,6 +194,7 @@ class Stac extends StatelessWidget { bool logStackTraces = true, StacErrorWidgetBuilder? errorWidgetBuilder, StacCacheConfig? cacheConfig, + StacBundleConfig? bundleConfig, }) async { return StacService.initialize( options: options, @@ -199,6 +206,7 @@ class Stac extends StatelessWidget { logStackTraces: logStackTraces, errorWidgetBuilder: errorWidgetBuilder, cacheConfig: cacheConfig, + bundleConfig: bundleConfig, ); } diff --git a/packages/stac/lib/src/framework/stac_service.dart b/packages/stac/lib/src/framework/stac_service.dart index b05ff8249..d272a3ee3 100644 --- a/packages/stac/lib/src/framework/stac_service.dart +++ b/packages/stac/lib/src/framework/stac_service.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:stac/src/framework/stac.dart'; import 'package:stac/src/framework/stac_error.dart'; import 'package:stac/src/framework/stac_registry.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/parsers/actions/stac_form_validate/stac_form_validate_parser.dart'; import 'package:stac/src/parsers/actions/stac_get_form_value/stac_get_form_value_parser.dart'; @@ -18,6 +19,8 @@ import 'package:stac/src/parsers/widgets/stac_inkwell/stac_inkwell_parser.dart'; import 'package:stac/src/parsers/widgets/stac_row/stac_row_parser.dart'; import 'package:stac/src/parsers/widgets/stac_text/stac_text_parser.dart'; import 'package:stac/src/parsers/widgets/stac_tool_tip/stac_tool_tip_parser.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; +import 'package:stac/src/services/stac_bundle_updater.dart'; import 'package:stac/src/services/stac_network_service.dart'; import 'package:stac/src/utils/variable_resolver.dart'; import 'package:stac_core/stac_core.dart'; @@ -173,6 +176,10 @@ class StacService { ); static StacCacheConfig get defaultCacheConfig => _defaultCacheConfig; + // Bundle configuration for bundle mode (opt-in, disabled by default). + static StacBundleConfig _bundleConfig = const StacBundleConfig(); + static StacBundleConfig get bundleConfig => _bundleConfig; + static Future initialize({ StacOptions? options, List parsers = const [], @@ -183,11 +190,21 @@ class StacService { bool logStackTraces = true, StacErrorWidgetBuilder? errorWidgetBuilder, StacCacheConfig? cacheConfig, + StacBundleConfig? bundleConfig, }) async { _options = options; if (cacheConfig != null) { _defaultCacheConfig = cacheConfig; } + if (bundleConfig != null) { + _bundleConfig = bundleConfig; + } + if (_bundleConfig.enabled) { + StacBundleUpdater.start(); + if (_bundleConfig.prefetchOnInit && options != null) { + unawaited(StacBundleService.sync()); + } + } _parsers.addAll(parsers); _actionParsers.addAll(actionParsers); StacRegistry.instance.registerAll(_parsers, override); diff --git a/packages/stac/lib/src/models/models.dart b/packages/stac/lib/src/models/models.dart index 34b2f5138..125f3be91 100644 --- a/packages/stac/lib/src/models/models.dart +++ b/packages/stac/lib/src/models/models.dart @@ -1,2 +1,4 @@ +export 'package:stac/src/models/stac_bundle.dart'; +export 'package:stac/src/models/stac_bundle_config.dart'; export 'package:stac/src/models/stac_cache_config.dart'; export 'package:stac/src/models/stac_cache.dart'; diff --git a/packages/stac/lib/src/models/stac_bundle.dart b/packages/stac/lib/src/models/stac_bundle.dart new file mode 100644 index 000000000..ad91cfcab --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +part 'stac_bundle.g.dart'; + +/// Model representing a bundle of screens and themes from Stac Cloud. +/// +/// A bundle contains every screen and theme for a project at a single +/// server-owned monotonic [version]. Screens render from the cached bundle; +/// the body is re-downloaded only when a conditional version check returns +/// a new version. +@JsonSerializable() +class StacBundle { + /// Creates a [StacBundle] instance. + const StacBundle({ + required this.projectId, + required this.version, + this.etag, + this.checksum, + required this.fetchedAt, + required this.screens, + required this.themes, + }); + + /// The Stac Cloud project this bundle belongs to. + final String projectId; + + /// The server-owned monotonic bundle version. + final int version; + + /// The HTTP ETag for conditional (`If-None-Match`) requests. + final String? etag; + + /// The sha256 checksum of the bundle content. + final String? checksum; + + /// The timestamp when this bundle was fetched or hydrated. + final DateTime fetchedAt; + + /// Screen name to Stac JSON string. + final Map screens; + + /// Theme name to Stac JSON string. + final Map themes; + + /// Returns the Stac JSON string for the screen [name], or `null` if the + /// screen is not part of this bundle. + String? screen(String name) => screens[name]; + + /// Returns the Stac JSON string for the theme [name], or `null` if the + /// theme is not part of this bundle. + String? theme(String name) => themes[name]; + + /// Creates a [StacBundle] from a JSON map. + factory StacBundle.fromJson(Map json) => + _$StacBundleFromJson(json); + + /// Converts this [StacBundle] to a JSON map. + Map toJson() => _$StacBundleToJson(this); + + /// Creates a [StacBundle] from a JSON string. + factory StacBundle.fromJsonString(String jsonString) { + return StacBundle.fromJson(jsonDecode(jsonString) as Map); + } + + /// Converts this [StacBundle] to a JSON string. + String toJsonString() { + return jsonEncode(toJson()); + } + + /// Creates a copy of this [StacBundle] with the given fields replaced. + StacBundle copyWith({ + String? projectId, + int? version, + String? etag, + String? checksum, + DateTime? fetchedAt, + Map? screens, + Map? themes, + }) { + return StacBundle( + projectId: projectId ?? this.projectId, + version: version ?? this.version, + etag: etag ?? this.etag, + checksum: checksum ?? this.checksum, + fetchedAt: fetchedAt ?? this.fetchedAt, + screens: screens ?? this.screens, + themes: themes ?? this.themes, + ); + } + + @override + String toString() { + return 'StacBundle(projectId: $projectId, version: $version, ' + 'screens: ${screens.length}, themes: ${themes.length}, ' + 'fetchedAt: $fetchedAt)'; + } +} diff --git a/packages/stac/lib/src/models/stac_bundle.g.dart b/packages/stac/lib/src/models/stac_bundle.g.dart new file mode 100644 index 000000000..8039702cf --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stac_bundle.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StacBundle _$StacBundleFromJson(Map json) => StacBundle( + projectId: json['projectId'] as String, + version: (json['version'] as num).toInt(), + etag: json['etag'] as String?, + checksum: json['checksum'] as String?, + fetchedAt: DateTime.parse(json['fetchedAt'] as String), + screens: Map.from(json['screens'] as Map), + themes: Map.from(json['themes'] as Map), +); + +Map _$StacBundleToJson(StacBundle instance) => + { + 'projectId': instance.projectId, + 'version': instance.version, + 'etag': instance.etag, + 'checksum': instance.checksum, + 'fetchedAt': instance.fetchedAt.toIso8601String(), + 'screens': instance.screens, + 'themes': instance.themes, + }; diff --git a/packages/stac/lib/src/models/stac_bundle_config.dart b/packages/stac/lib/src/models/stac_bundle_config.dart new file mode 100644 index 000000000..18f82c5b1 --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle_config.dart @@ -0,0 +1,126 @@ +/// Configuration for Stac bundle mode. +/// +/// Bundle mode replaces per-screen fetches with a single version-gated +/// bundle download containing every screen and theme for a project. +/// It is opt-in: [enabled] defaults to `false`, so an app without a +/// `bundleConfig` behaves exactly as before. +/// +/// Bundle caching behavior is fixed (no strategies): screens always render +/// from the cached bundle, and the body is re-downloaded only when a +/// conditional version check returns a new version. The check runs at app +/// launch ([prefetchOnInit]), on app-resume ([checkOnResume]), on an optional +/// poll interval ([pollingInterval]), or via an explicit +/// `StacBundleService.sync()` call. +/// +/// ## Basic Usage +/// +/// ```dart +/// await Stac.initialize( +/// options: StacOptions(...), +/// bundleConfig: StacBundleConfig( +/// enabled: true, +/// seedAsset: 'assets/stac_bundle.json', +/// ), +/// ); +/// ``` +class StacBundleConfig { + /// Creates a [StacBundleConfig] instance. + const StacBundleConfig({ + this.enabled = false, + this.prefetchOnInit = true, + this.baseUrl = 'https://api.stac.dev', + this.seedAsset, + this.checkOnResume = true, + this.pollingInterval, + }); + + /// Whether bundle mode is enabled. + /// + /// Defaults to `false` (opt-in). When disabled, the legacy per-artifact + /// fetch and cache behavior is untouched. + final bool enabled; + + /// Whether to kick off a conditional bundle sync during initialization. + /// + /// Defaults to `true`. The sync is not awaited, so it never delays startup. + final bool prefetchOnInit; + + /// The base URL used for bundle requests. + /// + /// Defaults to `https://api.stac.dev`. + final String baseUrl; + + /// Optional asset path of a seed bundle shipped with the app + /// (e.g. `assets/stac_bundle.json`, written by `stac deploy`). + /// + /// When set, first launch hydrates from the seed instantly — no loading + /// wait, and it works offline. Defaults to `null` (no seed). + final String? seedAsset; + + /// Whether to run a conditional bundle sync when the app returns to the + /// foreground. + /// + /// Defaults to `true`. + final bool checkOnResume; + + /// Optional interval for periodic conditional bundle syncs while the app + /// is foregrounded. + /// + /// Defaults to `null` (polling off). + final Duration? pollingInterval; + + // ───────────────────────────────────────────────────────────────────────── + // Methods + // ───────────────────────────────────────────────────────────────────────── + + /// Creates a copy of this config with the given fields replaced. + StacBundleConfig copyWith({ + bool? enabled, + bool? prefetchOnInit, + String? baseUrl, + String? seedAsset, + bool? checkOnResume, + Duration? pollingInterval, + }) { + return StacBundleConfig( + enabled: enabled ?? this.enabled, + prefetchOnInit: prefetchOnInit ?? this.prefetchOnInit, + baseUrl: baseUrl ?? this.baseUrl, + seedAsset: seedAsset ?? this.seedAsset, + checkOnResume: checkOnResume ?? this.checkOnResume, + pollingInterval: pollingInterval ?? this.pollingInterval, + ); + } + + @override + String toString() { + return 'StacBundleConfig(enabled: $enabled, prefetchOnInit: $prefetchOnInit, ' + 'baseUrl: $baseUrl, seedAsset: $seedAsset, checkOnResume: $checkOnResume, ' + 'pollingInterval: $pollingInterval)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is StacBundleConfig && + other.enabled == enabled && + other.prefetchOnInit == prefetchOnInit && + other.baseUrl == baseUrl && + other.seedAsset == seedAsset && + other.checkOnResume == checkOnResume && + other.pollingInterval == pollingInterval; + } + + @override + int get hashCode { + return Object.hash( + enabled, + prefetchOnInit, + baseUrl, + seedAsset, + checkOnResume, + pollingInterval, + ); + } +} diff --git a/packages/stac/lib/src/services/services.dart b/packages/stac/lib/src/services/services.dart index c9fdb6246..c07ed940e 100644 --- a/packages/stac/lib/src/services/services.dart +++ b/packages/stac/lib/src/services/services.dart @@ -1,2 +1,4 @@ +export 'package:stac/src/services/stac_bundle_service.dart'; +export 'package:stac/src/services/stac_bundle_store.dart'; export 'package:stac/src/services/stac_cache_service.dart'; export 'package:stac/src/services/stac_network_service.dart'; diff --git a/packages/stac/lib/src/services/stac_bundle_service.dart b/packages/stac/lib/src/services/stac_bundle_service.dart new file mode 100644 index 000000000..33937716d --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_service.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; +import 'package:stac/src/services/stac_bundle_store.dart'; +import 'package:stac_logger/stac_logger.dart'; + +/// Reads a string asset; seam for testing seed hydration without a real +/// asset bundle. +typedef StacBundleAssetReader = Future Function(String assetPath); + +/// Service for syncing and serving Stac bundles. +/// +/// A bundle is a single blob containing every screen and theme for a +/// project at one server-owned version. Screens always render from the +/// cached bundle; the body is re-downloaded only when a conditional +/// version check ([sync]) returns a new version (decision: no +/// strategies/TTL — the server-owned version is the only invalidator). +class StacBundleService { + const StacBundleService._(); + + static Dio _dio = _createDio(); + + static Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); + } + + /// Overrides the Dio client used for bundle requests. + @visibleForTesting + static set dio(Dio dio) => _dio = dio; + + static StacBundleStore _store = const SharedPreferencesBundleStore(); + + /// Overrides the store used to persist bundles. + @visibleForTesting + static set store(StacBundleStore store) => _store = store; + + static StacBundleAssetReader _assetReader = rootBundle.loadString; + + /// Overrides the asset reader used for seed bundle hydration. + @visibleForTesting + static set assetReader(StacBundleAssetReader reader) => + _assetReader = reader; + + /// The bundle currently held in memory, if any. + static StacBundle? _bundle; + + /// The bundle currently held in memory, if any. + static StacBundle? get current => _bundle; + + /// Whether hydration (store + seed asset) has completed. + static bool _hydrated = false; + + /// In-flight hydration, deduped across concurrent callers. + static Future? _inFlightHydration; + + /// In-flight sync, deduped so at most one GET runs at a time. + static Future? _inFlightSync; + + static StreamController _updatesController = + StreamController.broadcast(); + + /// Broadcast stream emitting whenever a sync lands a new bundle version, + /// so host apps can prompt the user or trigger a rebuild on their own + /// terms. Nothing is emitted on `304`/`204` (no change) or on errors. + static Stream get updates => _updatesController.stream; + + static StacBundleConfig get _config => StacService.bundleConfig; + + static String get _projectId { + final options = StacService.options; + if (options == null) { + throw Exception('StacOptions is not set'); + } + return options.projectId; + } + + /// Runs a conditional bundle version check against the server. + /// + /// Sends `GET {baseUrl}/bundles?projectId=..&since=` with + /// `If-None-Match` when an etag is cached. A `200` parses, persists and + /// swaps in the new bundle (and emits on [updates]); `304`/`204` keep the + /// current bundle; errors (offline, server failures) return the stale + /// bundle. Concurrent calls are deduped onto a single in-flight request. + /// + /// Set [force] to skip the conditional headers and re-download the body + /// unconditionally. + static Future sync({bool force = false}) { + return _inFlightSync ??= _syncInternal(force: force).whenComplete(() { + _inFlightSync = null; + }); + } + + static Future _syncInternal({required bool force}) async { + final projectId = _projectId; + + // Hydrate first so the request can be conditional on the cached version. + await _hydrate(); + + final cached = _bundle; + try { + final response = await _dio.get( + '${_config.baseUrl}/bundles', + queryParameters: { + 'projectId': projectId, + if (!force && cached != null) 'since': cached.version, + }, + options: Options( + headers: { + if (!force && cached?.etag != null) 'If-None-Match': cached!.etag, + }, + validateStatus: (status) => status != null && status < 500, + ), + ); + + final statusCode = response.statusCode; + + if (statusCode == 200) { + final data = response.data; + if (data is! Map) { + Log.w('StacBundleService: Unexpected bundle response body'); + return cached; + } + + final bundle = _bundleFromResponse( + Map.from(data), + projectId: projectId, + etagHeader: response.headers.value('etag'), + ); + if (bundle == null) { + Log.w('StacBundleService: Bundle response is missing a version'); + return cached; + } + + _bundle = bundle; + await _store.write(bundle); + _updatesController.add(bundle); + Log.d('StacBundleService: Synced bundle v${bundle.version}'); + return bundle; + } + + if (statusCode == 304 || statusCode == 204) { + // Not modified: keep the cached bundle. + return cached; + } + + Log.w( + 'StacBundleService: Bundle sync failed with status $statusCode, ' + 'using ${cached == null ? 'no bundle' : 'stale bundle v${cached.version}'}', + ); + return cached; + } catch (e) { + // Offline or server error: keep serving the stale bundle. + Log.d('StacBundleService: Bundle sync failed ($e), using stale bundle'); + return cached; + } + } + + /// Builds a [StacBundle] from a `200` response body. + /// + /// Returns `null` when the body has no usable version. + static StacBundle? _bundleFromResponse( + Map data, { + required String projectId, + String? etagHeader, + }) { + final version = data['version']; + if (version is! int) return null; + + return StacBundle( + projectId: data['projectId'] as String? ?? projectId, + version: version, + etag: etagHeader ?? data['etag'] as String?, + checksum: data['checksum'] as String?, + fetchedAt: DateTime.now(), + screens: _stringMap(data['screens']), + themes: _stringMap(data['themes']), + ); + } + + static Map _stringMap(dynamic value) { + if (value is! Map) return const {}; + return Map.from(value); + } + + /// Ensures a bundle is loaded, hydrating from on-device storage and the + /// seed asset (highest version wins) without any network call. + /// + /// Only when no bundle exists in either source (first launch of a + /// seedless app) does this await [sync] so the first screens can render. + static Future ensureLoaded() async { + await _hydrate(); + if (_bundle != null) return _bundle; + return sync(); + } + + /// Hydrates the in-memory bundle once from the store and seed asset. + static Future _hydrate() { + if (_hydrated) return Future.value(); + return _inFlightHydration ??= _hydrateInternal().whenComplete(() { + _hydrated = true; + _inFlightHydration = null; + }); + } + + static Future _hydrateInternal() async { + final projectId = _projectId; + + // Read the persisted bundle (schema mismatch reads as empty). + var stored = await _store.read(projectId); + if (stored != null && stored.projectId != projectId) { + // Stale data from another project: clear it. + Log.w( + 'StacBundleService: Stored bundle belongs to project ' + '${stored.projectId}, expected $projectId — clearing', + ); + await _store.clear(projectId); + stored = null; + } + + // Read the seed asset shipped with the app, when configured. + final seed = await _readSeed(projectId); + + // Highest version wins (an app-store update can ship a seed newer than + // the cached bundle, and vice versa). + if (seed != null && (stored == null || seed.version > stored.version)) { + _bundle = seed; + await _store.write(seed); + Log.d('StacBundleService: Hydrated from seed bundle v${seed.version}'); + } else if (stored != null) { + _bundle = stored; + Log.d('StacBundleService: Hydrated from stored bundle v${stored.version}'); + } + } + + /// Loads and parses the seed bundle asset, tolerating a missing or + /// malformed asset. + static Future _readSeed(String projectId) async { + final seedAsset = _config.seedAsset; + if (seedAsset == null) return null; + + try { + final raw = await _assetReader(seedAsset); + final data = jsonDecode(raw); + if (data is! Map) { + Log.w('StacBundleService: Seed asset $seedAsset is not a JSON object'); + return null; + } + + final map = Map.from(data); + // Seed bundles written by `stac deploy` carry no fetchedAt. + map.putIfAbsent('fetchedAt', () => DateTime.now().toIso8601String()); + + final seed = StacBundle.fromJson(map); + if (seed.projectId != projectId) { + Log.w( + 'StacBundleService: Seed bundle belongs to project ' + '${seed.projectId}, expected $projectId — ignoring', + ); + return null; + } + return seed; + } catch (e) { + // Missing or unreadable seed asset is not an error. + Log.d('StacBundleService: No usable seed bundle at $seedAsset ($e)'); + return null; + } + } + + /// Returns the Stac JSON string for the screen [name] from the bundle, + /// or `null` if the screen is not in the bundle. + static Future getScreenJson(String name) async { + final bundle = await ensureLoaded(); + return bundle?.screen(name); + } + + /// Returns the Stac JSON string for the theme [name] from the bundle, + /// or `null` if the theme is not in the bundle. + static Future getThemeJson(String name) async { + final bundle = await ensureLoaded(); + return bundle?.theme(name); + } + + /// Clears the persisted and in-memory bundle for the current project. + static Future clear() async { + _bundle = null; + return _store.clear(_projectId); + } + + /// Resets all static state; for tests only. + @visibleForTesting + static void reset() { + _bundle = null; + _hydrated = false; + _inFlightHydration = null; + _inFlightSync = null; + _dio = _createDio(); + _store = const SharedPreferencesBundleStore(); + _assetReader = rootBundle.loadString; + _updatesController.close(); + _updatesController = StreamController.broadcast(); + } +} diff --git a/packages/stac/lib/src/services/stac_bundle_store.dart b/packages/stac/lib/src/services/stac_bundle_store.dart new file mode 100644 index 000000000..3bfaafeb5 --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_store.dart @@ -0,0 +1,100 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac_logger/stac_logger.dart'; + +/// Storage abstraction for persisting Stac bundles on-device. +/// +/// The default implementation is [SharedPreferencesBundleStore]; hosts and +/// tests can provide their own implementation via +/// `StacBundleService.store`. +abstract class StacBundleStore { + /// Reads the persisted bundle for [projectId]. + /// + /// Returns `null` when no bundle is stored, when the stored blob cannot + /// be parsed, or when the stored schema version does not match. + Future read(String projectId); + + /// Persists [bundle] for its project. + Future write(StacBundle bundle); + + /// Removes the persisted bundle for [projectId]. + Future clear(String projectId); +} + +/// [StacBundleStore] backed by SharedPreferences. +/// +/// Stores the bundle blob at key `stac_bundle_{projectId}` with an int +/// schema version alongside it; a schema mismatch on read is treated as +/// an empty store. +class SharedPreferencesBundleStore implements StacBundleStore { + /// Creates a [SharedPreferencesBundleStore] instance. + const SharedPreferencesBundleStore(); + + /// Schema version of the persisted bundle blob. + /// + /// Bump this when the [StacBundle] persistence format changes in a + /// backward-incompatible way; older blobs are then treated as empty. + static const int bundleSchemaVersion = 1; + + static String _bundleKey(String projectId) => 'stac_bundle_$projectId'; + + static String _schemaKey(String projectId) => + 'stac_bundle_schema_$projectId'; + + @override + Future read(String projectId) async { + try { + final prefs = await SharedPreferences.getInstance(); + + final schemaVersion = prefs.getInt(_schemaKey(projectId)); + if (schemaVersion != bundleSchemaVersion) { + // Missing or mismatched schema version: treat as empty. + return null; + } + + final blob = prefs.getString(_bundleKey(projectId)); + if (blob == null) { + return null; + } + + return StacBundle.fromJsonString(blob); + } catch (e) { + Log.w('StacBundleStore: Failed to read bundle for $projectId: $e'); + return null; + } + } + + @override + Future write(StacBundle bundle) async { + try { + final prefs = await SharedPreferences.getInstance(); + final wroteBlob = await prefs.setString( + _bundleKey(bundle.projectId), + bundle.toJsonString(), + ); + final wroteSchema = await prefs.setInt( + _schemaKey(bundle.projectId), + bundleSchemaVersion, + ); + return wroteBlob && wroteSchema; + } catch (e) { + Log.w( + 'StacBundleStore: Failed to write bundle for ${bundle.projectId}: $e', + ); + return false; + } + } + + @override + Future clear(String projectId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final removedBlob = await prefs.remove(_bundleKey(projectId)); + final removedSchema = await prefs.remove(_schemaKey(projectId)); + return removedBlob && removedSchema; + } catch (e) { + Log.w('StacBundleStore: Failed to clear bundle for $projectId: $e'); + return false; + } + } +} diff --git a/packages/stac/lib/src/services/stac_bundle_updater.dart b/packages/stac/lib/src/services/stac_bundle_updater.dart new file mode 100644 index 000000000..08694d7f7 --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_updater.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; + +/// Keeps the Stac bundle fresh while the app runs. +/// +/// Registered by `StacService.initialize` when bundle mode is enabled. +/// Runs a conditional bundle sync on foreground resume (when +/// `StacBundleConfig.checkOnResume`) and on an optional periodic timer +/// (`StacBundleConfig.pollingInterval`) that only runs while the app is +/// foregrounded (cancelled on pause, restarted on resume). +/// +/// Every trigger is just the cheap conditional GET — a `304` is a no-op +/// and is not billed. +class StacBundleUpdater with WidgetsBindingObserver { + StacBundleUpdater._(); + + static StacBundleUpdater? _instance; + + /// The registered updater instance, if started; for tests only. + @visibleForTesting + static StacBundleUpdater? get instance => _instance; + + Timer? _pollingTimer; + + /// Registers the updater as a lifecycle observer and starts the polling + /// timer when configured. Does nothing if already started. + static void start() { + if (_instance != null) return; + + final updater = StacBundleUpdater._(); + WidgetsBinding.instance.addObserver(updater); + // The app starts foregrounded; arm the polling timer right away. + updater._startPollingTimer(); + _instance = updater; + } + + /// Unregisters the updater and cancels any polling timer. + static void stop() { + final updater = _instance; + if (updater == null) return; + + updater._cancelPollingTimer(); + WidgetsBinding.instance.removeObserver(updater); + _instance = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + if (StacService.bundleConfig.checkOnResume) { + unawaited(StacBundleService.sync()); + } + _startPollingTimer(); + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _cancelPollingTimer(); + case AppLifecycleState.inactive: + // Transient state (e.g. system dialogs): keep the timer running. + break; + } + } + + /// Starts the periodic conditional sync when [StacBundleConfig.pollingInterval] + /// is set and no timer is already running. + void _startPollingTimer() { + final interval = StacService.bundleConfig.pollingInterval; + if (interval == null) return; + + _pollingTimer ??= Timer.periodic(interval, (_) { + unawaited(StacBundleService.sync()); + }); + } + + void _cancelPollingTimer() { + _pollingTimer?.cancel(); + _pollingTimer = null; + } +} diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index 283516a2a..6184d8728 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -3,6 +3,7 @@ import 'package:stac/src/framework/stac_service.dart'; import 'package:stac/src/models/stac_artifact_type.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/models/stac_cache.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; import 'package:stac/src/services/stac_cache_service.dart'; import 'package:stac_logger/stac_logger.dart'; @@ -61,6 +62,24 @@ class StacCloud { throw Exception('StacOptions is not set'); } + // Bundle mode: serve the artifact from the cached bundle (decision 10 — + // renders never hit the network; sync keeps the bundle fresh). + if (StacService.bundleConfig.enabled) { + final bundleResponse = await _fetchArtifactFromBundle( + artifactType: artifactType, + artifactName: artifactName, + ); + if (bundleResponse != null) { + return bundleResponse; + } + // Absent from the bundle (deleted vs. not-yet-bundled is + // indistinguishable): fall through to the legacy per-artifact fetch. + Log.w( + 'StacCloud: ${artifactType.name} $artifactName not found in bundle, ' + 'falling back to per-artifact fetch', + ); + } + final cacheConfig = StacService.defaultCacheConfig; // Handle network-only strategy @@ -284,6 +303,34 @@ class StacCloud { return response; } + /// Fetches an artifact from the cached bundle (bundle mode). + /// + /// Returns a [Response] shaped exactly like [_buildArtifactCacheResponse], + /// or `null` when the artifact is absent from the bundle. + static Future _fetchArtifactFromBundle({ + required StacArtifactType artifactType, + required String artifactName, + }) async { + final stacJson = switch (artifactType) { + StacArtifactType.screen => await StacBundleService.getScreenJson( + artifactName, + ), + StacArtifactType.theme => await StacBundleService.getThemeJson( + artifactName, + ), + }; + if (stacJson == null) return null; + + return Response( + requestOptions: RequestOptions(path: _getFetchUrl(artifactType)), + data: { + 'name': artifactName, + 'stacJson': stacJson, + 'version': StacBundleService.current?.version ?? 0, + }, + ); + } + /// Builds a Response from cached artifact data. static Response _buildArtifactCacheResponse( StacArtifactType artifactType, diff --git a/packages/stac/test/models/stac_bundle_test.dart b/packages/stac/test/models/stac_bundle_test.dart new file mode 100644 index 000000000..d12a2d235 --- /dev/null +++ b/packages/stac/test/models/stac_bundle_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stac/src/models/stac_bundle.dart'; + +void main() { + final bundle = StacBundle( + projectId: 'proj_1', + version: 3, + etag: '"3"', + checksum: 'abc123', + fetchedAt: DateTime.utc(2026, 7, 3, 12), + screens: const { + 'home': '{"type":"text","data":"Home"}', + 'profile': '{"type":"text","data":"Profile"}', + }, + themes: const {'light': '{"brightness":"light"}'}, + ); + + group('StacBundle', () { + test('round-trips through toJson/fromJson', () { + final restored = StacBundle.fromJson(bundle.toJson()); + + expect(restored.projectId, bundle.projectId); + expect(restored.version, bundle.version); + expect(restored.etag, bundle.etag); + expect(restored.checksum, bundle.checksum); + expect(restored.fetchedAt, bundle.fetchedAt); + expect(restored.screens, bundle.screens); + expect(restored.themes, bundle.themes); + }); + + test('round-trips through toJsonString/fromJsonString', () { + final restored = StacBundle.fromJsonString(bundle.toJsonString()); + + expect(restored.projectId, bundle.projectId); + expect(restored.version, bundle.version); + expect(restored.etag, bundle.etag); + expect(restored.checksum, bundle.checksum); + expect(restored.fetchedAt, bundle.fetchedAt); + expect(restored.screens, bundle.screens); + expect(restored.themes, bundle.themes); + }); + + test('round-trips null etag and checksum', () { + final minimal = StacBundle( + projectId: 'proj_1', + version: 1, + fetchedAt: DateTime.utc(2026), + screens: const {}, + themes: const {}, + ); + + final restored = StacBundle.fromJsonString(minimal.toJsonString()); + + expect(restored.etag, isNull); + expect(restored.checksum, isNull); + expect(restored.screens, isEmpty); + expect(restored.themes, isEmpty); + }); + + test('screen and theme lookups return json or null', () { + expect(bundle.screen('home'), '{"type":"text","data":"Home"}'); + expect(bundle.theme('light'), '{"brightness":"light"}'); + expect(bundle.screen('missing'), isNull); + expect(bundle.theme('missing'), isNull); + }); + + test('copyWith replaces given fields and keeps the rest', () { + final copy = bundle.copyWith(version: 4, etag: '"4"'); + + expect(copy.version, 4); + expect(copy.etag, '"4"'); + expect(copy.projectId, bundle.projectId); + expect(copy.checksum, bundle.checksum); + expect(copy.fetchedAt, bundle.fetchedAt); + expect(copy.screens, bundle.screens); + expect(copy.themes, bundle.themes); + }); + }); +} diff --git a/packages/stac/test/services/stac_bundle_service_test.dart b/packages/stac/test/services/stac_bundle_service_test.dart new file mode 100644 index 000000000..abf20f42d --- /dev/null +++ b/packages/stac/test/services/stac_bundle_service_test.dart @@ -0,0 +1,589 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; +import 'package:stac/src/services/stac_bundle_store.dart'; +import 'package:stac/src/services/stac_bundle_updater.dart'; +import 'package:stac_core/stac_core.dart'; + +/// Fake [HttpClientAdapter] that records requests and answers via [handler]. +class _FakeHttpClientAdapter implements HttpClientAdapter { + ResponseBody Function(RequestOptions options) handler = (options) { + throw StateError('Unexpected network request to ${options.uri}'); + }; + + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +/// [StacBundleStore] spy that counts operations and delegates to [inner]. +class _SpyBundleStore implements StacBundleStore { + final StacBundleStore inner = const SharedPreferencesBundleStore(); + int readCount = 0; + int writeCount = 0; + int clearCount = 0; + + @override + Future read(String projectId) { + readCount++; + return inner.read(projectId); + } + + @override + Future write(StacBundle bundle) { + writeCount++; + return inner.write(bundle); + } + + @override + Future clear(String projectId) { + clearCount++; + return inner.clear(projectId); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const projectId = 'proj_1'; + + late _FakeHttpClientAdapter adapter; + + Map bundleBody({ + String projectId = projectId, + int version = 1, + Map? screens, + Map? themes, + }) { + return { + 'projectId': projectId, + 'version': version, + 'etag': '"$version"', + 'checksum': 'checksum-v$version', + 'screens': screens ?? {'home': '{"type":"text","data":"v$version"}'}, + 'themes': themes ?? {'light': '{"brightness":"light-v$version"}'}, + }; + } + + ResponseBody jsonResponse( + Map body, { + int statusCode = 200, + Map>? headers, + }) { + return ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: ['application/json'], + ...?headers, + }, + ); + } + + ResponseBody statusResponse(int statusCode) { + return ResponseBody.fromString('', statusCode); + } + + StacBundle storedBundle({ + String projectId = projectId, + int version = 1, + Map? screens, + Map? themes, + }) { + return StacBundle( + projectId: projectId, + version: version, + etag: '"$version"', + checksum: 'checksum-v$version', + fetchedAt: DateTime(2026, 7, 1), + screens: screens ?? {'home': '{"type":"text","data":"v$version"}'}, + themes: themes ?? {'light': '{"brightness":"light-v$version"}'}, + ); + } + + Future initStac({ + String projectId = projectId, + StacBundleConfig bundleConfig = const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + ), + }) { + return StacService.initialize( + options: StacOptions(name: 'Test', projectId: projectId), + bundleConfig: bundleConfig, + ); + } + + String? headerOf(RequestOptions options, String name) { + for (final entry in options.headers.entries) { + if (entry.key.toLowerCase() == name.toLowerCase()) { + return entry.value?.toString(); + } + } + return null; + } + + Future pumpUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), + }) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + StacBundleUpdater.stop(); + StacBundleService.reset(); + + adapter = _FakeHttpClientAdapter(); + StacBundleService.dio = Dio()..httpClientAdapter = adapter; + }); + + tearDown(() { + StacBundleUpdater.stop(); + StacBundleService.reset(); + }); + + group('sync', () { + test('200 parses, persists and swaps the bundle', () async { + adapter.handler = (options) => jsonResponse( + bundleBody(version: 1), + headers: { + 'etag': ['"1"'], + }, + ); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result, isNotNull); + expect(result!.version, 1); + expect(result.projectId, projectId); + expect(result.etag, '"1"'); + expect(StacBundleService.current?.version, 1); + + // Persisted blob + schema version. + final prefs = await SharedPreferences.getInstance(); + final blob = prefs.getString('stac_bundle_$projectId'); + expect(blob, isNotNull); + expect(StacBundle.fromJsonString(blob!).version, 1); + expect( + prefs.getInt('stac_bundle_schema_$projectId'), + SharedPreferencesBundleStore.bundleSchemaVersion, + ); + + // The first request is unconditional (nothing cached). + final request = adapter.requests.single; + expect(request.uri.queryParameters['projectId'], projectId); + expect(request.uri.queryParameters.containsKey('since'), isFalse); + expect(headerOf(request, 'If-None-Match'), isNull); + + // Lookups now serve from the synced bundle with no further requests. + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v1"}', + ); + expect(adapter.requests, hasLength(1)); + }); + + test( + 'sends If-None-Match and since; 304 keeps cache without rewrite', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + adapter.handler = (options) => statusResponse(304); + await initStac(); + + final result = await StacBundleService.sync(); + + final request = adapter.requests.single; + expect(request.uri.queryParameters['since'], '1'); + expect(headerOf(request, 'If-None-Match'), '"1"'); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + expect(spy.writeCount, 0); + }, + ); + + test('204 keeps cache without rewrite', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + adapter.handler = (options) => statusResponse(204); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(spy.writeCount, 0); + }); + + test('version bump swaps memory and store', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => jsonResponse(bundleBody(version: 2)); + await initStac(); + + expect(await StacBundleService.getScreenJson('home'), contains('v1')); + + final result = await StacBundleService.sync(); + + expect(result?.version, 2); + expect(StacBundleService.current?.version, 2); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v2"}', + ); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 2); + }); + + test('offline sync returns the stale bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => throw Exception('offline'); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + expect(await StacBundleService.getScreenJson('home'), contains('v1')); + }); + + test('server error status returns the stale bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => + jsonResponse({'error': 'limit'}, statusCode: 403); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + }); + + test('updates emits on 200 and not on 304', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final events = []; + final subscription = StacBundleService.updates.listen(events.add); + addTearDown(subscription.cancel); + + adapter.handler = (options) => jsonResponse(bundleBody(version: 2)); + await StacBundleService.sync(); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + expect(events.single.version, 2); + + adapter.handler = (options) => statusResponse(304); + await StacBundleService.sync(); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + }); + }); + + group('ensureLoaded', () { + test('concurrent calls dedupe to a single network request', () async { + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(); + + final results = await Future.wait([ + StacBundleService.ensureLoaded(), + StacBundleService.ensureLoaded(), + StacBundleService.ensureLoaded(), + ]); + + expect(adapter.requests, hasLength(1)); + for (final result in results) { + expect(result?.version, 1); + } + }); + + test('hydrated cache lookups make zero network calls', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 1); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v1"}', + ); + expect( + await StacBundleService.getThemeJson('light'), + '{"brightness":"light-v1"}', + ); + expect(adapter.requests, isEmpty); + }); + + test('screen absent from the bundle returns null', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + expect(await StacBundleService.getScreenJson('deleted'), isNull); + expect(await StacBundleService.getThemeJson('deleted'), isNull); + expect(adapter.requests, isEmpty); + }); + + test('stored bundle for another project is cleared', () async { + // A blob under this project's key that claims another projectId. + SharedPreferences.setMockInitialValues({ + 'stac_bundle_proj_2': storedBundle( + projectId: projectId, + version: 5, + ).toJsonString(), + 'stac_bundle_schema_proj_2': + SharedPreferencesBundleStore.bundleSchemaVersion, + }); + adapter.handler = (options) => + jsonResponse(bundleBody(projectId: 'proj_2', version: 1)); + await initStac(projectId: 'proj_2'); + + final result = await StacBundleService.ensureLoaded(); + + // The mismatched bundle was discarded, so the request is unconditional. + final request = adapter.requests.single; + expect(request.uri.queryParameters.containsKey('since'), isFalse); + + expect(result?.projectId, 'proj_2'); + expect(result?.version, 1); + + final persisted = await const SharedPreferencesBundleStore().read( + 'proj_2', + ); + expect(persisted?.projectId, 'proj_2'); + expect(persisted?.version, 1); + }); + + test('schema version mismatch reads as empty', () async { + SharedPreferences.setMockInitialValues({ + 'stac_bundle_$projectId': storedBundle(version: 5).toJsonString(), + 'stac_bundle_schema_$projectId': 999, + }); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(); + + final result = await StacBundleService.ensureLoaded(); + + expect(adapter.requests, hasLength(1)); + expect(result?.version, 1); + }); + }); + + group('seed hydration', () { + const seedConfig = StacBundleConfig( + enabled: true, + prefetchOnInit: false, + seedAsset: 'assets/stac_bundle.json', + ); + + /// Seed bundle body as written by `stac deploy` (no fetchedAt). + String seedJson({int version = 3, String projectId = projectId}) { + return jsonEncode({ + 'projectId': projectId, + 'version': version, + 'etag': '"$version"', + 'checksum': 'seed-checksum', + 'screens': {'home': '{"type":"text","data":"seed-v$version"}'}, + 'themes': {'light': '{"brightness":"seed"}'}, + }); + } + + test('empty store hydrates from the seed asset offline', () async { + StacBundleService.assetReader = (path) async { + expect(path, 'assets/stac_bundle.json'); + return seedJson(version: 3); + }; + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + // No network call was made at any point. + expect(adapter.requests, isEmpty); + + // The seed won, so it was persisted for the next launch. + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + expect(persisted?.etag, '"3"'); + }); + + test('newer stored bundle wins over an older seed', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 2), + ); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + StacBundleService.assetReader = (path) async => seedJson(version: 1); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 2); + expect(await StacBundleService.getScreenJson('home'), contains('v2')); + expect(spy.writeCount, 0); + expect(adapter.requests, isEmpty); + }); + + test('newer seed wins over an older stored bundle and is persisted', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 1), + ); + StacBundleService.assetReader = (path) async => seedJson(version: 3); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + expect(adapter.requests, isEmpty); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + }); + + test('missing seed asset is tolerated', () async { + StacBundleService.assetReader = (path) async { + throw FlutterError('Unable to load asset: $path'); + }; + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + // Falls back to a network sync as if no seed were configured. + expect(result?.version, 1); + expect(adapter.requests, hasLength(1)); + }); + + test('seed for another project is ignored', () async { + StacBundleService.assetReader = (path) async => + seedJson(version: 9, projectId: 'other_project'); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 1); + expect(adapter.requests, hasLength(1)); + }); + }); + + group('StacBundleUpdater', () { + test('resume triggers a conditional sync', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac(); + + final updater = StacBundleUpdater.instance; + expect(updater, isNotNull); + + updater!.didChangeAppLifecycleState(AppLifecycleState.resumed); + await pumpUntil(() => adapter.requests.isNotEmpty); + + final request = adapter.requests.single; + expect(request.uri.queryParameters['since'], '1'); + expect(headerOf(request, 'If-None-Match'), '"1"'); + }); + + test('resume does not sync when checkOnResume is off', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + ), + ); + + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.resumed, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(adapter.requests, isEmpty); + }); + + test('polling interval triggers conditional syncs and pause cancels them', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + pollingInterval: Duration(milliseconds: 20), + ), + ); + + await pumpUntil(() => adapter.requests.isNotEmpty); + expect(adapter.requests, isNotEmpty); + + // Pausing cancels the timer: no further requests come in. + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.paused, + ); + await Future.delayed(const Duration(milliseconds: 30)); + final countAfterPause = adapter.requests.length; + await Future.delayed(const Duration(milliseconds: 60)); + expect(adapter.requests.length, countAfterPause); + }); + }); + + group('clear', () { + test('removes the persisted and in-memory bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + expect(await StacBundleService.ensureLoaded(), isNotNull); + + await StacBundleService.clear(); + + expect(StacBundleService.current, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stac_bundle_$projectId'), isNull); + expect(prefs.getInt('stac_bundle_schema_$projectId'), isNull); + }); + }); +} diff --git a/packages/stac_cli/CHANGELOG.md b/packages/stac_cli/CHANGELOG.md index 3f8a9ad35..9d6c6c4f5 100644 --- a/packages/stac_cli/CHANGELOG.md +++ b/packages/stac_cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.7.0 + +- feat: Bundle deploys — `stac deploy` now publishes all screens and themes in a single atomic `POST /bundles` request (any failure exits non-zero with nothing partially applied). +- feat: Write a seed bundle to `assets/stac_bundle.json` after every successful deploy so first app launches can hydrate instantly and offline (warns when the asset is not declared in pubspec.yaml). +- feat: Add `--legacy` flag to `stac deploy` to fall back to the previous per-file screen/theme uploads. +- fix: `stac build` clears previous `screens`/`themes` outputs before writing, so deleted screens no longer leave stale JSON behind that gets re-deployed. + ## 1.6.0 - feat: Add console URL logging for successful deployments. diff --git a/packages/stac_cli/lib/src/commands/deploy_command.dart b/packages/stac_cli/lib/src/commands/deploy_command.dart index 5d294f164..18435eec2 100644 --- a/packages/stac_cli/lib/src/commands/deploy_command.dart +++ b/packages/stac_cli/lib/src/commands/deploy_command.dart @@ -31,12 +31,19 @@ class DeployCommand extends BaseCommand { help: 'Skip building before deployment (deploy existing files)', negatable: false, ); + argParser.addFlag( + 'legacy', + help: + 'Use the legacy per-file screen/theme uploads instead of the atomic bundle deploy', + negatable: false, + ); } @override Future execute() async { final projectPath = argResults?['project'] as String?; final skipBuild = argResults?['skip-build'] as bool? ?? false; + final legacy = argResults?['legacy'] as bool? ?? false; try { // Build before deploying unless --skip-build is specified @@ -58,7 +65,7 @@ class DeployCommand extends BaseCommand { } // Deploy the built files - await _deployService.deploy(projectPath: projectPath); + await _deployService.deploy(projectPath: projectPath, legacy: legacy); return 0; } catch (e) { ConsoleLogger.error('Deployment failed: $e'); diff --git a/packages/stac_cli/lib/src/services/build_service.dart b/packages/stac_cli/lib/src/services/build_service.dart index c9bd20b66..01babeb02 100644 --- a/packages/stac_cli/lib/src/services/build_service.dart +++ b/packages/stac_cli/lib/src/services/build_service.dart @@ -30,10 +30,13 @@ class BuildService { final outputDirPath = path.join(projectDir, options.outputDir); await Directory(outputDirPath).create(recursive: true); - // Clear the output directory before generating new files - await _clearOutputDirectory(outputDirPath); + // Clear previous screen/theme outputs before generating new files so a + // deleted DSL screen or theme can't leave a stale JSON behind that gets + // re-deployed with every bundle. Only these two directories are cleared. final screensOutputDir = path.join(outputDirPath, 'screens'); final themesOutputDir = path.join(outputDirPath, 'themes'); + await _clearOutputDirectory(screensOutputDir); + await _clearOutputDirectory(themesOutputDir); await Directory(screensOutputDir).create(recursive: true); await Directory(themesOutputDir).create(recursive: true); diff --git a/packages/stac_cli/lib/src/services/deploy_service.dart b/packages/stac_cli/lib/src/services/deploy_service.dart index b7420f5ae..a6089acf8 100644 --- a/packages/stac_cli/lib/src/services/deploy_service.dart +++ b/packages/stac_cli/lib/src/services/deploy_service.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; import 'package:path/path.dart' as path; import '../config/env.dart'; @@ -10,12 +13,26 @@ import '../utils/http_client.dart'; /// Service for deploying Stac JSON files to the cloud class DeployService { - final HttpClientService _httpClient = HttpClientService.instance; + /// Creates a deploy service. + /// + /// [httpClient] is injectable for testing; when omitted the shared + /// [HttpClientService.instance] is used (resolved lazily so constructing + /// the service never touches the network configuration). + DeployService({HttpClientService? httpClient}) + : _httpClientOverride = httpClient; - /// Deploy all built JSON files (from stac/.build) to the Screens API - /// - Reads projectId from lib/default_stac_options.dart - /// - For each {name}.json in stac/.build, POST to Cloud Function "screens" - Future deploy({String? projectPath}) async { + final HttpClientService? _httpClientOverride; + + HttpClientService get _httpClient => + _httpClientOverride ?? HttpClientService.instance; + + /// Deploy all built JSON files (from stac/.build) to the cloud. + /// + /// Default: assembles all screens and themes into a single bundle and + /// publishes it atomically with one POST to the Bundles API — any failure + /// leaves the project untouched. Pass [legacy] to use the previous + /// per-file `/screens` + `/themes` uploads instead. + Future deploy({String? projectPath, bool legacy = false}) async { final projectDir = projectPath ?? Directory.current.path; // Read projectId from default_stac_options.dart @@ -35,6 +52,266 @@ class DeployService { ); } + if (legacy) { + await _deployLegacy(projectId: projectId, buildDirPath: buildDirPath); + return; + } + + await _deployBundle( + projectDir: projectDir, + projectId: projectId, + buildDirPath: buildDirPath, + ); + } + + /// Computes the advisory sha256 checksum for a bundle. + /// + /// The digest is taken over a deterministic JSON document — + /// `{projectId, screens, themes}` with fixed top-level key order and + /// screen/theme map keys sorted — so the result is stable regardless of + /// map insertion order. The server recomputes and owns the authoritative + /// checksum; this value is advisory (used for the server-side no-op check) + /// and a mismatch alone never fails a deploy. + static String computeBundleChecksum({ + required String projectId, + required Map screens, + required Map themes, + }) { + Map sortByKey(Map map) { + final keys = map.keys.toList()..sort(); + return {for (final key in keys) key: map[key]!}; + } + + final canonicalJson = jsonEncode({ + 'projectId': projectId, + 'screens': sortByKey(screens), + 'themes': sortByKey(themes), + }); + return sha256.convert(utf8.encode(canonicalJson)).toString(); + } + + /// Publish all built screens/themes as a single atomic bundle. + Future _deployBundle({ + required String projectDir, + required String projectId, + required String buildDirPath, + }) async { + ConsoleLogger.info('Deploying bundle to cloud...'); + ConsoleLogger.debug('Project ID: $projectId'); + + final screens = await _readArtifactDirectory( + path.join(buildDirPath, 'screens'), + label: 'screen', + ); + final themes = await _readArtifactDirectory( + path.join(buildDirPath, 'themes'), + label: 'theme', + ); + + if (screens.isEmpty && themes.isEmpty) { + throw StacException( + 'No built screens or themes found in $buildDirPath. Run "stac build" first.', + ); + } + + final checksum = computeBundleChecksum( + projectId: projectId, + screens: screens, + themes: themes, + ); + + final bundlesApiUrl = _resolveBundlesApiUrl(); + ConsoleLogger.debug('Bundles API: $bundlesApiUrl'); + + final payload = { + 'projectId': projectId, + 'checksum': checksum, + 'screens': screens, + 'themes': themes, + }; + final payloadBytes = utf8.encode(jsonEncode(payload)).length; + ConsoleLogger.info( + 'Bundle contents: ${screens.length} screen(s), ${themes.length} theme(s) — payload ${_formatBytes(payloadBytes)} ($payloadBytes bytes)', + ); + + Response response; + try { + response = await _httpClient.post(bundlesApiUrl, data: payload); + } on StacException { + rethrow; + } catch (e) { + throw StacException('Bundle deploy failed: $e'); + } + + final status = response.statusCode; + if (status != 200 && status != 201) { + throw StacException( + 'Bundle deploy failed: unexpected response (${status ?? 'no-status'}) from $bundlesApiUrl.', + ); + } + + final body = _decodeResponseBody(response.data); + final version = body['version']; + + // The server's checksum is authoritative — ours is advisory only. + final serverChecksum = body['checksum']?.toString(); + if (serverChecksum != null && serverChecksum != checksum) { + ConsoleLogger.debug( + 'Local checksum ($checksum) differs from server checksum ($serverChecksum). Using the server value.', + ); + } + + if (status == 201) { + ConsoleLogger.success('✓ Deployed bundle v$version'); + } else { + ConsoleLogger.info('No changes — bundle v$version already current'); + } + + await _writeSeedAsset( + projectDir: projectDir, + projectId: projectId, + responseBody: body, + screens: screens, + themes: themes, + ); + + final consoleUrl = 'https://console.stac.dev/project/$projectId'; + ConsoleLogger.info( + 'Open your project in the Stac Console to inspect your screens and themes: $consoleUrl', + ); + } + + /// Read every `*.json` file in [dirPath] into a map keyed by the file name + /// without its `.json` extension. + Future> _readArtifactDirectory( + String dirPath, { + required String label, + }) async { + final artifacts = {}; + final dir = Directory(dirPath); + if (!await dir.exists()) { + ConsoleLogger.debug('No $label output found at $dirPath. Skipping.'); + return artifacts; + } + + await for (final entity in dir.list()) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + final name = path.basenameWithoutExtension(entity.path); + artifacts[name] = await entity.readAsString(); + } + return artifacts; + } + + /// Write the just-published bundle body to `/assets/stac_bundle.json` + /// so first app launches can hydrate instantly (and offline) from the seed. + /// + /// `version`/`etag`/`checksum` come from the deploy response — the server + /// is authoritative; a locally invented version is never written. + Future _writeSeedAsset({ + required String projectDir, + required String projectId, + required Map responseBody, + required Map screens, + required Map themes, + }) async { + final assetsDirPath = path.join(projectDir, 'assets'); + final seedPath = path.join(assetsDirPath, 'stac_bundle.json'); + + try { + await Directory(assetsDirPath).create(recursive: true); + final seed = { + 'projectId': projectId, + 'version': responseBody['version'], + 'etag': responseBody['etag'], + 'checksum': responseBody['checksum'], + 'screens': screens, + 'themes': themes, + }; + await File(seedPath).writeAsString(jsonEncode(seed)); + ConsoleLogger.info( + 'Seed bundle written to ${path.relative(seedPath, from: projectDir)}', + ); + } catch (e) { + // The bundle is already live server-side; a seed write failure must not + // turn a successful deploy into a failure — warn and move on. + ConsoleLogger.warning('Could not write seed bundle to $seedPath: $e'); + return; + } + + await _warnIfSeedAssetNotDeclared(projectDir); + } + + /// Warn when `assets/stac_bundle.json` is not declared under + /// `flutter/assets` in the app's pubspec.yaml. Never modifies the pubspec. + Future _warnIfSeedAssetNotDeclared(String projectDir) async { + const assetEntry = 'assets/stac_bundle.json'; + final pubspecPath = path.join(projectDir, 'pubspec.yaml'); + + var declared = false; + try { + final pubspec = await FileUtils.readYamlFile(pubspecPath); + final flutterSection = pubspec?['flutter']; + if (flutterSection is Map) { + final assets = flutterSection['assets']; + if (assets is List) { + declared = assets.any((entry) { + final value = entry?.toString().trim(); + return value == assetEntry || + value == 'assets/' || + value == 'assets'; + }); + } + } + } catch (e) { + ConsoleLogger.debug('Could not parse $pubspecPath: $e'); + } + + if (declared) return; + ConsoleLogger.warning( + 'The seed bundle is not declared as a Flutter asset in pubspec.yaml.\n' + ' Add it so first launches can render instantly (and offline):\n' + ' flutter:\n' + ' assets:\n' + ' - $assetEntry', + ); + } + + Map _decodeResponseBody(dynamic data) { + try { + dynamic decoded = data; + if (decoded is String && decoded.isNotEmpty) { + decoded = jsonDecode(decoded); + } + if (decoded is Map) { + final map = Map.from(decoded); + // Tolerate `{..., data: {...}}` envelopes. + if (map['version'] == null && map['data'] is Map) { + return Map.from(map['data'] as Map); + } + return map; + } + } catch (e) { + ConsoleLogger.debug('Could not parse bundle deploy response body: $e'); + } + return const {}; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; + } + + // --------------------------------------------------------------------- + // Legacy per-file deploy (kept behind `stac deploy --legacy` for one + // release, then delete). + // --------------------------------------------------------------------- + + /// Deploy each built JSON file one at a time to the Screens/Themes APIs. + Future _deployLegacy({ + required String projectId, + required String buildDirPath, + }) async { ConsoleLogger.info('Deploying screens/themes to cloud...'); ConsoleLogger.debug('Project ID: $projectId'); @@ -182,6 +459,11 @@ class DeployService { return match?.group(1); } + /// Resolve Cloud Function endpoint for bundles.save + String _resolveBundlesApiUrl() { + return '${env.baseApiUrl}/bundles'; + } + /// Resolve Cloud Function endpoint for screens.save String _resolveScreensApiUrl() { // Use current environment's base URL + /screens endpoint diff --git a/packages/stac_cli/pubspec.lock b/packages/stac_cli/pubspec.lock index 4fe9fd6bf..3bc09704f 100644 --- a/packages/stac_cli/pubspec.lock +++ b/packages/stac_cli/pubspec.lock @@ -162,7 +162,7 @@ packages: source: hosted version: "1.15.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/packages/stac_cli/pubspec.yaml b/packages/stac_cli/pubspec.yaml index 89c523dde..0ee8d6c3d 100644 --- a/packages/stac_cli/pubspec.yaml +++ b/packages/stac_cli/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: json_annotation: ^4.11.0 dotenv: ^4.2.0 cryptography: ^2.9.0 + crypto: ^3.0.3 # Executables that can be run globally executables: diff --git a/packages/stac_cli/test/services/deploy_service_test.dart b/packages/stac_cli/test/services/deploy_service_test.dart new file mode 100644 index 000000000..3123f61c2 --- /dev/null +++ b/packages/stac_cli/test/services/deploy_service_test.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; +import 'package:stac_cli/src/config/env.dart'; +import 'package:stac_cli/src/exceptions/stac_exception.dart'; +import 'package:stac_cli/src/services/deploy_service.dart'; +import 'package:stac_cli/src/utils/http_client.dart'; +import 'package:test/test.dart'; + +const _projectId = 'test-project'; + +/// Hand-rolled fake for [HttpClientService]; records every POST and answers +/// with the injected handler. +class _FakeHttpClientService implements HttpClientService { + _FakeHttpClientService(this._onPost); + + final Future> Function(String url, dynamic data) _onPost; + + final List<({String url, dynamic data})> postCalls = []; + + @override + Future> post(String path, {dynamic data}) async { + postCalls.add((url: path, data: data)); + return _onPost(path, data); + } + + @override + Future> get( + String path, { + Map? queryParameters, + }) => throw UnimplementedError('GET is not expected in these tests'); + + @override + Future> put(String path, {dynamic data}) => + throw UnimplementedError('PUT is not expected in these tests'); + + @override + Future> delete(String path) => + throw UnimplementedError('DELETE is not expected in these tests'); +} + +Response _jsonResponse( + String url, + int statusCode, + Map body, +) { + return Response( + requestOptions: RequestOptions(path: url), + statusCode: statusCode, + data: body, + ); +} + +/// Creates a temporary fixture project with `lib/default_stac_options.dart` +/// and a populated `stac/.build` directory. Returns the project path. +Future _createFixtureProject({ + Map screens = const {}, + Map themes = const {}, +}) async { + final dir = await Directory.systemTemp.createTemp('stac_cli_deploy_test_'); + final projectDir = dir.path; + + final optionsFile = File( + p.join(projectDir, 'lib', 'default_stac_options.dart'), + ); + await optionsFile.create(recursive: true); + await optionsFile.writeAsString(''' +const defaultStacOptions = StacOptions( + name: 'Test', + description: 'Test project', + projectId: '$_projectId', + sourceDir: 'stac', + outputDir: 'stac/.build', +); +'''); + + await Directory(p.join(projectDir, 'stac', '.build')).create(recursive: true); + for (final entry in screens.entries) { + final file = File( + p.join(projectDir, 'stac', '.build', 'screens', '${entry.key}.json'), + ); + await file.create(recursive: true); + await file.writeAsString(entry.value); + } + for (final entry in themes.entries) { + final file = File( + p.join(projectDir, 'stac', '.build', 'themes', '${entry.key}.json'), + ); + await file.create(recursive: true); + await file.writeAsString(entry.value); + } + + addTearDown(() => dir.delete(recursive: true)); + return projectDir; +} + +void main() { + const screensFixture = { + 'home': '{"type":"scaffold","body":{"type":"text","data":"Home"}}', + 'profile': '{"type":"scaffold","body":{"type":"text","data":"Profile"}}', + }; + const themesFixture = {'light': '{"brightness":"light"}'}; + + setUpAll(() { + // Values are only used as fallbacks when the real variables are absent + // from the process environment (configureEnvironment keeps OS env wins). + configureEnvironment({ + 'STAC_BASE_API_URL': 'https://api.stac.test', + 'STAC_GOOGLE_CLIENT_ID': 'test-client-id', + 'STAC_FIREBASE_API_KEY': 'test-firebase-key', + }); + }); + + group('DeployService.deploy (bundle mode)', () { + test( + 'sends a single POST to /bundles with correctly assembled maps', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 3, + 'etag': '"3"', + 'checksum': 'server-checksum', + 'deployedAt': '2026-07-03T00:00:00.000Z', + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + expect(client.postCalls, hasLength(1)); + final call = client.postCalls.single; + expect(call.url, '${env.baseApiUrl}/bundles'); + + final payload = call.data as Map; + expect(payload['projectId'], _projectId); + expect(payload['screens'], equals(screensFixture)); + expect(payload['themes'], equals(themesFixture)); + expect( + payload['checksum'], + DeployService.computeBundleChecksum( + projectId: _projectId, + screens: screensFixture, + themes: themesFixture, + ), + ); + }, + ); + + test('writes the seed asset using the server response version', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 7, + 'etag': '"7"', + 'checksum': 'authoritative-server-checksum', + 'deployedAt': '2026-07-03T00:00:00.000Z', + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + final seedFile = File(p.join(projectDir, 'assets', 'stac_bundle.json')); + expect(seedFile.existsSync(), isTrue); + + final seed = + jsonDecode(await seedFile.readAsString()) as Map; + expect(seed['projectId'], _projectId); + expect(seed['version'], 7); + expect(seed['etag'], '"7"'); + expect(seed['checksum'], 'authoritative-server-checksum'); + expect(seed['screens'], equals(screensFixture)); + expect(seed['themes'], equals(themesFixture)); + }); + + test( + 'treats a 200 noop response as success and refreshes the seed', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 200, { + 'projectId': _projectId, + 'version': 5, + 'etag': '"5"', + 'checksum': 'unchanged-checksum', + 'noop': true, + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + expect(client.postCalls, hasLength(1)); + final seedFile = File(p.join(projectDir, 'assets', 'stac_bundle.json')); + final seed = + jsonDecode(await seedFile.readAsString()) as Map; + expect(seed['version'], 5); + }, + ); + + test('throws StacException on a non-2xx response', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => + _jsonResponse(url, 500, {'error': 'internal error'}), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + + // Atomic: nothing was applied, so no seed asset is written either. + expect( + File(p.join(projectDir, 'assets', 'stac_bundle.json')).existsSync(), + isFalse, + ); + }); + + test('propagates HTTP-layer failures as StacException', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => throw StacException( + 'HTTP request failed (503) for $url: service unavailable', + ), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + }); + + test('throws StacException when there is nothing to deploy', () async { + final projectDir = await _createFixtureProject(); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, const {}), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + expect(client.postCalls, isEmpty); + }); + }); + + group('DeployService.computeBundleChecksum', () { + test('is stable across map insertion orders', () { + final a = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '1', 'b': '2'}, + themes: {'x': '9', 'y': '8'}, + ); + final b = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'b': '2', 'a': '1'}, + themes: {'y': '8', 'x': '9'}, + ); + expect(a, b); + }); + + test('changes when content changes', () { + final a = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '1'}, + themes: {}, + ); + final b = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '2'}, + themes: {}, + ); + final c = DeployService.computeBundleChecksum( + projectId: 'other-project', + screens: {'a': '1'}, + themes: {}, + ); + expect(a, isNot(b)); + expect(a, isNot(c)); + }); + }); +} From e24e705602011d958dacb532945b5349d2e65688 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 15 Jul 2026 17:08:11 +0530 Subject: [PATCH 2/2] fix(bundles): ensure widgets binding before registering the bundle updater Stac.initialize runs before runApp in most apps, so WidgetsBinding.instance may not exist when bundle mode registers its lifecycle observer. Create the binding defensively (idempotent) instead of requiring every app to add WidgetsFlutterBinding.ensureInitialized before Stac.initialize. --- packages/stac/lib/src/services/stac_bundle_updater.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/stac/lib/src/services/stac_bundle_updater.dart b/packages/stac/lib/src/services/stac_bundle_updater.dart index 08694d7f7..7d4dfa874 100644 --- a/packages/stac/lib/src/services/stac_bundle_updater.dart +++ b/packages/stac/lib/src/services/stac_bundle_updater.dart @@ -30,6 +30,11 @@ class StacBundleUpdater with WidgetsBindingObserver { static void start() { if (_instance != null) return; + // Stac.initialize is typically called before runApp, so the widgets + // binding may not exist yet; create it before registering a lifecycle + // observer (idempotent). Also required for seed-asset loading. + WidgetsFlutterBinding.ensureInitialized(); + final updater = StacBundleUpdater._(); WidgetsBinding.instance.addObserver(updater); // The app starts foregrounded; arm the polling timer right away.