Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/ensemble/doc/layout-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ renders only the tab navigation. Both are backed by `BaseTabBar`,
| EDL field or method | Behavior |
| --- | --- |
| `items` | List of tab definitions. Each item must provide at least one of `label`, `icon`, or `tabWidget`. |
| `testId` | Optional stable test key on a tab item. Test actions can target it without wrapping the label in a custom widget. |
| `body`, `widget`, `bodyWidget` | Aliases for the body rendered when the tab is selected. |
| `tabWidget`, `tabItem` | Aliases for a custom tab label widget. |
| `selectedIndex` | Initial and current selected tab index. Out-of-range values are reset to `0` when tabs are rebuilt. |
Expand Down Expand Up @@ -44,6 +45,7 @@ View:
Text:
text: Account overview
- label: Activity
testId: activityTab
visible: ${account.showActivity == true}
body:
ListView:
Expand Down
142 changes: 106 additions & 36 deletions modules/ensemble/lib/ensemble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,42 +187,8 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver {
);
}

// environment variable overrides
// Load all sources, then merge with precedence:
// 1) environmentVariables in ensemble-config.yaml
// 2) .env.config
// 3) appConfig.json envVariables
final Map<String, dynamic> envOverrides = {};
try {
dynamic path = yamlMap["definitions"]?['local']?["path"];
final configString =
await rootBundle.loadString('${path}/config/appConfig.json');
final Map<String, dynamic> configMap = json.decode(configString);
if (configMap["envVariables"] != null) {
configMap["envVariables"].forEach((key, value) {
envOverrides[key] = value;
});
}
} catch (e) {
// ignore missing/invalid appConfig.json
}

// 2) .env.config (middle priority)
dynamic localPath = yamlMap["definitions"]?['local']?["path"];
if (localPath is String && localPath.isNotEmpty) {
_mergeWithOverwrite(
envOverrides, await _loadDotEnvConfigFile('$localPath/.env.config'));
}
_mergeWithOverwrite(
envOverrides, await _loadDotEnvConfigFile('.env.config'));
_mergeWithOverwrite(
envOverrides, await _loadDotEnvConfigFile('ensemble/.env.config'));

// 1) environmentVariables from ensemble-config.yaml (highest priority)
dynamic env = yamlMap['environmentVariables'];
if (env is YamlMap) {
env.forEach((key, value) => envOverrides[key.toString()] = value);
}
final Map<String, dynamic> envOverrides =
await loadEnvironmentOverrides(yamlMap);

DefinitionProvider definitionProvider = DefinitionProvider.from(yamlMap);
_config = EnsembleConfig(
Expand All @@ -232,6 +198,13 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver {
services: Services.fromYaml(yamlMap['services']),
signInServices: SignInServices.fromYaml(yamlMap['services']),
envOverrides: envOverrides);
// Keep the resolved environment available to runtime services too. EnvConfig
// already resolves these values from envOverrides, but API provider
// initialization reads UserAppConfig.envVariables directly. Without this
// merge, values supplied only in .env.config (or the app-level YAML) work
// in bindings while provider configuration silently falls back to the
// possibly stale appConfig.json copy.
applyEnvironmentOverrides(_config!, envOverrides);
// Initializing Local Assets Service to store available local assets names
if (!LocalAssetsService.isInitialized) {
await LocalAssetsService.initialize(
Expand All @@ -251,6 +224,58 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver {
return yamlMap['definitions']?['ensemble']?['appId'];
}

/// Resolves environment values for runtime startup and host test harnesses.
/// Precedence is appConfig.json, app-scoped/root .env.config, then the
/// environmentVariables map in ensemble-config.yaml.
Future<Map<String, dynamic>> loadEnvironmentOverrides(
YamlMap yamlMap) async {
final Map<String, dynamic> envOverrides = {};
try {
final path = yamlMap['definitions']?['local']?['path'];
if (path is String && path.isNotEmpty) {
final configString =
await rootBundle.loadString('$path/config/appConfig.json');
final Map<String, dynamic> configMap = json.decode(configString);
final variables = configMap['envVariables'];
if (variables is Map) {
variables.forEach((key, value) {
envOverrides[key.toString()] = value;
});
}
}
} catch (_) {
// appConfig.json is optional; continue with the other env sources.
}

final localPath = yamlMap['definitions']?['local']?['path'];
if (localPath is String && localPath.isNotEmpty) {
_mergeWithOverwrite(envOverrides,
await _loadLocalDotEnvConfigFile(localPath, yamlMap));
}
_mergeWithOverwrite(
envOverrides, await _loadDotEnvConfigFile('.env.config'));
_mergeWithOverwrite(
envOverrides, await _loadDotEnvConfigFile('ensemble/.env.config'));

final env = yamlMap['environmentVariables'];
if (env is YamlMap) {
env.forEach((key, value) => envOverrides[key.toString()] = value);
}
return envOverrides;
}

/// Makes resolved environment values available to both bindings and runtime
/// services that read directly from [UserAppConfig].
void applyEnvironmentOverrides(
EnsembleConfig config, Map<String, dynamic> envOverrides) {
config.updateEnvOverrides(envOverrides);
final appConfig = config.definitionProvider.getAppConfig();
if (appConfig != null && envOverrides.isNotEmpty) {
appConfig.envVariables ??= <String, dynamic>{};
appConfig.envVariables!.addAll(envOverrides);
}
}

Future<Map<String, String>> _loadDotEnvConfigFile(String fileName) async {
try {
final String content = await rootBundle.loadString(fileName);
Expand All @@ -260,6 +285,51 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver {
}
}

/// Loads the dotenv file for locally bundled definitions.
///
/// `definitions.local.path` may point at a directory containing multiple
/// apps (for example `ensemble/apps/`) rather than the active app directory.
/// In that case, select the app-specific file from `local.appId` when it is
/// bundled, or use a nested file only when there is exactly one candidate.
/// This avoids silently combining environment values from unrelated apps.
Future<Map<String, String>> _loadLocalDotEnvConfigFile(
String localPath, YamlMap yamlMap) async {
final root = localPath.endsWith('/') ? localPath : '$localPath/';
final directPath = '${root}.env.config';
final directValues = await _loadDotEnvConfigFile(directPath);
if (directValues.isNotEmpty) {
return directValues;
}

try {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assets = manifest.listAssets();
final localConfig = yamlMap['definitions']?['local'];
final appId = localConfig is YamlMap ? localConfig['appId'] : null;
if (appId is String && appId.isNotEmpty) {
final appPath = '$root$appId/.env.config';
if (assets.contains(appPath)) {
return _loadDotEnvConfigFile(appPath);
}
}

final candidates = assets
.where((asset) =>
asset.startsWith(root) &&
asset.endsWith('/.env.config') &&
asset != directPath)
.toList();
if (candidates.length == 1) {
return _loadDotEnvConfigFile(candidates.single);
}
} catch (_) {
// Asset-manifest discovery is best-effort; preserve the other config
// sources if the manifest is unavailable on a platform.
}

return {};
}

void _mergeWithOverwrite(
Map<String, dynamic> target, Map<String, String> source) {
source.forEach((key, value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,16 @@ class FirestoreTimestamp
FirestoreTimestamp.fromDate(Date date)
: timestamp = Timestamp.fromDate(date.dateTime);

/// Lets JSON-backed storage serialize timestamps even when an app adds one
/// through a nested mutable storage reference (which does not call the
/// top-level storage setter). StorageManager recognizes this tagged shape
/// and restores the FirestoreTimestamp on reads.
Map<String, dynamic> toJson() => {
'__ensemble_storage_value_type__': 'firestore_timestamp_v1',
'seconds': timestamp.seconds,
'nanoseconds': timestamp.nanoseconds,
};

@override
Map<String, Function> getters() {
return {
Expand Down Expand Up @@ -311,4 +321,4 @@ class EnsembleFieldValue with Invokable {
});
return data;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,9 @@ class HttpResponse extends Response {
try {
body = json.decode(response.body);
} on FormatException catch (_, e) {
// Keep non-JSON payloads available to callers and diagnostics instead
// of discarding the actual server response.
body = response.body;
log('Warning - Only JSON response is supported');
}
super.apiState = apiState;
Expand Down Expand Up @@ -501,4 +504,4 @@ class HttpResponse extends Response {
bool get isOkay =>
statusCode != null && statusCode! >= 200 && statusCode! <= 299;
// bool get isError => !isSuccess;
}
}
11 changes: 9 additions & 2 deletions modules/ensemble/lib/framework/assets_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,15 @@ class LocalAssetsService {
List<String> foundAssets = [];
if (assetEnvVariables.isNotEmpty) {
for (var entry in assetEnvVariables.entries) {
String assetName =
Utils.getAssetName(entry.value); // Get the asset name
// App configuration may include numeric and boolean settings alongside
// asset URLs. Only string values can name an asset; passing another
// value to getAssetName throws during otherwise normal app startup.
if (entry.value is! String || entry.value.isEmpty) continue;
String assetName = Utils.getAssetName(entry.value as String);
// A base URL such as `https://.../assets/` has no filename. Treating
// its empty last path segment as a bundled asset can mark the assets
// directory itself as an image and later try to decode that directory.
if (assetName.isEmpty) continue;
String provider = definations['definitions']?['from'];
String path = definations['definitions']?['local']['path'];
String assetPath = provider == 'local'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,10 @@ class AppModel {

// Check if artifact refresh is enabled via environment variable
bool get _isArtifactRefreshEnabled {
String? enableRefresh = appConfig?.envVariables?['ENABLE_ARTIFACT_REFRESH'];
if (enableRefresh != null) {
return enableRefresh.toLowerCase() == 'true';
}
return false; // Default: disabled
final value = appConfig?.envVariables?['ENABLE_ARTIFACT_REFRESH'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
return false;
}

// Get the last checked time from persistent storage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ abstract class DefinitionProvider {
// Check if artifact refresh is enabled via environment variable
// Default: false (disabled) - apps must explicitly enable it
bool isArtifactRefreshEnabled() {
String? enableRefresh = getAppConfig()?.envVariables?['ENABLE_ARTIFACT_REFRESH'];
if (enableRefresh != null) {
return enableRefresh.toLowerCase() == 'true';
}
return false; // Default: disabled
final value = getAppConfig()?.envVariables?['ENABLE_ARTIFACT_REFRESH'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
return false;
}

// build the definition model from YAML
Expand Down
29 changes: 19 additions & 10 deletions modules/ensemble/lib/framework/secrets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,25 @@ class SecretsStore with Invokable {
if (provider == 'local') {
String path =
EnsembleConfigService.config["definitions"]?['local']?["path"];
final secretsString =
await rootBundle.loadString('${path}/config/secrets.json');
final Map<String, dynamic> appSecretsMap = json.decode(secretsString);
appSecretsMap["secrets"].forEach((key, value) {
secrets![key] = value;
});

// add secrets from ${path}/.env.secrets file
await dotenv.load(fileName: '${path}/.env.secrets');
secrets.addAll(dotenv.env);
// The legacy JSON file is optional. Keep its failure isolated so a
// missing config/secrets.json does not prevent loading .env.secrets.
try {
final secretsString =
await rootBundle.loadString('${path}/config/secrets.json');
final Map<String, dynamic> appSecretsMap =
json.decode(secretsString);
final appSecrets = appSecretsMap['secrets'];
if (appSecrets is Map) {
appSecrets.forEach((key, value) {
secrets[key.toString()] = value;
});
}
} catch (_) {}

try {
await dotenv.load(fileName: '${path}/.env.secrets');
secrets.addAll(dotenv.env);
} catch (_) {}
}
} catch (_) {}
// add secrets from .env.secrets file in ensemble folder
Expand Down
Loading
Loading