separate SyncCodeSigning to multiple actions
This commit is contained in:
parent
e44811d1b7
commit
e732e16d21
|
|
@ -6,7 +6,7 @@ RESET := $(shell tput -Txterm sgr0)
|
|||
RUBY_VERSION="2.7.6"
|
||||
|
||||
open_project=(open *.xcworkspace)
|
||||
install_dev_certs=(bundle exec fastlane SyncCodeSigning type:development readonly:true)
|
||||
install_dev_certs=(bundle exec fastlane InstallDevelopmentSigningIdentities)
|
||||
install_pods=(bundle exec pod install || bundle exec pod install --repo-update)
|
||||
init_rbenv=(if command -v rbenv &> /dev/null; then eval "$$(rbenv init -)"; fi)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@ $appName = File.basename(Dir['../*.xcworkspace'].first, '.*')
|
|||
|
||||
require_relative 'fastlane/touchlane/lib/touchlane'
|
||||
|
||||
# ugly hack to add support for custom storage
|
||||
|
||||
Match.module_eval do
|
||||
def self.storage_modes
|
||||
return %w(git google_cloud s3 local)
|
||||
end
|
||||
end
|
||||
|
||||
private_lane :installDependencies do |options|
|
||||
podsReposPath = File.expand_path "~/.cocoapods/repos/master/"
|
||||
|
|
@ -104,7 +97,7 @@ private_lane :buildConfiguration do |options|
|
|||
options[:workspace] = options[:workspace] || File.expand_path("../#{options[:appName]}.xcworkspace")
|
||||
|
||||
configuration_type = Touchlane::ConfigurationType.from_lane_name(lane_name)
|
||||
options = fill_up_options_using_configuration_type(options, configuration_type)
|
||||
options = fill_up_options_using_configuration_type(options, configuration_type, true)
|
||||
|
||||
generate_xcodeproj_if_needed(options)
|
||||
|
||||
|
|
@ -123,13 +116,13 @@ private_lane :buildConfiguration do |options|
|
|||
if !(options[:uploadToFabric] || options[:uploadToAppStore])
|
||||
options[:skip_package_ipa] = true
|
||||
|
||||
sync_code_signing_using_options(options)
|
||||
install_signing_identities(options)
|
||||
|
||||
buildArchive(options) # check build failures and static analysis
|
||||
end
|
||||
|
||||
if options[:uploadToFabric]
|
||||
sync_code_signing_using_options(options)
|
||||
install_signing_identities(options)
|
||||
addShield(options)
|
||||
buildArchive(options)
|
||||
uploadToFirebase(options)
|
||||
|
|
@ -138,7 +131,7 @@ private_lane :buildConfiguration do |options|
|
|||
if options[:uploadToAppStore]
|
||||
options[:include_symbols] = options[:include_symbols].nil? ? true : options[:include_symbols]
|
||||
|
||||
sync_code_signing_using_options(options)
|
||||
install_signing_identities(options)
|
||||
buildArchive(options)
|
||||
upload_to_app_store_using_options(options)
|
||||
end
|
||||
|
|
@ -192,11 +185,39 @@ private_lane :buildArchive do |options|
|
|||
)
|
||||
end
|
||||
|
||||
lane :SyncCodeSigning do |options|
|
||||
configuration_type = Touchlane::ConfigurationType.from_type(options[:type])
|
||||
lane :InstallDevelopmentSigningIdentities do |options|
|
||||
configuration_type = Touchlane::ConfigurationType.from_type("development")
|
||||
options = fill_up_options_using_configuration_type(options, configuration_type)
|
||||
|
||||
sync_code_signing_using_options(options)
|
||||
install_signing_identities(options)
|
||||
end
|
||||
|
||||
lane :RefreshProfiles do |options|
|
||||
type = options[:type] || "development"
|
||||
|
||||
configuration_type = Touchlane::ConfigurationType.from_type(type)
|
||||
options = fill_up_options_using_configuration_type(options, configuration_type)
|
||||
|
||||
refresh_profiles(options)
|
||||
end
|
||||
|
||||
lane :ReplaceDevelopmentCertificate do |options|
|
||||
configuration_type = Touchlane::ConfigurationType.from_type("development")
|
||||
options = fill_up_options_using_configuration_type(options, configuration_type, true)
|
||||
|
||||
replace_development_certificate(options)
|
||||
end
|
||||
|
||||
lane :SyncAppStoreIdentities do |options|
|
||||
configuration_type = Touchlane::ConfigurationType.from_type("appstore")
|
||||
options = fill_up_options_using_configuration_type(options, configuration_type, true)
|
||||
|
||||
options[:readonly] = false
|
||||
sync_signing_identities(options)
|
||||
end
|
||||
|
||||
lane :ManuallyUpdateCodeSigning do |options|
|
||||
manually_update_code_signing(get_default_options.merge(options))
|
||||
end
|
||||
|
||||
private_lane :openKeychain do |options|
|
||||
|
|
@ -221,98 +242,19 @@ private_lane :openKeychain do |options|
|
|||
end
|
||||
end
|
||||
|
||||
lane :ManuallyUpdateCodeSigning do |options|
|
||||
register_local_storage_for_match()
|
||||
|
||||
require 'match'
|
||||
|
||||
storage_factory = lambda do
|
||||
new_storage = Match::Storage.for_mode('local', { git_url: get_signing_identities_path() })
|
||||
new_storage.download
|
||||
return new_storage
|
||||
end
|
||||
|
||||
encryption_factory = lambda do |stor|
|
||||
new_encryption = Match::Encryption.for_storage_mode('local', { working_directory: stor.working_directory })
|
||||
new_encryption.decrypt_files
|
||||
return new_encryption
|
||||
end
|
||||
|
||||
get_all_files = lambda do |stor|
|
||||
Dir[File.join(stor.working_directory, "**", "*.{cer,p12,mobileprovision}")]
|
||||
end
|
||||
|
||||
storage = storage_factory.call
|
||||
encryption = encryption_factory.call(storage)
|
||||
old_files = get_all_files.call(storage)
|
||||
|
||||
sh("open #{storage.working_directory}")
|
||||
|
||||
# we are not using prompt() since it requires non-empty input which is not a case for Enter (\n)
|
||||
puts "Enter any key when you're done"
|
||||
STDIN.gets
|
||||
|
||||
encryption.encrypt_files
|
||||
|
||||
files_to_commit = get_all_files.call(storage)
|
||||
old_directory = storage.working_directory
|
||||
storage.save_changes!(files_to_commit: files_to_commit)
|
||||
|
||||
|
||||
# need to check, because saving changes with delete is another function (update repo if needed)
|
||||
files_diff = old_files - files_to_commit
|
||||
|
||||
# match can not work with both save/delete functionality `You can't provide both files_to_delete and files_to_commit right now`
|
||||
# to avoid this we use storage twice if needed
|
||||
|
||||
if files_diff.length > 0
|
||||
storage = storage_factory.call
|
||||
encryption = encryption_factory.call(storage)
|
||||
|
||||
files_to_delete = files_diff.map do |file|
|
||||
old_file = file
|
||||
old_file.slice! old_directory
|
||||
new_file = File.join(storage.working_directory, old_file)
|
||||
File.delete(new_file) if File.exist?(new_file)
|
||||
file = new_file
|
||||
end
|
||||
|
||||
encryption.encrypt_files
|
||||
storage.save_changes!(files_to_delete: files_to_delete)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def sync_code_signing_using_options(options)
|
||||
register_local_storage_for_match()
|
||||
|
||||
match(
|
||||
app_identifier: options[:app_identifier],
|
||||
username: options[:username] || options[:apple_id],
|
||||
api_key_path: options[:api_key_path],
|
||||
api_key: options[:api_key],
|
||||
team_id: options[:team_id],
|
||||
type: options[:type],
|
||||
readonly: options[:readonly].nil? ? true : options[:readonly],
|
||||
storage_mode: "local",
|
||||
# we can't pass signing_identities_path as parameter name since params is hardcoded in match/runner.rb
|
||||
git_url: get_signing_identities_path(),
|
||||
skip_docs: true,
|
||||
keychain_name: options[:keychain_name],
|
||||
keychain_password: options[:keychain_password]
|
||||
)
|
||||
end
|
||||
|
||||
def register_local_storage_for_match
|
||||
Match::Storage.register_backend(type: 'local', storage_class: Touchlane::LocalStorage)
|
||||
Match::Encryption.register_backend(type: 'local', encryption_class: Match::Encryption::OpenSSL)
|
||||
def get_default_options
|
||||
{
|
||||
:git_url => get_signing_identities_path(),
|
||||
:signing_identities_path => get_signing_identities_path(),
|
||||
:storage_mode => Touchlane::LocalStorage::STORAGE_TYPE
|
||||
}
|
||||
end
|
||||
|
||||
def get_signing_identities_path
|
||||
File.expand_path "../EncryptedSigningIdentities"
|
||||
end
|
||||
|
||||
def fill_up_options_using_configuration_type(options, configuration_type)
|
||||
def fill_up_options_using_configuration_type(options, configuration_type, keychain_password_required = false)
|
||||
configuration = get_configuration_for_type(configuration_type.type)
|
||||
|
||||
api_key_path = File.expand_path "../fastlane/#{configuration_type.prefix}_api_key.json"
|
||||
|
|
@ -320,7 +262,7 @@ def fill_up_options_using_configuration_type(options, configuration_type)
|
|||
|
||||
# default_options required to be empty due to the possibility of skipping the configuration type check below
|
||||
|
||||
default_options = {}
|
||||
default_options = get_default_options
|
||||
|
||||
# Check whether configuration type is required to configure one of api key parameters or not
|
||||
|
||||
|
|
@ -333,19 +275,19 @@ def fill_up_options_using_configuration_type(options, configuration_type)
|
|||
# If exists then fill in all required information through api_key_path parameter
|
||||
# and set a value to an options` parameter respectively
|
||||
|
||||
default_options = {:api_key_path => api_key_path}
|
||||
default_options[:api_key_path] = api_key_path
|
||||
else
|
||||
|
||||
# If doesn't exist then build api_key parameter through app_store_connect_api_key action
|
||||
# and set a value to an options` parameter respectively also
|
||||
|
||||
default_options = {:api_key => get_app_store_connect_api_key()}
|
||||
default_options[:api_key] = get_app_store_connect_api_key()
|
||||
end
|
||||
end
|
||||
|
||||
default_options
|
||||
.merge(configuration.to_options)
|
||||
.merge(get_keychain_options(options))
|
||||
.merge(get_keychain_options(options, keychain_password_required))
|
||||
.merge(options)
|
||||
end
|
||||
|
||||
|
|
@ -363,15 +305,15 @@ def get_app_store_connect_api_key()
|
|||
)
|
||||
end
|
||||
|
||||
def get_keychain_options(options)
|
||||
def get_keychain_options(options, keychain_password_required = false)
|
||||
keychain_name = options[:keychain_name]
|
||||
keychain_password = options[:keychain_password]
|
||||
|
||||
if is_ci?
|
||||
keychain_name = keychain_name || "ci.keychain"
|
||||
keychain_password = keychain_password || ""
|
||||
else
|
||||
keychain_password = keychain_password || prompt(
|
||||
elsif keychain_password_required && keychain_password.nil?
|
||||
keychain_password = prompt(
|
||||
text: "Please enter your keychain password (account password): ",
|
||||
secure_text: true
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ module Touchlane
|
|||
class LocalStorage < Match::Storage::Interface
|
||||
attr_accessor :signing_identities_path
|
||||
|
||||
STORAGE_TYPE = "local"
|
||||
|
||||
def self.configure(params)
|
||||
return self.new(
|
||||
# we can't pass signing_identities_path since params is hardcoded in match/runner.rb
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
require 'match'
|
||||
|
||||
# ugly hack to add support for custom storage
|
||||
|
||||
Match.module_eval do
|
||||
def self.storage_modes
|
||||
return ['git', 'google_cloud' 's3', Touchlane::LocalStorage::STORAGE_TYPE]
|
||||
end
|
||||
end
|
||||
|
||||
def register_local_storage_for_match
|
||||
storage_type = Touchlane::LocalStorage::STORAGE_TYPE
|
||||
|
||||
Match::Storage.register_backend(type: storage_type, storage_class: Touchlane::LocalStorage)
|
||||
Match::Encryption.register_backend(type: storage_type, encryption_class: Match::Encryption::OpenSSL)
|
||||
end
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
module Touchlane
|
||||
require_relative "touchlane/configuration_type"
|
||||
require_relative "touchlane/configuration"
|
||||
require_relative "touchlane/features"
|
||||
require_relative "match/storage/local_storage"
|
||||
require_relative "match/storage/local_storage_register"
|
||||
require_relative "touchlane/actions/sync_signing_identities"
|
||||
require_relative "touchlane/actions/refresh_profiles"
|
||||
require_relative "touchlane/actions/replace_development_certificate"
|
||||
require_relative "touchlane/actions/manually_update_code_signing"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
require 'match'
|
||||
require_relative '../../match/storage/local_storage'
|
||||
|
||||
def manually_update_code_signing(options)
|
||||
register_local_storage_for_match()
|
||||
|
||||
storage_factory = lambda do
|
||||
new_storage = Match::Storage.from_params(options)
|
||||
new_storage.download
|
||||
return new_storage
|
||||
end
|
||||
|
||||
encryption_factory = lambda do |stor|
|
||||
new_encryption = Match::Encryption.for_storage_mode(options[:storage_mode], { working_directory: stor.working_directory })
|
||||
new_encryption.decrypt_files
|
||||
return new_encryption
|
||||
end
|
||||
|
||||
get_all_files = lambda do |stor|
|
||||
Dir[File.join(stor.working_directory, "**", "*.{cer,p12,mobileprovision}")]
|
||||
end
|
||||
|
||||
storage = storage_factory.call
|
||||
encryption = encryption_factory.call(storage)
|
||||
old_files = get_all_files.call(storage)
|
||||
|
||||
sh("open #{storage.working_directory}")
|
||||
|
||||
# we are not using prompt() since it requires non-empty input which is not a case for Enter (\n)
|
||||
puts "Enter any key when you're done"
|
||||
STDIN.gets
|
||||
|
||||
encryption.encrypt_files
|
||||
|
||||
files_to_commit = get_all_files.call(storage)
|
||||
old_directory = storage.working_directory
|
||||
storage.save_changes!(files_to_commit: files_to_commit)
|
||||
|
||||
|
||||
# need to check, because saving changes with delete is another function (update repo if needed)
|
||||
files_diff = old_files - files_to_commit
|
||||
|
||||
# match can not work with both save/delete functionality `You can't provide both files_to_delete and files_to_commit right now`
|
||||
# to avoid this we use storage twice if needed
|
||||
|
||||
if files_diff.length > 0
|
||||
storage = storage_factory.call
|
||||
encryption = encryption_factory.call(storage)
|
||||
|
||||
files_to_delete = files_diff.map do |file|
|
||||
old_file = file
|
||||
old_file.slice! old_directory
|
||||
new_file = File.join(storage.working_directory, old_file)
|
||||
File.delete(new_file) if File.exist?(new_file)
|
||||
file = new_file
|
||||
end
|
||||
|
||||
encryption.encrypt_files
|
||||
storage.save_changes!(files_to_delete: files_to_delete)
|
||||
end
|
||||
|
||||
end
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
require 'match'
|
||||
require 'fastlane_core'
|
||||
require 'Spaceship'
|
||||
|
||||
def refresh_profiles(options)
|
||||
register_local_storage_for_match()
|
||||
|
||||
profiles_tmp_dir = Dir.mktmpdir
|
||||
|
||||
unless options[:cert_id]
|
||||
cert_type = Match.cert_type_sym(options[:type])
|
||||
|
||||
storage = Match::Storage.from_params(options)
|
||||
storage.download
|
||||
|
||||
output_dir_certs = File.join(storage.prefixed_working_directory, "certs", cert_type.to_s)
|
||||
|
||||
matched_certs = Dir.glob("*.cer", base: output_dir_certs)
|
||||
|
||||
if matched_certs.empty?
|
||||
FastlaneCore::UI.error("Unable to locate certificate to upate profiles")
|
||||
raise "No certificates found at #{output_dir_certs}"
|
||||
end
|
||||
|
||||
if matched_certs.length > 1
|
||||
options[:cert_id] = File.basename(FastlaneCore::UI.select("Please select the certificate", matched_certs), ".cer")
|
||||
else
|
||||
options[:cert_id] = File.basename(matched_certs.first, ".cer")
|
||||
end
|
||||
|
||||
if options[:cert_path].nil? && options[:p12_path].nil?
|
||||
encryption = Match::Encryption.for_storage_mode(options[:storage_mode], { working_directory: storage.working_directory })
|
||||
encryption.decrypt_files
|
||||
|
||||
tmp_certs_dir = Dir.mktmpdir
|
||||
|
||||
cer_file_name = "#{options[:cert_id]}.cer"
|
||||
cert_path = File.join(output_dir_certs, cer_file_name)
|
||||
options[:cert_path] = File.join(tmp_certs_dir, cer_file_name)
|
||||
|
||||
p12_file_name = "#{options[:cert_id]}.p12"
|
||||
p12_path = File.join(output_dir_certs, p12_file_name)
|
||||
options[:p12_path] = File.join(tmp_certs_dir, p12_file_name)
|
||||
|
||||
IO.copy_stream(cert_path, options[:cert_path])
|
||||
IO.copy_stream(p12_path, options[:p12_path])
|
||||
end
|
||||
end
|
||||
|
||||
app_identifier = options[:app_identifier]
|
||||
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from_json_file(options[:api_key_path])
|
||||
|
||||
if app_identifier.is_a? Array
|
||||
app_identifier.each { |app_id| refresh_profile_for_app(options, app_id, profiles_tmp_dir) }
|
||||
else
|
||||
refresh_profile_for_app(options, app_identifier, profiles_tmp_dir)
|
||||
end
|
||||
end
|
||||
|
||||
def refresh_profile_for_app(options, app_id, profiles_tmp_dir)
|
||||
provisioning_name = Fastlane::Actions.lane_context[Touchlane::SharedValues::TOUCH_BUNDLE_ID_PROFILE_NAME_MAPPING][app_id]
|
||||
|
||||
profiles = Spaceship::ConnectAPI::Profile.all(filter: { name: provisioning_name })
|
||||
|
||||
if profiles.empty?
|
||||
sigh_for_app(options, app_id, provisioning_name, profiles_tmp_dir)
|
||||
else
|
||||
FastlaneCore::UI.important("Did find existing profile #{provisioning_name}. Removing it from dev portal.")
|
||||
|
||||
profiles.each { |profile| profile.delete! if profile.name == provisioning_name }
|
||||
sigh_for_app(options, app_id, provisioning_name, profiles_tmp_dir)
|
||||
end
|
||||
|
||||
Match::Importer.new.import_cert(
|
||||
options,
|
||||
cert_path: options[:cert_path],
|
||||
p12_path: options[:p12_path],
|
||||
profile_path: lane_context[Fastlane::Actions::SharedValues::SIGH_PROFILE_PATH]
|
||||
)
|
||||
end
|
||||
|
||||
def sigh_for_app(options, app_id, provisioning_name, profiles_tmp_dir)
|
||||
sigh(
|
||||
app_identifier: app_id,
|
||||
development: options[:development],
|
||||
username: options[:username] || options[:apple_id],
|
||||
api_key_path: options[:api_key_path],
|
||||
api_key: options[:api_key],
|
||||
team_id: options[:team_id],
|
||||
provisioning_name: provisioning_name,
|
||||
output_path: profiles_tmp_dir,
|
||||
cert_id: options[:cert_id],
|
||||
force: true # will also add all available devices to this profile
|
||||
)
|
||||
end
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
def replace_development_certificate(options)
|
||||
register_local_storage_for_match()
|
||||
|
||||
certs_path_tmp_dir = Dir.mktmpdir
|
||||
|
||||
cert(
|
||||
development: true,
|
||||
username: options[:username] || options[:apple_id],
|
||||
api_key_path: options[:api_key_path],
|
||||
api_key: options[:api_key],
|
||||
team_id: options[:team_id],
|
||||
output_path: certs_path_tmp_dir,
|
||||
keychain_password: options[:keychain_password]
|
||||
)
|
||||
|
||||
options[:cert_id] = lane_context[Fastlane::Actions::SharedValues::CERT_CERTIFICATE_ID]
|
||||
options[:cert_path] = lane_context[Fastlane::Actions::SharedValues::CERT_FILE_PATH]
|
||||
options[:p12_path] = File.join(File.dirname(options[:cert_path]), "#{options[:cert_id]}.p12")
|
||||
options[:readonly] = false
|
||||
|
||||
refresh_profiles(options)
|
||||
|
||||
sh("open #{certs_path_tmp_dir}")
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
def install_signing_identities(options)
|
||||
readonly_options = options
|
||||
readonly_options[:readonly] = true
|
||||
|
||||
sync_signing_identities(readonly_options)
|
||||
end
|
||||
|
||||
def sync_signing_identities(options)
|
||||
register_local_storage_for_match()
|
||||
|
||||
match(
|
||||
app_identifier: options[:app_identifier],
|
||||
username: options[:username] || options[:apple_id],
|
||||
api_key_path: options[:api_key_path],
|
||||
api_key: options[:api_key],
|
||||
team_id: options[:team_id],
|
||||
type: options[:type],
|
||||
readonly: options[:readonly].nil? ? true : options[:readonly],
|
||||
storage_mode: options[:storage_mode],
|
||||
# we can't pass signing_identities_path as parameter name since params is hardcoded in match/runner.rb
|
||||
git_url: options[:signing_identities_path] || options[:git_url],
|
||||
skip_docs: true,
|
||||
keychain_name: options[:keychain_name],
|
||||
keychain_password: options[:keychain_password]
|
||||
)
|
||||
end
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
require "yaml"
|
||||
|
||||
module Touchlane
|
||||
module SharedValues
|
||||
TOUCH_BUNDLE_ID_PROFILE_NAME_MAPPING = :TOUCH_BUNDLE_ID_PROFILE_NAME_MAPPING
|
||||
end
|
||||
|
||||
class Configuration
|
||||
def initialize(type, app_identifier, apple_id, team_id, itc_team_id)
|
||||
@type = type
|
||||
|
|
@ -13,6 +17,8 @@ module Touchlane
|
|||
attr_reader :type, :app_identifier, :apple_id, :team_id, :itc_team_id
|
||||
|
||||
def self.from_file(path, type)
|
||||
Fastlane::Actions.lane_context[SharedValues::TOUCH_BUNDLE_ID_PROFILE_NAME_MAPPING] = {}
|
||||
|
||||
configuration_hash = load_configuration_from_file(path)
|
||||
attrs_hash = configuration_hash["types"][type]
|
||||
identifiers = get_app_identifiers_from_configuration_hash(configuration_hash, type)
|
||||
|
|
@ -35,8 +41,15 @@ module Touchlane
|
|||
|
||||
def self.get_app_identifiers_from_configuration_hash(configuration_hash, type)
|
||||
identifier_key = "PRODUCT_BUNDLE_IDENTIFIER"
|
||||
profile_name_key = "PROVISIONING_PROFILE_SPECIFIER"
|
||||
|
||||
configuration_hash["targets"].collect do |target, types|
|
||||
types[type][identifier_key] or raise "#{target}: There is no #{identifier_key} field in #{type}"
|
||||
bundle_id = types[type][identifier_key]
|
||||
profile_name = types[type][profile_name_key]
|
||||
|
||||
Fastlane::Actions.lane_context[SharedValues::TOUCH_BUNDLE_ID_PROFILE_NAME_MAPPING][bundle_id] = profile_name
|
||||
|
||||
bundle_id or raise "#{target}: There is no #{identifier_key} field in #{type}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ module Touchlane
|
|||
def to_options
|
||||
{
|
||||
:type => @type,
|
||||
:development => @is_development,
|
||||
:export_method => @export_method,
|
||||
:configuration => @configuration
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue