1require 'fileutils'
2require 'colored2'
3
4module Expo
5  module ProjectIntegrator
6    include Pod
7
8    CONFIGURATION_FLAG_PREFIX = 'EXPO_CONFIGURATION_'
9    SWIFT_FLAGS = 'OTHER_SWIFT_FLAGS'
10
11    # Integrates targets in the project and generates modules providers.
12    def self.integrate_targets_in_project(targets, project)
13      # Find the targets that use expo modules and need the modules provider
14      targets_with_modules_provider = targets.select do |target|
15        autolinking_manager = target.target_definition.autolinking_manager
16        autolinking_manager.present? && autolinking_manager.should_generate_modules_provider?
17      end
18
19      # Find existing PBXGroup for modules providers.
20      generated_group = modules_providers_group(project, targets_with_modules_provider.any?)
21
22      # Return early when the modules providers group has not been auto-created in the line above.
23      return if generated_group.nil?
24
25      # Remove existing groups for targets without modules provider.
26      generated_group.groups.each do |group|
27        # Remove the group if there is no target for this group.
28        if targets.none? { |target| target.target_definition.name == group.name && targets_with_modules_provider.include?(target) }
29          recursively_remove_group(group)
30        end
31      end
32
33      targets_with_modules_provider.sort_by(&:name).each do |target|
34        # The user target name (without `Pods-` prefix which is a part of `target.name`)
35        target_name = target.target_definition.name
36
37        # PBXNativeTarget of the user target
38        native_target = project.native_targets.find { |native_target| native_target.name == target_name }
39
40        # Shorthand ref for the autolinking manager.
41        autolinking_manager = target.target_definition.autolinking_manager
42
43        UI.message '- Generating the provider for ' << target_name.green << ' target' do
44          # Get the absolute path to the modules provider
45          modules_provider_path = autolinking_manager.modules_provider_path(target)
46
47          # Run `expo-modules-autolinking` command to generate the file
48          autolinking_manager.generate_package_list(target_name, modules_provider_path)
49
50          # PBXGroup for generated files per target
51          generated_target_group = generated_group.find_subpath(target_name, true)
52
53          # PBXGroup uses relative paths, so we need to strip the absolute path
54          modules_provider_relative_path = Pathname.new(modules_provider_path).relative_path_from(generated_target_group.real_path).to_s
55
56          if generated_target_group.find_file_by_path(modules_provider_relative_path).nil?
57            # Create new PBXFileReference if the modules provider is not in the group yet
58            modules_provider_file_reference = generated_target_group.new_file(modules_provider_path)
59
60            if native_target.source_build_phase.files_references.find { |ref| ref.present? && ref.path == modules_provider_relative_path }.nil?
61              # Put newly created PBXFileReference to the source files of the native target
62              native_target.add_file_references([modules_provider_file_reference])
63              project.mark_dirty!
64            end
65          end
66        end
67
68        integrate_build_script(autolinking_manager, project, target, native_target)
69      end
70
71      # Remove the generated group if it has nothing left inside
72      if targets_with_modules_provider.empty?
73        recursively_remove_group(generated_group)
74      end
75    end
76
77    def self.recursively_remove_group(group)
78      return if group.nil?
79
80      UI.message '- Removing ' << group.name.green << ' group' do
81        group.recursive_children.each do |child|
82          UI.message ' - Removing a reference to ' << child.name.green
83          child.remove_from_project
84        end
85
86        group.remove_from_project
87        group.project.mark_dirty!
88      end
89    end
90
91    # CocoaPods doesn't properly remove file references from the build phase
92    # They appear as nils and it's safe to just delete them from native targets
93    def self.remove_nils_from_source_files(project)
94      project.native_targets.each do |native_target|
95        native_target.source_build_phase.files.each do |build_file|
96          next unless build_file.file_ref.nil?
97
98          build_file.remove_from_project
99          project.mark_dirty!
100        end
101      end
102    end
103
104    def self.modules_providers_group(project, autocreate = false)
105      project.main_group.find_subpath(Constants::GENERATED_GROUP_NAME, autocreate)
106    end
107
108    # Sets EXPO_CONFIGURATION_* compiler flag for Swift.
109    def self.set_autolinking_configuration(project)
110      project.native_targets.each do |native_target|
111        native_target.build_configurations.each do |build_configuration|
112          configuration_flag = "-D #{CONFIGURATION_FLAG_PREFIX}#{build_configuration.debug? ? "DEBUG" : "RELEASE"}"
113          build_settings = build_configuration.build_settings
114
115          # For some targets it might be `nil` by default which is an equivalent to `$(inherited)`
116          if build_settings[SWIFT_FLAGS].nil?
117            build_settings[SWIFT_FLAGS] ||= '$(inherited)'
118          end
119
120          # If the correct flag is not set yet
121          if !build_settings[SWIFT_FLAGS].include?(configuration_flag)
122            # Remove existing flag to make sure we don't put another one each time
123            build_settings[SWIFT_FLAGS] = build_settings[SWIFT_FLAGS].gsub(/\b-D\s+#{Regexp.quote(CONFIGURATION_FLAG_PREFIX)}\w+/, '')
124
125            # Add the correct flag
126            build_settings[SWIFT_FLAGS] << ' ' << configuration_flag
127
128            # Make sure the project will be saved as we did some changes
129            project.mark_dirty!
130          end
131        end
132      end
133    end
134
135    # Makes sure that the build script configuring the project is installed,
136    # is up-to-date and is placed before the "Compile Sources" phase.
137    def self.integrate_build_script(autolinking_manager, project, target, native_target)
138      build_phases = native_target.build_phases
139      modules_provider_path = autolinking_manager.modules_provider_path(target)
140
141      # Look for our own build script phase
142      xcode_build_script = native_target.shell_script_build_phases.find { |script|
143        script.name == Constants::CONFIGURE_PROJECT_BUILD_SCRIPT_NAME
144      }
145
146      if xcode_build_script.nil?
147        # Inform the user that we added a build script.
148        puts "[Expo] ".blue << "Installing the build script for target " << native_target.name.green
149
150        # Create a new build script in the target, it's added as the last phase
151        xcode_build_script = native_target.new_shell_script_build_phase(Constants::CONFIGURE_PROJECT_BUILD_SCRIPT_NAME)
152      end
153
154      # Make sure it is before the "Compile Sources" build phase
155      xcode_build_script_index = build_phases.find_index(xcode_build_script)
156      compile_sources_index = build_phases.find_index { |phase|
157        phase.is_a?(Xcodeproj::Project::PBXSourcesBuildPhase)
158      }
159
160      if xcode_build_script_index.nil?
161        # This is almost impossible to get here as the script was just created with `new_shell_script_build_phase`
162        # that puts the script at the end of the phases, but let's log it just in case.
163        puts "[Expo] ".blue << "Unable to find the configuring build script in the Xcode project".red
164      end
165
166      if compile_sources_index.nil?
167        # In this case the project will probably not compile but that's not our fault
168        # and it doesn't block us from updating our build script.
169        puts "[Expo] ".blue << "Unable to find the compilation build phase in the Xcode project".red
170      end
171
172      # Insert our script before the "Compile Sources" phase when necessary
173      unless compile_sources_index.nil? || xcode_build_script_index < compile_sources_index
174        build_phases.insert(
175          compile_sources_index,
176          build_phases.delete_at(xcode_build_script_index)
177        )
178      end
179
180      # Get path to the script that will be added to the target support files
181      support_script_path = File.join(target.support_files_dir, Constants::CONFIGURE_PROJECT_SCRIPT_FILE_NAME)
182      support_script_relative_path = Pathname.new(support_script_path).relative_path_from(project.project_dir)
183
184      # Write to the shell script so it's always in-sync with the autolinking configuration
185      IO.write(
186        support_script_path,
187        generate_support_script(autolinking_manager, modules_provider_path)
188      )
189
190      # Make the support script executable
191      FileUtils.chmod('+x', support_script_path)
192
193      # Force the build phase script to run on each build (including incremental builds)
194      xcode_build_script.always_out_of_date = '1'
195
196      # Make sure the build script in Xcode is up to date, but probably it's not going to change
197      # as it just runs the script generated in the target support files
198      xcode_build_script.shell_script = generate_xcode_build_script(support_script_relative_path)
199    end
200
201    # Generates the shell script of the build script phase.
202    # Try not to modify this since it involves changes in the pbxproj so
203    # it's better to modify the support script instead, if possible.
204    def self.generate_xcode_build_script(script_relative_path)
205      escaped_path = script_relative_path.to_s.gsub(/[^a-zA-Z0-9,\._\+@%\/\-]/) { |char| "\\#{char}" }
206
207      <<~XCODE_BUILD_SCRIPT
208      # This script configures Expo modules and generates the modules provider file.
209      bash -l -c "./#{escaped_path}"
210      XCODE_BUILD_SCRIPT
211    end
212
213    # Generates the support script that is executed by the build script phase.
214    def self.generate_support_script(autolinking_manager, modules_provider_path)
215      args = autolinking_manager.base_command_args.map { |arg| "\"#{arg}\"" }.join(' ')
216
217      <<~SUPPORT_SCRIPT
218      #!/usr/bin/env bash
219      # @generated by expo-modules-autolinking
220
221      set -eo pipefail
222
223      function with_node() {
224        # Start with a default
225        export NODE_BINARY=$(command -v node)
226
227        # Override the default with the global environment
228        ENV_PATH="$PODS_ROOT/../.xcode.env"
229        if [[ -f "$ENV_PATH" ]]; then
230          source "$ENV_PATH"
231        fi
232
233        # Override the global with the local environment
234        LOCAL_ENV_PATH="${ENV_PATH}.local"
235        if [[ -f "$LOCAL_ENV_PATH" ]]; then
236          source "$LOCAL_ENV_PATH"
237        fi
238
239        if [[ -n "$NODE_BINARY" && -x "$NODE_BINARY" ]]; then
240          echo "Node found at: ${NODE_BINARY}"
241        else
242          cat >&2 << NODE_NOT_FOUND
243      error: Could not find "node" executable while running an Xcode build script.
244      You need to specify the path to your Node.js executable by defining an environment variable named NODE_BINARY in your project's .xcode.env or .xcode.env.local file.
245      You can set this up quickly by running:
246
247      echo "export NODE_BINARY=\\$(command -v node)" >> .xcode.env
248
249      in the ios folder of your project.
250      NODE_NOT_FOUND
251
252          exit 1
253        fi
254
255        # Execute argument, if present
256        if [[ "$#" -gt 0 ]]; then
257          "$NODE_BINARY" "$@"
258        fi
259      }
260
261      with_node --no-warnings --eval "require(\'expo-modules-autolinking\')(process.argv.slice(1))" generate-package-list #{args} --target "#{modules_provider_path}"
262      SUPPORT_SCRIPT
263    end
264
265  end # module ProjectIntegrator
266end # module Expo
267