UNPKG

react-native

Version:

A framework for building native apps using React

379 lines (351 loc) • 11.6 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format */ 'use strict'; /** * The iOS platform floor of the SwiftPM manifests React Native generates. * SwiftPM refuses to link a product whose minimum is above the depending * target's, so the floor tracks the app's own deployment target rather than a * hardcoded value a dependency (every Expo package: iOS 16.4) can exceed. */ const {targetBuildConfigUuids} = require('./generate-spm-xcodeproj'); const { findApplicationTargets, findField, findObjectByUuid, findProjectObject, forEachObjectInSection, uuidsInArray, } = require('./spm-pbxproj'); const fs = require('node:fs'); const path = require('node:path'); // Keep in sync with `min_ios_version_supported` in scripts/cocoapods/helpers.rb. const MIN_IOS_VERSION_SUPPORTED /*: string */ = '15.1'; const IOS_VERSION_RE = /^\d+(\.\d+){0,2}$/; const DEPLOYMENT_TARGET_KEY = 'IPHONEOS_DEPLOYMENT_TARGET'; // An xcconfig may `#include` another; cap the chain instead of trusting it. const MAX_XCCONFIG_DEPTH = 16; /*:: // Everything the reader needs to follow a configuration's xcconfig. Without a // srcRoot the xcconfig step is skipped (a caller with only pbxproj text). type XcconfigContext = {srcRoot: ?string, readFile: (absPath: string) => ?string}; */ function normalizeIosVersion(version /*: string */) /*: string */ { return version.includes('.') ? version : `${version}.0`; } function segment( parts /*: Array<string> */, index /*: number */, ) /*: number */ { return index < parts.length ? Number(parts[index]) : 0; } /** Numeric, not lexicographic: `9.0` < `10.0` and `16.4` < `16.10`. */ function compareIosVersions(a /*: string */, b /*: string */) /*: number */ { const left = a.split('.'); const right = b.split('.'); for (let i = 0; i < Math.max(left.length, right.length); i++) { const delta = segment(left, i) - segment(right, i); if (delta !== 0) { return delta < 0 ? -1 : 1; } } return 0; } function unquote(value /*: string */) /*: string */ { return value.replace(/^"|"$/g, ''); } /** * A version reduced to something safe to interpolate into a manifest: * normalized, and never below React Native's own minimum. */ function sanitizeIosDeploymentTarget(raw /*: ?string */) /*: string */ { if (raw == null || !IOS_VERSION_RE.test(raw)) { return MIN_IOS_VERSION_SUPPORTED; } const version = normalizeIosVersion(raw); return compareIosVersions(version, MIN_IOS_VERSION_SUPPORTED) > 0 ? version : MIN_IOS_VERSION_SUPPORTED; } function defaultReadFile(absPath /*: string */) /*: ?string */ { try { return fs.readFileSync(absPath, 'utf8'); } catch { return null; } } /** * `IPHONEOS_DEPLOYMENT_TARGET` as an xcconfig defines it, following `#include` * lines in place so a later assignment in the includer wins. Null when the file * is unreadable, sets no unconditional value, or sets one that is not a plain * version (`$(inherited)`). */ function parseXcconfigSetting( absPath /*: string */, content /*: string */, readFile /*: (absPath: string) => ?string */, visited /*: Set<string> */ = new Set(), depth /*: number */ = 0, ) /*: ?string */ { if (visited.has(absPath)) { return null; } visited.add(absPath); const assignment = new RegExp(`^${DEPLOYMENT_TARGET_KEY}\\s*=\\s*([^;]*);?$`); let value /*: ?string */ = null; for (const rawLine of content.split('\n')) { const line = rawLine.replace(/\/\/.*$/, '').trim(); const include = line.match(/^#include\??\s+"([^"]+)"/); if (include != null) { const included = readXcconfigSetting( path.resolve(path.dirname(absPath), include[1]), readFile, visited, depth + 1, ); if (included != null) { value = included; } continue; } const match = line.match(assignment); if (match != null) { const raw = unquote(match[1].trim()); value = IOS_VERSION_RE.test(raw) ? raw : null; } } return value; } /** parseXcconfigSetting for a path not read yet — the `#include` entry point. */ function readXcconfigSetting( absPath /*: string */, readFile /*: (absPath: string) => ?string */, visited /*: Set<string> */, depth /*: number */, ) /*: ?string */ { if (depth > MAX_XCCONFIG_DEPTH) { return null; } const content = readFile(absPath); return content != null ? parseXcconfigSetting(absPath, content, readFile, visited, depth) : null; } /** * Directory components of the PBXGroup chain holding `uuid`, outermost first — * how a `"<group>"` file reference's path is anchored to the project dir. */ function groupPathPrefix( text /*: string */, uuid /*: string */, ) /*: Array<string> */ { const groups /*: Array<{uuid: string, path: ?string, children: Set<string>}> */ = []; forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { const children = findField(text, body, 'children'); const groupPath = findField(text, body, 'path'); groups.push({ uuid: groupUuid, path: groupPath != null ? unquote(groupPath.value) : null, children: children != null ? uuidsInArray(children.value) : new Set(), }); }); const parts /*: Array<string> */ = []; let current = uuid; for (let i = 0; i < groups.length; i++) { const parent = groups.find(group => group.children.has(current)); if (parent == null) { break; } if (parent.path != null) { parts.unshift(parent.path); } current = parent.uuid; } return parts; } /** * Absolute paths to try for the xcconfig a configuration is based on, in order: * the reference's own anchoring first, then plain `<srcRoot>/<path>`. */ function xcconfigCandidates( text /*: string */, configObj /*: {bodyOpen: number, bodyClose: number, ...} */, srcRoot /*: string */, ) /*: Array<string> */ { const base = findField(text, configObj, 'baseConfigurationReference'); const refUuid = base?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; if (refUuid == null) { return []; } const ref = findObjectByUuid(text, refUuid); if (ref == null) { return []; } const pathField = findField(text, ref, 'path'); if (pathField == null) { return []; } const refPath = unquote(pathField.value); if (path.isAbsolute(refPath)) { return [refPath]; } const sourceTreeField = findField(text, ref, 'sourceTree'); const sourceTree = sourceTreeField != null ? unquote(sourceTreeField.value) : ''; const fallback = path.join(srcRoot, refPath); if (sourceTree !== '<group>') { return [fallback]; } const anchored = path.join( srcRoot, ...groupPathPrefix(text, refUuid), refPath, ); return anchored === fallback ? [fallback] : [anchored, fallback]; } function configName( text /*: string */, configObj /*: {bodyOpen: number, bodyClose: number, ...} */, ) /*: ?string */ { const field = findField(text, configObj, 'name'); return field != null ? unquote(field.value) : null; } /** A plain-version `IPHONEOS_DEPLOYMENT_TARGET` literal in a configuration. */ function configLiteral( text /*: string */, configObj /*: {bodyOpen: number, bodyClose: number, ...} */, ) /*: ?string */ { const settings = findField(text, configObj, 'buildSettings'); if (settings == null) { return null; } const field = findField( text, {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1}, DEPLOYMENT_TARGET_KEY, ); if (field == null) { return null; } const raw = unquote(field.value); return IOS_VERSION_RE.test(raw) ? raw : null; } /** * What one configuration declares: its own literal, else the xcconfig it is * based on (Xcode's own precedence). */ function configDeploymentTarget( text /*: string */, configObj /*: {bodyOpen: number, bodyClose: number, ...} */, ctx /*: XcconfigContext */, ) /*: ?string */ { const literal = configLiteral(text, configObj); if (literal != null || ctx.srcRoot == null) { return literal; } for (const candidate of xcconfigCandidates(text, configObj, ctx.srcRoot)) { const content = ctx.readFile(candidate); if (content != null) { return parseXcconfigSetting(candidate, content, ctx.readFile); } } return null; } /** * The app's declared iOS deployment target, or null when nothing declares one. * Target selection: the marker's `targetUuid`, else the app target named * `targetName` (`--product-name`), else every app target. Each configuration * falls back to the project-level one of the same name (each level accepting a * literal or the xcconfig it is based on), and the lowest wins — a single * manifest floor has to hold for every configuration. */ function readIosDeploymentTargetFromPbxproj( text /*: string */, opts /*:: ?: { targetUuid?: ?string, targetName?: ?string, srcRoot?: ?string, readFile?: (absPath: string) => ?string, } */, ) /*: ?string */ { const ctx /*: XcconfigContext */ = { srcRoot: opts?.srcRoot, readFile: opts?.readFile ?? defaultReadFile, }; const targetUuid = opts?.targetUuid; const marked = targetUuid != null ? findObjectByUuid(text, targetUuid) : null; const apps = findApplicationTargets(text); const named = opts?.targetName != null ? apps.find(app => app.name === opts.targetName) : null; const targets = marked != null ? [marked] : named != null ? [named] : apps; const projectDefaults /*: Map<string, string> */ = new Map(); const project = findProjectObject(text); if (project != null) { for (const uuid of targetBuildConfigUuids(text, project)) { const config = findObjectByUuid(text, uuid); const name = config != null ? configName(text, config) : null; const value = config != null ? configDeploymentTarget(text, config, ctx) : null; if (name != null && value != null) { projectDefaults.set(name, value); } } } let floor /*: ?string */ = null; for (const target of targets) { for (const uuid of targetBuildConfigUuids(text, target)) { const config = findObjectByUuid(text, uuid); if (config == null) { continue; } const name = configName(text, config); const value = configDeploymentTarget(text, config, ctx) ?? (name != null ? projectDefaults.get(name) : null) ?? null; if ( value != null && (floor == null || compareIosVersions(value, floor) < 0) ) { floor = value; } } } return floor; } /** * The sanitized floor read from `<xcodeprojPath>/project.pbxproj`, or null when * there is no project, it cannot be read, or it declares nothing usable. */ function resolveIosDeploymentTarget( opts /*: {xcodeprojPath: ?string, targetUuid?: ?string, targetName?: ?string} */, ) /*: ?string */ { const {xcodeprojPath, targetUuid, targetName} = opts; if (xcodeprojPath == null) { return null; } try { const found = readIosDeploymentTargetFromPbxproj( fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'), {targetUuid, targetName, srcRoot: path.dirname(xcodeprojPath)}, ); return found != null ? sanitizeIosDeploymentTarget(found) : null; } catch { return null; } } module.exports = { MIN_IOS_VERSION_SUPPORTED, readIosDeploymentTargetFromPbxproj, resolveIosDeploymentTarget, sanitizeIosDeploymentTarget, };