diff --git a/README.md b/README.md index 2801675..a79318a 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,106 @@ -## webtrit_phone_tools +# webtrit_phone_tools -WebTrit Phone CLI tools. - -Generated by the [Very Good CLI][very_good_cli_link] πŸ€– +**WebTrit Phone CLI tools** β€” A comprehensive toolkit for automating the preparation, configuration, +and signing of WebTrit mobile applications. --- -## Getting Started πŸš€ +## Getting Started -If the CLI application is available on [pub](https://pub.dev), activate globally via: +Activate the CLI globally via **pub.dev**: ```sh dart pub global activate webtrit_phone_tools + ``` -Or locally via: +Or install it locally from the source: ```sh -dart pub global activate --source=path +dart pub global activate --source=path + ``` +--- + ## Usage -**Android Keystore Signing** +### Android Keystore Signing + +Tools for managing signing keys and certificates. ```sh -# Keystore-generate command -$ webtrit_phone_tools keystore-generate --bundleId="com.webtrit.app" --appendDirectory ../webtrit_phone_keystores -# Keystore-commit command -$ webtrit_phone_tools keystore-commit --bundleId="com.webtrit.app" --appendDirectory ../webtrit_phone_keystores -# Keystore-verify command -$ webtrit_phone_tools keystore-verify ../webtrit_phone_keystores/com.webtrit.app +# Generate a new keystore +$ webtrit_phone_tools keystore-generate --bundleId="com.webtrit.app" --appendDirectory ../keystores + +# Commit changes to the keystore repository +$ webtrit_phone_tools keystore-commit --bundleId="com.webtrit.app" --appendDirectory ../keystores + +# Verify an existing keystore +$ webtrit_phone_tools keystore-verify ../keystores/com.webtrit.app + ``` -**Application Resource Configuration:** +### Resources & Configuration + +Core commands for fetching assets, translations, and themes. ```sh -# Configure application resources (using configurator tool) -$ webtrit_phone_tools configure --applicationId=$(id) $(KEYSTORES_PATH) --$(BUILD_FLOW) -# Generate configuration files +# Fetch resources (assets, translations, themes) +$ webtrit_phone_tools resources-get --applicationId= --token= --keystores-path= + +# Generate local configuration files $ webtrit_phone_tools configurator-generate -# Create demo classic configuration (using configurator tool) -$ webtrit_phone_tools create-demo-classic +# Create metadata (Assetlinks and Apple App Site Association) +$ webtrit_phone_tools assetlinks-generate --bundleId= --appleTeamID= --androidFingerprints= --output= -# Create assetlink and apple-app-site-association files -$ webtrit_phone_tools assetlinks-generate --bundleId=$(bundle_id) --appleTeamID=$(team_id) --androidFingerprints=$(SHA256_key) --output=$(out_path) $(metadata_path) +``` -**Additional Commands:** +--- -# Show CLI version -$ webtrit_phone_tools --version +## Architecture -# Show usage help -$ webtrit_phone_tools --help -``` +Complex commands in this toolkit follow the **Orchestrator Pattern**. This design ensures a clean +separation of concerns, making the code easier to test, maintain, and scale. ---- +### Layer Responsibilities -**Advanced Usage (Configurator Tool):** +| Layer | Entity | Responsibility | +|----------------|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Command** | `...Command` | **The Conductor.** Parses CLI arguments, initializes the `Context`, and dictates the execution flow. It contains no data processing logic. | +| **Context** | `...Context` | **The State.** An immutable object that holds all validated parameters (paths, IDs, tokens). It acts as the single source of truth passed between layers. | +| **Services** | `...Fetcher` / `...Service` | **External Data.** Handles interactions with APIs or external databases. Returns clean DTOs or models to the conductor. | +| **Processors** | `...Processor` | **Business Logic & I/O.** Manages data transformation, asset migration, and disk read/write operations. Each processor handles one logical domain. | +| **Util** | `...Util` | **The Toolbox. Stateless logic containing reusable helpers specific to the command’s domain. It handles repetitive tasks like string manipulation, path formatting, or data validation.. | +| **Runners** | `...Runner` | **Infrastructure.** Executes external system commands (`make`, `shell scripts`, etc.) and handles their `stdout` and `stderr` streams. | -These commands are for developers familiar with the `configurator` tool used internally. +### Execution Flow -* [`configurator-resources`](./lib/src/commands/configurator_get_resources_command.dart) -* [`configurator-generate`](./lib/src/commands/configurator_generate_command.dart) +1. **Parsing:** The `Command` extracts raw data from `argResults`. +2. **Contextualization:** A `Context` is built, normalizing paths and validating inputs. +3. **Fetching:** A `Service` retrieves necessary remote data. +4. **Processing:** One or more `Processors` perform file system manipulations or data transforms. +5. **Execution:** If required, a `Runner` triggers external processes to finalize the output. +6. **Exit:** The command returns a standard `ExitCode`. --- ## Providing Assets for Builds -In the Dart CLI, there isn't a mechanism like in Flutter where you can directly store files in executed builds. Instead, -we need to stringify assets before creating the build. This ensures that assets are properly embedded into the Dart -code. +Since Dart CLI tools do not have a native "assets" mechanism like Flutter, we use **stringification +** to embed resources directly into the CLI binary. -To stringify assets, use the `stringify_assets.sh` script. This script converts asset files into Dart code that can be -included in your build. - -**Example Usage:** +Run this script before building the package: ```sh ./stringify_assets.sh assets lib/src/gen/stringify_assets.dart -``` - -This command takes all files in the `assets` directory and converts them into a Dart file located at -`lib/src/gen/stringify_assets.dart`. -Make sure to run this script before creating a build to ensure all assets are properly included. +``` -## πŸ“„ Related Documentation +--- -See [Shared Makefile Reference](docs/shared_makefile_reference.md) for build flavor logic and usage via Makefile. +## Documentation -[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli +For build flavor logic and advanced usage via Makefile, see +the [Shared Makefile Reference](https://www.google.com/search?q=docs/shared_makefile_reference.md). diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index 9fc1ca6..0457746 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -61,7 +61,7 @@ class WebtritPhoneToolsCommandRunner extends CompletionCommandRunner { ); // Add sub commands - addCommand(ConfiguratorGetResourcesCommand( + addCommand(ResourcesGetCommand( logger: _logger, httpClient: _httpClient, datasource: _datasource, diff --git a/lib/src/commands/commands.dart b/lib/src/commands/commands.dart index 9e31606..7ca90fc 100644 --- a/lib/src/commands/commands.dart +++ b/lib/src/commands/commands.dart @@ -1,8 +1,8 @@ export 'assetlinks_generate_command.dart'; export 'configurator_generate_command.dart'; -export 'configurator_get_resources_command.dart'; export 'keystore_commit_command.dart'; export 'keystore_generate_command.dart'; -export 'keystore_verify_command.dart'; export 'keystore_init_command.dart'; +export 'keystore_verify_command.dart'; +export 'resources/resources_get_command.dart'; export 'update_command.dart'; diff --git a/lib/src/commands/configurator_get_resources_command.dart b/lib/src/commands/configurator_get_resources_command.dart deleted file mode 100644 index d6d0015..0000000 --- a/lib/src/commands/configurator_get_resources_command.dart +++ /dev/null @@ -1,702 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:crypto/crypto.dart'; -import 'package:data/datasource/datasource.dart'; -import 'package:data/dto/dto.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as path; -import 'package:yaml/yaml.dart'; - -import 'package:webtrit_phone_tools/src/commands/constants.dart'; -import 'package:webtrit_phone_tools/src/extension/extension.dart'; -import 'package:webtrit_phone_tools/src/utils/utils.dart'; - -const _applicationId = 'applicationId'; -const _token = 'token'; -const _keystoresPath = 'keystores-path'; -const _cacheSessionDataPath = 'cache-session-data-path'; - -const _directoryParameterName = ''; -const _directoryParameterDescriptionName = '$_directoryParameterName (optional)'; - -/// Fetches resources from Configurator and prepares local assets/configs. -/// Includes a generic JSON migration that: -/// - finds image URLs (http/https) across the config (ImageSource.uri, plain `uri`, any `*Url` key) -/// - downloads them to assets/images -/// - rewrites the config to `asset://assets/images/` -class ConfiguratorGetResourcesCommand extends Command { - ConfiguratorGetResourcesCommand({ - required Logger logger, - required HttpClient httpClient, - required ConfiguratorBackandDatasource datasource, - }) : _logger = logger, - _httpClient = httpClient, - _datasource = datasource { - argParser - ..addOption( - _applicationId, - help: 'Configurator application id.', - mandatory: true, - ) - ..addOption( - _token, - help: 'JWT token for configurator API.', - mandatory: true, - ) - ..addOption( - _keystoresPath, - help: "Path to the project's keystore folder.", - mandatory: true, - ) - ..addOption( - _cacheSessionDataPath, - help: - 'Path to file which cache temporarily stores user session data to enhance performance and maintain state across different processes.', - ); - } - - @override - String get name => 'configurator-resources'; - - final HttpClient _httpClient; - final ConfiguratorBackandDatasource _datasource; - - @override - String get description { - final buffer = StringBuffer() - ..writeln('Get resources to customize application') - ..write(parameterIndent) - ..write(_directoryParameterDescriptionName) - ..write(parameterDelimiter) - ..writeln('Specify the directory for creating keystore and metadata files.') - ..write(' ' * (parameterIndent.length + _directoryParameterDescriptionName.length + parameterDelimiter.length)) - ..write('Defaults to the current working directory if not provided.'); - return buffer.toString(); - } - - @override - String get invocation => '${super.invocation} [$_directoryParameterName]'; - - final Logger _logger; - - late String workingDirectoryPath; - - // --- Asset migration settings --- - // Where files are saved on disk (relative to working dir) - static const _imagesAssetDiskDir = 'assets/images'; - - // How URIs are written into configs - static const _imagesAssetLogicalPrefix = 'asset://assets/images'; - - // Single-flight cache for URL β†’ logical URI - final Map _assetCache = {}; - - @override - Future run() async { - final commandArgResults = argResults!; - - if (commandArgResults.rest.isEmpty) { - workingDirectoryPath = Directory.current.path; - } else if (commandArgResults.rest.length == 1) { - workingDirectoryPath = commandArgResults.rest[0]; - } else { - _logger.err('Only one "$_directoryParameterName" parameter can be passed.'); - return ExitCode.usage.code; - } - - final keystorePath = (commandArgResults[_keystoresPath] as String?) ?? ''; - if (keystorePath.isEmpty) { - _logger.err('Option "$_keystoresPath" can not be empty.'); - return ExitCode.usage.code; - } - - final paramCacheSessionDataPath = commandArgResults[_cacheSessionDataPath] as String?; - final cacheSessionDataPath = paramCacheSessionDataPath ?? defaultCacheSessionDataPath; - final cacheSessionDataDir = Directory(path.dirname(cacheSessionDataPath)); - - final applicationId = commandArgResults[_applicationId] as String; - if (applicationId.isEmpty) { - _logger.err('Option "$_applicationId" can not be empty.'); - return ExitCode.usage.code; - } - - final jwtToken = commandArgResults[_token] as String; - final authHeader = {'Authorization': 'Bearer $jwtToken'}; - if (jwtToken.isEmpty) { - _logger.err('Option "$_token" can not be empty.'); - return ExitCode.usage.code; - } - - _datasource.addInterceptor(HeadersInterceptor(authHeader)); - - final keystoreDirectoryPath = _workingDirectory(keystorePath); - if (Directory(keystoreDirectoryPath).existsSync()) { - _logger.info('- Keystores directory path: $keystoreDirectoryPath'); - } else { - _logger.err('- Keystores directory path does not exist: $keystoreDirectoryPath'); - return ExitCode.usage.code; - } - - final projectKeystoreDirectoryPath = path.join(keystoreDirectoryPath, applicationId); - if (Directory(projectKeystoreDirectoryPath).existsSync()) { - _logger.info('- Project keystore directory path: $projectKeystoreDirectoryPath'); - } else { - _logger.err('- Project keystores directory path does not exist: $projectKeystoreDirectoryPath'); - return ExitCode.usage.code; - } - - if (cacheSessionDataDir.path != '.' && !cacheSessionDataDir.existsSync()) { - _logger.err('- The directory specified by $_cacheSessionDataPath does not exist.'); - return ExitCode.data.code; - } - - late ApplicationDTO application; - late ThemeDTO theme; - - try { - application = await _datasource.getApplication( - applicationId: applicationId, - headers: authHeader, - ); - } catch (e) { - _logger.err(e.toString()); - return ExitCode.usage.code; - } - - if (application.theme == null) { - _logger.err('Application $applicationId does not have a default theme'); - return ExitCode.usage.code; - } - - try { - theme = await _datasource.getTheme( - applicationId: applicationId, - themeId: application.theme!, - headers: authHeader, - ); - _logger.info('- Fetched theme with id: ${theme.id} for application: $applicationId'); - } catch (e) { - _logger.err(e.toString()); - return ExitCode.usage.code; - } - - try { - await _configureTranslations(applicationId); - } catch (e) { - _logger.err(e.toString()); - return ExitCode.usage.code; - } - - final projectSSlCertificatesDirectoryPath = path.join(projectKeystoreDirectoryPath, kSSLCertificatePath); - final directorySSlCertificates = Directory(projectSSlCertificatesDirectoryPath); - - if (directorySSlCertificates.existsSync()) { - _logger.info('- Project ssl certificates directory path: $projectSSlCertificatesDirectoryPath'); - - await for (final entity in directorySSlCertificates.list()) { - if (entity is File) { - _logger.info('--- path: ${entity.path}'); - final certFile = File(entity.path); - final directory = _workingDirectory(assetSSLCertificate); - final newFilePath = path.join(directory, path.basename(certFile.path)); - _logger.info('--- copy: ${entity.path} to $newFilePath'); - - await certFile.copy(newFilePath); - - final sslCertificatesCredentialsPath = path.join(projectKeystoreDirectoryPath, kSSLCertificateCredentialPath); - final sslCertificatesCredentials = File(sslCertificatesCredentialsPath); - - if (sslCertificatesCredentials.existsSync()) { - _logger.info('- Project ssl certificates directory credentials path exists'); - - final newSSLCertificatesCredentials = path.join(directory, assetSSLCertificateCredentials); - await sslCertificatesCredentials.copy(newSSLCertificatesCredentials); - } else { - _logger.info('- Project ssl certificates directory credentials path does not exist'); - } - } - } - } else { - _logger.warn('- Project ssl certificates directory path does not exist'); - } - - if (application.androidVersion?.buildName == null || application.androidVersion?.buildNumber == null) { - _logger.err('Option "$_applicationId" cannot be empty: Android version build name or build number is missing.'); - return ExitCode.usage.code; - } - - if (application.iosVersion?.buildName == null || application.iosVersion?.buildNumber == null) { - _logger.err('Option "$_applicationId" cannot be empty: iOS version build name or build number is missing.'); - return ExitCode.usage.code; - } - - // Prepare build cache - final buildConfig = { - bundleIdAndroidField: application.androidPlatformId, - buildNameAndroidField: application.androidVersion?.buildName, - buildNumberAndroidField: application.androidVersion?.buildNumber, - bundleIdIosField: application.iosPlatformId, - buildNameIOSField: application.iosVersion?.buildName, - buildNumberIOSField: application.iosVersion?.buildNumber, - keystorePathField: projectKeystoreDirectoryPath, - }; - - final buildConfigPath = _workingDirectory(cacheSessionDataPath); - File(buildConfigPath).writeAsStringSync(buildConfig.toStringifyJson()); - _logger.success('βœ“ Written successfully to $buildConfigPath'); - - // Splash & launch icons (kept as-is; these are special-cased) - final splash = await _datasource.getSplashAsset(applicationId: applicationId, themeId: theme.id!); - await _downloadAndSave( - url: splash.splashUrl, - relativePath: assetSplashIconPath, - assetLabel: 'splash image', - ); - - final launchIcons = await _datasource.getLaunchAssetsByTheme(applicationId: applicationId, themeId: theme.id!); - await _downloadAndSave( - url: launchIcons.androidLegacyUrl, - relativePath: assetLauncherAndroidIconPath, - assetLabel: 'android launcher icon', - ); - await _downloadAndSave( - url: launchIcons.androidAdaptiveForegroundUrl, - relativePath: assetLauncherIconAdaptiveForegroundPath, - assetLabel: 'android adaptive foreground icon', - ); - await _downloadAndSave( - url: launchIcons.webUrl, - relativePath: assetLauncherWebIconPath, - assetLabel: 'web launcher icon', - ); - await _downloadAndSave( - url: launchIcons.iosUrl, - relativePath: assetLauncherIosIconPath, - assetLabel: 'ios launcher icon', - ); - - try { - await _configureTheme(applicationId, theme.id!); - } catch (e) { - _logger.err(e.toString()); - return ExitCode.usage.code; - } - - _logger.info('- Prepare config for flutter_launcher_icons_template'); - - if (launchIcons.entity.source?.backgroundColorHex != null) { - await Process.start( - 'make', - ['generate-launcher-icons-config'], - workingDirectory: workingDirectoryPath, - runInShell: true, - environment: { - 'LAUNCHER_ICON_IMAGE_ANDROID': assetLauncherAndroidIconPath, - 'ICON_BACKGROUND_COLOR': splash.source?.backgroundColorHex?.toHex6WithHash() ?? '', - 'LAUNCHER_ICON_FOREGROUND': assetLauncherIconAdaptiveForegroundPath, - 'LAUNCHER_ICON_IMAGE_IOS': assetLauncherIosIconPath, - 'LAUNCHER_ICON_IMAGE_WEB': assetLauncherWebIconPath, - 'THEME_COLOR': launchIcons.entity.source?.backgroundColorHex?.toHex6WithHash() ?? '', - }, - ); - } else { - _logger.warn('backgroundColorHex is null in launch icons source'); - } - - if (splash.source?.backgroundColorHex != null) { - _logger.info('- Prepare config for flutter_native_splash_template'); - await Process.start( - 'make', - ['generate-native-splash-config'], - workingDirectory: workingDirectoryPath, - runInShell: true, - environment: { - 'SPLASH_COLOR': splash.source?.backgroundColorHex?.toHex6WithHash() ?? '', - 'SPLASH_IMAGE': assetSplashIconPath, - 'ANDROID_12_SPLASH_COLOR': splash.source?.backgroundColorHex?.toHex6WithHash() ?? '', - }, - ); - } else { - _logger.warn('backgroundColorHex is null in splash source'); - } - - _logger.info('- Prepare config for package_rename_config_template'); - - await Process.start( - 'make', - ['generate-package-config'], - workingDirectory: workingDirectoryPath, - runInShell: true, - environment: { - 'ANDROID_APP_NAME': application.name ?? '', - 'PACKAGE_NAME': application.androidPlatformId ?? '', - 'IOS_APP_NAME': application.name ?? '', - 'BUNDLE_ID': application.iosPlatformId ?? '', - }, - ); - - try { - _configurePhoneEnv(application, theme, projectKeystoreDirectoryPath); - } catch (e) { - _logger.err(e.toString()); - return ExitCode.usage.code; - } - - return ExitCode.success.code; - } - - // -------------------- THEME PIPE -------------------- - - Future _configureTheme(String applicationId, String themeId) async { - await _writeColorSchemeConfig(applicationId, themeId); - await _writePageLightConfig(applicationId, themeId); - await _writeWidgetsLightConfig(applicationId, themeId); - await _writeAppConfig(applicationId, themeId); // Updated method here - } - - Future _writeColorSchemeConfig(String applicationId, String themeId) async { - final colorSchemeDTO = - await _datasource.getColorSchemeByVariant(applicationId: applicationId, themeId: themeId, variant: 'light'); - - await _writeJsonToFile(_workingDirectory(assetLightColorSchemePath), colorSchemeDTO.config); - // TODO: switch to real dark when available - await _writeJsonToFile(_workingDirectory(assetDarkColorSchemePath), colorSchemeDTO.config); - } - - Future _writePageLightConfig(String applicationId, String themeId) async { - final pageConfigDTO = - await _datasource.getPageConfigByThemeVariant(applicationId: applicationId, themeId: themeId, variant: 'light'); - - // MIGRATION: rewrite all URLs to asset://assets/images and download them - final migrated = await _migrateUrisInJson(pageConfigDTO.config); - - // Optionally validate with model: - // final model = ThemePageConfig.fromJson(migrated); - // await _writeJsonToFile(_workingDirectory(assetPageLightConfig), model.toJson()); - - await _writeJsonToFile(_workingDirectory(assetPageLightConfig), migrated); - await _writeJsonToFile(_workingDirectory(assetPageDarkConfig), migrated); - } - - Future _writeWidgetsLightConfig(String applicationId, String themeId) async { - final widgetsConfigDTO = await _datasource.getWidgetConfigByThemeVariant( - applicationId: applicationId, - themeId: themeId, - variant: 'light', - ); - - // MIGRATION: rewrite all URLs to asset://assets/images and download them - final migrated = await _migrateUrisInJson(widgetsConfigDTO.config); - - await _writeJsonToFile(_workingDirectory(assetWidgetsLightConfig), migrated); - await _writeJsonToFile(_workingDirectory(assetWidgetsDarkConfig), migrated); - } - - /// Updated method: now creates app.config.json and app.config.embeddeds.json - Future _writeAppConfig(String applicationId, String themeId) async { - // Get both data sources - final featureAccessDto = await _datasource.getFeatureAccessByTheme(applicationId: applicationId, themeId: themeId); - final embeds = await _datasource.getEmbeds(applicationId); - - // Process and write the main app.config.json - // Run URI migration only for feature config - final migratedAppConfig = await _migrateUrisInJson(featureAccessDto.config); - final appConfigPath = _workingDirectory(assetAppConfigPath); - await _writeJsonToFile(appConfigPath, migratedAppConfig); - - // Process and write the separate app.config.embeddeds.json - // Convert DTO to a JSON list - final embedsList = embeds.map((e) => e.toJson()).toList(); - - // We do NOT run _migrateUrisInJson on embedsList, - // because the original code was designed to skip - // migration for 'embeddedResources' (which is the correct behavior - // for external URIs). - - final embedsConfigPath = _workingDirectory(assetAppConfigEmbeddedsPath); // Using the new constant - await _writeJsonToFile(embedsConfigPath, embedsList); - } - - // -------------------- TRANSLATIONS -------------------- - - Future _configureTranslations(String applicationId) async { - final configFile = File(_workingDirectory('localizely.yml')); - if (!configFile.existsSync()) { - _logger.warn('localizely.yml file not found in the working directory.'); - return; - } - - final configContent = await configFile.readAsString(); - final config = loadYaml(configContent); - - // ignore: dynamic_invocation, avoid_dynamic_calls - final downloadFiles = config['download']['files'] as List; - // ignore: dynamic_invocation, avoid_dynamic_calls - final localeCodes = downloadFiles.map((file) => file['locale_code']).toList(); - - _logger.info('Locales to be downloaded: ${localeCodes.join(', ')}'); - - final translationsZip = await _httpClient.getTranslationFiles(applicationId); - _logger.info('Locales downloaded: ${translationsZip.map((file) => file.name).join(', ')}'); - - for (final file in translationsZip) { - final filename = file.name; - final localeCode = filename.split('.').first; // expected ".arb" - - if (localeCodes.contains(localeCode)) { - final outPath = _workingDirectory('$translationsArbPath/app_$filename'); - await File(outPath).writeAsBytes(file.content); - _logger.success('βœ“ Written successfully to $outPath'); - } else { - _logger.info('Locale $localeCode is not in the list of desired locales, skipping.'); - } - } - } - - // -------------------- ENV & IO -------------------- - - void _configurePhoneEnv( - ApplicationDTO application, - ThemeDTO theme, - String projectKeystoreDirectoryPath, - ) { - final dartDefinePath = _workingDirectory(configureDartDefinePath); - final applicationEnvironment = application.environment; - - // Make mutable copy - final mutableEnvironment = Map.from(applicationEnvironment ?? {}); - mutableEnvironment['WEBTRIT_ANDROID_RELEASE_UPLOAD_KEYSTORE_PATH'] = projectKeystoreDirectoryPath; - - final env = mutableEnvironment.toStringifyJson(); - File(dartDefinePath).writeAsStringSync(env); - _logger - ..info('- Phone environment: $env') - ..success('βœ“ Written successfully to $dartDefinePath'); - } - - /// Updated method: now accepts `dynamic` for writing a List or a Map - Future _writeJsonToFile(String pathStr, dynamic jsonContent) async { - // Use the standard encoder, which works for both Map and List. - // .withIndent(' ') makes "pretty-print" JSON, - // which is likely what your toStringifyJson() method did. - final jsonString = const JsonEncoder.withIndent(' ').convert(jsonContent); - - // Now we pass the correct String to writeAsStringSync - File(pathStr).writeAsStringSync(jsonString); - _logger.success('βœ“ Written successfully to $pathStr'); - } - - String _workingDirectory(String relativePath) { - return path.normalize(path.join(workingDirectoryPath, relativePath)); - } - - Future _downloadAndSave({ - required String? url, - required String relativePath, - String? assetLabel, - }) async { - if (url == null || url.isEmpty) { - _logger.warn('Skip ${assetLabel ?? 'asset'}: empty URL'); - return; - } - try { - final bytes = await _httpClient.getBytes(url); - final outPath = _workingDirectory(relativePath); - if (bytes != null) { - File(outPath).createSync(recursive: true); - File(outPath).writeAsBytesSync(bytes); - _logger.success('βœ“ Written successfully to $outPath'); - } else { - _logger.err('βœ— Failed to download ${assetLabel ?? 'asset'} from $url'); - } - } catch (e) { - _logger.err('βœ— Error while downloading ${assetLabel ?? 'asset'}: $e'); - } - } - - // -------------------- URL β†’ ASSET MIGRATION -------------------- - - Future> _migrateUrisInJson(Map json) async { - final rewriter = _JsonUriRewriter( - fetchBytes: _httpClient.getBytes, - assetsRootOnDisk: _workingDirectory(_imagesAssetDiskDir), - assetLogicalPrefix: _imagesAssetLogicalPrefix, - deriveFilename: _deriveFilenameFromUrl, - sniffExt: _sniffImageExt, - cache: _assetCache, - info: _logger.info, - warn: _logger.warn, - err: _logger.err, - ); - - final transformed = await rewriter.transform(json); - return Map.from(transformed as Map); - } - - // Stable filename = sanitized basename + short sha1(url|query) + extension - String _deriveFilenameFromUrl(String url, {String? fallbackExt}) { - final uri = Uri.tryParse(url); - final last = (uri?.pathSegments.isNotEmpty ?? false) ? uri!.pathSegments.last : ''; - final base = last.isEmpty ? 'image' : last; - - String sanitize(String name) => - name.trim().toLowerCase().replaceAll(RegExp(r'[^a-z0-9_\-]'), '_').replaceAll(RegExp('_+'), '_'); - - String? guessExt(String basename) { - final ext = path.extension(basename).toLowerCase().replaceFirst('.', ''); - const known = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'bmp', 'ico', 'avif'}; - return ext.isEmpty ? null : (known.contains(ext) ? ext : null); - } - - final q = uri?.query ?? ''; - final hash = sha1.convert(utf8.encode('$url|$q')).toString().substring(0, 10); - - final ext = guessExt(base) ?? fallbackExt ?? 'bin'; - final stem = sanitize(base.replaceAll(RegExp(r'\.[A-Za-z0-9]+$'), '')); - return '${stem.isEmpty ? 'image' : stem}_$hash.$ext'; - } - - String? _sniffImageExt(List bytes) { - if (bytes.length >= 8) { - if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) return 'png'; - if (bytes[0] == 0xFF && bytes[1] == 0xD8) return 'jpg'; - if (bytes[0] == 0x52 && - bytes[1] == 0x49 && - bytes[2] == 0x46 && - bytes[3] == 0x46 && - bytes.length >= 12 && - bytes[8] == 0x57 && - bytes[9] == 0x45 && - bytes[10] == 0x42 && - bytes[11] == 0x50) { - return 'webp'; - } - if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) return 'gif'; - if (bytes[0] == 0x42 && bytes[1] == 0x4D) return 'bmp'; - if (bytes[0] == 0x00 && bytes[1] == 0x00 && bytes[2] == 0x01 && bytes[3] == 0x00) return 'ico'; - } - try { - final head = utf8.decode(bytes.take(200).toList(), allowMalformed: true).toLowerCase(); - if (head.contains('= 12 && - bytes[4] == 0x66 && - bytes[5] == 0x74 && - bytes[6] == 0x79 && - bytes[7] == 0x70 && - String.fromCharCodes(bytes.sublist(8, 12)) == 'avif') return 'avif'; - return null; - } -} - -/// Internal, schema-agnostic JSON rewriter. -/// Rewrites any URL-looking value under keys: `uri`, `url`, `*Url`, `*URL`, -/// and also `imageSource: { uri: ... }`. -class _JsonUriRewriter { - _JsonUriRewriter({ - required this.fetchBytes, - required this.assetsRootOnDisk, - required this.assetLogicalPrefix, - required this.deriveFilename, - required this.sniffExt, - required this.cache, - required this.info, - required this.warn, - required this.err, - }); - - final Future?> Function(String url) fetchBytes; - final String assetsRootOnDisk; - final String assetLogicalPrefix; - final String Function(String url, {String? fallbackExt}) deriveFilename; - final String? Function(List bytes) sniffExt; - final Map cache; - final void Function(String) info; - final void Function(String) warn; - final void Function(String) err; - - bool _looksUrl(Object? v) => v is String && (v.startsWith('http://') || v.startsWith('https://')); - - bool _insideEmbeddedResources(List path) { - // If the path already contains the key 'embeddedResources' β€” skip the entire branch without changes - return path.contains('embeddedResources'); - } - - Future transform(dynamic node, {List path = const []}) async { - // If we are inside embeddedResources β€” do not change anything at all - if (_insideEmbeddedResources(path)) { - return node; - } - - if (node is Map) { - final result = {}; - for (final entry in node.entries) { - final k = entry.key.toString(); - final v = entry.value; - - // Typical keys with URLs - final urlish = k == 'uri' || k == 'url' || k.endsWith('Url') || k.endsWith('URL'); - - if (urlish && _looksUrl(v)) { - result[k] = await _downloadAndMakeAssetUri(v as String); - continue; - } - - // Common case: { imageSource: { uri: ... } } - if (k == 'imageSource' && v is Map && _looksUrl(v['uri'])) { - final newUri = await _downloadAndMakeAssetUri(v['uri'] as String); - final newImageSource = Map.from(v)..['uri'] = newUri; - result[k] = newImageSource; - continue; - } - - // Recursion with updated path - result[k] = await transform(v, path: [...path, k]); - } - return result; - } else if (node is List) { - final out = []; - for (var i = 0; i < node.length; i++) { - out.add(await transform(node[i], path: [...path, '[$i]'])); - } - return out; - } else { - return node; - } - } - - Future _downloadAndMakeAssetUri(String url) async { - if (cache.containsKey(url)) return cache[url]!; - - final bytes = await fetchBytes(url); - if (bytes == null) { - err('Failed to download: $url'); - return url; - } - - final ext = sniffExt(bytes) ?? 'bin'; - final filename = deriveFilename(url, fallbackExt: ext); - - final outDisk = path.normalize(path.join(assetsRootOnDisk, filename)); - await File(outDisk).create(recursive: true); - await File(outDisk).writeAsBytes(bytes); - - final logical = '$assetLogicalPrefix/$filename'; - info('Saved $url β†’ $logical'); - cache[url] = logical; - return logical; - } -} - -extension HexSanitizer on String { - String toHex6() { - final hex = replaceAll('#', '').toUpperCase(); - if (hex.length == 8) return hex.substring(2); - if (hex.length == 6) return hex; - throw FormatException('Invalid hex color string: $this'); - } - - String toHex6WithHash() => '#${toHex6()}'; -} diff --git a/lib/src/commands/resources/models/command_context.dart b/lib/src/commands/resources/models/command_context.dart new file mode 100644 index 0000000..57ee28c --- /dev/null +++ b/lib/src/commands/resources/models/command_context.dart @@ -0,0 +1,24 @@ +import 'package:path/path.dart' as path; + +class CommandContext { + const CommandContext({ + required this.workingDirectoryPath, + required this.applicationId, + required this.projectKeystorePath, + required this.authHeader, + required this.cachePathArg, + }); + + final String workingDirectoryPath; + final String applicationId; + final String projectKeystorePath; + final Map authHeader; + final String? cachePathArg; + + String resolvePath(String inputPath) { + if (path.isAbsolute(inputPath)) { + return path.normalize(inputPath); + } + return path.normalize(path.join(workingDirectoryPath, inputPath)); + } +} diff --git a/lib/src/commands/resources/models/models.dart b/lib/src/commands/resources/models/models.dart new file mode 100644 index 0000000..2d4e838 --- /dev/null +++ b/lib/src/commands/resources/models/models.dart @@ -0,0 +1 @@ +export 'command_context.dart'; diff --git a/lib/src/commands/resources/processors/asset_processor.dart b/lib/src/commands/resources/processors/asset_processor.dart new file mode 100644 index 0000000..dbe845a --- /dev/null +++ b/lib/src/commands/resources/processors/asset_processor.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; + +import 'package:data/datasource/datasource.dart'; +import 'package:data/dto/dto.dart'; + +import 'package:webtrit_phone_tools/src/commands/constants.dart'; +import 'package:webtrit_phone_tools/src/utils/utils.dart'; + +class AssetProcessor { + const AssetProcessor({ + required this.httpClient, + required this.datasource, + required this.logger, + }); + + final HttpClient httpClient; + final ConfiguratorBackandDatasource datasource; + final Logger logger; + + Future processSplashAssets({ + required String applicationId, + required String themeId, + required String Function(String) resolvePath, + }) async { + final splash = await datasource.getSplashAsset(applicationId: applicationId, themeId: themeId); + await _downloadAsset(splash.splashUrl, assetSplashIconPath, 'splash image', resolvePath); + return splash; + } + + Future processLaunchIcons({ + required String applicationId, + required String themeId, + required String Function(String) resolvePath, + }) async { + final icons = await datasource.getLaunchAssetsByTheme(applicationId: applicationId, themeId: themeId); + + await Future.wait([ + _downloadAsset(icons.androidLegacyUrl, assetLauncherAndroidIconPath, 'android legacy icon', resolvePath), + _downloadAsset(icons.androidAdaptiveForegroundUrl, assetLauncherIconAdaptiveForegroundPath, + 'android adaptive icon', resolvePath), + _downloadAsset(icons.webUrl, assetLauncherWebIconPath, 'web icon', resolvePath), + _downloadAsset(icons.iosUrl, assetLauncherIosIconPath, 'ios icon', resolvePath), + ]); + + return icons; + } + + Future _downloadAsset( + String? url, + String relativePath, + String label, + String Function(String) resolvePath, + ) async { + if (url == null || url.isEmpty) { + logger.warn('Skip $label: empty URL'); + return; + } + + try { + final bytes = await httpClient.getBytes(url); + if (bytes != null) { + final file = File(resolvePath(relativePath)); + if (!file.parent.existsSync()) { + await file.parent.create(recursive: true); + } + await file.writeAsBytes(bytes); + logger.success('βœ“ Downloaded $label'); + } else { + logger.err('βœ— Failed to download $label from $url'); + } + } catch (e) { + logger.err('βœ— Error downloading $label: $e'); + } + } +} diff --git a/lib/src/commands/resources/processors/certificate_processor.dart b/lib/src/commands/resources/processors/certificate_processor.dart new file mode 100644 index 0000000..dbaf975 --- /dev/null +++ b/lib/src/commands/resources/processors/certificate_processor.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as path; + +import 'package:webtrit_phone_tools/src/commands/constants.dart'; + +class CertificateProcessor { + const CertificateProcessor({required this.logger}); + + final Logger logger; + + Future process({ + required String projectKeystorePath, + required String Function(String) resolvePath, + }) async { + final sslDir = Directory(path.join(projectKeystorePath, kSSLCertificatePath)); + + if (!sslDir.existsSync()) { + logger.warn('- Project SSL certificates directory does not exist.'); + return; + } + + logger.info('- Processing SSL certificates...'); + final targetDir = Directory(resolvePath(assetSSLCertificate)); + if (!targetDir.existsSync()) await targetDir.create(recursive: true); + + await for (final entity in sslDir.list()) { + if (entity is! File) continue; + + final newPath = path.join(targetDir.path, path.basename(entity.path)); + await entity.copy(newPath); + logger.info(' Copy: ${entity.path} -> $newPath'); + } + + final credsFile = File(path.join(projectKeystorePath, kSSLCertificateCredentialPath)); + if (credsFile.existsSync()) { + await credsFile.copy(path.join(targetDir.path, assetSSLCertificateCredentials)); + logger.info(' Copy: Credentials file.'); + } + } +} diff --git a/lib/src/commands/resources/processors/local_config_processor.dart b/lib/src/commands/resources/processors/local_config_processor.dart new file mode 100644 index 0000000..02b2417 --- /dev/null +++ b/lib/src/commands/resources/processors/local_config_processor.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; + +import 'package:data/dto/dto.dart'; + +import 'package:webtrit_phone_tools/src/commands/constants.dart'; +import 'package:webtrit_phone_tools/src/utils/utils.dart'; + +import '../utils/utils.dart'; + +class LocalConfigProcessor { + const LocalConfigProcessor({required this.logger}); + + final Logger logger; + + Future writeBuildCache({ + required ApplicationDTO application, + required String projectKeystorePath, + required String? cachePathArg, + required String Function(String) resolvePath, + }) async { + final config = AppConfigFactory.createBuildCacheConfig(application, projectKeystorePath); + + final cachePath = cachePathArg ?? defaultCacheSessionDataPath; + await writeJsonToFile(resolvePath(cachePath), config, logger: logger); + } + + Future writeEnvironmentConfig({ + required ApplicationDTO application, + required String projectKeystorePath, + required String Function(String) resolvePath, + }) async { + final env = AppConfigFactory.createDartDefineEnv(application, projectKeystorePath); + + final file = File(resolvePath(configureDartDefinePath)); + if (!file.parent.existsSync()) { + await file.parent.create(recursive: true); + } + + await file.writeAsString(jsonEncode(env)); + logger.success('βœ“ Environment config written to ${file.path}'); + } +} diff --git a/lib/src/commands/resources/processors/processors.dart b/lib/src/commands/resources/processors/processors.dart new file mode 100644 index 0000000..a74d1cd --- /dev/null +++ b/lib/src/commands/resources/processors/processors.dart @@ -0,0 +1,5 @@ +export 'asset_processor.dart'; +export 'certificate_processor.dart'; +export 'theme_config_processor.dart'; +export 'translation_processor.dart'; +export 'local_config_processor.dart'; diff --git a/lib/src/commands/resources/processors/theme_config_processor.dart b/lib/src/commands/resources/processors/theme_config_processor.dart new file mode 100644 index 0000000..f82eab6 --- /dev/null +++ b/lib/src/commands/resources/processors/theme_config_processor.dart @@ -0,0 +1,136 @@ +import 'dart:typed_data'; + +import 'package:mason_logger/mason_logger.dart'; + +import 'package:data/datasource/datasource.dart'; + +import 'package:webtrit_phone_tools/src/commands/constants.dart'; +import 'package:webtrit_phone_tools/src/utils/utils.dart'; + +class ThemeConfigProcessor { + const ThemeConfigProcessor({ + required this.httpClient, + required this.datasource, + required this.logger, + }); + + final HttpClient httpClient; + final ConfiguratorBackandDatasource datasource; + final Logger logger; + + static const _imagesAssetDiskDir = 'assets/images'; + static const _imagesAssetLogicalPrefix = 'asset://assets/images'; + + Future process({ + required String applicationId, + required String themeId, + required String Function(String) resolvePath, + }) async { + await _writeColorScheme(applicationId, themeId, resolvePath); + await _writePageConfig(applicationId, themeId, resolvePath); + await _writeWidgetConfig(applicationId, themeId, resolvePath); + await _writeAppConfigs(applicationId, themeId, resolvePath); + } + + Future _writeColorScheme( + String applicationId, + String themeId, + String Function(String) resolvePath, + ) async { + final lightDto = await datasource.getColorSchemeByVariant( + applicationId: applicationId, + themeId: themeId, + variant: 'light', + ); + await writeJsonToFile(resolvePath(assetLightColorSchemePath), lightDto.config, logger: logger); + + final darkDto = await datasource.getColorSchemeByVariant( + applicationId: applicationId, + themeId: themeId, + variant: 'dark', + ); + await writeJsonToFile(resolvePath(assetDarkColorSchemePath), darkDto.config, logger: logger); + } + + Future _writePageConfig( + String applicationId, + String themeId, + String Function(String) resolvePath, + ) async { + final lightDto = await datasource.getPageConfigByThemeVariant( + applicationId: applicationId, + themeId: themeId, + variant: 'light', + ); + final migratedLight = await _migrateAssetsInJson(resolvePath, lightDto.config); + await writeJsonToFile(resolvePath(assetPageLightConfig), migratedLight, logger: logger); + + final darkDto = await datasource.getPageConfigByThemeVariant( + applicationId: applicationId, + themeId: themeId, + variant: 'dark', + ); + final migratedDark = await _migrateAssetsInJson(resolvePath, darkDto.config); + await writeJsonToFile(resolvePath(assetPageDarkConfig), migratedDark, logger: logger); + } + + Future _writeWidgetConfig( + String applicationId, + String themeId, + String Function(String) resolvePath, + ) async { + final lightDto = await datasource.getWidgetConfigByThemeVariant( + applicationId: applicationId, + themeId: themeId, + variant: 'light', + ); + final migratedLight = await _migrateAssetsInJson(resolvePath, lightDto.config); + await writeJsonToFile(resolvePath(assetWidgetsLightConfig), migratedLight, logger: logger); + + final darkDto = await datasource.getWidgetConfigByThemeVariant( + applicationId: applicationId, + themeId: themeId, + variant: 'dark', + ); + final migratedDark = await _migrateAssetsInJson(resolvePath, darkDto.config); + await writeJsonToFile(resolvePath(assetWidgetsDarkConfig), migratedDark, logger: logger); + } + + Future _writeAppConfigs( + String applicationId, + String themeId, + String Function(String) resolvePath, + ) async { + final featureDto = await datasource.getFeatureAccessByTheme( + applicationId: applicationId, + themeId: themeId, + ); + final embedsDto = await datasource.getEmbeds(applicationId); + + final migratedFeatures = await _migrateAssetsInJson(resolvePath, featureDto.config); + await writeJsonToFile(resolvePath(assetAppConfigPath), migratedFeatures, logger: logger); + + final embedsList = embedsDto.map((e) => e.toJson()).toList(); + await writeJsonToFile(resolvePath(assetAppConfigEmbeddedsPath), embedsList, logger: logger); + } + + Future> _migrateAssetsInJson( + String Function(String) resolvePath, + Map json, + ) async { + Future fetchBytesAdapter(String url) async { + final List? bytes = await httpClient.getBytes(url); + return bytes is Uint8List ? bytes : (bytes != null ? Uint8List.fromList(bytes) : null); + } + + final migrator = JsonAssetMigrator( + fetchBytes: fetchBytesAdapter, + assetsRootOnDisk: resolvePath(_imagesAssetDiskDir), + assetLogicalPrefix: _imagesAssetLogicalPrefix, + logger: logger, + ); + + final result = await migrator.transform(json); + return Map.from(result as Map); + } +} diff --git a/lib/src/commands/resources/processors/translation_processor.dart b/lib/src/commands/resources/processors/translation_processor.dart new file mode 100644 index 0000000..37221f4 --- /dev/null +++ b/lib/src/commands/resources/processors/translation_processor.dart @@ -0,0 +1,56 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'package:webtrit_phone_tools/src/commands/constants.dart'; +import 'package:webtrit_phone_tools/src/utils/utils.dart'; + +class TranslationProcessor { + const TranslationProcessor({ + required this.httpClient, + required this.logger, + }); + + final HttpClient httpClient; + final Logger logger; + + Future process({ + required String applicationId, + required String Function(String) resolvePath, + }) async { + final configFile = File(resolvePath('localizely.yml')); + + if (!configFile.existsSync()) { + logger.warn('localizely.yml file not found in the working directory.'); + return; + } + + final config = loadYaml(await configFile.readAsString()); + final localeCodes = (config['download']['files'] as List).map((e) => e['locale_code']).toSet(); + + logger.info('Downloading translations for: ${localeCodes.join(', ')}'); + final zipFiles = await httpClient.getTranslationFiles(applicationId); + + for (final file in zipFiles) { + final fileName = p.basename(file.name); + + if (fileName.isEmpty || fileName != file.name) { + logger.detail('Skipping suspicious file path: ${file.name}'); + continue; + } + + final locale = fileName.split('.').first; + if (localeCodes.contains(locale)) { + final outFile = File(resolvePath('$translationsArbPath/app_$fileName')); + if (!outFile.parent.existsSync()) { + await outFile.parent.create(recursive: true); + } + await outFile.writeAsBytes(file.content); + logger.success(' Saved: ${outFile.path}'); + } + } + } +} diff --git a/lib/src/commands/resources/resources.dart b/lib/src/commands/resources/resources.dart new file mode 100644 index 0000000..fe0d06b --- /dev/null +++ b/lib/src/commands/resources/resources.dart @@ -0,0 +1 @@ +export 'resources_get_command.dart'; diff --git a/lib/src/commands/resources/resources_get_command.dart b/lib/src/commands/resources/resources_get_command.dart new file mode 100644 index 0000000..ffe0f50 --- /dev/null +++ b/lib/src/commands/resources/resources_get_command.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as path; + +import 'package:data/datasource/datasource.dart'; + +import 'package:webtrit_phone_tools/src/utils/utils.dart'; + +import 'models/models.dart'; +import 'processors/processors.dart'; +import 'runners/external_generator_runner.dart'; +import 'services/services.dart'; + +const _argApplicationId = 'applicationId'; +const _argToken = 'token'; +const _argKeystoresPath = 'keystores-path'; +const _argCacheSessionDataPath = 'cache-session-data-path'; + +const _paramDirectory = ''; +const _descDirectory = '$_paramDirectory (optional)'; + +class ResourcesGetCommand extends Command { + ResourcesGetCommand({ + required Logger logger, + required HttpClient httpClient, + required ConfiguratorBackandDatasource datasource, + }) : _logger = logger, + _httpClient = httpClient, + _datasource = datasource { + argParser + ..addOption( + _argApplicationId, + help: 'Configurator application id.', + mandatory: true, + ) + ..addOption( + _argToken, + help: 'JWT token for configurator API.', + mandatory: true, + ) + ..addOption( + _argKeystoresPath, + help: "Path to the project's keystore folder.", + mandatory: true, + ) + ..addOption( + _argCacheSessionDataPath, + help: 'Path to file which cache temporarily stores user session data.', + ); + } + + @override + String get name => 'configurator-resources'; + + @override + String get description => CommandHelpFormatter.formatDescription( + title: 'Get resources to customize application', + parameter: _descDirectory, + description: 'Specify the directory for creating keystore and metadata files.', + note: 'Defaults to the current working directory if not provided.', + ); + + @override + String get invocation => '${super.invocation} [$_paramDirectory]'; + + final Logger _logger; + final HttpClient _httpClient; + final ConfiguratorBackandDatasource _datasource; + + @override + Future run() async { + try { + final context = _buildContext(); + + _datasource.addInterceptor(HeadersInterceptor(context.authHeader)); + + final (application, theme) = await ApplicationDataFetcher( + datasource: _datasource, + logger: _logger, + ).fetch( + applicationId: context.applicationId, + authHeader: context.authHeader, + ); + + await CertificateProcessor(logger: _logger).process( + projectKeystorePath: context.projectKeystorePath, + resolvePath: context.resolvePath, + ); + + await TranslationProcessor( + httpClient: _httpClient, + logger: _logger, + ).process( + applicationId: context.applicationId, + resolvePath: context.resolvePath, + ); + + final localConfigProcessor = LocalConfigProcessor(logger: _logger); + + await localConfigProcessor.writeBuildCache( + application: application, + projectKeystorePath: context.projectKeystorePath, + cachePathArg: context.cachePathArg, + resolvePath: context.resolvePath, + ); + + final assetProcessor = AssetProcessor( + httpClient: _httpClient, + datasource: _datasource, + logger: _logger, + ); + + final splashInfo = await assetProcessor.processSplashAssets( + applicationId: context.applicationId, + themeId: theme.id!, + resolvePath: context.resolvePath, + ); + + final launchIcons = await assetProcessor.processLaunchIcons( + applicationId: context.applicationId, + themeId: theme.id!, + resolvePath: context.resolvePath, + ); + + await ThemeConfigProcessor( + httpClient: _httpClient, + datasource: _datasource, + logger: _logger, + ).process( + applicationId: context.applicationId, + themeId: theme.id!, + resolvePath: context.resolvePath, + ); + + await ExternalGeneratorRunner(logger: _logger).runGenerators( + workingDirectoryPath: context.workingDirectoryPath, + application: application, + splashInfo: splashInfo, + launchIcons: launchIcons, + ); + + await localConfigProcessor.writeEnvironmentConfig( + application: application, + projectKeystorePath: context.projectKeystorePath, + resolvePath: context.resolvePath, + ); + + return ExitCode.success.code; + } catch (e, s) { + _logger + ..err('Execution failed: $e') + ..detail('$s'); + return ExitCode.usage.code; + } + } + + CommandContext _buildContext() { + final rest = argResults!.rest; + + final workingDirectoryPath = rest.isEmpty + ? Directory.current.path + : rest.length == 1 + ? rest[0] + : throw UsageException('Only one "$_paramDirectory" parameter can be passed.', usage); + + final applicationId = argResults![_argApplicationId] as String; + final token = argResults![_argToken] as String; + + if (applicationId.isEmpty || token.isEmpty) { + throw UsageException('Application ID and Token must not be empty.', usage); + } + + final keystoreArg = argResults![_argKeystoresPath] as String; + + final keystoreDirPath = path.isAbsolute(keystoreArg) + ? path.normalize(keystoreArg) + : path.normalize(path.join(workingDirectoryPath, keystoreArg)); + + final keystoreDir = Directory(keystoreDirPath); + + if (!keystoreDir.existsSync()) { + _logger.err('Keystores directory path does not exist: ${keystoreDir.path}'); + throw UsageException('Invalid keystore path', usage); + } + + final projectKeystoreDir = Directory(path.join(keystoreDir.path, applicationId)); + if (!projectKeystoreDir.existsSync()) { + _logger.err('Project keystore directory path does not exist: ${projectKeystoreDir.path}'); + throw UsageException('Invalid project keystore path', usage); + } + + _logger.info('- Project keystore directory: ${projectKeystoreDir.path}'); + + return CommandContext( + workingDirectoryPath: workingDirectoryPath, + applicationId: applicationId, + projectKeystorePath: projectKeystoreDir.path, + authHeader: {'Authorization': 'Bearer $token'}, + cachePathArg: argResults![_argCacheSessionDataPath] as String?, + ); + } +} diff --git a/lib/src/commands/resources/runners/external_generator_runner.dart b/lib/src/commands/resources/runners/external_generator_runner.dart new file mode 100644 index 0000000..a5e94f8 --- /dev/null +++ b/lib/src/commands/resources/runners/external_generator_runner.dart @@ -0,0 +1,71 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; + +import 'package:data/dto/dto.dart'; + +import '../utils/app_config_factory.dart'; + +class ExternalGeneratorRunner { + const ExternalGeneratorRunner({required this.logger}); + + final Logger logger; + + Future runGenerators({ + required String workingDirectoryPath, + required ApplicationDTO application, + required SplashAssetDto splashInfo, + required LaunchAssetsEnvelopeDto launchIcons, + }) async { + final launchBgColor = launchIcons.entity.source?.backgroundColorHex; + final splashBgColor = splashInfo.source?.backgroundColorHex; + + if (launchBgColor != null) { + logger.info('- Running: generate-launcher-icons-config'); + final env = AppConfigFactory.createLauncherIconsEnv( + launchBackgroundColorHex: launchBgColor, + splashBackgroundColorHex: splashBgColor ?? launchBgColor, + ); + await _runMakeCommand(workingDirectoryPath, 'generate-launcher-icons-config', env); + } else { + logger.warn('Skipping launcher generation: backgroundColorHex is null.'); + } + + if (splashBgColor != null) { + logger.info('- Running: generate-native-splash-config'); + final env = AppConfigFactory.createNativeSplashEnv(splashBgColor); + await _runMakeCommand(workingDirectoryPath, 'generate-native-splash-config', env); + } else { + logger.warn('Skipping splash generation: backgroundColorHex is null.'); + } + + logger.info('- Running: generate-package-config'); + final packageEnv = AppConfigFactory.createPackageConfigEnv(application); + await _runMakeCommand(workingDirectoryPath, 'generate-package-config', packageEnv); + } + + Future _runMakeCommand(String workingDirectoryPath, String target, Map environment) async { + logger.info('Running generator: $target...'); + + final process = await Process.start( + 'make', + [target], + workingDirectory: workingDirectoryPath, + runInShell: true, + environment: environment, + ); + + final stdoutFuture = process.stdout.transform(utf8.decoder).forEach((data) => logger.detail(data.trim())); + final stderrFuture = process.stderr.transform(utf8.decoder).forEach((data) => logger.warn(data.trim())); + + await Future.wait([stdoutFuture, stderrFuture]); + final exitCode = await process.exitCode; + + if (exitCode != 0) { + throw Exception('Command "make $target" failed with exit code $exitCode'); + } + logger.success('βœ“ Generator $target completed'); + } +} diff --git a/lib/src/commands/resources/runners/runners.dart b/lib/src/commands/resources/runners/runners.dart new file mode 100644 index 0000000..583c802 --- /dev/null +++ b/lib/src/commands/resources/runners/runners.dart @@ -0,0 +1 @@ +export 'external_generator_runner.dart'; diff --git a/lib/src/commands/resources/services/application_data_fetcher.dart b/lib/src/commands/resources/services/application_data_fetcher.dart new file mode 100644 index 0000000..67ab816 --- /dev/null +++ b/lib/src/commands/resources/services/application_data_fetcher.dart @@ -0,0 +1,37 @@ +import 'package:mason_logger/mason_logger.dart'; + +import 'package:data/datasource/datasource.dart'; +import 'package:data/dto/dto.dart'; + +class ApplicationDataFetcher { + const ApplicationDataFetcher({ + required this.datasource, + required this.logger, + }); + + final ConfiguratorBackandDatasource datasource; + final Logger logger; + + Future<(ApplicationDTO, ThemeDTO)> fetch({ + required String applicationId, + required Map authHeader, + }) async { + final application = await datasource.getApplication( + applicationId: applicationId, + headers: authHeader, + ); + + if (application.theme == null) { + throw Exception('Application $applicationId does not have a default theme.'); + } + + final theme = await datasource.getTheme( + applicationId: applicationId, + themeId: application.theme!, + headers: authHeader, + ); + + logger.info('- Fetched theme: ${theme.id}'); + return (application, theme); + } +} diff --git a/lib/src/commands/resources/services/services.dart b/lib/src/commands/resources/services/services.dart new file mode 100644 index 0000000..f63bd4d --- /dev/null +++ b/lib/src/commands/resources/services/services.dart @@ -0,0 +1 @@ +export 'application_data_fetcher.dart'; diff --git a/lib/src/commands/resources/utils/app_config_factory.dart b/lib/src/commands/resources/utils/app_config_factory.dart new file mode 100644 index 0000000..397930e --- /dev/null +++ b/lib/src/commands/resources/utils/app_config_factory.dart @@ -0,0 +1,62 @@ +import 'package:data/dto/dto.dart'; + +import 'package:webtrit_phone_tools/src/commands/constants.dart'; +import 'package:webtrit_phone_tools/src/extension/extension.dart'; + +class AppConfigFactory { + static Map createBuildCacheConfig(ApplicationDTO application, String keystorePath) { + if (application.androidVersion?.buildName == null || + application.androidVersion?.buildNumber == null || + application.iosVersion?.buildName == null || + application.iosVersion?.buildNumber == null) { + throw Exception('Android or iOS version build info is missing.'); + } + + return { + bundleIdAndroidField: application.androidPlatformId, + buildNameAndroidField: application.androidVersion?.buildName, + buildNumberAndroidField: application.androidVersion?.buildNumber, + bundleIdIosField: application.iosPlatformId, + buildNameIOSField: application.iosVersion?.buildName, + buildNumberIOSField: application.iosVersion?.buildNumber, + keystorePathField: keystorePath, + }; + } + + static Map createDartDefineEnv(ApplicationDTO application, String keystorePath) { + final env = Map.from(application.environment ?? {}); + env['WEBTRIT_ANDROID_RELEASE_UPLOAD_KEYSTORE_PATH'] = keystorePath; + return env; + } + + static Map createLauncherIconsEnv({ + required String launchBackgroundColorHex, + required String splashBackgroundColorHex, + }) { + return { + 'LAUNCHER_ICON_IMAGE_ANDROID': assetLauncherAndroidIconPath, + 'ICON_BACKGROUND_COLOR': splashBackgroundColorHex.toHex6WithHash(), + 'LAUNCHER_ICON_FOREGROUND': assetLauncherIconAdaptiveForegroundPath, + 'LAUNCHER_ICON_IMAGE_IOS': assetLauncherIosIconPath, + 'LAUNCHER_ICON_IMAGE_WEB': assetLauncherWebIconPath, + 'THEME_COLOR': launchBackgroundColorHex.toHex6WithHash(), + }; + } + + static Map createNativeSplashEnv(String backgroundColorHex) { + return { + 'SPLASH_COLOR': backgroundColorHex.toHex6WithHash(), + 'SPLASH_IMAGE': assetSplashIconPath, + 'ANDROID_12_SPLASH_COLOR': backgroundColorHex.toHex6WithHash(), + }; + } + + static Map createPackageConfigEnv(ApplicationDTO application) { + return { + 'ANDROID_APP_NAME': application.name ?? '', + 'PACKAGE_NAME': application.androidPlatformId ?? '', + 'IOS_APP_NAME': application.name ?? '', + 'BUNDLE_ID': application.iosPlatformId ?? '', + }; + } +} diff --git a/lib/src/commands/resources/utils/utils.dart b/lib/src/commands/resources/utils/utils.dart new file mode 100644 index 0000000..94c33dd --- /dev/null +++ b/lib/src/commands/resources/utils/utils.dart @@ -0,0 +1 @@ +export 'app_config_factory.dart'; diff --git a/lib/src/extension/string_extension.dart b/lib/src/extension/string_extension.dart index a32d6bd..3101f7a 100755 --- a/lib/src/extension/string_extension.dart +++ b/lib/src/extension/string_extension.dart @@ -8,4 +8,21 @@ extension StringExtensions on String { throw FormatException('Failed to convert string to map: $e'); } } + + /// Converts a hex string (e.g. #AABBCC, AABBCC, #FFAABBCC) + /// to a 6-character hex string with hash (e.g. #AABBCC). + /// Handles alpha channel stripping if present (8 chars). + String toHex6WithHash() { + final hex = replaceAll('#', '').toUpperCase(); + + if (hex.length == 8) { + return '#${hex.substring(2)}'; + } + + if (hex.length == 6) { + return '#$hex'; + } + + throw FormatException('Invalid hex color string: "$this"'); + } } diff --git a/lib/src/utils/asset_migration_utils.dart b/lib/src/utils/asset_migration_utils.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/src/utils/asset_migrator.dart b/lib/src/utils/asset_migrator.dart new file mode 100644 index 0000000..62cf854 --- /dev/null +++ b/lib/src/utils/asset_migrator.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as path; + +import 'hash_utils.dart'; + +typedef BytesFetcher = Future Function(String url); + +class JsonAssetMigrator { + JsonAssetMigrator({ + required this.fetchBytes, + required this.assetsRootOnDisk, + required this.assetLogicalPrefix, + required this.logger, + }); + + final BytesFetcher fetchBytes; + final String assetsRootOnDisk; + final String assetLogicalPrefix; + final Logger logger; + + final Map _cache = {}; + + Future transform(dynamic node, {List path = const []}) async { + if (path.contains('embeddedResources')) return node; + + if (node is Map) { + final result = {}; + for (final entry in node.entries) { + final k = entry.key.toString(); + final v = entry.value; + + if (_isUrlKey(k) && _isUrlValue(v)) { + result[k] = await _processUrl(v as String); + continue; + } + + if (k == 'imageSource' && v is Map && _isUrlValue(v['uri'])) { + final newUri = await _processUrl(v['uri'] as String); + result[k] = Map.from(v)..['uri'] = newUri; + continue; + } + + result[k] = await transform(v, path: [...path, k]); + } + return result; + } else if (node is List) { + final result = []; + for (var i = 0; i < node.length; i++) { + result.add(await transform(node[i], path: [...path, '[$i]'])); + } + return result; + } + + return node; + } + + bool _isUrlKey(String key) => ['uri', 'url'].contains(key) || key.endsWith('Url') || key.endsWith('URL'); + + bool _isUrlValue(Object? value) => value is String && (value.startsWith('http://') || value.startsWith('https://')); + + Future _processUrl(String url) async { + if (_cache.containsKey(url)) return _cache[url]!; + + final bytes = await fetchBytes(url); + if (bytes == null) { + logger.warn('Failed to download asset: $url'); + return url; + } + + final ext = _sniffExtension(bytes) ?? 'bin'; + final filename = _deriveFilename(url, ext); + final diskPath = path.join(assetsRootOnDisk, filename); + + try { + final file = File(diskPath); + if (!file.parent.existsSync()) { + await file.parent.create(recursive: true); + } + await file.writeAsBytes(bytes); + + final logicalPath = '$assetLogicalPrefix/$filename'; + _cache[url] = logicalPath; + + return logicalPath; + } catch (e) { + logger.err('Failed to save asset to disk: $e'); + return url; + } + } + + String _deriveFilename(String url, String ext) { + final uri = Uri.tryParse(url); + final lastSegment = uri?.pathSegments.lastOrNull ?? 'image'; + + final hash = HashUtils.generateShort(url); + + final name = lastSegment.replaceAll(RegExp(r'\.[A-Za-z0-9]+$'), '').replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); + + return '${name}_$hash.$ext'; + } + + String? _sniffExtension(List bytes) { + if (bytes.length < 12) return null; + + if (bytes[0] == 0x89 && bytes[1] == 0x50) return 'png'; + if (bytes[0] == 0xFF && bytes[1] == 0xD8) return 'jpg'; + if (bytes[0] == 0x47 && bytes[1] == 0x49) return 'gif'; + if (bytes[0] == 0x42 && bytes[1] == 0x4D) return 'bmp'; + + if (bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46 && + bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return 'webp'; + } + + try { + final header = utf8.decode(bytes.take(100).toList(), allowMalformed: true); + if (header.contains(' writeJsonToFile(String path, dynamic content, {Logger? logger}) async { + try { + final file = File(path); + if (!file.parent.existsSync()) { + await file.parent.create(recursive: true); + } + + final jsonString = const JsonEncoder.withIndent(' ').convert(content); + await file.writeAsString(jsonString); + + logger?.success('βœ“ Config written: $path'); + } catch (e) { + logger?.err('βœ— Failed to write JSON to $path: $e'); + rethrow; + } +} diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index bb8e1dc..2cb18fb 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -1,3 +1,6 @@ +export 'asset_migrator.dart'; +export 'command_help_formatter.dart'; export 'http_client.dart'; +export 'json_utils.dart'; export 'keystore_readme_updater.dart'; export 'password_generator.dart';