import 'dart:convert'; import 'dart:io'; import 'package:yaml/yaml.dart'; final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$'); const List _defaultPreferredOrientations = [ 'portraitUp', 'portraitDown', ]; Future main(List args) async { if (args.isEmpty) { print('\x1B[31m用法:dart run tool/generate_app.dart <品牌名>\x1B[0m'); print('\x1B[33m示例:dart run tool/generate_app.dart aixue\x1B[0m'); exit(1); } final brand = args.first; if (!_validBrandName.hasMatch(brand)) { print('\x1B[31m[错误] 品牌名 "$brand" 格式无效,仅允许小写字母、数字和下划线。\x1B[0m'); exit(1); } final configFile = File('flavors/$brand.yaml'); if (!configFile.existsSync()) { print('\x1B[31m[错误] 未找到配置文件:${configFile.path}\x1B[0m'); exit(1); } print('\x1B[34m[信息] 正在为品牌生成应用:$brand...\x1B[0m'); final yamlString = await configFile.readAsString(); final config = loadYaml(yamlString) as YamlMap; final appName = config['app_name'] as String; final applicationId = config['application_id'] as String; final appKey = config['app_key'] as String; final defaultUrl = config['default_url'] as String?; final bootstrapConfigUrl = config['bootstrap_config_url'] as String?; final upgradeConfigUrl = config['upgrade_config_url'] as String?; final preferredOrientations = _readPreferredOrientations( config['preferred_orientations'], ); final theme = config['theme'] as YamlMap; final accentColor = theme['accent_color'] as String; final bgColor = theme['bg_color'] as String; final textColor = theme['text_color'] as String; final mutedTextColor = theme['muted_text_color'] as String; final appDir = 'apps/$brand'; await _createFlutterApp(brand, appDir, applicationId); await _addCoreDependency(appDir); await _rewriteAndroidPackageId(appDir, applicationId); await _overwriteMainActivity(appDir, applicationId); await _overwriteManifestLabel(appDir, appName); await _generateDartEntrypoint( appDir: appDir, appName: appName, appKey: appKey, accentColor: accentColor, backgroundColor: bgColor, textColor: textColor, mutedTextColor: mutedTextColor, bootstrapConfigUrl: bootstrapConfigUrl, upgradeConfigUrl: upgradeConfigUrl, ); await _generateBootstrapConfig( appDir: appDir, defaultUrl: defaultUrl, preferredOrientations: preferredOrientations, ); await _generateBrandingAssets(brand, appDir, config); await _registerFlutterAssets(appDir); await _configureSigning(appDir, config); print('\x1B[32m✔ 应用 $brand 已生成到 $appDir!\x1B[0m'); print('\x1B[34m构建应用请执行:\x1B[0m'); print(' cd $appDir && flutter build apk'); } List _readPreferredOrientations(Object? raw) { if (raw is! YamlList && raw is! List) { return _defaultPreferredOrientations; } final values = []; for (final item in (raw as List)) { final normalized = _normalizeOrientation(item?.toString()); if (normalized != null && !values.contains(normalized)) { values.add(normalized); } } return values.isEmpty ? _defaultPreferredOrientations : values; } String? _normalizeOrientation(String? raw) { final value = raw?.trim(); if (value == null || value.isEmpty) { return null; } return switch (value) { 'portraitUp' || 'DeviceOrientation.portraitUp' => 'portraitUp', 'portraitDown' || 'DeviceOrientation.portraitDown' => 'portraitDown', 'landscapeLeft' || 'DeviceOrientation.landscapeLeft' => 'landscapeLeft', 'landscapeRight' || 'DeviceOrientation.landscapeRight' => 'landscapeRight', _ => null, }; } Future _createFlutterApp( String brand, String appDir, String applicationId, ) async { print('\x1B[34m[信息] 正在执行 flutter create...\x1B[0m'); final dir = Directory(appDir); if (dir.existsSync()) { print('\x1B[33m[警告] 目录 $appDir 已存在,正在清理...\x1B[0m'); dir.deleteSync(recursive: true); } final segments = applicationId.split('.'); final org = segments.sublist(0, segments.length - 1).join('.'); final result = await Process.run('flutter', [ 'create', '--org', org, '--project-name', brand, '--platforms', 'android', '--android-language', 'java', appDir, ]); if (result.exitCode != 0) { print('\x1B[31m[错误] flutter create 执行失败:\n${result.stderr}\x1B[0m'); exit(1); } } Future _addCoreDependency(String appDir) async { print('\x1B[34m[信息] 正在添加 web_shell_core 依赖...\x1B[0m'); final result = await Process.run('flutter', [ 'pub', 'add', 'web_shell_core', '--path', '../../packages/web_shell_core', ], workingDirectory: appDir); if (result.exitCode != 0) { print('\x1B[31m[错误] 添加依赖失败:\n${result.stderr}\x1B[0m'); exit(1); } } Future _rewriteAndroidPackageId( String appDir, String applicationId, ) async { print('\x1B[34m[信息] 正在校正 Android applicationId / namespace...\x1B[0m'); final gradleFile = File('$appDir/android/app/build.gradle.kts'); if (!gradleFile.existsSync()) { print('\x1B[31m[错误] 未找到 ${gradleFile.path}\x1B[0m'); exit(1); } var content = await gradleFile.readAsString(); content = content.replaceFirst( RegExp(r'namespace\s*=\s*"[^"]+"'), 'namespace = "$applicationId"', ); content = content.replaceFirst( RegExp(r'applicationId\s*=\s*"[^"]+"'), 'applicationId = "$applicationId"', ); await gradleFile.writeAsString(content); final javaDir = Directory('$appDir/android/app/src/main/java'); if (javaDir.existsSync()) { javaDir.deleteSync(recursive: true); } await javaDir.create(recursive: true); } Future _overwriteMainActivity(String appDir, String applicationId) async { print('\x1B[34m[信息] 正在注入 CoreShellActivity 继承关系...\x1B[0m'); final mainActivityPath = '$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java'; final javaContent = ''' package $applicationId; import com.yuanxuan.webshell.core.web_shell_core.CoreShellActivity; public class MainActivity extends CoreShellActivity { } '''; final mainActivityFile = File(mainActivityPath); await mainActivityFile.parent.create(recursive: true); await mainActivityFile.writeAsString(javaContent); } Future _overwriteManifestLabel(String appDir, String appName) async { print('\x1B[34m[信息] 正在更新 AndroidManifest.xml 应用名...\x1B[0m'); final manifestFile = File('$appDir/android/app/src/main/AndroidManifest.xml'); var content = await manifestFile.readAsString(); content = content.replaceAll( RegExp(r'android:label="[^"]*"'), 'android:label="$appName"', ); await manifestFile.writeAsString(content); } Future _generateDartEntrypoint({ required String appDir, required String appName, required String appKey, required String accentColor, required String backgroundColor, required String textColor, required String mutedTextColor, String? bootstrapConfigUrl, String? upgradeConfigUrl, }) async { print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m'); final mainFile = File('$appDir/lib/main.dart'); final extraLines = [ " bootstrapConfigAsset: 'assets/config/bootstrap.json',", if (bootstrapConfigUrl != null && bootstrapConfigUrl.trim().isNotEmpty) " bootstrapConfigUrl: '${bootstrapConfigUrl.trim()}',", if (upgradeConfigUrl != null && upgradeConfigUrl.trim().isNotEmpty) " upgradeConfigUrl: '${upgradeConfigUrl.trim()}',", ].join('\n'); final dartContent = ''' import 'package:flutter/material.dart'; import 'package:web_shell_core/web_shell_core.dart'; void main() { runShellApp( ShellEnvironment( appName: '$appName', appKey: '$appKey', accentColor: const Color($accentColor), backgroundColor: const Color($backgroundColor), textColor: const Color($textColor), mutedTextColor: const Color($mutedTextColor), splashImage: const AssetImage('assets/branding/splash.png'), $extraLines ), ); } '''; await mainFile.writeAsString(dartContent); final defaultTestFile = File('$appDir/test/widget_test.dart'); if (defaultTestFile.existsSync()) { await defaultTestFile.delete(); } } Future _generateBootstrapConfig({ required String appDir, required String? defaultUrl, required List preferredOrientations, }) async { print('\x1B[34m[信息] 正在生成默认启动配置 assets/config/bootstrap.json...\x1B[0m'); final file = File('$appDir/assets/config/bootstrap.json'); await file.parent.create(recursive: true); final data = { if (defaultUrl != null && defaultUrl.trim().isNotEmpty) 'initialUrl': defaultUrl.trim(), 'preferredOrientations': preferredOrientations, }; final content = const JsonEncoder.withIndent(' ').convert(data); await file.writeAsString('$content\n'); } Future _generateBrandingAssets( String brand, String appDir, YamlMap config, ) async { print('\x1B[34m[信息] 正在配置图标与启动页...\x1B[0m'); if (config['branding'] == null) { print('\x1B[33m[警告] 配置中未找到 branding 段,跳过资源生成。\x1B[0m'); return; } final branding = config['branding'] as YamlMap; final brandSourceDir = Directory('flavors/$brand'); final brandTargetDir = Directory('$appDir/assets/branding'); if (!brandSourceDir.existsSync()) { print('\x1B[31m[错误] 品牌资源目录不存在:${brandSourceDir.path}\x1B[0m'); exit(1); } await brandTargetDir.create(recursive: true); for (final entity in brandSourceDir.listSync(recursive: true)) { if (entity is! File) { continue; } final relativePath = entity.path.substring(brandSourceDir.path.length + 1); final targetFile = File('${brandTargetDir.path}/$relativePath'); await targetFile.parent.create(recursive: true); await entity.copy(targetFile.path); } print('\x1B[32m✔ 品牌资源已复制到 ${brandTargetDir.path}\x1B[0m'); print('\x1B[34m[信息] 正在添加资源生成器依赖...\x1B[0m'); final addDevDepsResult = await Process.run('flutter', [ 'pub', 'add', '--dev', 'flutter_launcher_icons', 'flutter_native_splash', ], workingDirectory: appDir); if (addDevDepsResult.exitCode != 0) { print('\x1B[31m[错误] 添加资源生成器依赖失败:\n${addDevDepsResult.stderr}\x1B[0m'); exit(1); } await Process.run('flutter', [ 'pub', 'get', ], workingDirectory: appDir); final iconPath = 'assets/branding/${branding['icon']}'; final iconForeground = 'assets/branding/${branding['icon_foreground']}'; final iconBackground = branding['icon_background'] as String; final iconsYaml = ''' flutter_launcher_icons: android: true image_path: "$iconPath" adaptive_icon_background: "$iconBackground" adaptive_icon_foreground: "$iconForeground" '''; await File('$appDir/flutter_launcher_icons.yaml').writeAsString(iconsYaml); final splashPath = 'assets/branding/${branding['splash']}'; final splashColor = branding['splash_color'] as String; final splashYaml = ''' flutter_native_splash: color: "$splashColor" image: "$splashPath" android_12: image: "$splashPath" icon_background_color: "$splashColor" '''; await File('$appDir/flutter_native_splash.yaml').writeAsString(splashYaml); print('\x1B[34m[信息] 正在生成应用图标...\x1B[0m'); final iconsResult = await Process.run('dart', [ 'run', 'flutter_launcher_icons', '-f', 'flutter_launcher_icons.yaml', ], workingDirectory: appDir); if (iconsResult.exitCode != 0) { print('\x1B[31m[错误] 图标生成失败:\n${iconsResult.stderr}\x1B[0m'); print('\x1B[33mstdout:\n${iconsResult.stdout}\x1B[0m'); exit(1); } print('\x1B[34m[信息] 正在生成启动页...\x1B[0m'); final splashResult = await Process.run('dart', [ 'run', 'flutter_native_splash:create', '--path=flutter_native_splash.yaml', ], workingDirectory: appDir); if (splashResult.exitCode != 0) { print('\x1B[31m[错误] 启动页生成失败:\n${splashResult.stderr}\x1B[0m'); print('\x1B[33mstdout:\n${splashResult.stdout}\x1B[0m'); exit(1); } print('\x1B[34m[信息] 正在移除资源生成器依赖...\x1B[0m'); await Process.run('flutter', [ 'pub', 'remove', 'flutter_launcher_icons', 'flutter_native_splash', ], workingDirectory: appDir); } Future _registerFlutterAssets(String appDir) async { print('\x1B[34m[信息] 正在注册 Flutter assets...\x1B[0m'); final pubspecFile = File('$appDir/pubspec.yaml'); var content = await pubspecFile.readAsString(); if (!RegExp(r'^\s+assets:\s*$', multiLine: true).hasMatch(content)) { content = content.replaceFirst( RegExp(r'^flutter:\s*\n', multiLine: true), 'flutter:\n assets:\n - assets/branding/\n - assets/config/\n', ); } else { if (!content.contains(' - assets/branding/')) { content = content.replaceFirst( RegExp(r'^\s+assets:\s*\n', multiLine: true), ' assets:\n - assets/branding/\n', ); } if (!content.contains(' - assets/config/')) { content = content.replaceFirst( ' - assets/branding/\n', ' - assets/branding/\n - assets/config/\n', ); } } await pubspecFile.writeAsString(content); } Future _configureSigning(String appDir, YamlMap config) async { print('\x1B[34m[信息] 正在配置应用签名...\x1B[0m'); final signing = config['signing'] as YamlMap?; final keyAlias = signing?['key_alias'] as String? ?? 'my-key-alias'; final keyPassword = signing?['key_password'] as String? ?? '123456'; final storePassword = signing?['store_password'] as String? ?? '123456'; final storeFile = signing?['store_file'] as String? ?? '../../../../tool/key.jks'; final keyPropsContent = ''' storePassword=$storePassword keyPassword=$keyPassword keyAlias=$keyAlias storeFile=$storeFile '''; await File('$appDir/android/key.properties').writeAsString(keyPropsContent); final gradleFile = File('$appDir/android/app/build.gradle.kts'); if (!gradleFile.existsSync()) { print('\x1B[33m[警告] 未找到 build.gradle.kts,跳过注入签名配置。\x1B[0m'); return; } var content = await gradleFile.readAsString(); if (!content.contains('val keystoreProperties = Properties()')) { content = content.replaceFirst('android {', ''' import java.util.Properties import java.io.FileInputStream val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } android { '''); } if (!content.contains('signingConfigs {\n create("release") {')) { const newBuildTypes = ''' signingConfigs { create("release") { keyAlias = keystoreProperties["keyAlias"] as String? keyPassword = keystoreProperties["keyPassword"] as String? storeFile = keystoreProperties["storeFile"]?.let { file(it as String) } storePassword = keystoreProperties["storePassword"] as String? } } buildTypes { getByName("release") { signingConfig = signingConfigs.getByName("release") } } '''; final buildTypeRegex = RegExp( r'\s*buildTypes\s*\{[\s\S]*?signingConfigs\.getByName\("debug"\)\s*\}\s*\}', ); content = content.replaceFirst(buildTypeRegex, '\n$newBuildTypes'); } await gradleFile.writeAsString(content); }