lib/xcodeproj/project.rb
# frozen_string_literal: true
require 'atomos'
require 'fileutils'
require 'securerandom'
require 'xcodeproj/project/object'
require 'xcodeproj/project/project_helper'
require 'xcodeproj/project/uuid_generator'
require 'xcodeproj/plist'
module Xcodeproj
# This class represents a Xcode project document.
#
# It can be used to manipulate existing documents or even create new ones
# from scratch.
#
# An Xcode project document is a plist file where the root is a dictionary
# containing the following keys:
#
# - archiveVersion: the version of the document.
# - objectVersion: the version of the objects description.
# - classes: a key that apparently is always empty.
# - objects: a dictionary where the UUID of every object is associated to
# its attributes.
# - rootObject: the UUID identifier of the root object ({PBXProject}).
#
# Every object is in turn a dictionary that specifies an `isa` (the class of
# the object) and in accordance to it maintains a set attributes. Those
# attributes might reference one or more other objects by UUID. If the
# reference is a collection, it is ordered.
#
# The {Project} API returns instances of {AbstractObject} which wrap the
# objects described in the Xcode project document. All the attributes types
# are preserved from the plist, except for the relationships which are
# replaced with objects instead of UUIDs.
#
# An object might be referenced by multiple objects, an when no other object
# is references it, it becomes unreachable (the root object is referenced by
# the project itself). Xcodeproj takes care of adding and removing those
# objects from the `objects` dictionary so the project is always in a
# consistent state.
#
class Project
include Object
# @return [Pathname] the path of the project.
#
attr_reader :path
# @return [Pathname] the directory of the project
#
attr_reader :project_dir
# @param [Pathname, String] path @see path
# The path provided will be expanded to an absolute path.
# @param [Bool] skip_initialization
# Wether the project should be initialized from scratch.
# @param [Int] object_version
# Object version to use for serialization, defaults to Xcode 3.2 compatible.
#
# @example Creating a project
# Project.new("path/to/Project.xcodeproj")
#
# @note When initializing the project, Xcodeproj mimics the Xcode behaviour
# including the setup of a debug and release configuration. If you want a
# clean project without any configurations, you should override the
# `initialize_from_scratch` method to not add these configurations and
# manually set the object version.
#
def initialize(path, skip_initialization = false, object_version = Constants::DEFAULT_OBJECT_VERSION)
@path = Pathname.new(path).expand_path
@project_dir = @path.dirname
@objects_by_uuid = {}
@generated_uuids = []
@available_uuids = []
@dirty = true
unless skip_initialization.is_a?(TrueClass) || skip_initialization.is_a?(FalseClass)
raise ArgumentError, '[Xcodeproj] Initialization parameter expected to ' \
"be a boolean #{skip_initialization}"
end
unless skip_initialization
initialize_from_scratch
@object_version = object_version.to_s
unless Constants::COMPATIBILITY_VERSION_BY_OBJECT_VERSION.key?(object_version)
raise ArgumentError, "[Xcodeproj] Unable to find compatibility version string for object version `#{object_version}`."
end
root_object.compatibility_version = Constants::COMPATIBILITY_VERSION_BY_OBJECT_VERSION[object_version]
end
end
# Opens the project at the given path.
#
# @param [Pathname, String] path
# The path to the Xcode project document (xcodeproj).
#
# @raise If the project versions are more recent than the ones know to
# Xcodeproj to prevent it from corrupting existing projects.
# Naturally, this would never happen with a project generated by
# xcodeproj itself.
#
# @raise If it can't find the root object. This means that the project is
# malformed.
#
# @example Opening a project
# Project.open("path/to/Project.xcodeproj")
#
def self.open(path)
path = Pathname.pwd + path
unless Pathname.new(path).exist?
raise "[Xcodeproj] Unable to open `#{path}` because it doesn't exist."
end
project = new(path, true)
project.send(:initialize_from_file)
project
end
# @return [String] the archive version.
#
attr_reader :archive_version
# @return [Hash] an dictionary whose purpose is unknown.
#
attr_reader :classes
# @return [String] the objects version.
#
attr_reader :object_version
# @return [Hash{String => AbstractObject}] A hash containing all the
# objects of the project by UUID.
#
attr_reader :objects_by_uuid
# @return [PBXProject] the root object of the project.
#
attr_reader :root_object
# A fast way to see if two {Project} instances refer to the same projects on
# disk. Use this over {#eql?} when you do not need to compare the full data.
#
# This shallow comparison was chosen as the (common) `==` implementation,
# because it was too easy to introduce changes into the Xcodeproj code-base
# that were slower than O(1).
#
# @return [Boolean] whether or not the two `Project` instances refer to the
# same projects on disk, determined solely by {#path} and
# `root_object.uuid` equality.
#
# @todo If ever needed, we could also compare `uuids.sort` instead.
#
def ==(other)
other && path == other.path && root_object.uuid == other.root_object.uuid
end
# Compares the project to another one, or to a plist representation.
#
# @note This operation can be extremely expensive, because it converts a
# `Project` instance to a hash, and should _only_ ever be used to
# determine wether or not the data contents of two `Project` instances
# are completely equal.
#
# To simply determine wether or not two {Project} instances refer to
# the same projects on disk, use the {#==} method instead.
#
# @param [#to_hash] other the object to compare.
#
# @return [Boolean] whether the project is equivalent to the given object.
#
def eql?(other)
other.respond_to?(:to_hash) && to_hash == other.to_hash
end
def to_s
"#<#{self.class}> path:`#{path}` UUID:`#{root_object.uuid}`"
end
alias_method :inspect, :to_s
public
# @!group Initialization
#-------------------------------------------------------------------------#
# Initializes the instance from scratch.
#
def initialize_from_scratch
@archive_version = Constants::LAST_KNOWN_ARCHIVE_VERSION.to_s
@classes = {}
root_object.remove_referrer(self) if root_object
@root_object = new(PBXProject)
root_object.add_referrer(self)
root_object.main_group = new(PBXGroup)
root_object.product_ref_group = root_object.main_group.new_group('Products')
config_list = new(XCConfigurationList)
root_object.build_configuration_list = config_list
config_list.default_configuration_name = 'Release'
config_list.default_configuration_is_visible = '0'
add_build_configuration('Debug', :debug)
add_build_configuration('Release', :release)
new_group('Frameworks')
end
# Initializes the instance with the project stored in the `path` attribute.
#
def initialize_from_file
pbxproj_path = path + 'project.pbxproj'
plist = Plist.read_from_path(pbxproj_path.to_s)
root_object.remove_referrer(self) if root_object
@root_object = new_from_plist(plist['rootObject'], plist['objects'], self)
@archive_version = plist['archiveVersion']
@object_version = plist['objectVersion']
@classes = plist['classes'] || {}
@dirty = false
unless root_object
raise "[Xcodeproj] Unable to find a root object in #{pbxproj_path}."
end
if archive_version.to_i > Constants::LAST_KNOWN_ARCHIVE_VERSION
UI.warn "[Xcodeproj] Archive version (#{archive_version.to_i}) is higher than the latest supported by xcodeproj (#{Constants::LAST_KNOWN_ARCHIVE_VERSION})."
end
if object_version.to_i > Constants::LAST_KNOWN_OBJECT_VERSION
UI.warn "[Xcodeproj] Xcode project version (#{object_version.to_i}) is higher than the latest supported by xcodeproj (#{Constants::LAST_KNOWN_OBJECT_VERSION})."
end
# Projects can have product_ref_groups that are not listed in the main_groups["Products"]
root_object.product_ref_group ||= root_object.main_group['Products'] || root_object.main_group.new_group('Products')
end
public
# @!group Plist serialization
#-------------------------------------------------------------------------#
# Creates a new object from the given UUID and `objects` hash (of a plist).
#
# The method sets up any relationship of the new object, generating the
# destination object(s) if not already present in the project.
#
# @note This method is used to generate the root object
# from a plist. Subsequent invocation are called by the
# {AbstractObject#configure_with_plist}. Clients of {Xcodeproj} are
# not expected to call this method.
#
# @param [String] uuid
# The UUID of the object that needs to be generated.
#
# @param [Hash {String => Hash}] objects_by_uuid_plist
# The `objects` hash of the plist representation of the project.
#
# @param [Boolean] root_object
# Whether the requested object is the root object and needs to be
# retained by the project before configuration to add it to the
# `objects` hash and avoid infinite loops.
#
# @return [AbstractObject] the new object.
#
# @visibility private.
#
def new_from_plist(uuid, objects_by_uuid_plist, root_object = false)
attributes = objects_by_uuid_plist[uuid]
if attributes
klass = Object.const_get(attributes['isa'])
object = klass.new(self, uuid)
objects_by_uuid[uuid] = object
object.add_referrer(self) if root_object
object.configure_with_plist(objects_by_uuid_plist)
object
end
end
# @return [Hash] The hash representation of the project.
#
def to_hash
plist = {}
objects_dictionary = {}
objects.each { |obj| objects_dictionary[obj.uuid] = obj.to_hash }
plist['objects'] = objects_dictionary
plist['archiveVersion'] = archive_version.to_s
plist['objectVersion'] = object_version.to_s
plist['classes'] = classes
plist['rootObject'] = root_object.uuid
plist
end
def to_ascii_plist
plist = {}
objects_dictionary = {}
objects
.sort_by { |o| [o.isa, o.uuid] }
.each do |obj|
key = Nanaimo::String.new(obj.uuid, obj.ascii_plist_annotation)
value = obj.to_ascii_plist.tap { |a| a.annotation = nil }
objects_dictionary[key] = value
end
plist['archiveVersion'] = archive_version.to_s
plist['classes'] = classes
plist['objectVersion'] = object_version.to_s
plist['objects'] = objects_dictionary
plist['rootObject'] = Nanaimo::String.new(root_object.uuid, root_object.ascii_plist_annotation)
Nanaimo::Plist.new.tap { |p| p.root_object = plist }
end
# Converts the objects tree to a hash substituting the hash
# of the referenced to their UUID reference. As a consequence the hash of
# an object might appear multiple times and the information about their
# uniqueness is lost.
#
# This method is designed to work in conjunction with {Hash#recursive_diff}
# to provide a complete, yet readable, diff of two projects *not* affected
# by differences in UUIDs.
#
# @return [Hash] a hash representation of the project different from the
# plist one.
#
def to_tree_hash
hash = {}
objects_dictionary = {}
hash['objects'] = objects_dictionary
hash['archiveVersion'] = archive_version.to_s
hash['objectVersion'] = object_version.to_s
hash['classes'] = classes
hash['rootObject'] = root_object.to_tree_hash
hash
end
# @return [Hash{String => Hash}] A hash suitable to display the project
# to the user.
#
def pretty_print
build_configurations = root_object.build_configuration_list.build_configurations
{
'File References' => root_object.main_group.pretty_print.values.first,
'Targets' => root_object.targets.map(&:pretty_print),
'Build Configurations' => build_configurations.sort_by(&:name).map(&:pretty_print),
}
end
# Serializes the project in the xcodeproj format using the path provided
# during initialization or the given path (`xcodeproj` file). If a path is
# provided file references depending on the root of the project are not
# updated automatically, thus clients are responsible to perform any needed
# modification before saving.
#
# @param [String, Pathname] path
# The optional path where the project should be saved.
#
# @example Saving a project
# project.save
# project.save
#
# @return [void]
#
def save(save_path = nil)
save_path ||= path
@dirty = false if save_path == path
FileUtils.mkdir_p(save_path)
file = File.join(save_path, 'project.pbxproj')
Atomos.atomic_write(file) do |f|
Nanaimo::Writer::PBXProjWriter.new(to_ascii_plist, :pretty => true, :output => f, :strict => false).write
end
end
# Marks the project as dirty, that is, modified from what is on disk.
#
# @return [void]
#
def mark_dirty!
@dirty = true
end
# @return [Boolean] Whether this project has been modified since read from
# disk or saved.
#
def dirty?
@dirty == true
end
# Replaces all the UUIDs in the project with deterministic MD5 checksums.
#
# @note The current sorting of the project is taken into account when
# generating the new UUIDs.
#
# @note This method should only be used for entirely machine-generated
# projects, as true UUIDs are useful for tracking changes in the
# project.
#
# @return [void]
#
def predictabilize_uuids
UUIDGenerator.new([self]).generate!
end
# Replaces all the UUIDs in the list of provided projects with deterministic MD5 checksums.
#
# @param [Array<Project>] projects
#
# @note The current sorting of the project is taken into account when
# generating the new UUIDs.
#
# @note This method should only be used for entirely machine-generated
# projects, as true UUIDs are useful for tracking changes in the
# project.
#
# @return [void]
#
def self.predictabilize_uuids(projects)
UUIDGenerator.new(projects).generate!
end
public
# @!group Creating objects
#-------------------------------------------------------------------------#
# Creates a new object with a suitable UUID.
#
# The object is only configured with the default values of the `:simple`
# attributes, for this reason it is better to use the convenience methods
# offered by the {AbstractObject} subclasses or by this class.
#
# @param [Class, String] klass
# The concrete subclass of AbstractObject for new object or its
# ISA.
#
# @return [AbstractObject] the new object.
#
def new(klass)
if klass.is_a?(String)
klass = Object.const_get(klass)
end
object = klass.new(self, generate_uuid)
object.initialize_defaults
object
end
# Generates a UUID unique for the project.
#
# @note UUIDs are not guaranteed to be generated unique because we need
# to trim the ones generated in the xcodeproj extension.
#
# @note Implementation detail: as objects usually are created serially
# this method creates a batch of UUID and stores the not colliding
# ones, so the search for collisions with known UUIDS (a
# performance bottleneck) is performed less often.
#
# @return [String] A UUID unique to the project.
#
def generate_uuid
generate_available_uuid_list while @available_uuids.empty?
@available_uuids.shift
end
# @return [Array<String>] the list of all the generated UUIDs.
#
# @note Used for checking new UUIDs for duplicates with UUIDs already
# generated but used for objects which are not yet part of the
# `objects` hash but which might be added at a later time.
#
attr_reader :generated_uuids
# Pre-generates the given number of UUIDs. Useful for optimizing
# performance when the rough number of objects that will be created is
# known in advance.
#
# @param [Integer] count
# the number of UUIDs that should be generated.
#
# @note This method might generated a minor number of uniques UUIDs than
# the given count, because some might be duplicated a thus will be
# discarded.
#
# @return [void]
#
def generate_available_uuid_list(count = 100)
new_uuids = (0..count).map { SecureRandom.hex(12).upcase }
uniques = (new_uuids - (@generated_uuids + uuids))
@generated_uuids += uniques
@available_uuids += uniques
end
public
# @!group Convenience accessors
#-------------------------------------------------------------------------#
# @return [Array<AbstractObject>] all the objects of the project.
#
def objects
objects_by_uuid.values
end
# @return [Array<String>] all the UUIDs of the project.
#
def uuids
objects_by_uuid.keys
end
# @return [Array<AbstractObject>] all the objects of the project with a
# given ISA.
#
def list_by_class(klass)
objects.select { |o| o.class == klass }
end
# @return [PBXGroup] the main top-level group.
#
def main_group
root_object.main_group
end
# @return [ObjectList<PBXGroup>] a list of all the groups in the
# project.
#
def groups
main_group.groups
end
# Returns a group at the given subpath relative to the main group.
#
# @example
# frameworks = project['Frameworks']
# frameworks.name #=> 'Frameworks'
# main_group.children.include? frameworks #=> True
#
# @param [String] group_path @see MobileCoreServices
#
# @return [PBXGroup] the group at the given subpath.
#
def [](group_path)
main_group[group_path]
end
# @return [ObjectList<PBXFileReference>] a list of all the files in the
# project.
#
def files
objects.grep(PBXFileReference)
end
# Returns the file reference for the given absolute path.
#
# @param [#to_s] absolute_path
# The absolute path of the file whose reference is needed.
#
# @return [PBXFileReference] The file reference.
# @return [Nil] If no file reference could be found.
#
def reference_for_path(absolute_path)
absolute_pathname = Pathname.new(absolute_path)
unless absolute_pathname.absolute?
raise ArgumentError, "Paths must be absolute #{absolute_path}"
end
objects.find do |child|
child.isa == 'PBXFileReference' && child.real_path == absolute_pathname
end
end
# @return [ObjectList<AbstractTarget>] A list of all the targets in the
# project.
#
def targets
root_object.targets
end
# @return [ObjectList<PBXNativeTarget>] A list of all the targets in the
# project excluding aggregate targets.
#
def native_targets
root_object.targets.grep(PBXNativeTarget)
end
# Checks the native target for any targets in the project
# that are dependent on the native target and would be
# embedded in it at build time
#
# @param [PBXNativeTarget] native target to check for
# embedded targets
#
#
# @return [Array<PBXNativeTarget>] A list of all targets that
# are embedded in the passed in target
#
def embedded_targets_in_native_target(native_target)
native_targets.select do |target|
host_targets_for_embedded_target(target).any? { |host| host.uuid == native_target.uuid }
end
end
# Returns the native targets, in which the embedded target is
# embedded. This works by traversing the targets to find those
# where the target is a dependency.
#
# @param [PBXNativeTarget] native target that might be embedded
# in another target
#
# @return [Array<PBXNativeTarget>] the native targets that host the
# embedded target
#
def host_targets_for_embedded_target(embedded_target)
native_targets.select do |native_target|
((embedded_target.uuid != native_target.uuid) &&
(native_target.dependencies.map(&:native_target_uuid).include? embedded_target.uuid))
end
end
# @return [PBXGroup] The group which holds the product file references.
#
def products_group
root_object.product_ref_group
end
# @return [ObjectList<PBXFileReference>] A list of the product file
# references.
#
def products
products_group.children
end
# @return [PBXGroup] the `Frameworks` group creating it if necessary.
#
def frameworks_group
main_group['Frameworks'] || main_group.new_group('Frameworks')
end
# @return [ObjectList<XCConfigurationList>] The build configuration list of
# the project.
#
def build_configuration_list
root_object.build_configuration_list
end
# @return [ObjectList<XCBuildConfiguration>] A list of project wide
# build configurations.
#
def build_configurations
root_object.build_configuration_list.build_configurations
end
# Returns the build settings of the project wide build configuration with
# the given name.
#
# @param [String] name
# The name of a project wide build configuration.
#
# @return [Hash] The build settings.
#
def build_settings(name)
root_object.build_configuration_list.build_settings(name)
end
public
# @!group Helpers
#-------------------------------------------------------------------------#
# Creates a new file reference in the main group.
#
# @param @see PBXGroup#new_file
#
# @return [PBXFileReference] the new file.
#
def new_file(path, source_tree = :group)
main_group.new_file(path, source_tree)
end
# Creates a new group at the given subpath of the main group.
#
# @param @see PBXGroup#new_group
#
# @return [PBXGroup] the new group.
#
def new_group(name, path = nil, source_tree = :group)
main_group.new_group(name, path, source_tree)
end
# Creates a new target and adds it to the project.
#
# The target is configured for the given platform and its file reference it
# is added to the {products_group}.
#
# The target is pre-populated with common build settings, and the
# appropriate Framework according to the platform is added to to its
# Frameworks phase.
#
# @param [Symbol] type
# the type of target. Can be `:application`, `:framework`,
# `:dynamic_library` or `:static_library`.
#
# @param [String] name
# the name of the target product.
#
# @param [Symbol] platform
# the platform of the target. Can be `:ios` or `:osx`.
#
# @param [String] deployment_target
# the deployment target for the platform.
#
# @param [PBXGroup] product_group
# the product group, where to add to a file reference of the
# created target.
#
# @param [Symbol] language
# the primary language of the target, can be `:objc` or `:swift`.
#
# @return [PBXNativeTarget] the target.
#
def new_target(type, name, platform, deployment_target = nil, product_group = nil, language = nil, product_basename = nil)
product_group ||= products_group
product_basename ||= name
ProjectHelper.new_target(self, type, name, platform, deployment_target, product_group, language, product_basename)
end
# Creates a new resource bundles target and adds it to the project.
#
# The target is configured for the given platform and its file reference it
# is added to the {products_group}.
#
# The target is pre-populated with common build settings
#
# @param [String] name
# the name of the resources bundle.
#
# @param [Symbol] platform
# the platform of the resources bundle. Can be `:ios` or `:osx`.
#
# @return [PBXNativeTarget] the target.
#
def new_resources_bundle(name, platform, product_group = nil, product_basename = nil)
product_group ||= products_group
product_basename ||= name
ProjectHelper.new_resources_bundle(self, name, platform, product_group, product_basename)
end
# Creates a new target and adds it to the project.
#
# The target is configured for the given platform and its file reference it
# is added to the {products_group}.
#
# The target is pre-populated with common build settings, and the
# appropriate Framework according to the platform is added to to its
# Frameworks phase.
#
# @param [String] name
# the name of the target.
#
# @param [Array<AbstractTarget>] target_dependencies
# targets, which should be added as dependencies.
#
# @param [Symbol] platform
# the platform of the aggregate target. Can be `:ios` or `:osx`.
#
# @param [String] deployment_target
# the deployment target for the platform.
#
# @return [PBXNativeTarget] the target.
#
def new_aggregate_target(name, target_dependencies = [], platform = nil, deployment_target = nil)
ProjectHelper.new_aggregate_target(self, name, platform, deployment_target).tap do |aggregate_target|
target_dependencies.each do |dep|
aggregate_target.add_dependency(dep)
end
end
end
# Adds a new build configuration to the project and populates its with
# default settings according to the provided type.
#
# @param [String] name
# The name of the build configuration.
#
# @param [Symbol] type
# The type of the build configuration used to populate the build
# settings, must be :debug or :release.
#
# @return [XCBuildConfiguration] The new build configuration.
#
def add_build_configuration(name, type)
build_configuration_list = root_object.build_configuration_list
if build_configuration = build_configuration_list[name]
build_configuration
else
build_configuration = new(XCBuildConfiguration)
build_configuration.name = name
common_settings = Constants::PROJECT_DEFAULT_BUILD_SETTINGS
settings = ProjectHelper.deep_dup(common_settings[:all])
settings.merge!(ProjectHelper.deep_dup(common_settings[type]))
build_configuration.build_settings = settings
build_configuration_list.build_configurations << build_configuration
build_configuration
end
end
# Sorts the project.
#
# @param [Hash] options
# the sorting options.
# @option options [Symbol] :groups_position
# the position of the groups can be either `:above` or `:below`.
#
# @return [void]
#
def sort(options = nil)
root_object.sort_recursively(options)
end
public
# @!group Schemes
#-------------------------------------------------------------------------#
# Get list of shared schemes in project
#
# @param [String] path
# project path
#
# @return [Array]
#
def self.schemes(project_path)
schemes = Dir[File.join(project_path, 'xcshareddata', 'xcschemes', '*.xcscheme')].map do |scheme|
File.basename(scheme, '.xcscheme')
end
schemes << File.basename(project_path, '.xcodeproj') if schemes.empty?
schemes
end
# Recreates the user schemes of the project from scratch (removes the
# folder) and optionally hides them.
#
# @param [Bool] visible
# Whether the schemes should be visible or hidden.
#
# @return [void]
#
def recreate_user_schemes(visible = true)
schemes_dir = XCScheme.user_data_dir(path)
FileUtils.rm_rf(schemes_dir)
FileUtils.mkdir_p(schemes_dir)
xcschememanagement = {}
xcschememanagement['SchemeUserState'] = {}
xcschememanagement['SuppressBuildableAutocreation'] = {}
targets.each do |target|
scheme = XCScheme.new
test_target = target if target.respond_to?(:test_target_type?) && target.test_target_type?
launch_target = target.respond_to?(:launchable_target_type?) && target.launchable_target_type?
scheme.configure_with_targets(target, test_target, :launch_target => launch_target)
yield scheme, target if block_given?
scheme.save_as(path, target.name, false)
xcschememanagement['SchemeUserState']["#{target.name}.xcscheme"] = {}
xcschememanagement['SchemeUserState']["#{target.name}.xcscheme"]['isShown'] = visible
end
xcschememanagement_path = schemes_dir + 'xcschememanagement.plist'
Plist.write_to_path(xcschememanagement, xcschememanagement_path)
end
#-------------------------------------------------------------------------#
end
end