oto/lib/oto/commands/build/build_ios_build.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);
}
}