react-native-acoustic-connect-beta
Version:
BETA: React native plugin for Acoustic Connect
385 lines • 22.6 kB
JavaScript
;
// 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