493 lines
16 KiB
Dart
493 lines
16 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:yaml/yaml.dart';
|
||
|
||
final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$');
|
||
const List<String> _defaultPreferredOrientations = <String>[
|
||
'portraitUp',
|
||
'portraitDown',
|
||
];
|
||
|
||
Future<void> main(List<String> 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<String> _readPreferredOrientations(Object? raw) {
|
||
if (raw is! YamlList && raw is! List) {
|
||
return _defaultPreferredOrientations;
|
||
}
|
||
|
||
final values = <String>[];
|
||
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<void> _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', <String>[
|
||
'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<void> _addCoreDependency(String appDir) async {
|
||
print('\x1B[34m[信息] 正在添加 web_shell_core 依赖...\x1B[0m');
|
||
final result = await Process.run('flutter', <String>[
|
||
'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<void> _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<void> _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<void> _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<void> _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 = <String>[
|
||
" 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<void> _generateBootstrapConfig({
|
||
required String appDir,
|
||
required String? defaultUrl,
|
||
required List<String> 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 = <String, Object?>{
|
||
if (defaultUrl != null && defaultUrl.trim().isNotEmpty)
|
||
'initialUrl': defaultUrl.trim(),
|
||
'preferredOrientations': preferredOrientations,
|
||
};
|
||
|
||
final content = const JsonEncoder.withIndent(' ').convert(data);
|
||
await file.writeAsString('$content\n');
|
||
}
|
||
|
||
Future<void> _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', <String>[
|
||
'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', <String>[
|
||
'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', <String>[
|
||
'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', <String>[
|
||
'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', <String>[
|
||
'pub',
|
||
'remove',
|
||
'flutter_launcher_icons',
|
||
'flutter_native_splash',
|
||
], workingDirectory: appDir);
|
||
}
|
||
|
||
Future<void> _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<void> _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);
|
||
}
|