UNPKG

react-native-acoustic-connect-beta

Version:

BETA: React native plugin for Acoustic Connect

385 lines 22.6 kB
"use strict"; // Copyright (C) 2026 Acoustic, L.P. All rights reserved. // // NOTICE: This file contains material that is confidential and proprietary to // Acoustic, L.P. and/or other developers. No license is granted under any // intellectual or industrial property rights of Acoustic, L.P. except as may // be provided in an agreement with Acoustic, L.P. Any unauthorized copying or // distribution of content from this file is prohibited. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.withConnectNCE = exports.withNCEPodfile = exports.withNCEXcodeProject = exports.injectPodfileBlock = exports.buildPodfileBlock = exports.buildNCEEntitlements = exports.buildNCEInfoPlist = void 0; const config_plugins_1 = require("@expo/config-plugins"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const withConnectNSE_1 = require("./withConnectNSE"); // ─── Target / config constants ───────────────────────────────────────────── const NSE_TARGET_NAME = 'ConnectNSE'; const NCE_TARGET_NAME = 'ConnectNCE'; // Configs belonging to either extension target must be skipped when mirroring // host build settings. The NCE mod runs AFTER the NSE mod in the composition, // so by the time this executes the pbxproj already carries ConnectNSE configs; // without skipping both, the NCE could mirror the NSE target's settings rather // than the host app's. const EXTENSION_TARGET_NAMES = [NSE_TARGET_NAME, NCE_TARGET_NAME]; // ─── Plist helpers ──────────────────────────────────────────────────────────── /** * Generates the Info.plist content for the ConnectNCE target. * * The NSExtensionAttributes mirror the canonical bare-workflow reference * (Examples/bare-workflow/ios/ConnectNCE/Info.plist), which itself mirrors the * iOS-SDK NCE 1:1: * - UNNotificationExtensionCategory: the category identifiers the Connect * APNs payload routes rich-media notifications through. Must match the * category the SDK/Collector sets — keep in sync with the iOS-SDK NCE. * - UNNotificationExtensionDefaultContentHidden=false: keep the default * system body alongside the custom content view. * - UNNotificationExtensionInitialContentSizeRatio=1.0: start full-height; * the SDK content controller resizes to fit the rendered media. * * RCTNewArchEnabled is intentionally omitted: the NCE links the Connect SDK * only — no React Native runtime is present in the extension process. (The * bare-workflow reference carries it as an Xcode-template leftover; the * plugin-generated target drops it, consistent with the NSE template.) */ function buildNCEInfoPlist() { return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> \t<key>CFBundleDevelopmentRegion</key> \t<string>$(DEVELOPMENT_LANGUAGE)</string> \t<key>CFBundleDisplayName</key> \t<string>ConnectNCE</string> \t<key>CFBundleExecutable</key> \t<string>$(EXECUTABLE_NAME)</string> \t<key>CFBundleIdentifier</key> \t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> \t<key>CFBundleInfoDictionaryVersion</key> \t<string>6.0</string> \t<key>CFBundleName</key> \t<string>$(PRODUCT_NAME)</string> \t<key>CFBundlePackageType</key> \t<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> \t<key>CFBundleShortVersionString</key> \t<string>$(MARKETING_VERSION)</string> \t<key>CFBundleVersion</key> \t<string>$(CURRENT_PROJECT_VERSION)</string> \t<key>NSExtension</key> \t<dict> \t\t<key>NSExtensionAttributes</key> \t\t<dict> \t\t\t<key>UNNotificationExtensionCategory</key> \t\t\t<array> \t\t\t\t<string>ACOUSTIC_RICH_NOTIFICATION</string> \t\t\t\t<string>ACTIONABLE_NOTIFICATION</string> \t\t\t</array> \t\t\t<key>UNNotificationExtensionDefaultContentHidden</key> \t\t\t<false/> \t\t\t<key>UNNotificationExtensionInitialContentSizeRatio</key> \t\t\t<real>1.0</real> \t\t</dict> \t\t<key>NSExtensionPointIdentifier</key> \t\t<string>com.apple.usernotifications.content-extension</string> \t\t<key>NSExtensionPrincipalClass</key> \t\t<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string> \t</dict> </dict> </plist> `; } exports.buildNCEInfoPlist = buildNCEInfoPlist; /** Generates the entitlements plist content for the ConnectNCE target. */ function buildNCEEntitlements(appGroupIdentifier) { return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> \t<key>com.apple.security.application-groups</key> \t<array> \t\t<string>${appGroupIdentifier}</string> \t</array> </dict> </plist> `; } exports.buildNCEEntitlements = buildNCEEntitlements; // ─── Podfile injection ──────────────────────────────────────────────────────── const PODFILE_MARKER = '# @generated ConnectNCE target (react-native-acoustic-connect-beta)'; /** * Returns the Ruby snippet to inject into the Expo-generated Podfile for the * ConnectNCE target. Thin wrapper over the shared * {@link buildConnectPodTargetBlock} (single source of truth for the * AcousticConnect pod-resolution helper), parameterised with the NCE marker, * helper name, and target name. */ function buildPodfileBlock() { return (0, withConnectNSE_1.buildConnectPodTargetBlock)(PODFILE_MARKER, 'acoustic_connect_pod_nce', NCE_TARGET_NAME); } exports.buildPodfileBlock = buildPodfileBlock; /** * Injects the ConnectNCE Podfile block into `podfileContent` if not already * present (idempotent, guarded by PODFILE_MARKER). */ function injectPodfileBlock(podfileContent) { if (podfileContent.includes(PODFILE_MARKER)) { return podfileContent; } return podfileContent + buildPodfileBlock(); } exports.injectPodfileBlock = injectPodfileBlock; // ─── Xcode project mod ─────────────────────────────────────────────────────── /** * Adds the ConnectNCE Xcode target (app_extension) to the project. * * Sibling to withNSEXcodeProject; the only differences are the target name, * the Swift source file (NotificationViewController.swift), the NCE Info.plist * (content-extension NSExtension keys), and skipping BOTH extension targets' * configs when mirroring host build settings. * * Idempotent: if a target named ConnectNCE already exists, skips target * creation but still (re)writes the source files. */ function withNCEXcodeProject(config, appGroupIdentifier, swiftContent) { return (0, config_plugins_1.withXcodeProject)(config, (c) => { var _a, _b, _c, _d; const xcodeProject = c.modResults; // ios/ directory — modRequest.platformProjectRoot is the canonical // native project root. (xcodeProject.filepath points INSIDE the // .xcodeproj bundle, so deriving from it misplaces the files.) const iosDir = c.modRequest.platformProjectRoot; const nceDir = path.join(iosDir, NCE_TARGET_NAME); // Always write / overwrite the source files (idempotent) fs.mkdirSync(nceDir, { recursive: true }); fs.writeFileSync(path.join(nceDir, 'NotificationViewController.swift'), swiftContent, 'utf8'); fs.writeFileSync(path.join(nceDir, 'Info.plist'), buildNCEInfoPlist(), 'utf8'); fs.writeFileSync(path.join(nceDir, `${NCE_TARGET_NAME}.entitlements`), buildNCEEntitlements(appGroupIdentifier), 'utf8'); // Idempotency check — skip if target already registered in the pbxproj. // Note: pbxTargetByName misses targets on a REPARSED project because the // xcode lib stores written names with literal quotes ('"ConnectNCE"'), // so compare quote-stripped names across the native-target section. const nativeTargets = xcodeProject.pbxNativeTargetSection(); const targetExists = Object.values(nativeTargets).some((t) => typeof t === 'object' && typeof t.name === 'string' && t.name.replace(/"/g, '') === NCE_TARGET_NAME); if (targetExists) { return c; } // Derive the host bundle id — the NCE bundle id must be // `<hostBundleId>.ConnectNCE`. Fail fast rather than emit a placeholder // (a wrong bundle id breaks App Group pairing and App Store submission). const hostBundleId = (_a = c.ios) === null || _a === void 0 ? void 0 : _a.bundleIdentifier; if (!hostBundleId) { throw new Error(`[react-native-acoustic-connect-beta] ios.bundleIdentifier is not set.\n\n` + `The ConnectNCE extension bundle id is derived as ` + `"<ios.bundleIdentifier>.ConnectNCE", so the host bundle id must be ` + `defined in app.json before prebuild:\n` + ` { "expo": { "ios": { "bundleIdentifier": "com.example.app" } } }`); } // Add the native target (app_extension) const nceTarget = xcodeProject.addTarget(NCE_TARGET_NAME, 'app_extension', NCE_TARGET_NAME, `${hostBundleId}.ConnectNCE`); if (!nceTarget) { throw new Error(`[react-native-acoustic-connect-beta] Failed to add ConnectNCE Xcode target.`); } // Add the build phase for Swift sources xcodeProject.addBuildPhase(['NotificationViewController.swift'], 'PBXSourcesBuildPhase', 'Sources', nceTarget.uuid); // Add Resources build phase (empty, but required by Xcode) xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', nceTarget.uuid); // Add the Frameworks build phase, then explicitly link the two // user-notifications system frameworks into the NCE target. // // The NCE principal class conforms to `UNNotificationContentExtension`, // whose extension-point context class // (`_UNNotificationContentExtensionVendorContext`) is vended by // UserNotificationsUI.framework, and whose `didReceive(_:)` consumes // `UNNotification` / `UNNotificationContent` from UserNotifications.framework. // Without these explicit links the extension binary never loads them, so on // a PHYSICAL DEVICE iOS logs "Unable to find NSExtensionContextClass … did // you link the framework that declares the extension point?" and never calls // `didReceive(_:)`. The custom expanded view (rich-media `expandedImage`) // then silently never renders, while the NSE-produced thumbnail still shows. // The iOS Simulator resolves these classes implicitly, masking the omission — // so this only surfaces on device. Both are Apple SYSTEM frameworks, so // CocoaPods does NOT add them; they must be linked here, mirroring the // canonical Examples/bare-workflow ConnectNCE target (which also lists the // auto-linked Foundation/UIKit — omitted here as Swift links them implicitly). xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', nceTarget.uuid); xcodeProject.addFramework('System/Library/Frameworks/UserNotifications.framework', { target: nceTarget.uuid }); xcodeProject.addFramework('System/Library/Frameworks/UserNotificationsUI.framework', { target: nceTarget.uuid }); // Make the host app target depend on ConnectNCE. CocoaPods resolves an // extension's host target through PBXTargetDependency // (xcodeproj's host_targets_for_embedded_target), NOT through the embed // copy phase — without this edge `pod install` fails with "Unable to find // host target(s) for ConnectNCE". // The xcode lib SILENTLY no-ops addTargetDependency when these sections // are absent from the parsed project — create them first. (The NSE mod // already creates them when it runs before this; guarding here keeps the // NCE mod correct if it ever runs first.) const objects = xcodeProject.hash.project.objects; objects['PBXTargetDependency'] = (_b = objects['PBXTargetDependency']) !== null && _b !== void 0 ? _b : {}; objects['PBXContainerItemProxy'] = (_c = objects['PBXContainerItemProxy']) !== null && _c !== void 0 ? _c : {}; const hostTargetUuid = xcodeProject.getFirstTarget().uuid; xcodeProject.addTargetDependency(hostTargetUuid, [nceTarget.uuid]); // Create a PBXGroup for ConnectNCE source files const nceGroup = xcodeProject.addPbxGroup([ 'NotificationViewController.swift', 'Info.plist', `${NCE_TARGET_NAME}.entitlements`, ], NCE_TARGET_NAME, NCE_TARGET_NAME); // Add the group to the main project group const mainGroup = xcodeProject.getFirstProject().firstProject.mainGroup; xcodeProject.addToPbxGroup(nceGroup.uuid, mainGroup); // Set per-configuration build settings — mirror each host configuration // individually so Debug gets Debug values and Release gets Release values. const buildConfigurations = xcodeProject.pbxXCConfigurationList(); const nceConfigListUuid = nceTarget.pbxNativeTarget.buildConfigurationList; if (nceConfigListUuid) { const configList = buildConfigurations[nceConfigListUuid]; if (configList && Array.isArray(configList .buildConfigurations)) { const configs = xcodeProject.pbxXCBuildConfigurationSection(); for (const entry of configList.buildConfigurations) { const buildConfig = configs[entry.value]; if (buildConfig && buildConfig.buildSettings) { // Determine which host config name to mirror (Debug → Debug, etc.) const configName = (_d = buildConfig.name) !== null && _d !== void 0 ? _d : 'Debug'; // Mirror per-configuration values; fall back to the other config's // value only when the matching one is absent. Skip BOTH extension // targets so the NCE never mirrors the NSE target's settings. const deploymentTarget = (0, withConnectNSE_1.getHostBuildSettingForConfig)(xcodeProject, 'IPHONEOS_DEPLOYMENT_TARGET', configName, // Matches the AcousticConnect podspec floor (iOS >= 15.1) so the // NCE never demands a higher minimum than the host app. '15.1', EXTENSION_TARGET_NAMES); const swiftVersion = (0, withConnectNSE_1.getHostBuildSettingForConfig)(xcodeProject, 'SWIFT_VERSION', configName, '5.0', EXTENSION_TARGET_NAMES); const marketingVersion = (0, withConnectNSE_1.getHostBuildSettingForConfig)(xcodeProject, 'MARKETING_VERSION', configName, '1.0', EXTENSION_TARGET_NAMES); const currentProjectVersion = (0, withConnectNSE_1.getHostBuildSettingForConfig)(xcodeProject, 'CURRENT_PROJECT_VERSION', configName, '1', EXTENSION_TARGET_NAMES); Object.assign(buildConfig.buildSettings, { // Prevent Xcode from synthesising a second Info.plist that would // shadow the NSExtension dictionary in ours. GENERATE_INFOPLIST_FILE: 'NO', INFOPLIST_FILE: `${NCE_TARGET_NAME}/Info.plist`, // Extension must target the device SDK and restrict to // extension-safe APIs (mirrors bare-workflow reference target). SDKROOT: 'iphoneos', APPLICATION_EXTENSION_API_ONLY: 'YES', IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget, SWIFT_VERSION: swiftVersion, MARKETING_VERSION: marketingVersion, CURRENT_PROJECT_VERSION: currentProjectVersion, CODE_SIGN_ENTITLEMENTS: `${NCE_TARGET_NAME}/${NCE_TARGET_NAME}.entitlements`, PRODUCT_NAME: NCE_TARGET_NAME, PRODUCT_BUNDLE_IDENTIFIER: `${hostBundleId}.ConnectNCE`, TARGETED_DEVICE_FAMILY: '"1,2"', CODE_SIGN_STYLE: 'Automatic', }); } } } } return c; }); } exports.withNCEXcodeProject = withNCEXcodeProject; // ─── Podfile mod ───────────────────────────────────────────────────────────── /** * Injects the ConnectNCE Podfile target block via withDangerousMod. * Guarded by a marker comment — re-runs are no-ops. * * Throws a clear, actionable error when the Podfile does not exist at * mod-execution time, instead of silently returning — a silent skip would * ship an NCE whose `import Connect` cannot resolve. */ function withNCEPodfile(config) { return (0, config_plugins_1.withDangerousMod)(config, [ 'ios', async (c) => { const podfilePath = path.join(c.modRequest.platformProjectRoot, 'Podfile'); if (!fs.existsSync(podfilePath)) { throw new Error(`[react-native-acoustic-connect-beta] ios/Podfile not found at ${podfilePath}.\n\n` + `This mod runs after prebuild generates the ios/ directory. ` + `If you are running this plugin outside of \`expo prebuild\`, ` + `ensure the Podfile exists before the dangerous mod phase executes.\n` + `Check prebuild ordering: withDangerousMod('ios') runs after ` + `withXcodeProject, so the ios/ directory should already be present.`); } const current = fs.readFileSync(podfilePath, 'utf8'); const updated = injectPodfileBlock(current); if (updated !== current) { fs.writeFileSync(podfilePath, updated, 'utf8'); } return c; }, ]); } exports.withNCEPodfile = withNCEPodfile; // ─── Composed mod ───────────────────────────────────────────────────────────── /** * Expo Config Plugin mod that provisions a Notification Content Extension * (NCE) Xcode target named `ConnectNCE`. * * Applies three mutations — all idempotent: * 1. Host app entitlements: merges App Group into * `com.apple.security.application-groups` (reuses the NSE host-entitlements * mod — the host shares one App Group across app + NSE + NCE; the merge is * a no-op when the NSE mod already added it). * 2. Xcode project: adds ConnectNCE target + files (skips if already present). * 3. Podfile: injects `target 'ConnectNCE'` block (guarded by marker comment). * * `config._internal.projectRoot` is required (injected by Expo CLI during * `expo prebuild`); an actionable error is thrown if absent rather than * silently falling back to `process.cwd()`. * * NSE coupling (intentional, tracked for refactor): this mod deliberately * reuses three pieces of withConnectNSE — `withNSEEntitlements` (the host App * Group is shared across app + NSE + NCE), `buildConnectPodTargetBlock` (one * source for the pod-resolution helper), and `getHostBuildSettingForConfig` * (the per-config mirroring logic). The composition always runs withConnectNSE * before withConnectNCE, so the shared host entitlement and the * PBXTargetDependency/PBXContainerItemProxy sections already exist when this * runs. Extracting a shared `withConnectExtension` base for NSE + NCE is * tracked as a follow-up refactor (see PR description) rather than done here, * to keep the already-landed NSE mod stable. */ const withConnectNCE = (config, props = {}) => { var _a; const projectRoot = (_a = config._internal) === null || _a === void 0 ? void 0 : _a.projectRoot; if (!projectRoot) { throw new Error(`[react-native-acoustic-connect-beta] config._internal.projectRoot is not set.\n\n` + `This value is injected by Expo CLI during \`expo prebuild\`. ` + `If you are calling this plugin outside of Expo CLI (e.g. in a test or ` + `custom script), set config._internal = { projectRoot: '/abs/path/to/project' } ` + `before invoking the plugin.`); } const appGroupIdentifier = (0, withConnectNSE_1.resolveAppGroupIdentifier)(projectRoot, props); const swiftTemplatePath = path.join(__dirname, '..', 'swift', 'NotificationViewController.swift'); const swiftContent = (0, withConnectNSE_1.substituteSwiftTemplate)(swiftTemplatePath, appGroupIdentifier); let result = (0, withConnectNSE_1.withNSEEntitlements)(config, appGroupIdentifier); result = withNCEXcodeProject(result, appGroupIdentifier, swiftContent); result = withNCEPodfile(result); return result; }; exports.withConnectNCE = withConnectNCE; //# sourceMappingURL=withConnectNCE.js.map