web_shell_flutter/tool/generate_app.dart

708 lines
22 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',
];
final Directory _repoRootDir = File.fromUri(Platform.script).parent.parent;
final String _currentDartExecutable = Platform.resolvedExecutable;
final String _flutterExecutable = _resolveFlutterExecutable();
final String _dartExecutable = _resolveDartExecutable();
Future<void> main(List<String> 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 = <String>[
..._toolCandidatesFromFlutterRoot(_flutterToolRelativePaths),
..._toolCandidatesFromRepo(_flutterToolRelativePaths),
..._flutterCandidatesFromCurrentDart(),
'flutter',
];
return _firstAvailableExecutable(candidates, fallback: 'flutter');
}
String _resolveDartExecutable() {
final candidates = <String>[
..._toolCandidatesFromFlutterRoot(_dartToolRelativePaths),
..._toolCandidatesFromRepo(_dartToolRelativePaths),
_currentDartExecutable,
'dart',
];
return _firstAvailableExecutable(candidates, fallback: 'dart');
}
List<String> _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 <String>[];
}
final flutterBat = File.fromUri(flutterBinDir.uri.resolve('flutter.bat'));
final flutterShell = File.fromUri(flutterBinDir.uri.resolve('flutter'));
return <String>[
if (flutterBat.existsSync()) flutterBat.path,
if (flutterShell.existsSync()) flutterShell.path,
];
}
List<String> get _flutterToolRelativePaths => Platform.isWindows
? <String>['bin/flutter.bat', 'bin/flutter']
: <String>['bin/flutter', 'bin/flutter.bat'];
List<String> get _dartToolRelativePaths => Platform.isWindows
? <String>[
'bin/dart.bat',
'bin/cache/dart-sdk/bin/dart.exe',
'bin/dart',
'bin/cache/dart-sdk/bin/dart',
]
: <String>['bin/dart', 'bin/cache/dart-sdk/bin/dart'];
List<String> _toolCandidatesFromFlutterRoot(List<String> relativePaths) {
final flutterRoot = Platform.environment['FLUTTER_ROOT'];
if (flutterRoot == null || flutterRoot.isEmpty) {
return <String>[];
}
return <String>[
for (final relativePath in relativePaths)
if (_fileExists(flutterRoot, relativePath))
_joinPath(flutterRoot, relativePath),
];
}
List<String> _toolCandidatesFromRepo(List<String> relativePaths) {
return <String>[
for (final relativePath in relativePaths)
if (_repoFile(relativePath).existsSync()) _repoFile(relativePath).path,
];
}
String _firstAvailableExecutable(
List<String> 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<ProcessResult> _runProcess(
String executable,
List<String> 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<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 _runProcess(_flutterExecutable, <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 _runProcess(_flutterExecutable, <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 _runProcess(_flutterExecutable, <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 _runProcess(_flutterExecutable, <String>[
'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, <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 _runProcess(_dartExecutable, <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 _runProcess(_flutterExecutable, <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.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);
}