@bacons/apple-targets
Version:
Generate Apple Targets with Expo Prebuild
359 lines (358 loc) • 21.3 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 with_ios_colorset_1 = require("./colorset/with-ios-colorset");
const AssetContents_1 = require("@expo/prebuild-config/build/plugins/icons/AssetContents");
const with_image_asset_1 = require("./icon/with-image-asset");
const with_ios_symbolset_1 = require("./symbolset/with-ios-symbolset");
const with_ios_icon_1 = require("./icon/with-ios-icon");
const target_1 = require("./target");
const entitlements_1 = require("./entitlements");
const with_eas_credentials_1 = require("./with-eas-credentials");
const with_xcode_changes_1 = require("./with-xcode-changes");
const util_1 = require("./util");
const DEFAULT_DEPLOYMENT_TARGET = "18.0";
const DEFAULT_WATCHOS_DEPLOYMENT_TARGET = "11.0";
const withWidget = (config, props) => {
// TODO: Magically based on the top-level folders in the `ios-widgets/` folder
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
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 = (0, util_1.sanitizeNameForNonDisplayUse)(props.name || targetDirName) ||
(0, util_1.sanitizeNameForNonDisplayUse)(targetDirName) ||
(0, util_1.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) {
(0, util_1.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) {
(0, util_1.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;
util_1.LOG_QUEUE.add(() => {
(0, util_1.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 {
util_1.LOG_QUEUE.add(() => {
var _a, _b;
return (0, util_1.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 in the config, generate a
// `generated.entitlements` file inside the prebuild `ios/` folder so the
// target's source directory stays clean of derived artifacts. The file is
// written under `ios/<TARGET_GENERATED_DIR>/<productName>/` to make it obvious
// the contents are generated and should not be edited by hand. The matching
// `CODE_SIGN_ENTITLEMENTS` override is wired up in
// `configureTargetWithEntitlements` (with-xcode-changes.ts).
if (entitlementsJson) {
const definedEntitlements = entitlementsJson;
(0, config_plugins_1.withDangerousMod)(config, [
"ios",
async (config) => {
if (entitlementsFiles[0]) {
const projectRoot = config.modRequest.projectRoot;
if ((0, entitlements_1.classifySourceEntitlementsFile)(entitlementsFiles[0]) ===
"stale-generated") {
// A leftover `generated.entitlements` in the source folder (written
// by an older version of this plugin) plus an `entitlements` object
// in the config is an ambiguous, conflicting source of truth. This
// is non-fatal — the config wins and the file is generated under
// `ios/` — but warn loudly (in red) so the user removes one of the
// two and resolves the ambiguity.
(0, util_1.warnOnce)(chalk_1.default.red(`[${targetDirName}] ${(0, entitlements_1.getEntitlementsConflictMessage)(path_1.default.relative(projectRoot, entitlementsFiles[0]), path_1.default.relative(projectRoot, props.configPath))}`));
}
else {
// A hand-written *.entitlements file in the source folder is no
// longer used when entitlements come from the config — leave it
// untouched but tell the user why it's ignored and their options.
const relativeName = path_1.default.relative(targetDirAbsolutePath, entitlementsFiles[0]);
const generatedRelativePath = `ios/${(0, entitlements_1.getGeneratedEntitlementsCodeSignPath)(productName)}`;
console.log((0, chalk_1.default) `[${targetDirName}] Ignoring {bold ${relativeName}} because {bold expo-target.config} defines an {bold entitlements} object; entitlements are generated into {bold ${generatedRelativePath}}. To hand-manage the entitlements file instead, remove the {bold entitlements} object from {bold expo-target.config}. Otherwise the source ${relativeName} is unused and safe to delete.`);
}
}
(0, entitlements_1.writeGeneratedEntitlements)(config.modRequest.projectRoot, productName, definedEntitlements);
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) => {
util_1.LOG_QUEUE.flush();
fs_1.default.mkdirSync(targetDirAbsolutePath, { recursive: true });
const files = [
["Info.plist", plist_1.default.build((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 mainAppBundleId + "." + (0, util_1.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, with_xcode_changes_1.withXcodeChanges)(config, {
productName,
configPath: props.configPath,
name: targetDisplayName,
displayName: props.displayName,
cwd: "../" +
path_1.default.relative(config._internal.projectRoot, path_1.default.resolve(props.directory)),
deploymentTarget: (_f = props.deploymentTarget) !== null && _f !== void 0 ? _f : (props.type === "watch" || props.type === "watch-widget"
? DEFAULT_WATCHOS_DEPLOYMENT_TARGET
: 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, with_eas_credentials_1.withEASTargets)(config, {
targetName: productName,
bundleIdentifier: bundleId,
entitlements: entitlementsJson,
});
if (props.images) {
Object.entries(props.images).forEach(([name, image]) => {
if (typeof image === "string" && image.endsWith(".svg")) {
const imageSrc = image;
// SVGs might be SF Symbol templates — detection requires reading
// the content (and possibly fetching a URL), so defer to an async mod.
(0, config_plugins_1.withDangerousMod)(config, [
"ios",
async (config) => {
const isUrl = /^https?:\/\//.test(imageSrc);
let svgContent;
let svgFilename;
if (isUrl) {
const res = await fetch(imageSrc);
svgContent = await res.text();
const urlPath = new URL(imageSrc).pathname;
svgFilename = path_1.default.basename(urlPath) || `${name}.svg`;
}
else {
const resolvedPath = path_1.default.join(props.directory, imageSrc);
svgContent = await fs_1.default.promises.readFile(resolvedPath, "utf-8");
svgFilename = path_1.default.basename(resolvedPath);
}
const projectRoot = config.modRequest.projectRoot;
if ((0, with_ios_symbolset_1.isSFSymbolContent)(svgContent)) {
const symbolsetDir = path_1.default.join(projectRoot, props.directory, `Assets.xcassets/${name}.symbolset`);
await fs_1.default.promises.mkdir(symbolsetDir, { recursive: true });
await fs_1.default.promises.writeFile(path_1.default.join(symbolsetDir, svgFilename), svgContent);
await fs_1.default.promises.writeFile(path_1.default.join(symbolsetDir, "Contents.json"), JSON.stringify({
info: { author: "expo", version: 1 },
symbols: [{ filename: svgFilename, idiom: "universal" }],
}, null, 2));
}
else {
// Not an SF Symbol — generate a normal imageset inline.
const iosNamedProjectRoot = path_1.default.join(projectRoot, props.directory);
const imgPath = `Assets.xcassets/${name}.imageset`;
await fs_1.default.promises.mkdir(path_1.default.join(iosNamedProjectRoot, imgPath), { recursive: true });
await (0, AssetContents_1.writeContentsJsonAsync)(path_1.default.join(iosNamedProjectRoot, imgPath), {
images: await (0, with_image_asset_1.generateResizedImageAsync)({ "1x": isUrl ? imageSrc : path_1.default.join(props.directory, imageSrc), "2x": undefined, "3x": undefined }, name, projectRoot, iosNamedProjectRoot, path_1.default.join(props.directory, "gen-image", name)),
});
}
return config;
},
]);
}
else {
(0, with_image_asset_1.withImageAsset)(config, {
image,
name,
cwd: props.directory,
});
}
});
}
withConfigColors(config, props);
if (props.icon) {
(0, with_ios_icon_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, with_ios_colorset_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;