diff --git a/package.json b/package.json index 2b2c858..b2101ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-app-clip", - "version": "0.9.2", + "version": "0.9.3", "description": "Config plugin to add an App Clip to a React Native iOS app", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/plugin/build/cliPlugin.d.ts b/plugin/build/cliPlugin.d.ts new file mode 100644 index 0000000..ff50448 --- /dev/null +++ b/plugin/build/cliPlugin.d.ts @@ -0,0 +1,3 @@ +export declare const run: (config: { + dependencies: Record; +}, exclude: string[]) => Promise; diff --git a/plugin/build/cliPlugin.js b/plugin/build/cliPlugin.js new file mode 100644 index 0000000..0e0fcc5 --- /dev/null +++ b/plugin/build/cliPlugin.js @@ -0,0 +1,26 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.run = void 0; +const run = async (config, exclude) => { + // Validation + if (typeof config !== "object") { + throw new Error("config must be an object"); + } + if (!config.dependencies) { + throw new Error("config must have a dependencies key"); + } + if (!Array.isArray(exclude)) { + throw new Error("exclude must be an array"); + } + for (const packageName of exclude) { + if (typeof packageName !== "string") { + throw new Error("exclude must be an array of strings"); + } + } + // Removing excluded packages + for (const packageName of exclude) { + delete config.dependencies[packageName]; + } + console.log(JSON.stringify(config)); +}; +exports.run = run; diff --git a/plugin/build/index.d.ts b/plugin/build/index.d.ts new file mode 100644 index 0000000..e93cf17 --- /dev/null +++ b/plugin/build/index.d.ts @@ -0,0 +1,17 @@ +import { type ConfigPlugin } from "expo/config-plugins"; +declare const withAppClip: ConfigPlugin<{ + name?: string; + enabled?: boolean; + bundleIdSuffix?: string; + targetSuffix?: string; + groupIdentifier?: string; + deploymentTarget?: string; + requestEphemeralUserNotification?: boolean; + requestLocationConfirmation?: boolean; + appleSignin?: boolean; + applePayMerchantIds?: string[]; + excludedPackages?: string[]; + pushNotifications?: boolean; + enableCompression?: boolean; +}>; +export default withAppClip; diff --git a/plugin/build/index.js b/plugin/build/index.js new file mode 100644 index 0000000..32e9fb4 --- /dev/null +++ b/plugin/build/index.js @@ -0,0 +1,61 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const config_plugins_1 = require("expo/config-plugins"); +const withConfig_1 = require("./withConfig"); +const withEntitlements_1 = require("./withEntitlements"); +const withPlist_1 = require("./withPlist"); +const withPodfile_1 = require("./withPodfile"); +const withXcode_1 = require("./withXcode"); +const DeviceFamily_1 = require("@expo/config-plugins/build/ios/DeviceFamily"); +const withAppClip = (config, { name, enabled, bundleIdSuffix, targetSuffix, groupIdentifier, deploymentTarget, requestEphemeralUserNotification, requestLocationConfirmation, appleSignin, applePayMerchantIds, excludedPackages, pushNotifications, enableCompression, } = {}) => { + name ??= "Clip"; + bundleIdSuffix ??= "Clip"; + targetSuffix ??= "Clip"; + deploymentTarget ??= "15.1"; + requestEphemeralUserNotification ??= false; + requestLocationConfirmation ??= false; + appleSignin ??= false; + enabled ??= true; + pushNotifications ??= false; + if (!enabled) { + return config; + } + if (!config.ios?.bundleIdentifier) { + throw new Error("No bundle identifier specified in app config"); + } + const bundleIdentifier = `${config.ios.bundleIdentifier}.${bundleIdSuffix}`; + const targetName = `${config_plugins_1.IOSConfig.XcodeUtils.sanitizedName(config.name)}${targetSuffix}`; + const modifiedConfig = (0, config_plugins_1.withPlugins)(config, [ + DeviceFamily_1.withDeviceFamily, + [ + withConfig_1.withConfig, + { targetName, bundleIdentifier, appleSignin, applePayMerchantIds, pushNotifications }, + ], + [ + withEntitlements_1.withEntitlements, + { targetName, groupIdentifier, appleSignin, applePayMerchantIds, pushNotifications }, + ], + [withPodfile_1.withPodfile, { targetName, excludedPackages }], + [ + withPlist_1.withPlist, + { + targetName, + deploymentTarget, + requestEphemeralUserNotification, + requestLocationConfirmation, + }, + ], + [ + withXcode_1.withXcode, + { + name, + targetName, + bundleIdentifier, + deploymentTarget, + enableCompression, + }, + ], + ]); + return modifiedConfig; +}; +exports.default = withAppClip; diff --git a/plugin/build/lib/getAppClipEntitlements.d.ts b/plugin/build/lib/getAppClipEntitlements.d.ts new file mode 100644 index 0000000..ada8dfb --- /dev/null +++ b/plugin/build/lib/getAppClipEntitlements.d.ts @@ -0,0 +1,8 @@ +import type { ExportedConfigWithProps, InfoPlist } from "expo/config-plugins"; +export declare function getAppClipEntitlements(iosConfig: ExportedConfigWithProps["ios"], { groupIdentifier, appleSignin, applePayMerchantIds, pushNotifications, }: { + groupIdentifier?: string; + appleSignin: boolean; + applePayMerchantIds?: string[]; + pushNotifications: boolean; +}): InfoPlist; +export declare function addApplicationGroupsEntitlement(entitlements: InfoPlist, groupIdentifier?: string): InfoPlist; diff --git a/plugin/build/lib/getAppClipEntitlements.js b/plugin/build/lib/getAppClipEntitlements.js new file mode 100644 index 0000000..4aa79e1 --- /dev/null +++ b/plugin/build/lib/getAppClipEntitlements.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getAppClipEntitlements = getAppClipEntitlements; +exports.addApplicationGroupsEntitlement = addApplicationGroupsEntitlement; +function getAppClipEntitlements(iosConfig, { groupIdentifier, appleSignin, applePayMerchantIds, pushNotifications, }) { + const appBundleIdentifier = iosConfig?.bundleIdentifier; + const entitlements = { + "com.apple.developer.parent-application-identifiers": [ + `$(AppIdentifierPrefix)${appBundleIdentifier}`, + ], + "com.apple.developer.on-demand-install-capable": true, + }; + addApplicationGroupsEntitlement(entitlements, groupIdentifier); + if (appleSignin) { + entitlements["com.apple.developer.applesignin"] = ["Default"]; + } + if (applePayMerchantIds) { + entitlements["com.apple.developer.in-app-payments"] = applePayMerchantIds; + } + if (pushNotifications) { + // XCode automatically changes this to "production" when archiving https://stackoverflow.com/a/42293632/4047926 + entitlements["aps-environment"] = "development"; + } + if (iosConfig?.associatedDomains) { + entitlements["com.apple.developer.associated-domains"] = + iosConfig.associatedDomains; + } + return entitlements; +} +function addApplicationGroupsEntitlement(entitlements, groupIdentifier) { + if (groupIdentifier) { + const existingApplicationGroups = entitlements["com.apple.security.application-groups"] ?? []; + entitlements["com.apple.security.application-groups"] = [ + groupIdentifier, + ...existingApplicationGroups, + ]; + } + return entitlements; +} diff --git a/plugin/build/lib/getAppClipIcon.d.ts b/plugin/build/lib/getAppClipIcon.d.ts new file mode 100644 index 0000000..cef2122 --- /dev/null +++ b/plugin/build/lib/getAppClipIcon.d.ts @@ -0,0 +1,5 @@ +import type { ConfigPlugin } from "expo/config-plugins"; +export declare function getAppClipIcon(config: Pick[0], "icon" | "ios">): { + name: string; + fileName: string; +} | undefined; diff --git a/plugin/build/lib/getAppClipIcon.js b/plugin/build/lib/getAppClipIcon.js new file mode 100644 index 0000000..511cd32 --- /dev/null +++ b/plugin/build/lib/getAppClipIcon.js @@ -0,0 +1,20 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getAppClipIcon = getAppClipIcon; +const node_path_1 = __importDefault(require("node:path")); +function getAppClipIcon(config) { + let icon = config.ios?.icon || config.icon; + if (icon && typeof icon === "object" && !icon.light && !icon.dark && !icon.tinted) { + icon = config.icon; + } + if (typeof icon !== "string" || node_path_1.default.extname(icon) !== ".icon") { + return undefined; + } + return { + name: node_path_1.default.basename(icon, ".icon"), + fileName: node_path_1.default.basename(icon), + }; +} diff --git a/plugin/build/withConfig.d.ts b/plugin/build/withConfig.d.ts new file mode 100644 index 0000000..6f3644c --- /dev/null +++ b/plugin/build/withConfig.d.ts @@ -0,0 +1,8 @@ +import type { ConfigPlugin } from "expo/config-plugins"; +export declare const withConfig: ConfigPlugin<{ + targetName: string; + bundleIdentifier: string; + appleSignin: boolean; + applePayMerchantIds: string[]; + pushNotifications: boolean; +}>; diff --git a/plugin/build/withConfig.js b/plugin/build/withConfig.js new file mode 100644 index 0000000..4d0013c --- /dev/null +++ b/plugin/build/withConfig.js @@ -0,0 +1,66 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withConfig = void 0; +const getAppClipEntitlements_1 = require("./lib/getAppClipEntitlements"); +const withConfig = (config, { targetName, bundleIdentifier, appleSignin, applePayMerchantIds, pushNotifications }) => { + let configIndex = null; + config.extra?.eas?.build?.experimental?.ios?.appExtensions?.forEach((ext, index) => { + if (ext.targetName === targetName) { + configIndex = index; + } + }); + if (!configIndex) { + config.extra = { + ...config.extra, + eas: { + ...config.extra?.eas, + build: { + ...config.extra?.eas?.build, + experimental: { + ...config.extra?.eas?.build?.experimental, + ios: { + ...config.extra?.eas?.build?.experimental?.ios, + appExtensions: [ + ...(config.extra?.eas?.build?.experimental?.ios + ?.appExtensions ?? []), + { + targetName, + bundleIdentifier, + }, + ], + }, + }, + }, + }, + }; + configIndex = 0; + } + if (configIndex !== null && config.extra) { + const appClipConfig = config.extra.eas.build.experimental.ios.appExtensions[configIndex]; + appClipConfig.entitlements = { + ...appClipConfig.entitlements, + ...(0, getAppClipEntitlements_1.getAppClipEntitlements)(config.ios, { + appleSignin, + applePayMerchantIds, + // groupIdentifier, // Throws an error in EAS + pushNotifications, + }), + }; + } + // Entitlements + config.ios = { + ...config.ios, + entitlements: { + // ...addApplicationGroupsEntitlement( + // config.ios?.entitlements ?? {}, + // groupIdentifier, + // ), + ...config.ios?.entitlements, + "com.apple.developer.associated-appclip-app-identifiers": [ + `$(AppIdentifierPrefix)${bundleIdentifier}`, + ], + }, + }; + return config; +}; +exports.withConfig = withConfig; diff --git a/plugin/build/withEntitlements.d.ts b/plugin/build/withEntitlements.d.ts new file mode 100644 index 0000000..dedc537 --- /dev/null +++ b/plugin/build/withEntitlements.d.ts @@ -0,0 +1,8 @@ +import { type ConfigPlugin } from "expo/config-plugins"; +export declare const withEntitlements: ConfigPlugin<{ + targetName: string; + groupIdentifier: string; + appleSignin: boolean; + applePayMerchantIds: string[]; + pushNotifications: boolean; +}>; diff --git a/plugin/build/withEntitlements.js b/plugin/build/withEntitlements.js new file mode 100644 index 0000000..d4051dc --- /dev/null +++ b/plugin/build/withEntitlements.js @@ -0,0 +1,30 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withEntitlements = void 0; +const plist_1 = __importDefault(require("@expo/plist")); +const config_plugins_1 = require("expo/config-plugins"); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const getAppClipEntitlements_1 = require("./lib/getAppClipEntitlements"); +const withEntitlements = (config, { targetName, groupIdentifier, appleSignin, applePayMerchantIds, pushNotifications }) => { + return (0, config_plugins_1.withInfoPlist)(config, (config) => { + const targetPath = node_path_1.default.join(config.modRequest.platformProjectRoot, targetName); + const filePath = node_path_1.default.join(targetPath, `${targetName}.entitlements`); + if (config.ios === undefined) { + throw new Error("Missing iOS config"); + } + const appClipEntitlements = (0, getAppClipEntitlements_1.getAppClipEntitlements)(config.ios, { + groupIdentifier, + appleSignin, + applePayMerchantIds, + pushNotifications, + }); + node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true }); + node_fs_1.default.writeFileSync(filePath, plist_1.default.build(appClipEntitlements)); + return config; + }); +}; +exports.withEntitlements = withEntitlements; diff --git a/plugin/build/withPlist.d.ts b/plugin/build/withPlist.d.ts new file mode 100644 index 0000000..8a17c46 --- /dev/null +++ b/plugin/build/withPlist.d.ts @@ -0,0 +1,7 @@ +import { type ConfigPlugin } from "expo/config-plugins"; +export declare const withPlist: ConfigPlugin<{ + targetName: string; + deploymentTarget: string; + requestEphemeralUserNotification: boolean; + requestLocationConfirmation: boolean; +}>; diff --git a/plugin/build/withPlist.js b/plugin/build/withPlist.js new file mode 100644 index 0000000..525562c --- /dev/null +++ b/plugin/build/withPlist.js @@ -0,0 +1,79 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withPlist = void 0; +const plist_1 = __importDefault(require("@expo/plist")); +const config_plugins_1 = require("expo/config-plugins"); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const getAppClipIcon_1 = require("./lib/getAppClipIcon"); +/** + * List of keys that are not allowed in the Info.plist due to not being supported in App Clip. Add to this array as new build issues arise. + */ +const DisallowedPlistKeys = [ + "UIBackgroundModes", +]; +const withPlist = (config, { targetName, deploymentTarget, requestEphemeralUserNotification, requestLocationConfirmation, }) => { + return (0, config_plugins_1.withInfoPlist)(config, (config) => { + const targetPath = node_path_1.default.join(config.modRequest.platformProjectRoot, targetName); + // Info.plist + const filePath = node_path_1.default.join(targetPath, "Info.plist"); + const infoPlist = { + NSAppClip: { + NSAppClipRequestEphemeralUserNotification: requestEphemeralUserNotification, + NSAppClipRequestLocationConfirmation: requestLocationConfirmation, + }, + NSAppTransportSecurity: { + NSAllowsArbitraryLoads: config.developmentClient, + NSExceptionDomains: { + localhost: { + NSExceptionAllowsInsecureHTTPLoads: config.developmentClient, + }, + }, + NSAllowsLocalNetworking: config.developmentClient, + }, + CFBundleName: "$(PRODUCT_MODULE_NAME)", + CFBundleDisplayName: "$(PRODUCT_NAME)", + CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)", + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)", + CFBundleExecutable: "$(EXECUTABLE_NAME)", + CFBundlePackageType: "$(PRODUCT_BUNDLE_PACKAGE_TYPE)", + CFBundleShortVersionString: config.version, + UIViewControllerBasedStatusBarAppearance: "NO", + UILaunchStoryboardName: "SplashScreen", + UIRequiresFullScreen: true, + MinimumOSVersion: deploymentTarget, + }; + if (config.ios?.infoPlist) { + for (const key of Object.keys(config.ios?.infoPlist)) { + if (config.ios?.infoPlist && !DisallowedPlistKeys.some((disallowedKey) => disallowedKey === key)) { + infoPlist[key] = config.ios.infoPlist[key]; + } + } + } + infoPlist.CFBundleName = "$(PRODUCT_MODULE_NAME)"; + const appClipIcon = (0, getAppClipIcon_1.getAppClipIcon)(config); + if (appClipIcon) { + infoPlist.CFBundleIconName = appClipIcon.name; + } + node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { + recursive: true, + }); + node_fs_1.default.writeFileSync(filePath, plist_1.default.build(infoPlist)); + // Expo.plist + const expoPlistFilePath = node_path_1.default.join(targetPath, "Supporting/Expo.plist"); + const expoPlist = { + EXUpdatesRuntimeVersion: "exposdk:51.0.0", // TODO + // EXUpdatesURL: "", // TODO + EXUpdatesEnabled: false, + }; + node_fs_1.default.mkdirSync(node_path_1.default.dirname(expoPlistFilePath), { + recursive: true, + }); + node_fs_1.default.writeFileSync(expoPlistFilePath, plist_1.default.build(expoPlist)); + return config; + }); +}; +exports.withPlist = withPlist; diff --git a/plugin/build/withPodfile.d.ts b/plugin/build/withPodfile.d.ts new file mode 100644 index 0000000..b818fdd --- /dev/null +++ b/plugin/build/withPodfile.d.ts @@ -0,0 +1,5 @@ +import { type ConfigPlugin } from "expo/config-plugins"; +export declare const withPodfile: ConfigPlugin<{ + targetName: string; + excludedPackages?: string[]; +}>; diff --git a/plugin/build/withPodfile.js b/plugin/build/withPodfile.js new file mode 100644 index 0000000..1b10c9e --- /dev/null +++ b/plugin/build/withPodfile.js @@ -0,0 +1,77 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withPodfile = void 0; +const config_plugins_1 = require("expo/config-plugins"); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const withPodfile = (config, { targetName, excludedPackages }) => { + return (0, config_plugins_1.withDangerousMod)(config, [ + "ios", + (config) => { + const podFilePath = node_path_1.default.join(config.modRequest.platformProjectRoot, "Podfile"); + let podfileContent = node_fs_1.default.readFileSync(podFilePath).toString(); + const useExpoModules = excludedPackages && excludedPackages.length > 0 + ? `exclude = ["${excludedPackages.join(`", "`)}"]\n use_expo_modules!(exclude: exclude)` + : "use_expo_modules!"; + const appClipTarget = ` +# @generated begin react-native-app-clip +target '${targetName}' do + ${useExpoModules} + + if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' + config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; + else + config_command = [ + 'npx', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios' + ] + end + + # Running the command in the same manner as \`use_react_native\` then running that result through our cliPlugin + json, message, status = Pod::Executable.capture_command(config_command[0], config_command[1..], capture: :both) + if not status.success? + Pod::UI.warn "The command: '#{config_command.join(" ").bold.yellow}' returned a status code of #{status.exitstatus.to_s.bold.red}, #{message}", [ + "App Clip autolinking failed. Please ensure autolinking works correctly for the main app target and try again.", + ] + exit(status.exitstatus) + end + + # \`react-native-app-clip\` resolves to react-native-app-clip/build/index.js + clip_command = [ + 'node', + '--no-warnings', + '--eval', + 'require(require.resolve(\\'react-native-app-clip\\')+\\'/../../plugin/build/cliPlugin.js\\').run(' + json + ', [])' + ] + + config = use_native_modules!(clip_command) + + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] + use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] + + use_react_native!( + :path => config[:reactNativePath], + :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes', + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/..", + :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', + ) +end +# @generated end react-native-app-clip`; + // Strip any existing block then re-append at end of file (idempotent) + const blockRegex = new RegExp(`\\n*# @generated begin react-native-app-clip[\\s\\S]*?# @generated end react-native-app-clip`, "g"); + podfileContent = podfileContent.replace(blockRegex, "").trimEnd(); + podfileContent += `\n${appClipTarget}\n`; + node_fs_1.default.writeFileSync(podFilePath, podfileContent); + return config; + }, + ]); +}; +exports.withPodfile = withPodfile; diff --git a/plugin/build/withXcode.d.ts b/plugin/build/withXcode.d.ts new file mode 100644 index 0000000..e7ff1f2 --- /dev/null +++ b/plugin/build/withXcode.d.ts @@ -0,0 +1,8 @@ +import { type ConfigPlugin } from "expo/config-plugins"; +export declare const withXcode: ConfigPlugin<{ + name: string; + targetName: string; + bundleIdentifier: string; + deploymentTarget: string; + enableCompression?: boolean; +}>; diff --git a/plugin/build/withXcode.js b/plugin/build/withXcode.js new file mode 100644 index 0000000..83dd7ef --- /dev/null +++ b/plugin/build/withXcode.js @@ -0,0 +1,81 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withXcode = void 0; +const config_plugins_1 = require("expo/config-plugins"); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const getAppClipIcon_1 = require("./lib/getAppClipIcon"); +const addBuildPhases_1 = require("./xcode/addBuildPhases"); +const addPbxGroup_1 = require("./xcode/addPbxGroup"); +const modifyAppDelegate_1 = require("./xcode/modifyAppDelegate"); +const addProductFile_1 = require("./xcode/addProductFile"); +const addTargetDependency_1 = require("./xcode/addTargetDependency"); +const addToPbxNativeTargetSection_1 = require("./xcode/addToPbxNativeTargetSection"); +const addToPbxProjectSection_1 = require("./xcode/addToPbxProjectSection"); +const addXCConfigurationList_1 = require("./xcode/addXCConfigurationList"); +const withXcode = (config, { name, targetName, bundleIdentifier, deploymentTarget, enableCompression }) => { + return (0, config_plugins_1.withXcodeProject)(config, (config) => { + const xcodeProject = config.modResults; + const appClipIcon = (0, getAppClipIcon_1.getAppClipIcon)(config); + const targetUuid = xcodeProject.generateUuid(); + const groupName = "Embed App Clips"; + const { projectName, platformProjectRoot } = config.modRequest; + const xCConfigurationList = (0, addXCConfigurationList_1.addXCConfigurationList)(xcodeProject, { + name, + targetName, + currentProjectVersion: config.ios?.buildNumber || "1", + bundleIdentifier, + deploymentTarget, + appleTeamId: config.ios?.appleTeamId, + appIconName: appClipIcon?.name, + }); + const productFile = (0, addProductFile_1.addProductFile)(xcodeProject, { + targetName, + groupName, + }); + const target = (0, addToPbxNativeTargetSection_1.addToPbxNativeTargetSection)(xcodeProject, { + targetName, + targetUuid, + productFile, + xCConfigurationList, + }); + (0, addToPbxProjectSection_1.addToPbxProjectSection)(xcodeProject, target); + (0, addTargetDependency_1.addTargetDependency)(xcodeProject, target); + (0, addPbxGroup_1.addPbxGroup)(xcodeProject, { + projectName: projectName, + targetName, + platformProjectRoot, + appIconFileName: appClipIcon?.fileName, + }); + if (enableCompression) { + const appDelegatePath = node_path_1.default.join(platformProjectRoot, targetName, "AppDelegate.swift"); + if (node_fs_1.default.existsSync(appDelegatePath)) { + (0, modifyAppDelegate_1.modifyAppDelegate)(appDelegatePath); + } + else { + console.warn(`⚠️ AppDelegate not found at ${appDelegatePath}`); + } + } + (0, addBuildPhases_1.addBuildPhases)(xcodeProject, { + targetUuid, + groupName, + productFile, + enableCompression, + resourcesPhaseName: appClipIcon ? "Resources" : undefined, + }); + if (appClipIcon) { + config_plugins_1.IOSConfig.XcodeUtils.addResourceFileToGroup({ + filepath: appClipIcon.fileName, + groupName: targetName, + project: xcodeProject, + isBuildFile: true, + targetUuid, + }); + } + return config; + }); +}; +exports.withXcode = withXcode; diff --git a/plugin/build/xcode/addBuildPhases.d.ts b/plugin/build/xcode/addBuildPhases.d.ts new file mode 100644 index 0000000..38d3b42 --- /dev/null +++ b/plugin/build/xcode/addBuildPhases.d.ts @@ -0,0 +1,13 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addBuildPhases(xcodeProject: XcodeProject, { targetUuid, groupName, productFile, enableCompression, resourcesPhaseName, }: { + targetUuid: string; + groupName: string; + productFile: { + uuid: string; + target: string; + basename: string; + group: string; + }; + enableCompression?: boolean; + resourcesPhaseName?: string; +}): void; diff --git a/plugin/build/xcode/addBuildPhases.js b/plugin/build/xcode/addBuildPhases.js new file mode 100644 index 0000000..bf9c491 --- /dev/null +++ b/plugin/build/xcode/addBuildPhases.js @@ -0,0 +1,44 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addBuildPhases = addBuildPhases; +const node_util_1 = __importDefault(require("node:util")); +function addBuildPhases(xcodeProject, { targetUuid, groupName, productFile, enableCompression, resourcesPhaseName, }) { + const buildPath = `"$(CONTENTS_FOLDER_PATH)/AppClips"`; + const folderType = "watch2_app"; // "watch2_app" uses the same subfolder spec (16), app_clip does not exist in cordova-node-xcode yet + const buildPhaseFiles = ["AppDelegate.swift"]; + // Add shell script build phase "Start Packager" + xcodeProject.addBuildPhase([], "PBXShellScriptBuildPhase", "Start Packager", targetUuid, { + shellPath: "/bin/sh", + shellScript: 'if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then\n source "$PODS_ROOT/../.xcode.env"\nfi\nif [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then\n source "$PODS_ROOT/../.xcode.env.local"\nfi\n\nexport RCT_METRO_PORT="${RCT_METRO_PORT:=8081}"\necho "export RCT_METRO_PORT=${RCT_METRO_PORT}" > `$NODE_BINARY --print "require(\'path\').dirname(require.resolve(\'react-native/package.json\')) + \'/scripts/.packager.env\'"`\nif [ -z "${RCT_NO_LAUNCH_PACKAGER+xxx}" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s "http://localhost:${RCT_METRO_PORT}/status" | grep -q "packager-status:running" ; then\n echo "Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly"\n exit 2\n fi\n else\n open `$NODE_BINARY --print "require(\'path\').dirname(require.resolve(\'expo/package.json\')) + \'/scripts/launchPackager.command\'"` || echo "Can\'t start packager automatically"\n fi\nfi\n', + }, buildPath); + // Sources build phase + xcodeProject.addBuildPhase(buildPhaseFiles, "PBXSourcesBuildPhase", groupName, targetUuid, folderType, buildPath); + // Copy files build phase + xcodeProject.addBuildPhase([], "PBXCopyFilesBuildPhase", groupName, xcodeProject.getFirstTarget().uuid, folderType, buildPath); + xcodeProject + .buildPhaseObject("PBXCopyFilesBuildPhase", groupName, productFile.target) + .files.push({ + value: productFile.uuid, + comment: node_util_1.default.format("%s in %s", productFile.basename, productFile.group), // longComment(file); + }); + xcodeProject.addToPbxBuildFileSection(productFile); + // Frameworks build phase + xcodeProject.addBuildPhase([], "PBXFrameworksBuildPhase", groupName, targetUuid, folderType, buildPath); + // Resources build phase + xcodeProject.addBuildPhase(["Images.xcassets", "SplashScreen.storyboard", "Supporting/Expo.plist"], "PBXResourcesBuildPhase", resourcesPhaseName ?? groupName, targetUuid, folderType, buildPath); + // Add shell script build phase + xcodeProject.addBuildPhase([], "PBXShellScriptBuildPhase", "Bundle React Native code and images", targetUuid, { + shellPath: "/bin/sh", + shellScript: 'export BUILDING_FOR_APP_CLIP=1\nif [[ -f "$PODS_ROOT/../.xcode.env" ]]; then\n source "$PODS_ROOT/../.xcode.env"\nfi\nif [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then\n source "$PODS_ROOT/../.xcode.env.local"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT="$PROJECT_DIR"/..\n\nif [[ -f "$PROJECT_ROOT/.env" ]]; then\n set -a\n source "$PROJECT_ROOT/.env"\n set +a\nfi\n\nif [[ "$CONFIGURATION" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z "$ENTRY_FILE" ]]; then\n # Set the entry JS file using the bundler\'s entry resolution.\n export ENTRY_FILE="$("$NODE_BINARY" -e "require(\'expo/scripts/resolveAppEntry\')" "$PROJECT_ROOT" ios relative | tail -n 1)"\nfi\n\nif [[ -z "$CLI_PATH" ]]; then\n # Use Expo CLI\n export CLI_PATH="$("$NODE_BINARY" --print "require.resolve(\'@expo/cli\')")"\nfi\nif [[ -z "$BUNDLE_COMMAND" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND="export:embed"\nfi\n\n`"$NODE_BINARY" --print "require(\'path\').dirname(require.resolve(\'react-native/package.json\')) + \'/scripts/react-native-xcode.sh\'"`\n\n', + }, buildPath); + // Add compression build phase if enabled + if (enableCompression) { + xcodeProject.addBuildPhase([], "PBXShellScriptBuildPhase", "Compress JS Bundle", targetUuid, { + shellPath: "/bin/sh", + shellScript: 'if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then\n source "$PODS_ROOT/../.xcode.env"\nfi\nif [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then\n source "$PODS_ROOT/../.xcode.env.local"\nfi\n\nif [[ "$CONFIGURATION" != "Release" ]]; then\n exit 0\nfi\n\nBUNDLE="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/main.jsbundle"\n\nif [ -f "$BUNDLE" ]; then\n "$NODE_BINARY" -e "const fs=require(\'fs\');const zlib=require(\'zlib\');const data=fs.readFileSync(\'${BUNDLE}\');const compressed=zlib.gzipSync(data,{level:9});fs.writeFileSync(\'${BUNDLE}.gz\',compressed);fs.unlinkSync(\'${BUNDLE}\');"\nfi\n', + }, buildPath); + } +} diff --git a/plugin/build/xcode/addPbxGroup.d.ts b/plugin/build/xcode/addPbxGroup.d.ts new file mode 100644 index 0000000..e539cec --- /dev/null +++ b/plugin/build/xcode/addPbxGroup.d.ts @@ -0,0 +1,7 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addPbxGroup(xcodeProject: XcodeProject, { projectName, targetName, platformProjectRoot, appIconFileName, }: { + projectName: string; + targetName: string; + platformProjectRoot: string; + appIconFileName?: string; +}): void; diff --git a/plugin/build/xcode/addPbxGroup.js b/plugin/build/xcode/addPbxGroup.js new file mode 100644 index 0000000..0c06652 --- /dev/null +++ b/plugin/build/xcode/addPbxGroup.js @@ -0,0 +1,72 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addPbxGroup = addPbxGroup; +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +function addPbxGroup(xcodeProject, { projectName, targetName, platformProjectRoot, appIconFileName, }) { + const targetPath = node_path_1.default.join(platformProjectRoot, targetName); + if (!node_fs_1.default.existsSync(targetPath)) { + node_fs_1.default.mkdirSync(targetPath, { recursive: true }); + } + const filesToCopy = [ + "SplashScreen.storyboard", + "AppDelegate.swift", + ]; + for (const file of filesToCopy) { + const source = node_path_1.default.join(platformProjectRoot, projectName, file); + copyFileSync(source, targetPath); + } + // Copy Images.xcassets + const imagesXcassetsSource = node_path_1.default.join(platformProjectRoot, projectName, "Images.xcassets"); + copyFolderRecursiveSync(imagesXcassetsSource, targetPath); + if (appIconFileName) { + node_fs_1.default.cpSync(node_path_1.default.join(platformProjectRoot, projectName, appIconFileName), node_path_1.default.join(targetPath, appIconFileName), { recursive: true }); + } + // Add PBX group + const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup([ + "AppDelegate.swift", + "Info.plist", + "Images.xcassets", + "SplashScreen.storyboard", + `${targetName}.entitlements`, + "Supporting/Expo.plist", + /* "main.jsbundle", */ + ], targetName, targetName); + // Add PBXGroup to top level group + const groups = xcodeProject.hash.project.objects.PBXGroup; + if (pbxGroupUuid) { + for (const key of Object.keys(groups)) { + if (groups[key].name === undefined && groups[key].path === undefined) { + xcodeProject.addToPbxGroup(pbxGroupUuid, key); + } + } + } +} +function copyFileSync(source, target) { + let targetFile = target; + if (node_fs_1.default.existsSync(target) && node_fs_1.default.lstatSync(target).isDirectory()) { + targetFile = node_path_1.default.join(target, node_path_1.default.basename(source)); + } + node_fs_1.default.writeFileSync(targetFile, node_fs_1.default.readFileSync(source)); +} +function copyFolderRecursiveSync(source, target) { + const targetPath = node_path_1.default.join(target, node_path_1.default.basename(source)); + if (!node_fs_1.default.existsSync(targetPath)) { + node_fs_1.default.mkdirSync(targetPath, { recursive: true }); + } + if (node_fs_1.default.lstatSync(source).isDirectory()) { + const files = node_fs_1.default.readdirSync(source); + for (const file of files) { + const currentPath = node_path_1.default.join(source, file); + if (node_fs_1.default.lstatSync(currentPath).isDirectory()) { + copyFolderRecursiveSync(currentPath, targetPath); + } + else { + copyFileSync(currentPath, targetPath); + } + } + } +} diff --git a/plugin/build/xcode/addProductFile.d.ts b/plugin/build/xcode/addProductFile.d.ts new file mode 100644 index 0000000..66fdb5f --- /dev/null +++ b/plugin/build/xcode/addProductFile.d.ts @@ -0,0 +1,5 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addProductFile(xcodeProject: XcodeProject, { targetName, groupName }: { + targetName: string; + groupName: string; +}): any; diff --git a/plugin/build/xcode/addProductFile.js b/plugin/build/xcode/addProductFile.js new file mode 100644 index 0000000..2dc3217 --- /dev/null +++ b/plugin/build/xcode/addProductFile.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addProductFile = addProductFile; +function addProductFile(xcodeProject, { targetName, groupName }) { + const options = { + basename: `${targetName}.app`, + // fileRef: xcodeProject.generateUuid(), + // uuid: xcodeProject.generateUuid(), + group: groupName, + explicitFileType: "wrapper.application", + /* fileEncoding: 4, */ + settings: { + ATTRIBUTES: ["RemoveHeadersOnCopy"], + }, + includeInIndex: 0, + path: `${targetName}.app`, + sourceTree: "BUILT_PRODUCTS_DIR", + }; + const productFile = xcodeProject.addProductFile(targetName, options); + return productFile; +} diff --git a/plugin/build/xcode/addTargetDependency.d.ts b/plugin/build/xcode/addTargetDependency.d.ts new file mode 100644 index 0000000..a4141d4 --- /dev/null +++ b/plugin/build/xcode/addTargetDependency.d.ts @@ -0,0 +1,4 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addTargetDependency(xcodeProject: XcodeProject, target: { + uuid: string; +}): void; diff --git a/plugin/build/xcode/addTargetDependency.js b/plugin/build/xcode/addTargetDependency.js new file mode 100644 index 0000000..8ebf2fb --- /dev/null +++ b/plugin/build/xcode/addTargetDependency.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addTargetDependency = addTargetDependency; +function addTargetDependency(xcodeProject, target) { + if (!xcodeProject.hash.project.objects.PBXTargetDependency) { + xcodeProject.hash.project.objects.PBXTargetDependency = {}; + } + if (!xcodeProject.hash.project.objects.PBXContainerItemProxy) { + xcodeProject.hash.project.objects.PBXContainerItemProxy = {}; + } + xcodeProject.addTargetDependency(xcodeProject.getFirstTarget().uuid, [ + target.uuid, + ]); +} diff --git a/plugin/build/xcode/addToPbxNativeTargetSection.d.ts b/plugin/build/xcode/addToPbxNativeTargetSection.d.ts new file mode 100644 index 0000000..353430c --- /dev/null +++ b/plugin/build/xcode/addToPbxNativeTargetSection.d.ts @@ -0,0 +1,24 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addToPbxNativeTargetSection(xcodeProject: XcodeProject, { targetName, targetUuid, productFile, xCConfigurationList, }: { + targetName: string; + targetUuid: string; + productFile: { + fileRef: string; + }; + xCConfigurationList: { + uuid: string; + }; +}): { + uuid: string; + pbxNativeTarget: { + isa: string; + name: string; + productName: string; + productReference: string; + productType: string; + buildConfigurationList: string; + buildPhases: never[]; + buildRules: never[]; + dependencies: never[]; + }; +}; diff --git a/plugin/build/xcode/addToPbxNativeTargetSection.js b/plugin/build/xcode/addToPbxNativeTargetSection.js new file mode 100644 index 0000000..5631f9b --- /dev/null +++ b/plugin/build/xcode/addToPbxNativeTargetSection.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addToPbxNativeTargetSection = addToPbxNativeTargetSection; +function addToPbxNativeTargetSection(xcodeProject, { targetName, targetUuid, productFile, xCConfigurationList, }) { + const target = { + uuid: targetUuid, + pbxNativeTarget: { + isa: "PBXNativeTarget", + name: targetName, + productName: targetName, + productReference: productFile.fileRef, + productType: `"com.apple.product-type.application.on-demand-install-capable"`, + buildConfigurationList: xCConfigurationList.uuid, + buildPhases: [], + buildRules: [], + dependencies: [], + }, + }; + xcodeProject.addToPbxNativeTargetSection(target); + return target; +} diff --git a/plugin/build/xcode/addToPbxProjectSection.d.ts b/plugin/build/xcode/addToPbxProjectSection.d.ts new file mode 100644 index 0000000..c32cf23 --- /dev/null +++ b/plugin/build/xcode/addToPbxProjectSection.d.ts @@ -0,0 +1,4 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addToPbxProjectSection(xcodeProject: XcodeProject, target: { + uuid: string; +}): void; diff --git a/plugin/build/xcode/addToPbxProjectSection.js b/plugin/build/xcode/addToPbxProjectSection.js new file mode 100644 index 0000000..c7639c7 --- /dev/null +++ b/plugin/build/xcode/addToPbxProjectSection.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addToPbxProjectSection = addToPbxProjectSection; +function addToPbxProjectSection(xcodeProject, target) { + xcodeProject.addToPbxProjectSection(target); + // Add target attributes to project section + if (!xcodeProject.pbxProjectSection()[xcodeProject.getFirstProject().uuid] + .attributes.TargetAttributes) { + xcodeProject.pbxProjectSection()[xcodeProject.getFirstProject().uuid].attributes.TargetAttributes = {}; + } + xcodeProject.pbxProjectSection()[xcodeProject.getFirstProject().uuid].attributes.TargetAttributes[target.uuid] = { + LastSwiftMigration: 1250, + }; +} diff --git a/plugin/build/xcode/addXCConfigurationList.d.ts b/plugin/build/xcode/addXCConfigurationList.d.ts new file mode 100644 index 0000000..14f96c7 --- /dev/null +++ b/plugin/build/xcode/addXCConfigurationList.d.ts @@ -0,0 +1,10 @@ +import type { XcodeProject } from "expo/config-plugins"; +export declare function addXCConfigurationList(xcodeProject: XcodeProject, { name, targetName, currentProjectVersion, bundleIdentifier, deploymentTarget, appleTeamId, appIconName, }: { + name: string; + targetName: string; + currentProjectVersion: string; + bundleIdentifier: string; + deploymentTarget: string; + appleTeamId?: string; + appIconName?: string; +}): any; diff --git a/plugin/build/xcode/addXCConfigurationList.js b/plugin/build/xcode/addXCConfigurationList.js new file mode 100644 index 0000000..38c1057 --- /dev/null +++ b/plugin/build/xcode/addXCConfigurationList.js @@ -0,0 +1,41 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addXCConfigurationList = addXCConfigurationList; +function addXCConfigurationList(xcodeProject, { name, targetName, currentProjectVersion, bundleIdentifier, deploymentTarget, appleTeamId, appIconName, }) { + const commonBuildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME: appIconName + ? JSON.stringify(appIconName) + : "AppIcon", + CLANG_ENABLE_MODULES: "YES", + CURRENT_PROJECT_VERSION: `"${currentProjectVersion}"`, + INFOPLIST_FILE: `${targetName}/Info.plist`, + IPHONEOS_DEPLOYMENT_TARGET: `"${deploymentTarget}"`, + // LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks", + // OTHER_LDFLAGS: `("$(inherited)","-ObjC","-lc++",)`, + PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, + PRODUCT_NAME: `"${name}"`, + SWIFT_VERSION: "5.0", + VERSIONING_SYSTEM: `"apple-generic"`, + // TARGETED_DEVICE_FAMILY: `"1,2"`, + CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`, + ...(appleTeamId ? { DEVELOPMENT_TEAM: appleTeamId } : {}), + }; + const buildConfigurationsList = [ + { + name: "Debug", + isa: "XCBuildConfiguration", + buildSettings: { + ...commonBuildSettings, + }, + }, + { + name: "Release", + isa: "XCBuildConfiguration", + buildSettings: { + ...commonBuildSettings, + }, + }, + ]; + const xCConfigurationList = xcodeProject.addXCConfigurationList(buildConfigurationsList, "Release", `Build configuration list for PBXNativeTarget "${targetName}"`); + return xCConfigurationList; +} diff --git a/plugin/build/xcode/modifyAppDelegate.d.ts b/plugin/build/xcode/modifyAppDelegate.d.ts new file mode 100644 index 0000000..81ca6dc --- /dev/null +++ b/plugin/build/xcode/modifyAppDelegate.d.ts @@ -0,0 +1 @@ +export declare function modifyAppDelegate(appDelegatePath: string): void; diff --git a/plugin/build/xcode/modifyAppDelegate.js b/plugin/build/xcode/modifyAppDelegate.js new file mode 100644 index 0000000..dd383d0 --- /dev/null +++ b/plugin/build/xcode/modifyAppDelegate.js @@ -0,0 +1,112 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.modifyAppDelegate = modifyAppDelegate; +const node_fs_1 = __importDefault(require("node:fs")); +function modifyAppDelegate(appDelegatePath) { + let content = node_fs_1.default.readFileSync(appDelegatePath, "utf8"); + // Check if already modified + if (content.includes("decompressGzipBundle")) { + console.log("✅ AppDelegate already configured for compression"); + return; + } + // Add zlib import at the top + if (!content.includes("import zlib")) { + content = content.replace(/(import.*\n)+/, (imports) => imports + "import zlib\n"); + } + // Find and replace the bundleURL() function + const bundleURLPattern = /override func bundleURL\(\) -> URL\? \{[\s\S]*?#if DEBUG[\s\S]*?#else[\s\S]*?#endif[\s\S]*?\}/; + if (bundleURLPattern.test(content)) { + content = content.replace(bundleURLPattern, `override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return decompressedBundleURL() +#endif + } + + // MARK: - Bundle Decompression + + private func decompressedBundleURL() -> URL? { + if let gzPath = Bundle.main.url(forResource: "main.jsbundle", withExtension: "gz") { + return decompressGzipBundle(at: gzPath) + } + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") + } + + private func decompressGzipBundle(at compressedURL: URL) -> URL? { + let decompressedPath = NSTemporaryDirectory() + "main.jsbundle" + let decompressedURL = URL(fileURLWithPath: decompressedPath) + + try? FileManager.default.removeItem(at: decompressedURL) + + do { + let compressedData = try Data(contentsOf: compressedURL) + guard let decompressedData = compressedData.gunzipped() else { + print("❌ Failed to decompress bundle") + return nil + } + + try decompressedData.write(to: decompressedURL, options: .atomic) + print("✅ Decompressed: \\(compressedData.count / 1024) KB → \\(decompressedData.count / 1024) KB") + + return decompressedURL + } catch { + print("❌ Error: \\(error)") + return nil + } + } +} + +// MARK: - Gzip Extension +extension Data { + func gunzipped() -> Data? { + guard !self.isEmpty else { return self } + + var stream = z_stream() + var decompressed = Data(capacity: self.count * 2) + + let status: CInt = self.withUnsafeBytes { (inputPtr: UnsafeRawBufferPointer) -> CInt in + stream.avail_in = UInt32(self.count) + stream.next_in = UnsafeMutablePointer(mutating: inputPtr.bindMemory(to: UInt8.self).baseAddress!) + + guard inflateInit2_(&stream, 15 + 32, ZLIB_VERSION, Int32(MemoryLayout.size)) == Z_OK else { + return Z_STREAM_ERROR + } + + var result: CInt = Z_OK + repeat { + if Int(stream.total_out) >= decompressed.count { + decompressed.count += self.count / 2 + } + + let outputCount = decompressed.count + let totalOut = Int(stream.total_out) + + result = decompressed.withUnsafeMutableBytes { (outputPtr: UnsafeMutableRawBufferPointer) -> CInt in + stream.avail_out = UInt32(outputCount) - UInt32(totalOut) + stream.next_out = outputPtr.bindMemory(to: UInt8.self).baseAddress!.advanced(by: totalOut) + return inflate(&stream, Z_SYNC_FLUSH) + } + } while result == Z_OK + + inflateEnd(&stream) + return result + } + + guard status == Z_STREAM_END else { + return nil + } + + decompressed.count = Int(stream.total_out) + return decompressed + }`); + node_fs_1.default.writeFileSync(appDelegatePath, content); + console.log("✅ Modified AppDelegate for gzip decompression"); + } + else { + console.warn("⚠️ Could not find bundleURL() function in AppDelegate"); + } +}