apple-targets-hugo-patch
Version:
Generate Apple Targets with Expo Prebuild
331 lines (330 loc) • 17.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const config_plugins_1 = require("@expo/config-plugins");
const plist_1 = __importDefault(require("@expo/plist"));
const fs_1 = __importDefault(require("fs"));
const glob_1 = require("glob");
const path_1 = __importDefault(require("path"));
const chalk_1 = __importDefault(require("chalk"));
const withIosColorset_1 = require("./colorset/withIosColorset");
const withImageAsset_1 = require("./icon/withImageAsset");
const withIosIcon_1 = require("./icon/withIosIcon");
const target_1 = require("./target");
const withEasCredentials_1 = require("./withEasCredentials");
const withXcodeChanges_1 = require("./withXcodeChanges");
const DEFAULT_DEPLOYMENT_TARGET = "18.0";
function memoize(fn) {
const cache = new Map();
return ((...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
});
}
const warnOnce = memoize(console.warn);
const logOnce = memoize(console.log);
function createLogQueue() {
const queue = [];
const flush = () => {
queue.forEach((fn) => fn());
queue.length = 0;
};
return {
flush,
add: (fn) => {
queue.push(fn);
},
};
}
// Queue up logs so they only run when prebuild is actually running and not during standard config reads.
const prebuildLogQueue = createLogQueue();
const withWidget = (config, props) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
prebuildLogQueue.add(() => warnOnce((0, chalk_1.default) `\nUsing experimental Config Plugin {bold @bacons/apple-targets} that is subject to breaking changes.`));
// TODO: Magically based on the top-level folders in the `ios-widgets/` folder
if (props.icon && !/https?:\/\//.test(props.icon)) {
props.icon = path_1.default.join(props.directory, props.icon);
}
// This value should be used for the target name and other internal uses.
const targetDirName = path_1.default.basename(path_1.default.dirname(props.configPath));
// Sanitized for general usage. This name just needs to resemble the input value since it shouldn't be used for user-facing values such as the home screen or app store.
const productName = sanitizeNameForNonDisplayUse(props.name || targetDirName) ||
sanitizeNameForNonDisplayUse(targetDirName) ||
sanitizeNameForNonDisplayUse(props.type);
// This should never happen.
if (!productName) {
throw new Error(`[bacons/apple-targets][${props.type}] Target name does not contain any valid characters: ${targetDirName}`);
}
// TODO: Are there characters that aren't allowed in `CFBundleDisplayName`?
const targetDisplayName = (_a = props.name) !== null && _a !== void 0 ? _a : productName;
const targetDirAbsolutePath = path_1.default.join((_c = (_b = config._internal) === null || _b === void 0 ? void 0 : _b.projectRoot) !== null && _c !== void 0 ? _c : "", props.directory);
const entitlementsFiles = (0, glob_1.globSync)("*.entitlements", {
absolute: true,
cwd: targetDirAbsolutePath,
});
if (entitlementsFiles.length > 1) {
throw new Error(`[bacons/apple-targets][${props.type}] Found more than one '*.entitlements' file in ${targetDirAbsolutePath}`);
}
let entitlementsJson = props.entitlements;
if (entitlementsJson) {
// Apply default entitlements that must be present for a target to work.
const applyDefaultEntitlements = (entitlements) => {
var _a, _b, _c, _d, _e, _f;
if (props.type === "clip") {
entitlements["com.apple.developer.parent-application-identifiers"] = [
`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
];
// Try to extract the linked website from the original associated domains:
const associatedDomainsKey = "com.apple.developer.associated-domains";
// If the target doesn't explicitly define associated domains, then try to use the main app's associated domains.
if (!entitlements[associatedDomainsKey]) {
const associatedDomains = (_b = (_a = config.ios) === null || _a === void 0 ? void 0 : _a.associatedDomains) !== null && _b !== void 0 ? _b : (_d = (_c = config.ios) === null || _c === void 0 ? void 0 : _c.entitlements) === null || _d === void 0 ? void 0 : _d["com.apple.developer.associated-domains"];
if (!associatedDomains ||
!Array.isArray(associatedDomains) ||
associatedDomains.length === 0) {
warnOnce((0, chalk_1.default) `{yellow [${targetDirName}]} Apple App Clip may require the associated domains entitlement but none were found in the Expo config.\nExample:\n${JSON.stringify({
ios: {
associatedDomains: [`applinks:placeholder.expo.app`],
},
}, null, 2)}`);
}
else {
// Associated domains are found:
// "applinks:pillarvalley.expo.app",
// "webcredentials:pillarvalley.expo.app",
// "activitycontinuation:pillarvalley.expo.app"
const sanitizedUrls = associatedDomains
.map((url) => {
return (url
.replace(/^(appclips|applinks|webcredentials|activitycontinuation):/, "")
// Remove trailing slashes
.replace(/\/$/, "")
// Remove http/https
.replace(/^https?:\/\//, ""));
})
.filter(Boolean);
const unique = [...new Set(sanitizedUrls)];
if (unique.length) {
warnOnce((0, chalk_1.default) `{gray [${targetDirName}]} Apple App Clip expo-target.config.js missing associated domains entitlements in the target config. Using the following defaults:\n${JSON.stringify({
entitlements: {
[associatedDomainsKey]: [
`appclips:${unique[0] || "mywebsite.expo.app"}`,
],
},
}, null, 2)}`);
// Add anyways
entitlements[associatedDomainsKey] = unique.map((url) => `appclips:${url}`);
}
}
}
// NOTE: This doesn't seem to be required anymore (Oct 12 2024):
// entitlements["com.apple.developer.on-demand-install-capable"] = true;
}
const APP_GROUP_KEY = "com.apple.security.application-groups";
const hasDefinedAppGroupsManually = APP_GROUP_KEY in entitlements;
if (
// If the user hasn't manually defined the app groups array.
!hasDefinedAppGroupsManually &&
// And the target is part of a predefined list of types that benefit from app groups that match the main app...
target_1.SHOULD_USE_APP_GROUPS_BY_DEFAULT[props.type]) {
const mainAppGroups = (_f = (_e = config.ios) === null || _e === void 0 ? void 0 : _e.entitlements) === null || _f === void 0 ? void 0 : _f[APP_GROUP_KEY];
if (Array.isArray(mainAppGroups) && mainAppGroups.length > 0) {
// Then set the target app groups to match the main app.
entitlements[APP_GROUP_KEY] = mainAppGroups;
prebuildLogQueue.add(() => {
logOnce((0, chalk_1.default) `[${targetDirName}] Syncing app groups with main app. {dim Define entitlements[${JSON.stringify(APP_GROUP_KEY)}] in the {bold expo-target.config} file to override.}`);
});
}
else {
prebuildLogQueue.add(() => {
var _a, _b;
return warnOnce((0, chalk_1.default) `{yellow [${targetDirName}]} Apple target may require the App Groups entitlement but none were found in the Expo config.\nExample:\n${JSON.stringify({
ios: {
entitlements: {
[APP_GROUP_KEY]: [
`group.${(_b = (_a = config.ios) === null || _a === void 0 ? void 0 : _a.bundleIdentifier) !== null && _b !== void 0 ? _b : `com.example.${config.slug}`}`,
],
},
},
}, null, 2)}`);
});
}
}
return entitlements;
};
entitlementsJson = applyDefaultEntitlements(entitlementsJson);
}
// If the user defined entitlements, then overwrite any existing entitlements file
if (entitlementsJson) {
(0, config_plugins_1.withDangerousMod)(config, [
"ios",
async (config) => {
var _a;
const GENERATED_ENTITLEMENTS_FILE_NAME = "generated.entitlements";
const entitlementsFilePath = (_a = entitlementsFiles[0]) !== null && _a !== void 0 ? _a :
// Use the name `generated` to help indicate that this file should be in sync with the config
path_1.default.join(targetDirAbsolutePath, GENERATED_ENTITLEMENTS_FILE_NAME);
if (entitlementsFiles[0]) {
const relativeName = path_1.default.relative(targetDirAbsolutePath, entitlementsFiles[0]);
if (relativeName !== GENERATED_ENTITLEMENTS_FILE_NAME) {
console.log(`[${targetDirName}] Replacing ${path_1.default.relative(targetDirAbsolutePath, entitlementsFiles[0])} with entitlements JSON from config`);
}
}
fs_1.default.writeFileSync(entitlementsFilePath, plist_1.default.build(entitlementsJson));
return config;
},
]);
}
else {
entitlementsJson = entitlementsFiles[0]
? plist_1.default.parse(fs_1.default.readFileSync(entitlementsFiles[0], "utf8"))
: undefined;
}
// Ensure the entry file exists
(0, config_plugins_1.withDangerousMod)(config, [
"ios",
async (config) => {
prebuildLogQueue.flush();
fs_1.default.mkdirSync(targetDirAbsolutePath, { recursive: true });
const files = [
["Info.plist", (0, target_1.getTargetInfoPlistForType)(props.type)],
];
// if (props.type === "widget") {
// files.push(
// [
// "index.swift",
// ENTRY_FILE.replace(
// "// Export widgets here",
// "// Export widgets here\n" + ` ${widget}()`
// ),
// ],
// [widget + ".swift", WIDGET.replace(/alpha/g, widget)],
// [widget + ".intentdefinition", INTENT_DEFINITION]
// );
// }
files.forEach(([filename, content]) => {
const filePath = path_1.default.join(targetDirAbsolutePath, filename);
if (!fs_1.default.existsSync(filePath)) {
fs_1.default.writeFileSync(filePath, content);
}
});
return config;
},
]);
const mainAppBundleId = config.ios.bundleIdentifier;
const bundleId = (() => {
var _a;
// Support the bundle identifier being appended to the main app's bundle identifier.
if ((_a = props.bundleIdentifier) === null || _a === void 0 ? void 0 : _a.startsWith(".")) {
return mainAppBundleId + props.bundleIdentifier;
}
else if (props.bundleIdentifier) {
return props.bundleIdentifier;
}
if (props.type === "clip") {
// Use a more standardized bundle identifier for App Clips.
return mainAppBundleId + ".clip";
}
let bundleId = mainAppBundleId;
bundleId += ".";
// Generate the bundle identifier. This logic needs to remain generally stable since it's used for a permanent value.
// Key here is simplicity and predictability since it's already appended to the main app's bundle identifier.
return bundleId + getSanitizedBundleIdentifier(props.type);
})();
const deviceFamilies = ((_d = config.ios) === null || _d === void 0 ? void 0 : _d.isTabletOnly)
? ["tablet"]
: ((_e = config.ios) === null || _e === void 0 ? void 0 : _e.supportsTablet)
? ["phone", "tablet"]
: ["phone"];
(0, withXcodeChanges_1.withXcodeChanges)(config, {
productName,
configPath: props.configPath,
name: targetDisplayName,
cwd: "../" +
path_1.default.relative(config._internal.projectRoot, path_1.default.resolve(props.directory)),
deploymentTarget: (_f = props.deploymentTarget) !== null && _f !== void 0 ? _f : DEFAULT_DEPLOYMENT_TARGET,
bundleId,
icon: props.icon,
orientation: config.orientation,
hasAccentColor: !!((_g = props.colors) === null || _g === void 0 ? void 0 : _g.$accent),
deviceFamilies,
// @ts-expect-error: who cares
currentProjectVersion: ((_h = config.ios) === null || _h === void 0 ? void 0 : _h.buildNumber) || 1,
frameworks: (0, target_1.getFrameworksForType)(props.type).concat(props.frameworks || []),
type: props.type,
teamId: props.appleTeamId,
colors: props.colors,
exportJs: (_j = props.exportJs) !== null && _j !== void 0 ? _j :
// Assume App Clips are used for React Native.
props.type === "clip",
});
config = (0, withEasCredentials_1.withEASTargets)(config, {
targetName: productName,
bundleIdentifier: bundleId,
entitlements: entitlementsJson,
});
if (props.images) {
Object.entries(props.images).forEach(([name, image]) => {
(0, withImageAsset_1.withImageAsset)(config, {
image,
name,
cwd: props.directory,
});
});
}
withConfigColors(config, props);
if (props.icon) {
(0, withIosIcon_1.withIosIcon)(config, {
type: props.type,
cwd: props.directory,
// TODO: read from the top-level icon.png file in the folder -- ERR this doesn't allow for URLs
iconFilePath: props.icon,
isTransparent: ["action"].includes(props.type),
});
}
return config;
};
const withConfigColors = (config, props) => {
var _a;
props.colors = (_a = props.colors) !== null && _a !== void 0 ? _a : {};
// const colors: NonNullable<Props["colors"]> = props.colors ?? {};
// You use the WidgetBackground and `$accent` to style the widget configuration interface of a configurable widget. Apple could have chosen names to make that more obvious.
// https://useyourloaf.com/blog/widget-background-and-accent-color/
// i.e. when you press and hold on a widget to configure it, the background color of the widget configuration interface changes to the background color we set here.
// if (props.widgetBackgroundColor)
// colors["$widgetBackground"] = props.widgetBackgroundColor;
// if (props.accentColor) colors["AccentColor"] = props.accentColor;
if (props.colors) {
Object.entries(props.colors).forEach(([name, color]) => {
(0, withIosColorset_1.withIosColorset)(config, {
cwd: props.directory,
name,
color: typeof color === "string" ? color : color.light,
darkColor: typeof color === "string" ? undefined : color.dark,
});
});
}
// TODO: Add clean-up maybe? This would possibly restrict the ability to create native colors outside of the Expo target config.
return config;
};
exports.default = withWidget;
function getSanitizedBundleIdentifier(value) {
// According to the behavior observed when using the UI in Xcode.
// Must start with a letter, period, or hyphen (not number).
// Can only contain alphanumeric characters, periods, and hyphens.
// Can have empty segments (e.g. com.example..app).
return value.replace(/(^[^a-zA-Z.-]|[^a-zA-Z0-9-.])/g, "-");
}
function sanitizeNameForNonDisplayUse(name) {
return name
.replace(/[\W_]+/g, "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}