359 lines
11 KiB
Dart
359 lines
11 KiB
Dart
import 'dart:io';
|
||
import 'package:yaml/yaml.dart';
|
||
|
||
/// 品牌名允许的字符:小写字母、数字、下划线。
|
||
final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$');
|
||
|
||
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 quanxue\x1B[0m');
|
||
exit(1);
|
||
}
|
||
|
||
final String brand = args.first;
|
||
|
||
if (!_validBrandName.hasMatch(brand)) {
|
||
print(
|
||
'\x1B[31m[错误] 品牌名 "$brand" 格式无效,'
|
||
'仅允许小写字母、数字和下划线(且必须以字母开头)。\x1B[0m',
|
||
);
|
||
exit(1);
|
||
}
|
||
|
||
final File configFile = File('flavors/$brand.yaml');
|
||
|
||
if (!configFile.existsSync()) {
|
||
print('\x1B[31m[错误] 未找到配置文件:${configFile.path}\x1B[0m');
|
||
exit(1);
|
||
}
|
||
|
||
print('\x1B[34m[信息] 正在为品牌生成应用:$brand...\x1B[0m');
|
||
|
||
// 1. 解析 YAML 配置
|
||
final String yamlString = await configFile.readAsString();
|
||
final YamlMap config = loadYaml(yamlString) as YamlMap;
|
||
|
||
final String appName = config['app_name'] as String;
|
||
final String applicationId = config['application_id'] as String;
|
||
final String appKey = config['app_key'] as String;
|
||
|
||
final YamlMap theme = config['theme'] as YamlMap;
|
||
final String accentColor = theme['accent_color'] as String;
|
||
final String bgColor = theme['bg_color'] as String;
|
||
final String textColor = theme['text_color'] as String;
|
||
final String mutedTextColor = theme['muted_text_color'] as String;
|
||
|
||
final String appDir = 'apps/$brand';
|
||
|
||
print('\x1B[32m✔ 配置加载完成。\x1B[0m');
|
||
|
||
// 2. 创建 Flutter 应用
|
||
await _createFlutterApp(brand, appDir, applicationId);
|
||
|
||
// 3. 添加核心依赖
|
||
await _addCoreDependency(appDir);
|
||
|
||
// 4. 覆盖 MainActivity.java 以继承 CoreShellActivity
|
||
await _overwriteMainActivity(appDir, applicationId);
|
||
|
||
// 5. 覆盖 AndroidManifest.xml 中的应用名称
|
||
await _overwriteManifestLabel(appDir, appName);
|
||
|
||
final String? defaultUrl = config['default_url'] as String?;
|
||
|
||
// 6. 生成 lib/main.dart
|
||
await _generateDartEntrypoint(
|
||
appDir,
|
||
appName,
|
||
appKey,
|
||
accentColor,
|
||
bgColor,
|
||
textColor,
|
||
mutedTextColor,
|
||
defaultUrl,
|
||
);
|
||
|
||
// 7. 生成图标与启动页配置
|
||
await _generateBrandingAssets(brand, appDir, config);
|
||
|
||
// 8. 在 pubspec.yaml 中注册 Flutter assets
|
||
await _registerFlutterAssets(appDir);
|
||
|
||
print('\x1B[32m✔ 应用 $brand 已生成到 $appDir!\x1B[0m');
|
||
print('\x1B[34m构建应用请执行:\x1B[0m');
|
||
print(' cd $appDir && flutter build apk');
|
||
}
|
||
|
||
Future<void> _createFlutterApp(
|
||
String brand,
|
||
String appDir,
|
||
String applicationId,
|
||
) async {
|
||
print('\x1B[34m[信息] 正在执行 flutter create...\x1B[0m');
|
||
final Directory dir = Directory(appDir);
|
||
if (dir.existsSync()) {
|
||
print('\x1B[33m[警告] 目录 $appDir 已存在,正在清理...\x1B[0m');
|
||
dir.deleteSync(recursive: true);
|
||
}
|
||
|
||
// 提取组织名
|
||
// 例如:com.yuanxuan.quanxue -> org: com.yuanxuan
|
||
final List<String> segments = applicationId.split('.');
|
||
final String org = segments.sublist(0, segments.length - 1).join('.');
|
||
|
||
final ProcessResult result = await Process.run('flutter', [
|
||
'create',
|
||
'--org',
|
||
org,
|
||
'--project-name',
|
||
brand.replaceAll('-', '_'),
|
||
'--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 ProcessResult 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<void> _overwriteMainActivity(String appDir, String applicationId) async {
|
||
print('\x1B[34m[信息] 正在注入 CoreShellActivity 继承关系...\x1B[0m');
|
||
final String mainActivityPath =
|
||
"$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java";
|
||
|
||
// 准备 Java 文件内容
|
||
final String javaContent =
|
||
'''
|
||
package $applicationId;
|
||
|
||
import com.yuanxuan.webshell.core.web_shell_core.CoreShellActivity;
|
||
|
||
public class MainActivity extends CoreShellActivity {
|
||
}
|
||
''';
|
||
|
||
await File(mainActivityPath).create(recursive: true);
|
||
await File(mainActivityPath).writeAsString(javaContent);
|
||
}
|
||
|
||
Future<void> _overwriteManifestLabel(String appDir, String appName) async {
|
||
print('\x1B[34m[信息] 正在更新 AndroidManifest.xml 应用名...\x1B[0m');
|
||
final File manifestFile = File(
|
||
'$appDir/android/app/src/main/AndroidManifest.xml',
|
||
);
|
||
String content = await manifestFile.readAsString();
|
||
|
||
// 使用简单正则替换 android:label="..."
|
||
content = content.replaceAll(
|
||
RegExp(r'android:label="[^"]*"'),
|
||
'android:label="$appName"',
|
||
);
|
||
|
||
await manifestFile.writeAsString(content);
|
||
}
|
||
|
||
Future<void> _generateDartEntrypoint(
|
||
String appDir,
|
||
String appName,
|
||
String appKey,
|
||
String accentColor,
|
||
String bgColor,
|
||
String textColor,
|
||
String mutedTextColor,
|
||
String? defaultUrl,
|
||
) async {
|
||
print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m');
|
||
final File mainFile = File('$appDir/lib/main.dart');
|
||
|
||
final String urlParam = defaultUrl != null ? "initialUrl: '$defaultUrl'," : "";
|
||
|
||
final String 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($bgColor),
|
||
textColor: const Color($textColor),
|
||
mutedTextColor: const Color($mutedTextColor),
|
||
splashImage: const AssetImage('assets/branding/splash.png'),
|
||
$urlParam
|
||
),
|
||
);
|
||
}
|
||
''';
|
||
|
||
await mainFile.writeAsString(dartContent);
|
||
|
||
// 清理 flutter create 默认生成的测试文件(因为它依赖已删除的 MyApp 类)
|
||
final File defaultTestFile = File('$appDir/test/widget_test.dart');
|
||
if (defaultTestFile.existsSync()) {
|
||
await defaultTestFile.delete();
|
||
}
|
||
}
|
||
|
||
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 YamlMap branding = config['branding'] as YamlMap;
|
||
|
||
// ── 1. 复制品牌资源到生成的应用目录 ──
|
||
final Directory brandSourceDir = Directory('flavors/$brand');
|
||
final Directory brandTargetDir = Directory('$appDir/assets/branding');
|
||
if (!brandSourceDir.existsSync()) {
|
||
print('\x1B[31m[错误] 品牌资源目录不存在:${brandSourceDir.path}\x1B[0m');
|
||
print(
|
||
'\x1B[33m请在 flavors/$brand/ 目录下放置 icon.png、'
|
||
'icon_foreground.png、splash.png 等资源文件。\x1B[0m',
|
||
);
|
||
exit(1);
|
||
}
|
||
|
||
await brandTargetDir.create(recursive: true);
|
||
for (final entity in brandSourceDir.listSync(recursive: true)) {
|
||
if (entity is File) {
|
||
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');
|
||
|
||
// ── 2. 添加资源生成器的 dev 依赖 ──
|
||
print('\x1B[34m[信息] 正在添加资源生成器依赖...\x1B[0m');
|
||
final ProcessResult 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);
|
||
}
|
||
|
||
// ── 3. 确保依赖已解析 ──
|
||
await Process.run('flutter', ['pub', 'get'], workingDirectory: appDir);
|
||
|
||
// ── 4. 生成 flutter_launcher_icons 配置 ──
|
||
// 资源路径指向复制后的位置(相对于 appDir)
|
||
final String iconPath = 'assets/branding/${branding['icon']}';
|
||
final String iconForeground =
|
||
'assets/branding/${branding['icon_foreground']}';
|
||
final String iconBackground = branding['icon_background'] as String;
|
||
|
||
final String 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);
|
||
|
||
// ── 4. 生成 flutter_native_splash 配置 ──
|
||
final String splashPath = 'assets/branding/${branding['splash']}';
|
||
final String splashColor = branding['splash_color'] as String;
|
||
|
||
final String 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);
|
||
|
||
// ── 5. 执行资源生成器 ──
|
||
print('\x1B[34m[信息] 正在生成应用图标...\x1B[0m');
|
||
final ProcessResult 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[32m✔ 应用图标已生成。\x1B[0m');
|
||
|
||
print('\x1B[34m[信息] 正在生成启动页...\x1B[0m');
|
||
final ProcessResult 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[32m✔ 启动页已生成。\x1B[0m');
|
||
}
|
||
|
||
Future<void> _registerFlutterAssets(String appDir) async {
|
||
print('\x1B[34m[信息] 正在注册 Flutter assets...\x1B[0m');
|
||
final File pubspecFile = File('$appDir/pubspec.yaml');
|
||
String content = await pubspecFile.readAsString();
|
||
|
||
// 检查是否已有未注释的 assets: 声明
|
||
final bool hasAssets = RegExp(r'^\s+assets:', multiLine: true).hasMatch(content);
|
||
if (!hasAssets) {
|
||
// 必须匹配行首的顶层 flutter: 键,忽略缩进的 flutter: (SDK 依赖)
|
||
content = content.replaceFirst(
|
||
RegExp(r'^flutter:\s*\n', multiLine: true),
|
||
'flutter:\n assets:\n - assets/branding/\n',
|
||
);
|
||
await pubspecFile.writeAsString(content);
|
||
}
|
||
print('\x1B[32m✔ Flutter assets 已注册。\x1B[0m');
|
||
}
|