bugsnag/bugsnag-js

View on GitHub
packages/react-native-cli/src/lib/Xcode.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import { Logger } from '../Logger'
import { promises as fs } from 'fs'
import path from 'path'
import xcode from 'xcode'
import semver from 'semver'

const DOCS_LINK = 'https://docs.bugsnag.com/platforms/react-native/react-native/showing-full-stacktraces/#ios'
const UNLOCATED_PROJ_MSG = `The Xcode project was not in the expected location and so couldn't be updated automatically.

Please see ${DOCS_LINK} for more information`

const EXTRA_INPUT_FILES = ['"$(SRCROOT)/.xcode.env.local"', '"$(SRCROOT)/.xcode.env"']
const EXTRA_PACKAGER_ARGS = 'export SOURCE_MAP_PATH=$(pwd)/build/sourcemaps\nif [ ! -d "$SOURCE_MAP_PATH" ]; then\n\tmkdir -p "$SOURCE_MAP_PATH";\nfi\nexport EXTRA_PACKAGER_ARGS="--sourcemap-output $(pwd)/build/sourcemaps/main.jsbundle.map"'

export async function updateXcodeProject (projectRoot: string, endpoint: string|undefined, reactNativeVersion: string|undefined, logger: Logger) {
  const iosDir = path.join(projectRoot, 'ios')
  const xcodeprojDir = (await fs.readdir(iosDir)).find(p => p.endsWith('.xcodeproj'))

  if (!xcodeprojDir) {
    logger.warn(UNLOCATED_PROJ_MSG)
    return
  }

  const pbxProjPath = path.join(iosDir, xcodeprojDir, 'project.pbxproj')
  const proj = xcode.project(pbxProjPath)

  await new Promise<void>((resolve, reject) => {
    proj.parse((err) => {
      if (err) return reject(err)
      resolve()
    })
  })

  const buildPhaseMap = proj?.hash?.project?.objects?.PBXShellScriptBuildPhase || []
  logger.info('Ensuring React Native build phase outputs source maps')

  const didUpdate = await updateBuildReactNativeTask(buildPhaseMap, iosDir, reactNativeVersion, logger)

  if (!didUpdate) return

  await fs.writeFile(pbxProjPath, proj.writeSync(), 'utf8')
  logger.success('Written changes to Xcode project')
}

async function updateBuildReactNativeTask (buildPhaseMap: Record<string, Record<string, unknown>>, iosDir: string, reactNativeVersion: string | undefined, logger: Logger): Promise<boolean> {
  let didAnythingUpdate = false
  let didThisUpdate

  for (const shellBuildPhaseKey in buildPhaseMap) {
    const phase = buildPhaseMap[shellBuildPhaseKey]
    // The shell script can vary slightly... Vanilla RN projects contain
    //   ../node_modules/react-native/scripts/react-native-xcode.sh
    // and ejected Expo projects contain
    //   `node --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'"`
    // so we need a little leniency
    if (typeof phase.shellScript === 'string' && phase.shellScript.includes('/react-native-xcode.sh')) {
      if (reactNativeVersion) {
        // If we're dealing with RN >= 0.69.0 setup the .xcode.env file to export the source maps to our happy path
        if (semver.gte(reactNativeVersion, '0.69.0')) {
          [phase.inputPaths, didThisUpdate] = addExtraInputFiles(shellBuildPhaseKey, phase.inputPaths as string[], logger)
          if (didThisUpdate) {
            didAnythingUpdate = true
          }
        } else {
          // If we're dealing with RN < 0.69.0 add the extra package arguments to the xcode build phase as use of the .xcode.env file is not supported.
          [phase.shellScript, didThisUpdate] = addExtraPackagerArgs(shellBuildPhaseKey, phase.shellScript, logger)
          if (didThisUpdate) {
            didAnythingUpdate = true
          }
        }
      }
    }
  }

  await updateXcodeEnv(iosDir, logger)

  return didAnythingUpdate
}

function addExtraInputFiles (phaseId: string, existingInputFiles: string[], logger: Logger): [string[], boolean] {
  if (arrayContainsElements(existingInputFiles, EXTRA_INPUT_FILES)) {
    logger.info(`The "Bundle React Native Code and Images" build phase (${phaseId}) already includes the required arguments`)
    return [existingInputFiles, false]
  }
  return [EXTRA_INPUT_FILES.concat(existingInputFiles), true]
}

function arrayContainsElements (mainArray: any[], subArray: any[]): boolean {
  return subArray.every(element => mainArray.some(mainElement => mainElement === element))
}

function addExtraPackagerArgs (phaseId: string, existingShellScript: string, logger: Logger): [string, boolean] {
  const parsedExistingShellScript = JSON.parse(existingShellScript) as string
  if (parsedExistingShellScript.includes(EXTRA_PACKAGER_ARGS)) {
    logger.info(`The "Bundle React Native Code and Images" build phase (${phaseId}) already includes the required arguments`)
    return [existingShellScript, false]
  }
  const scriptLines = parsedExistingShellScript.split('\n')
  return [JSON.stringify([EXTRA_PACKAGER_ARGS].concat(scriptLines).join('\n')), true]
}

async function updateXcodeEnv (iosDir: string, logger: Logger): Promise<boolean> {
  const searchString = 'SOURCEMAP_FILE='
  const envFilePath = path.join(iosDir, '.xcode.env')

  try {
    await fs.readFile(envFilePath)

    const xcodeEnvData = await fs.readFile(envFilePath, 'utf8')

    if (xcodeEnvData?.includes(searchString)) {
      logger.warn(`The .xcode.env file already contains a section for "${searchString}"`)
      return false
    } else {
      const newData = `${xcodeEnvData}\n\n# React Native Source Map File\nexport SOURCE_MAP_PATH=$(pwd)/build/sourcemaps\nif [ ! -d "$SOURCE_MAP_PATH" ]; then\n\tmkdir -p "$SOURCE_MAP_PATH";\nfi\nexport ${searchString}$(pwd)/build/sourcemaps/main.jsbundle.map`
      await fs.writeFile(envFilePath, newData, 'utf8')
      return true
    }
  } catch (error) {
    if (error.code === 'ENOENT') {
      logger.info(`Creating a new .xcode.env file at ${envFilePath}`)
      const newData = `export NODE_BINARY=$(command -v node)\n\n# React Native Source Map File\nexport SOURCE_MAP_PATH=$(pwd)/build/sourcemaps\nif [ ! -d "$SOURCE_MAP_PATH" ]; then\n\tmkdir -p "$SOURCE_MAP_PATH";\nfi\nexport ${searchString}$(pwd)/build/sourcemaps/main.jsbundle.map`

      await fs.writeFile(envFilePath, newData, 'utf8')
      return true
    } else {
      console.error(`Error updating .xcode.env file: ${error.message}`)
      return false
    }
  }
}