web_shell_flutter/tool/generate_app.dart

489 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
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 && 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,
}) 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<void> _generateBootstrapConfig({
required String appDir,
required String? defaultUrl,
required List<String> 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 = <String, Object?>{
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<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);
}