403 lines
14 KiB
Dart
403 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:dart_framework/utils/string_util.dart';
|
|
import 'package:dart_framework/utils/system_util.dart';
|
|
import 'package:xml/xml.dart';
|
|
import 'package:oto/oto/application.dart';
|
|
import 'package:oto/oto/commands/build/build_flutter.dart';
|
|
import 'package:oto/oto/commands/build/fastlane_template.dart';
|
|
import 'package:oto/oto/commands/command.dart';
|
|
import 'package:oto/oto/commands/command_runtime.dart';
|
|
import 'package:oto/oto/data/command_data.dart';
|
|
|
|
class BuildiOS extends Command {
|
|
Function? get error => null;
|
|
|
|
@override
|
|
Future execute(DataCommand command) async {
|
|
var data = DataBuildiOS.fromJson(getParam(command));
|
|
var xcodeProjectPath =
|
|
data.xcworkspaceFilePath ?? data.xcodeProjectFilePath;
|
|
if (xcodeProjectPath == null) {
|
|
throw Exception(
|
|
'"xcodeProjectFilePath" or "xcworkspaceFilePath" must set at least one.');
|
|
}
|
|
var xcworkspaceFilePath = Command.getPathNormal(xcodeProjectPath);
|
|
|
|
String? version = data.version;
|
|
String? buildNumber = data.buildNumber;
|
|
var xcodeproj = data.xcodeProjectFilePath ??
|
|
await getMainXcodeprojPath(data.xcworkspaceFilePath!);
|
|
|
|
/************ Auto find build number ************/
|
|
var autoBuildNumber = (data.autoBuildNumber ?? false)
|
|
? !((version != null && version.isNotEmpty) ||
|
|
(buildNumber != null && buildNumber.isNotEmpty))
|
|
: false;
|
|
if (autoBuildNumber) {
|
|
var appID = await getAppIdentifier(
|
|
data.workspace, xcodeproj, data.scheme, data.setAppID);
|
|
if (appID == null) {
|
|
throw Exception('Can not find App Identifier.');
|
|
}
|
|
if (data.apiKeyPath == null) {
|
|
throw Exception(
|
|
'If autoVersionNumber = true then must set apiKeyPath.');
|
|
}
|
|
var fastFile = await initializeFastlane(
|
|
data.workspace!, data.apiKeyPath!, appID,
|
|
runtime: runtime);
|
|
var fastlaneContent = fastFile.readAsStringSync();
|
|
|
|
//Find PREPARE_FOR_SUBMISSION state Version
|
|
String? targetVersion;
|
|
var shell = StringBuffer();
|
|
shell.write('cd ${data.workspace}/fastlane');
|
|
shell.write(' && fastlane appstore_versions');
|
|
var process = await runtime.run(shell);
|
|
var arr = process.stdout.toString().split('\n');
|
|
const result = '[RESULT]: ';
|
|
var needIncrementVersionState = [
|
|
'WAITING_FOR_REVIEW',
|
|
'WAITING_FOR_REVIEW',
|
|
'IN_REVIEW',
|
|
'PENDING_DEVELOPER_RELEASE',
|
|
'PENDING_APPLE_RELEASE',
|
|
'REPLACED_WITH_NEW_VERSION'
|
|
];
|
|
List<dynamic> jsonArr;
|
|
String? readyForSaleVersion;
|
|
for (var item in arr) {
|
|
if (item.contains(result)) {
|
|
jsonArr =
|
|
jsonDecode(item.substring(item.indexOf(result) + result.length))
|
|
as List<dynamic>;
|
|
for (var item in jsonArr) {
|
|
if (item['state'] == 'PREPARE_FOR_SUBMISSION') {
|
|
targetVersion = item['version'];
|
|
break;
|
|
}
|
|
if (needIncrementVersionState.contains(item['state'])) {
|
|
targetVersion = incrementVersion(item['version']);
|
|
}
|
|
if (item['state'] == 'READY_FOR_SALE') {
|
|
readyForSaleVersion = item['version'];
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (targetVersion == null) {
|
|
if (readyForSaleVersion != null) {
|
|
targetVersion = incrementVersion(readyForSaleVersion);
|
|
} else {
|
|
throw Exception(
|
|
'Can not find target version when state = PREPARE_FOR_SUBMISSION, PENDING_DEVELOPER_RELEASE');
|
|
}
|
|
}
|
|
version = targetVersion;
|
|
|
|
//Find Last Build Number
|
|
String? targetBuildNumber;
|
|
fastlaneContent =
|
|
fastlaneContent.replaceFirst('[LATEST_VERSION]', version);
|
|
fastFile.writeAsStringSync(fastlaneContent, flush: true);
|
|
shell = StringBuffer();
|
|
shell.write('cd ${data.workspace}/fastlane');
|
|
shell.write(' && fastlane latest_build_number');
|
|
process = await runtime.run(shell);
|
|
arr = process.stdout.toString().split('\n');
|
|
for (var item in arr) {
|
|
if (item.contains(result)) {
|
|
var json =
|
|
jsonDecode(item.substring(item.indexOf(result) + result.length));
|
|
var key = 'latest_build_number';
|
|
if (json is Map && json.containsKey(key)) {
|
|
targetBuildNumber = json[key].toString();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (targetBuildNumber == null) {
|
|
throw Exception('Can not find target build number.');
|
|
}
|
|
buildNumber =
|
|
incrementVersion(targetBuildNumber, fixedLength: 4, reverse: true);
|
|
}
|
|
|
|
String? target = data.target;
|
|
// If version is provided, apply it; otherwise retain the existing value from the project
|
|
if (version != null && version.isNotEmpty) {
|
|
await updateMarketingVersion(
|
|
'${data.workspace}/$xcodeproj/project.pbxproj', version);
|
|
} else {
|
|
target = target ?? await getTargetXcodeproj(data.workspace!, xcodeproj!);
|
|
version = await getSettingXcodeproj(
|
|
data.workspace!, xcodeproj!, target!, 'MARKETING_VERSION');
|
|
}
|
|
// If buildNumber is provided, apply it; otherwise retain the existing value from the project
|
|
if (buildNumber != null && buildNumber.isNotEmpty) {
|
|
await updateBuildNumber(
|
|
'${data.workspace}/$xcodeproj/project.pbxproj', buildNumber);
|
|
} else {
|
|
target = target ?? await getTargetXcodeproj(data.workspace!, xcodeproj!);
|
|
buildNumber = await getSettingXcodeproj(
|
|
data.workspace!, xcodeproj!, target!, 'CURRENT_PROJECT_VERSION');
|
|
}
|
|
|
|
var setVersion = data.setVersion != null && data.setVersion!.isNotEmpty
|
|
? data.setVersion!
|
|
: '<@property.version>';
|
|
await setProperty(setVersion, version);
|
|
var setBuildNumber =
|
|
data.setBuildNumber != null && data.setBuildNumber!.isNotEmpty
|
|
? data.setBuildNumber!
|
|
: '<@property.buildNumber>';
|
|
await setProperty(setBuildNumber, buildNumber);
|
|
|
|
var os = 'iOS';
|
|
if (data.os != null) {
|
|
if (data.os == BuildPlatform.Macos) {
|
|
os = 'macOS';
|
|
}
|
|
}
|
|
var destination = "'generic/platform=$os'";
|
|
var cleanPod = data.cleanPod ?? false;
|
|
var xcodeProjectParentPath = Directory(xcworkspaceFilePath).parent.path;
|
|
var podFile = File('$xcodeProjectParentPath/Podfile');
|
|
|
|
var project = '';
|
|
if (data.xcworkspaceFilePath != null) {
|
|
project = '-workspace $xcodeProjectPath';
|
|
} else {
|
|
project = '-project $xcodeProjectPath';
|
|
}
|
|
|
|
var configuration = '';
|
|
if (data.configuration != null) {
|
|
configuration = ' -configuration ${data.configuration}';
|
|
}
|
|
var derivedDataPath = '';
|
|
if (data.derivedDataPath != null) {
|
|
derivedDataPath = ' -derivedDataPath ${data.derivedDataPath}';
|
|
}
|
|
var settings = '';
|
|
if (data.settings != null) {
|
|
var map = data.settings!;
|
|
for (var item in map.entries) {
|
|
settings += ' ${item.key}="${item.value}"';
|
|
}
|
|
}
|
|
|
|
var shell = StringBuffer(getCDPath(xcodeProjectParentPath));
|
|
if (data.macPassword != null) {
|
|
shell.write(' $and security unlock-keychain -p${data.macPassword}');
|
|
}
|
|
shell.write(' && export LANG=en_US.UTF-8');
|
|
if (podFile.existsSync() && cleanPod) {
|
|
shell.write(' && rm -rf Pods');
|
|
shell.write(' && rm -rf Podfile.lock');
|
|
if (cleanPod) {
|
|
shell.write(' && pod cache clean --all');
|
|
shell.write(' && pod repo update');
|
|
shell.write(' && pod install --repo-update');
|
|
} else {
|
|
shell.write(' && pod install');
|
|
}
|
|
}
|
|
shell.write(
|
|
' && xcodebuild $project -scheme ${data.scheme} -destination $destination$configuration$derivedDataPath$settings clean build');
|
|
|
|
var process = await runtime.start(shell,
|
|
printStderr: false, logHandler: Application.logWithType);
|
|
var exitCode = process.exitCode ?? await process.process.exitCode;
|
|
var success = exitCode == 0;
|
|
if (success) {
|
|
return await completeProcess(process, command);
|
|
} else {
|
|
if (exitCode == 31) {
|
|
data.cleanPod = true;
|
|
command.param = data.toJson();
|
|
return await execute(command);
|
|
} else {
|
|
return await completeProcess(process, command);
|
|
}
|
|
}
|
|
}
|
|
|
|
static Future<File> initializeFastlane(
|
|
String workspace, String apiKeyPath, String appID,
|
|
{CommandRuntime? runtime}) async {
|
|
var json = jsonDecode(File(apiKeyPath).readAsStringSync());
|
|
var fastlaneContent = fastlaneTemplate;
|
|
fastlaneContent = fastlaneContent
|
|
.replaceFirst('[APP_IDENTIFIER]', appID)
|
|
.replaceFirst('[API_KEY_ID]', json['key_id'])
|
|
.replaceFirst('[API_ISSUER_ID]', json['issuer_id'])
|
|
.replaceFirst('[AUTH_KEY_CONTENT]',
|
|
json['key'].toString().replaceAll('\n', '\\n'));
|
|
|
|
var shell = StringBuffer();
|
|
shell.write('cd $workspace');
|
|
shell.write(' && rm -rf "$workspace/fastlane"');
|
|
shell.write(' && yes | fastlane init');
|
|
await (runtime ?? Command.defaultRuntime).run(shell);
|
|
|
|
var fastFile = File('$workspace/fastlane/Fastfile');
|
|
fastFile.writeAsStringSync(fastlaneContent, flush: true);
|
|
return dataFutrue(fastFile);
|
|
}
|
|
|
|
Future<String?> getAppIdentifier(String? workspace, String? xcodeproj,
|
|
String scheme, String? setAppID) async {
|
|
String? identifier;
|
|
var shell = StringBuffer();
|
|
shell.write(
|
|
"xcodebuild -project '$workspace/$xcodeproj' -scheme $scheme -destination 'generic/platform=iOS Simulator' -showBuildSettings | grep 'PRODUCT_BUNDLE_IDENTIFIER' | tail -n1 | awk -F ' = ' '{print \$2}'");
|
|
|
|
var process = await runtime.start(shell,
|
|
workspace: workspace,
|
|
printStderr: false,
|
|
logHandler: Application.logWithType);
|
|
for (var item in process.stdoutArr) {
|
|
var appID = item.trim().replaceAll(' ', '');
|
|
if (appID.isNotEmpty) {
|
|
setAppID = (setAppID == null) ? '<@property.appIdentifier>' : setAppID;
|
|
await setProperty(setAppID, appID);
|
|
identifier = appID;
|
|
break;
|
|
}
|
|
}
|
|
return dataFutrue(identifier);
|
|
}
|
|
|
|
Future<String?> getMainXcodeprojPath(String workspacePath) async {
|
|
final contentsPath = '$workspacePath/contents.xcworkspacedata';
|
|
final file = File(contentsPath);
|
|
if (!await file.exists()) return null;
|
|
|
|
final xmlString = await file.readAsString();
|
|
final document = XmlDocument.parse(xmlString);
|
|
|
|
final fileRef = document.findAllElements('FileRef').firstWhere(
|
|
(element) =>
|
|
element.getAttribute('location')?.endsWith('.xcodeproj') ?? false,
|
|
orElse: () =>
|
|
XmlElement(XmlName('Dummy')), // sentinel for null-check below
|
|
);
|
|
|
|
final location =
|
|
fileRef.name.local == 'Dummy' ? null : fileRef.getAttribute('location');
|
|
if (location != null && location.startsWith('group:')) {
|
|
return location.replaceFirst('group:', '');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> updateMarketingVersion(
|
|
String pbxprojPath, String version) async {
|
|
await _updatePbxprojSetting(
|
|
pbxprojPath: pbxprojPath,
|
|
settingKey: 'MARKETING_VERSION',
|
|
settingValue: version,
|
|
);
|
|
print('✅ Updated MARKETING_VERSION = $version');
|
|
}
|
|
|
|
Future<void> updateBuildNumber(String pbxprojPath, String buildNumber) async {
|
|
await _updatePbxprojSetting(
|
|
pbxprojPath: pbxprojPath,
|
|
settingKey: 'CURRENT_PROJECT_VERSION',
|
|
settingValue: buildNumber,
|
|
);
|
|
print('✅ Updated CURRENT_PROJECT_VERSION = $buildNumber');
|
|
}
|
|
|
|
Future<void> _updatePbxprojSetting({
|
|
required String pbxprojPath,
|
|
required String settingKey,
|
|
required String settingValue,
|
|
}) async {
|
|
final file = File(pbxprojPath);
|
|
if (!await file.exists()) throw Exception('File not found: $pbxprojPath');
|
|
|
|
final lines = await file.readAsLines();
|
|
|
|
final updatedLines = <String>[];
|
|
bool insideBuildSettings = false;
|
|
bool hasTargetSetting = false;
|
|
|
|
for (var line in lines) {
|
|
final trimmed = line.trim();
|
|
|
|
if (trimmed == 'buildSettings = {') {
|
|
insideBuildSettings = true;
|
|
hasTargetSetting = false;
|
|
updatedLines.add(line);
|
|
continue;
|
|
}
|
|
|
|
if (insideBuildSettings && trimmed == '};') {
|
|
// Insert the setting if not already present
|
|
if (!hasTargetSetting) {
|
|
updatedLines.add(' $settingKey = $settingValue;');
|
|
}
|
|
insideBuildSettings = false;
|
|
updatedLines.add(line);
|
|
continue;
|
|
}
|
|
|
|
if (insideBuildSettings && trimmed.startsWith('$settingKey =')) {
|
|
line = line.replaceFirst(RegExp('$settingKey = .*?;'),
|
|
' $settingKey = $settingValue;');
|
|
hasTargetSetting = true;
|
|
}
|
|
|
|
updatedLines.add(line);
|
|
}
|
|
|
|
await file.writeAsString(updatedLines.join('\n'));
|
|
}
|
|
|
|
Future<String?> getSettingXcodeproj(
|
|
String workspace, String xcodeproj, String target, String marker) async {
|
|
String? version;
|
|
var shell = StringBuffer();
|
|
shell.write('cd $workspace');
|
|
shell.write(
|
|
' && xcodebuild -project $xcodeproj -target $target -showBuildSettings | grep $marker');
|
|
|
|
var mark = '$marker = ';
|
|
var process = await runtime.run(shell);
|
|
var arr = process.stdout.toString().split('\n');
|
|
for (var item in arr) {
|
|
if (item.contains(mark)) {
|
|
version = item.replaceAll(mark, '').trim();
|
|
break;
|
|
}
|
|
}
|
|
return dataFutrue(version);
|
|
}
|
|
|
|
Future<String?> getTargetXcodeproj(
|
|
String workspace, String xcoodeproj) async {
|
|
String? target;
|
|
var shell = StringBuffer();
|
|
shell.write('cd $workspace');
|
|
shell.write(
|
|
" && xcodebuild -project $xcoodeproj -list | awk '/Targets:/ {getline; print; exit}'");
|
|
|
|
var process = await runtime.run(shell);
|
|
var arr = process.stdout.toString().split('\n');
|
|
for (var item in arr) {
|
|
item = item.trim();
|
|
if (item.isNotEmpty) {
|
|
target = item.trim();
|
|
break;
|
|
}
|
|
}
|
|
return dataFutrue(target);
|
|
}
|
|
}
|