react-native
Version:
A framework for building native apps using React
332 lines (307 loc) • 10.1 kB
JavaScript
/**
* 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,
} = require('./spm-pbxproj');
const {
readXcconfig,
xcconfigIncludePath,
xcconfigReferencePaths,
} = require('./xcconfig-files');
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;
}
/**
* `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 includePath = xcconfigIncludePath(absPath, line);
if (includePath != null) {
const included = readXcconfigSetting(
includePath,
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;
}
/**
* 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 sourceTreeField = findField(text, ref, 'sourceTree');
return xcconfigReferencePaths(
text,
{
uuid: refUuid,
path: unquote(pathField.value),
sourceTree: sourceTreeField != null ? unquote(sourceTreeField.value) : '',
},
srcRoot,
);
}
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 ?? readXcconfig,
};
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,
};