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', ]; final Directory _repoRootDir = File.fromUri(Platform.script).parent.parent; final String _currentDartExecutable = Platform.resolvedExecutable; final String _flutterExecutable = _resolveFlutterExecutable(); final String _dartExecutable = _resolveDartExecutable(); Future main(List args) async { Directory.current = _repoRootDir.path; 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'); print('\x1B[34m[信息] Flutter 命令:$_flutterExecutable\x1B[0m'); print('\x1B[34m[信息] Dart 命令:$_dartExecutable\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, ); await _generateBootstrapConfig( appDir: appDir, defaultUrl: defaultUrl, preferredOrientations: preferredOrientations, bootstrapConfigUrl: bootstrapConfigUrl, upgradeConfigUrl: upgradeConfigUrl, ); 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'); print(' ${_buildApkCommandForCurrentPlatform()}'); } String _resolveFlutterExecutable() { final candidates = [ ..._toolCandidatesFromFlutterRoot(_flutterToolRelativePaths), ..._toolCandidatesFromRepo(_flutterToolRelativePaths), ..._flutterCandidatesFromCurrentDart(), 'flutter', ]; return _firstAvailableExecutable(candidates, fallback: 'flutter'); } String _resolveDartExecutable() { final candidates = [ ..._toolCandidatesFromFlutterRoot(_dartToolRelativePaths), ..._toolCandidatesFromRepo(_dartToolRelativePaths), _currentDartExecutable, 'dart', ]; return _firstAvailableExecutable(candidates, fallback: 'dart'); } List _flutterCandidatesFromCurrentDart() { final dartExecutable = File(_currentDartExecutable); final dartBinDir = dartExecutable.parent; final dartSdkDir = dartBinDir.parent; final cacheDir = dartSdkDir.parent; final flutterBinDir = cacheDir.parent; if (_basename(dartSdkDir.path) != 'dart-sdk' || _basename(cacheDir.path) != 'cache' || !_isSameDirectoryName(flutterBinDir.path, 'bin')) { return []; } final flutterBat = File.fromUri(flutterBinDir.uri.resolve('flutter.bat')); final flutterShell = File.fromUri(flutterBinDir.uri.resolve('flutter')); return [ if (flutterBat.existsSync()) flutterBat.path, if (flutterShell.existsSync()) flutterShell.path, ]; } List get _flutterToolRelativePaths => Platform.isWindows ? ['bin/flutter.bat', 'bin/flutter'] : ['bin/flutter', 'bin/flutter.bat']; List get _dartToolRelativePaths => Platform.isWindows ? [ 'bin/dart.bat', 'bin/cache/dart-sdk/bin/dart.exe', 'bin/dart', 'bin/cache/dart-sdk/bin/dart', ] : ['bin/dart', 'bin/cache/dart-sdk/bin/dart']; List _toolCandidatesFromFlutterRoot(List relativePaths) { final flutterRoot = Platform.environment['FLUTTER_ROOT']; if (flutterRoot == null || flutterRoot.isEmpty) { return []; } return [ for (final relativePath in relativePaths) if (_fileExists(flutterRoot, relativePath)) _joinPath(flutterRoot, relativePath), ]; } List _toolCandidatesFromRepo(List relativePaths) { return [ for (final relativePath in relativePaths) if (_repoFile(relativePath).existsSync()) _repoFile(relativePath).path, ]; } String _firstAvailableExecutable( List candidates, { required String fallback, }) { for (final candidate in candidates) { if (candidate == fallback || File(candidate).existsSync()) { return candidate; } } return fallback; } bool _fileExists(String? basePath, String childPath) { if (basePath == null || basePath.isEmpty) { return false; } return File(_joinPath(basePath, childPath)).existsSync(); } String _joinPath(String basePath, String childPath) { final normalizedBase = basePath.replaceAll('\\', '/'); final normalizedChild = childPath.replaceAll('\\', '/'); return File.fromUri( Directory(normalizedBase).uri.resolve(normalizedChild), ).path; } File _repoFile(String relativePath) { return File.fromUri(_repoRootDir.uri.resolve(relativePath)); } String _basename(String path) { final normalized = path .replaceAll('\\', '/') .replaceFirst(RegExp(r'/+$'), ''); final segments = normalized.split('/'); return segments.isEmpty ? normalized : segments.last; } bool _isSameDirectoryName(String path, String name) { return _basename(path).toLowerCase() == name.toLowerCase(); } String _buildApkCommandForCurrentPlatform() { final fvmFlutter = _repoFile( Platform.isWindows ? '.fvm/flutter_sdk/bin/flutter.bat' : '.fvm/flutter_sdk/bin/flutter', ); if (fvmFlutter.existsSync()) { final commandPath = Platform.isWindows ? '../../.fvm/flutter_sdk/bin/flutter.bat' : '../../.fvm/flutter_sdk/bin/flutter'; return '$commandPath build apk'; } return 'flutter build apk'; } Future _runProcess( String executable, List arguments, { String? workingDirectory, }) async { try { return await Process.run( executable, arguments, workingDirectory: workingDirectory, runInShell: Platform.isWindows && executable.toLowerCase().endsWith('.bat'), ); } on ProcessException catch (error) { print('\x1B[31m[错误] 外部命令启动失败:$executable\x1B[0m'); print('\x1B[33m命令参数:${arguments.join(' ')}\x1B[0m'); print('\x1B[33m系统信息:${error.message}\x1B[0m'); if (executable == _flutterExecutable || executable == 'flutter') { print('\x1B[33m建议先执行:\x1B[0m'); if (Platform.isWindows) { print( ' \$env:PATH = "${_repoFile('.fvm/flutter_sdk/bin').path};\$env:PATH"', ); } else { print( ' export PATH="${_repoFile('.fvm/flutter_sdk/bin').path}:\$PATH"', ); } } exit(1); } } 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 _runProcess(_flutterExecutable, [ '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 _runProcess(_flutterExecutable, [ '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, }) async { print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m'); final mainFile = File('$appDir/lib/main.dart'); 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'), bootstrapConfigAsset: 'assets/config/bootstrap.json', ), ); } '''; 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, String? bootstrapConfigUrl, String? upgradeConfigUrl, }) 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, if (bootstrapConfigUrl != null && bootstrapConfigUrl.trim().isNotEmpty) 'bootstrapConfigUrl': bootstrapConfigUrl.trim(), if (upgradeConfigUrl != null && upgradeConfigUrl.trim().isNotEmpty) 'upgradeConfigUrl': upgradeConfigUrl.trim(), }; 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 _runProcess(_flutterExecutable, [ '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 _runProcess(_flutterExecutable, [ 'pub', 'get', ], workingDirectory: appDir); final iconPath = 'assets/branding/${branding['icon']}'; final iconBackground = branding['icon_background'] as String; final iconsYaml = ''' flutter_launcher_icons: android: true image_path: "$iconPath" adaptive_icon_background: "$iconBackground" adaptive_icon_foreground: "$iconPath" '''; 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 _runProcess(_dartExecutable, [ '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 _runProcess(_dartExecutable, [ '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 _runProcess(_flutterExecutable, [ '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.io.File import java.util.Properties import java.io.FileInputStream val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") if (keystorePropertiesFile.exists()) { FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) } } val defaultReleaseStoreFile = rootProject.file("../../../tool/key.jks") fun resolveKeystoreFile(rawPath: String?): File { val candidate = rawPath?.trim().orEmpty() if (candidate.isEmpty()) { return defaultReleaseStoreFile } val expandedHome = if (candidate.startsWith("~/") || candidate == "~") { candidate.replaceFirst("~", System.getProperty("user.home")) } else { candidate } val normalized = expandedHome.replace('\\\\', File.separatorChar).replace('/', File.separatorChar) val storeFile = File(normalized) if (storeFile.isAbsolute) { return storeFile } val rootRelativeFile = rootProject.file(normalized) if (rootRelativeFile.exists()) { return rootRelativeFile } val moduleRelativeFile = project.file(normalized) if (moduleRelativeFile.exists()) { return moduleRelativeFile } return rootRelativeFile } val releaseStoreFile = resolveKeystoreFile(keystoreProperties["storeFile"] as String?) val releaseKeyAlias = keystoreProperties["keyAlias"] as String? ?: "my-key-alias" val releaseKeyPassword = keystoreProperties["keyPassword"] as String? ?: "123456" val releaseStorePassword = keystoreProperties["storePassword"] as String? ?: "123456" android { '''); } if (!content.contains('signingConfigs {\n create("release") {')) { const newBuildTypes = ''' signingConfigs { create("release") { keyAlias = releaseKeyAlias keyPassword = releaseKeyPassword storeFile = releaseStoreFile storePassword = releaseStorePassword } } 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); }