Skip to content

Commit 5ced519

Browse files
authored
refactor(apple): port some CocoaPods scripts to JS (#2412)
This commit adds JS ports of: - `ios/assets_catalog.rb` - `ios/xcode.rb`
1 parent 519d270 commit 5ced519

File tree

9 files changed

+1003
-0
lines changed

9 files changed

+1003
-0
lines changed

ios/assetsCatalog.mjs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// @ts-check
2+
import { spawnSync } from "node:child_process";
3+
import * as nodefs from "node:fs";
4+
import * as path from "node:path";
5+
import { sourceForAppConfig } from "../scripts/appConfig.mjs";
6+
import { readJSONFile } from "../scripts/helpers.js";
7+
import { isObject, projectPath } from "./utils.mjs";
8+
9+
/**
10+
* @import { ApplePlatform, JSONObject, JSONValue } from "../scripts/types.js";
11+
*
12+
* @typedef {{ filename: string; }} Icon;
13+
*/
14+
15+
const ALTERNATE_ICONS_KEY = "alternateIcons";
16+
const APP_ICON_KEY = "AppIcon";
17+
const PRIMARY_ICON_KEY = "primaryIcon";
18+
19+
/**
20+
* @param {JSONValue} value
21+
* @returns {value is Icon}
22+
*/
23+
function isIcon(value) {
24+
return Boolean(value && typeof value === "object" && "filename" in value);
25+
}
26+
27+
/**
28+
* @param {[string, JSONValue]} entry
29+
* @returns {entry is [string, Icon]}
30+
*/
31+
function isIconEntry(entry) {
32+
const [, value] = entry;
33+
return isIcon(value);
34+
}
35+
36+
/**
37+
* @param {JSONValue} icons
38+
* @returns {[string, Icon][]}
39+
*/
40+
function parseAlternateIcons(icons) {
41+
if (!isObject(icons)) {
42+
return [];
43+
}
44+
45+
/** @type {[string, Icon][]} */
46+
const sanitizedIcons = [];
47+
for (const entry of Object.entries(icons)) {
48+
if (entry[0] !== APP_ICON_KEY && isIconEntry(entry)) {
49+
sanitizedIcons.push(entry);
50+
}
51+
}
52+
53+
return sanitizedIcons;
54+
}
55+
56+
/**
57+
* @param {string} input
58+
* @param {string} output
59+
* @param {number} width
60+
* @param {number} height
61+
*/
62+
function resampleImage(input, output, width, height) {
63+
const args = [
64+
"--resampleHeightWidth",
65+
height.toString(),
66+
width.toString(),
67+
"--out",
68+
output,
69+
input,
70+
];
71+
spawnSync("sips", args, { stdio: "inherit" });
72+
}
73+
74+
/**
75+
* @param {JSONObject} appConfig
76+
* @param {ApplePlatform} targetPlatform
77+
* @param {string} destination
78+
* @returns {void}
79+
*/
80+
export function generateAssetsCatalogs(
81+
appConfig,
82+
targetPlatform,
83+
destination,
84+
resizeImage = resampleImage,
85+
fs = nodefs
86+
) {
87+
const xcassets_src = projectPath(
88+
"ReactTestApp/Assets.xcassets",
89+
targetPlatform
90+
);
91+
const xcassets_dst = path.join(destination, path.basename(xcassets_src));
92+
93+
fs.rmSync(xcassets_dst, { force: true, maxRetries: 3, recursive: true });
94+
fs.cpSync(xcassets_src, xcassets_dst, { recursive: true });
95+
96+
const platformConfig = appConfig[targetPlatform];
97+
if (!isObject(platformConfig)) {
98+
return;
99+
}
100+
101+
const icons = platformConfig["icons"];
102+
if (!isObject(icons)) {
103+
return;
104+
}
105+
106+
const primaryIcon = icons[PRIMARY_ICON_KEY];
107+
if (!isIcon(primaryIcon)) {
108+
return;
109+
}
110+
111+
const appIcons = parseAlternateIcons(icons[ALTERNATE_ICONS_KEY]);
112+
appIcons.push([APP_ICON_KEY, primaryIcon]);
113+
114+
const templateIconSet = "AppIcon.appiconset";
115+
const template = readJSONFile(
116+
path.join(xcassets_src, templateIconSet, "Contents.json")
117+
);
118+
if (!Array.isArray(template.images)) {
119+
throw new Error(`${templateIconSet}: Expected 'images' to be an array`);
120+
}
121+
122+
const appManifestDir = sourceForAppConfig(appConfig);
123+
const mkdirOptions = { recursive: true, mode: 0o755 };
124+
125+
for (const [setName, appIcon] of appIcons) {
126+
const appIconSet = path.join(destination, `${setName}.appiconset`);
127+
fs.mkdirSync(appIconSet, mkdirOptions);
128+
129+
const icon = path.join(appManifestDir, appIcon.filename);
130+
const extname = path.extname(icon);
131+
const basename = path.basename(icon, extname);
132+
133+
/** @type {Icon[]} */
134+
const images = [];
135+
136+
for (const image of template.images) {
137+
const { scale, size } = image;
138+
const [width, height] = size.split("x");
139+
const filename = `${basename}-${height}@${scale}${extname}`;
140+
images.push({ filename, ...image });
141+
142+
const output = path.join(appIconSet, filename);
143+
const x = parseFloat(scale);
144+
resizeImage(icon, output, Number(width) * x, Number(height) * x);
145+
}
146+
147+
const contents = { images, info: template["info"] };
148+
const dest = path.join(appIconSet, "Contents.json");
149+
fs.writeFileSync(dest, JSON.stringify(contents, undefined, 2));
150+
}
151+
}

ios/utils.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// @ts-check
2+
import * as path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
/**
6+
* @typedef {import("../scripts/types.ts").ApplePlatform} ApplePlatform;
7+
* @typedef {import("../scripts/types.ts").JSONObject} JSONObject;
8+
* @typedef {import("../scripts/types.ts").JSONValue} JSONValue;
9+
*/
10+
11+
/**
12+
* @param {JSONValue} obj
13+
* @returns {obj is JSONObject}
14+
*/
15+
export function isObject(obj) {
16+
return Boolean(obj && typeof obj === "object" && !Array.isArray(obj));
17+
}
18+
19+
/**
20+
* @param {string} p
21+
* @param {ApplePlatform} targetPlatform
22+
* @returns {string}
23+
*/
24+
export function projectPath(p, targetPlatform) {
25+
const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
26+
return path.join(packageDir, targetPlatform, p);
27+
}

ios/xcode.mjs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @ts-check
2+
import { XMLBuilder, XMLParser } from "fast-xml-parser";
3+
import * as nodefs from "node:fs";
4+
import * as path from "node:path";
5+
import { readTextFile } from "../scripts/helpers.js";
6+
import { isObject } from "./utils.mjs";
7+
8+
/**
9+
* @import { XmlBuilderOptions } from "fast-xml-parser";
10+
* @import { ApplePlatform, JSONObject } from "../scripts/types.js";
11+
*
12+
* @typedef {Pick<
13+
* Required<XmlBuilderOptions>,
14+
* "attributeNamePrefix" | "ignoreAttributes" | "format" | "indentBy"
15+
* >} XmlOptions;
16+
*/
17+
18+
export const IPHONEOS_DEPLOYMENT_TARGET = "IPHONEOS_DEPLOYMENT_TARGET";
19+
export const MACOSX_DEPLOYMENT_TARGET = "MACOSX_DEPLOYMENT_TARGET";
20+
export const XROS_DEPLOYMENT_TARGET = "XROS_DEPLOYMENT_TARGET";
21+
22+
export const CODE_SIGN_ENTITLEMENTS = "CODE_SIGN_ENTITLEMENTS";
23+
export const CODE_SIGN_IDENTITY = "CODE_SIGN_IDENTITY";
24+
export const DEVELOPMENT_TEAM = "DEVELOPMENT_TEAM";
25+
export const ENABLE_TESTING_SEARCH_PATHS = "ENABLE_TESTING_SEARCH_PATHS";
26+
export const GCC_PREPROCESSOR_DEFINITIONS = "GCC_PREPROCESSOR_DEFINITIONS";
27+
export const OTHER_SWIFT_FLAGS = "OTHER_SWIFT_FLAGS";
28+
export const PRODUCT_BUILD_NUMBER = "PRODUCT_BUILD_NUMBER";
29+
export const PRODUCT_BUNDLE_IDENTIFIER = "PRODUCT_BUNDLE_IDENTIFIER";
30+
export const PRODUCT_DISPLAY_NAME = "PRODUCT_DISPLAY_NAME";
31+
export const PRODUCT_VERSION = "PRODUCT_VERSION";
32+
export const USER_HEADER_SEARCH_PATHS = "USER_HEADER_SEARCH_PATHS";
33+
export const WARNING_CFLAGS = "WARNING_CFLAGS";
34+
35+
/**
36+
* @param {JSONObject} appConfig
37+
* @param {ApplePlatform} targetPlatform
38+
* @param {string} xcodeproj
39+
* @returns {void}
40+
*/
41+
export function configureXcodeSchemes(
42+
appConfig,
43+
targetPlatform,
44+
xcodeproj,
45+
fs = nodefs
46+
) {
47+
const xcschemesDir = path.join(xcodeproj, "xcshareddata", "xcschemes");
48+
const xcscheme = path.join(xcschemesDir, "ReactTestApp.xcscheme");
49+
50+
const platformConfig = appConfig[targetPlatform];
51+
const metalApiValidation =
52+
!isObject(platformConfig) || platformConfig["metalAPIValidation"];
53+
54+
// Oddly enough, to disable Metal API validation, we need to add
55+
// `enableGPUValidationMode = "1"` to the xcscheme Launch Action.
56+
if (metalApiValidation === false) {
57+
/** @type {XmlOptions} */
58+
const xmlOptions = {
59+
attributeNamePrefix: "@_",
60+
ignoreAttributes: false,
61+
format: true,
62+
indentBy: " ", // Xcode uses three spaces
63+
};
64+
65+
const parser = new XMLParser(xmlOptions);
66+
const xml = parser.parse(readTextFile(xcscheme, fs));
67+
68+
const key = xmlOptions.attributeNamePrefix + "enableGPUValidationMode";
69+
xml.Scheme.LaunchAction[key] = "1";
70+
71+
const builder = new XMLBuilder(xmlOptions);
72+
fs.writeFileSync(xcscheme, builder.build(xml));
73+
}
74+
75+
const { name } = appConfig;
76+
if (typeof name === "string" && name) {
77+
// Make a copy of `ReactTestApp.xcscheme` with the app name for convenience.
78+
fs.copyFileSync(xcscheme, path.join(xcschemesDir, `${name}.xcscheme`));
79+
}
80+
}
81+
82+
/**
83+
* @param {JSONObject} buildSettings
84+
* @param {JSONObject} overrides
85+
* @returns {JSONObject}
86+
*/
87+
export function overrideBuildSettings(buildSettings, overrides) {
88+
for (const [key, value] of Object.entries(overrides)) {
89+
buildSettings[key] = value;
90+
}
91+
return buildSettings;
92+
}

scripts/appConfig.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// @ts-check
2+
import * as nodefs from "node:fs";
3+
import { findFile, readJSONFile } from "./helpers.js";
4+
5+
/** @import { JSONObject } from "../scripts/types.js"; */
6+
7+
const SOURCE_KEY = Symbol.for("source");
8+
9+
/** @type {JSONObject} */
10+
let appConfig;
11+
12+
export function loadAppConfig(startDir = process.cwd(), fs = nodefs) {
13+
if (!appConfig) {
14+
const configFile = findFile("app.json", startDir, fs);
15+
appConfig = configFile ? readJSONFile(configFile) : {};
16+
appConfig[SOURCE_KEY] = configFile || startDir;
17+
}
18+
return appConfig;
19+
}
20+
21+
/**
22+
* @param {JSONObject} appConfig
23+
* @returns {string}
24+
*/
25+
export function sourceForAppConfig(appConfig) {
26+
const source = appConfig[SOURCE_KEY];
27+
if (typeof source !== "string") {
28+
throw new Error("Source for app config should've been set");
29+
}
30+
return source;
31+
}

scripts/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
export type JSONValue =
2+
| string
3+
| number
4+
| boolean
5+
| JSONArray
6+
| JSONObject
7+
| null;
8+
9+
export type JSONArray = JSONValue[];
10+
export type JSONObject = { [key: string | symbol]: JSONValue };
11+
112
/********************************
213
* android/android-manifest.mjs *
314
********************************/

test/fs.mock.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { fs as memfs, vol } from "memfs";
44

55
export const fs = memfs as unknown as typeof import("node:fs");
66

7+
// Stub `cpSync` until memfs implements it
8+
fs.cpSync = fs.cpSync ?? (() => undefined);
9+
710
export function setMockFiles(files: DirectoryJSON = {}) {
811
vol.reset();
912
vol.fromJSON(files);

0 commit comments

Comments
 (0)