708 lines
22 KiB
Dart
708 lines
22 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',
|
||
];
|
||
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);
|
||
}
|