diff --git a/lib/oto/commands/build/build_ios.dart b/lib/oto/commands/build/build_ios.dart index ba48f2d..15405bb 100644 --- a/lib/oto/commands/build/build_ios.dart +++ b/lib/oto/commands/build/build_ios.dart @@ -1,6 +1,12 @@ 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:oto_cli/oto/commands/build/fastlane_template.dart'; +import 'package:oto_cli/oto/commands/file/file.dart'; +import 'package:xml/xml.dart'; import 'package:oto_cli/oto/application.dart'; import 'package:oto_cli/oto/commands/build/build_flutter.dart'; import 'package:oto_cli/oto/commands/command.dart'; @@ -22,6 +28,100 @@ class BuildiOS extends Command { } var xcworkspaceFilePath = Command.getPathNormal(xcodeProjectPath); + String? version = data.version; + String? buildNumber = data.buildNumber; + var xcodeproj = data.xcodeProjectFilePath ?? + await getMainXcodeprojPath(data.xcworkspaceFilePath!); + if (data.autoBuildNumber ?? false) { + 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 json = jsonDecode(File(data.apiKeyPath!).readAsStringSync()); + var fastlaneContent = fastlaneTemplete; + 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 ${data.workspace}'); + shell.write(' && rm -rf "${data.workspace}/fastlane"'); + shell.write(' && yes | fastlane init'); + await ProcessExecutor.run(shell); + + var fastFile = File('${data.workspace}/fastlane/Fastfile'); + fastFile.writeAsStringSync(fastlaneContent, flush: true); + + //Find PREPARE_FOR_SUBMISSION state Version + String? targetVersion; + shell = StringBuffer(); + shell.write('cd ${data.workspace}/fastlane'); + shell.write(' && fastlane appstore_versions'); + var process = await ProcessExecutor.run(shell); + var arr = process.stdout.toString().split('\n'); + const result = '[RESULT]: '; + for (var item in arr) { + if (item.contains(result)) { + var jsonArr = + jsonDecode(item.substring(item.indexOf(result) + result.length)) + as List; + for (var item in jsonArr) { + if (item['state'] == 'PREPARE_FOR_SUBMISSION') { + targetVersion = item['version']; + break; + } + } + break; + } + } + if (targetVersion == null) { + throw Exception( + 'Can not find target version when state = PREPARE_FOR_SUBMISSION.'); + } + 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 ProcessExecutor.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)); + targetBuildNumber = json['latest_build_number']; + break; + } + } + if (targetBuildNumber == null) { + throw Exception('Can not find target build number.'); + } + buildNumber = incrementVersion(targetBuildNumber); + } + + if (version != null) { + await updateMarketingVersion( + '${data.workspace}/$xcodeproj/project.pbxproj', version); + } + if (buildNumber != null) { + await updateBuildNumber( + '${data.workspace}/$xcodeproj/project.pbxproj', buildNumber); + } + var os = 'iOS'; if (data.os != null) { if (data.os == BuildPlatform.Macos) { @@ -92,6 +192,116 @@ class BuildiOS extends Command { } } } + + Future 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 ProcessExecutor.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 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')), // 타입만 맞추고 null 체크는 나중에 + ); + + final location = + fileRef.name.local == 'Dummy' ? null : fileRef.getAttribute('location'); + if (location != null && location.startsWith('group:')) { + return location.replaceFirst('group:', ''); + } + return null; + } + + Future updateMarketingVersion( + String pbxprojPath, String version) async { + await _updatePbxprojSetting( + pbxprojPath: pbxprojPath, + settingKey: 'MARKETING_VERSION', + settingValue: version, + ); + print('✅ Updated MARKETING_VERSION = $version'); + } + + Future updateBuildNumber(String pbxprojPath, String buildNumber) async { + await _updatePbxprojSetting( + pbxprojPath: pbxprojPath, + settingKey: 'CURRENT_PROJECT_VERSION', + settingValue: buildNumber, + ); + print('✅ Updated CURRENT_PROJECT_VERSION = $buildNumber'); + } + + Future _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 = []; + 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 == '};') { + // 삽입 필요 시 추가 + 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')); + } } class CodeSign extends Command { diff --git a/lib/oto/commands/build/fastlane_template.dart b/lib/oto/commands/build/fastlane_template.dart new file mode 100644 index 0000000..e655080 --- /dev/null +++ b/lib/oto/commands/build/fastlane_template.dart @@ -0,0 +1,116 @@ +var fastlaneTemplete = ''' +default_platform(:ios) + +APP_IDENTIFIER = "[APP_IDENTIFIER]" + +platform :ios do + private_lane :base_api_key do + app_store_connect_api_key( + key_id: "[API_KEY_ID]", + issuer_id: "[API_ISSUER_ID]", + key_content: "[AUTH_KEY_CONTENT]" + ) + end + + + desc "지정한 앱 버전 + 빌드 넘버에 대한 TestFlight 상태 추적 (JSON 출력)" + lane :testflight_status do + require 'spaceship' + require 'json' + + api_key = base_api_key + + target_app_version = "[TARGET_VERSION]" # CFBundleShortVersionString + target_build_number = "[TARGET_BUILD_NUMBER]" # CFBundleVersion + + app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER) + builds = app.get_builds(includes: "preReleaseVersion") + + matched = builds.find do |b| + b.version.to_s == target_build_number && + b.respond_to?(:pre_release_version) && + b.pre_release_version&.version == target_app_version + end + + result = { + version: target_app_version, + build_number: target_build_number, + state: matched ? matched.processing_state : "NONE", + uploaded: matched ? matched.uploaded_date.to_s : nil + } + + puts "[RESULT]: #{JSON.generate(result)}" + end + + + desc "TestFlight에 등록된 특정 버전의 최신 빌드 넘버 가져오기 (JSON 출력)" + lane :latest_build_number do + require 'json' + + api_key = base_api_key + + target_version = "[LATEST_VERSION]" + + latest_build = app_store_build_number( + api_key: api_key, + app_identifier: APP_IDENTIFIER, + version: target_version, + live: false + ) + + result = { + version: target_version, + latest_build_number: latest_build + } + + puts "[RESULT]: #{JSON.generate(result)}" + end + + + desc "필터 조건에 맞춰 App Store Connect 버전 상태 JSON 출력" + lane :appstore_versions do + require 'spaceship' + require 'json' + require 'rubygems' # for Gem::Version + + api_key = base_api_key + + app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER) + + versions = app.get_app_store_versions + .select { |v| v.platform == "IOS" } + .reject { |v| v.app_store_state == "REPLACED_WITH_NEW_VERSION" } + + # 1. READY_FOR_SALE 중 최신 + latest_ready = versions + .select { |v| v.app_store_state == "READY_FOR_SALE" } + .max_by { |v| v.respond_to?(:created_date) ? v.created_date.to_s : "" } + + # 2. PREPARE_FOR_SUBMISSION 중 가장 버전 낮은 것 + prepare_versions = versions + .select { |v| v.app_store_state == "PREPARE_FOR_SUBMISSION" } + .sort_by { |v| Gem::Version.new(v.version_string) } + + lowest_prepare = prepare_versions.first + + # 3. 기타 상태 버전들 (READY_FOR_SALE, PREPARE_FOR_SUBMISSION, REPLACED 제외) + included_states = ["READY_FOR_SALE", "PREPARE_FOR_SUBMISSION", "REPLACED_WITH_NEW_VERSION"] + others = versions.reject { |v| included_states.include?(v.app_store_state) } + + selected = [] + selected << latest_ready if latest_ready + selected << lowest_prepare if lowest_prepare + selected += others + + json_output = selected.map do |v| + { + version: v.version_string, + state: v.app_store_state, + created: v.respond_to?(:created_date) ? v.created_date.to_s : nil + } + end + + puts "[RESULT]: #{JSON.generate(json_output)}" + end +end +'''; diff --git a/lib/oto/commands/build/testflight_ios.dart b/lib/oto/commands/build/testflight_ios.dart new file mode 100644 index 0000000..8632c30 --- /dev/null +++ b/lib/oto/commands/build/testflight_ios.dart @@ -0,0 +1,45 @@ +import 'package:dart_framework/platform/process.dart'; +import 'package:oto_cli/oto/application.dart'; +import 'package:oto_cli/oto/commands/command.dart'; +import 'package:oto_cli/oto/data/command_data.dart'; + +class TestflightUpload extends Command { + Function? get error => null; + + @override + Future execute(DataCommand command) async { + var data = DataTestflightUpload.fromJson(getParam(command)); + var apiKeyPath = Command.getPathNormal(data.apiKeyPath); + var ipaPath = Command.getPathNormal(data.ipaPath); + + var shell = StringBuffer(); + shell.write( + 'FASTLANE_DISABLE_PRECHECK=1 fastlane deliver upload_binary --ipa "$ipaPath" --api_key_path "$apiKeyPath" --force'); + + var process = await ProcessExecutor.start(shell, + workspace: workspace, + printStderr: false, + logHandler: Application.logWithType); + + return await completeProcess(process, command, passExitCodes: [1]); + } +} + +class IOSVersion extends Command { + Function? get error => null; + + @override + Future execute(DataCommand command) async { + var data = DataiOSVersion.fromJson(getParam(command)); + + var shell = StringBuffer(); + shell.write("echo 'test'"); + + var process = await ProcessExecutor.start(shell, + workspace: workspace, + printStderr: false, + logHandler: Application.logWithType); + + return await completeProcess(process, command, passExitCodes: [1]); + } +} diff --git a/lib/oto/commands/command.dart b/lib/oto/commands/command.dart index d087a78..fb301ba 100644 --- a/lib/oto/commands/command.dart +++ b/lib/oto/commands/command.dart @@ -13,6 +13,7 @@ import 'package:oto_cli/oto/commands/build/build_dot_net.dart'; import 'package:oto_cli/oto/commands/build/build_flutter.dart'; import 'package:oto_cli/oto/commands/build/build_ios.dart'; import 'package:oto_cli/oto/commands/build/build_msbuild.dart'; +import 'package:oto_cli/oto/commands/build/testflight_ios.dart'; import 'package:oto_cli/oto/commands/file/directory.dart'; import 'package:oto_cli/oto/commands/file/file.dart'; import 'package:oto_cli/oto/commands/file/file_diff_check.dart'; @@ -53,6 +54,8 @@ enum CommandType { BuildDartCompile, BuildDotNet, BuildiOS, + TestflightUpload, + IOSVersion, BuildMSBuild, CodeSign, CodeSignVerify, @@ -122,6 +125,8 @@ abstract class Command { CommandType.BuildDartCompile: () => BuildDartCompile(), CommandType.BuildDotNet: () => BuildDotNet(), CommandType.BuildiOS: () => BuildiOS(), + CommandType.TestflightUpload: () => TestflightUpload(), + CommandType.IOSVersion: () => IOSVersion(), CommandType.BuildMSBuild: () => BuildMSBuild(), CommandType.CodeSign: () => CodeSign(), CommandType.CodeSignVerify: () => CodeSignVerify(), @@ -147,7 +152,7 @@ abstract class Command { CommandType.StringReplace: () => StringReplace(), CommandType.StringReplacePattern: () => StringReplacePattern(), CommandType.StringIndex: () => StringIndex(), - CommandType.URLInfo: ()=> URLInfo(), + CommandType.URLInfo: () => URLInfo(), CommandType.Jira: () => Jira(), CommandType.Zip: () => Zip(), CommandType.Shell: () => Shell(), diff --git a/lib/oto/data/command_data.dart b/lib/oto/data/command_data.dart index 3216447..117e4b3 100644 --- a/lib/oto/data/command_data.dart +++ b/lib/oto/data/command_data.dart @@ -221,6 +221,13 @@ class DataBuildiOS extends DataParam { late String? configuration; late Map? settings; + //Version Setting + late String? version; + late String? buildNumber; + late bool? autoBuildNumber = false; + late String? apiKeyPath; //autoVersionNumber 사용시 필수 + late String? setAppID; + factory DataBuildiOS.fromJson(Map json) => _$DataBuildiOSFromJson(json); @override @@ -322,6 +329,35 @@ class DataExportiOS extends DataParam { Map toJson() => _$DataExportiOSToJson(this); } +//TestflightUpload +@JsonSerializable() +class DataTestflightUpload extends DataParam { + DataTestflightUpload(); + + late String apiKeyPath; + late String ipaPath; + + factory DataTestflightUpload.fromJson(Map json) => + _$DataTestflightUploadFromJson(json); + @override + Map toJson() => _$DataTestflightUploadToJson(this); +} + +//iOSVersion +@JsonSerializable() +class DataiOSVersion extends DataParam { + DataiOSVersion(); + + late String scheme; + late String xcworkspacePath; + late String? setAppID; + + factory DataiOSVersion.fromJson(Map json) => + _$DataiOSVersionFromJson(json); + @override + Map toJson() => _$DataiOSVersionToJson(this); +} + //PublishiOS @JsonSerializable() class DataPublishiOS extends DataParam { @@ -1005,7 +1041,6 @@ class DataURLInfo extends DataParam { Map toJson() => _$DataURLInfoToJson(this); } - //Web String Replace Item @JsonSerializable() class DataStringReplacePair extends DataParam { diff --git a/lib/oto/data/command_data.g.dart b/lib/oto/data/command_data.g.dart index 7372d2d..19d4e11 100644 --- a/lib/oto/data/command_data.g.dart +++ b/lib/oto/data/command_data.g.dart @@ -62,6 +62,8 @@ const _$CommandTypeEnumMap = { CommandType.BuildDartCompile: 'BuildDartCompile', CommandType.BuildDotNet: 'BuildDotNet', CommandType.BuildiOS: 'BuildiOS', + CommandType.TestflightUpload: 'TestflightUpload', + CommandType.IOSVersion: 'IOSVersion', CommandType.BuildMSBuild: 'BuildMSBuild', CommandType.CodeSign: 'CodeSign', CommandType.CodeSignVerify: 'CodeSignVerify', @@ -391,7 +393,12 @@ DataBuildiOS _$DataBuildiOSFromJson(Map json) => DataBuildiOS() ..configuration = json['configuration'] as String? ..settings = (json['settings'] as Map?)?.map( (k, e) => MapEntry(k, e as String), - ); + ) + ..version = json['version'] as String? + ..buildNumber = json['buildNumber'] as String? + ..autoBuildNumber = json['autoBuildNumber'] as bool? + ..apiKeyPath = json['apiKeyPath'] as String? + ..setAppID = json['setAppID'] as String?; Map _$DataBuildiOSToJson(DataBuildiOS instance) => { @@ -409,6 +416,11 @@ Map _$DataBuildiOSToJson(DataBuildiOS instance) => 'cleanPod': instance.cleanPod, 'configuration': instance.configuration, 'settings': instance.settings, + 'version': instance.version, + 'buildNumber': instance.buildNumber, + 'autoBuildNumber': instance.autoBuildNumber, + 'apiKeyPath': instance.apiKeyPath, + 'setAppID': instance.setAppID, }; const _$BuildPlatformEnumMap = { @@ -579,6 +591,56 @@ Map _$DataExportiOSToJson(DataExportiOS instance) => 'exportPath': instance.exportPath, }; +DataTestflightUpload _$DataTestflightUploadFromJson( + Map json) => + DataTestflightUpload() + ..workspace = json['workspace'] as String? + ..passExitCodes = (json['passExitCodes'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() + ..setResult = json['setResult'] as String? + ..setExitCode = json['setExitCode'] as String? + ..showPrint = json['showPrint'] as bool? + ..apiKeyPath = json['apiKeyPath'] as String + ..ipaPath = json['ipaPath'] as String; + +Map _$DataTestflightUploadToJson( + DataTestflightUpload instance) => + { + 'workspace': instance.workspace, + 'passExitCodes': instance.passExitCodes, + 'setResult': instance.setResult, + 'setExitCode': instance.setExitCode, + 'showPrint': instance.showPrint, + 'apiKeyPath': instance.apiKeyPath, + 'ipaPath': instance.ipaPath, + }; + +DataiOSVersion _$DataiOSVersionFromJson(Map json) => + DataiOSVersion() + ..workspace = json['workspace'] as String? + ..passExitCodes = (json['passExitCodes'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() + ..setResult = json['setResult'] as String? + ..setExitCode = json['setExitCode'] as String? + ..showPrint = json['showPrint'] as bool? + ..scheme = json['scheme'] as String + ..xcworkspacePath = json['xcworkspacePath'] as String + ..setAppID = json['setAppID'] as String?; + +Map _$DataiOSVersionToJson(DataiOSVersion instance) => + { + 'workspace': instance.workspace, + 'passExitCodes': instance.passExitCodes, + 'setResult': instance.setResult, + 'setExitCode': instance.setExitCode, + 'showPrint': instance.showPrint, + 'scheme': instance.scheme, + 'xcworkspacePath': instance.xcworkspacePath, + 'setAppID': instance.setAppID, + }; + DataPublishiOS _$DataPublishiOSFromJson(Map json) => DataPublishiOS() ..workspace = json['workspace'] as String? diff --git a/pubspec.yaml b/pubspec.yaml index a23bd68..e3132a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: color: ^3.0.0 file_hasher: ^0.1.0+0 cron: ^0.6.1 + xml: ^6.3.0 dev_dependencies: lints: ^5.0.0