react-native-acoustic-connect-beta 18.0.35 → 18.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,512 @@
1
+ // Copyright (C) 2026 Acoustic, L.P. All rights reserved.
2
+ //
3
+ // NOTICE: This file contains material that is confidential and proprietary to
4
+ // Acoustic, L.P. and/or other developers. No license is granted under any
5
+ // intellectual or industrial property rights of Acoustic, L.P. except as may
6
+ // be provided in an agreement with Acoustic, L.P. Any unauthorized copying or
7
+ // distribution of content from this file is prohibited.
8
+
9
+ import {
10
+ withXcodeProject,
11
+ withDangerousMod,
12
+ type ConfigPlugin,
13
+ } from '@expo/config-plugins'
14
+ import type { ExpoConfig } from '@expo/config-types'
15
+ import * as fs from 'fs'
16
+ import * as path from 'path'
17
+ import {
18
+ resolveAppGroupIdentifier,
19
+ substituteSwiftTemplate,
20
+ getHostBuildSettingForConfig,
21
+ buildConnectPodTargetBlock,
22
+ withNSEEntitlements,
23
+ type ConnectPluginProps,
24
+ } from './withConnectNSE'
25
+
26
+ // XcodeProject is declared in the `xcode` package which ships no TypeScript
27
+ // types of its own. We derive the type from the withXcodeProject callback
28
+ // so the compiler can still check our usage without requiring @types/xcode.
29
+ type XcodeProject = Parameters<
30
+ Parameters<typeof withXcodeProject>[1]
31
+ >[0]['modResults']
32
+
33
+ // ─── Target / config constants ─────────────────────────────────────────────
34
+
35
+ const NSE_TARGET_NAME = 'ConnectNSE'
36
+ const NCE_TARGET_NAME = 'ConnectNCE'
37
+
38
+ // Configs belonging to either extension target must be skipped when mirroring
39
+ // host build settings. The NCE mod runs AFTER the NSE mod in the composition,
40
+ // so by the time this executes the pbxproj already carries ConnectNSE configs;
41
+ // without skipping both, the NCE could mirror the NSE target's settings rather
42
+ // than the host app's.
43
+ const EXTENSION_TARGET_NAMES = [NSE_TARGET_NAME, NCE_TARGET_NAME]
44
+
45
+ // ─── Plist helpers ────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Generates the Info.plist content for the ConnectNCE target.
49
+ *
50
+ * The NSExtensionAttributes mirror the canonical bare-workflow reference
51
+ * (Examples/bare-workflow/ios/ConnectNCE/Info.plist), which itself mirrors the
52
+ * iOS-SDK NCE 1:1:
53
+ * - UNNotificationExtensionCategory: the category identifiers the Connect
54
+ * APNs payload routes rich-media notifications through. Must match the
55
+ * category the SDK/Collector sets — keep in sync with the iOS-SDK NCE.
56
+ * - UNNotificationExtensionDefaultContentHidden=false: keep the default
57
+ * system body alongside the custom content view.
58
+ * - UNNotificationExtensionInitialContentSizeRatio=1.0: start full-height;
59
+ * the SDK content controller resizes to fit the rendered media.
60
+ *
61
+ * RCTNewArchEnabled is intentionally omitted: the NCE links the Connect SDK
62
+ * only — no React Native runtime is present in the extension process. (The
63
+ * bare-workflow reference carries it as an Xcode-template leftover; the
64
+ * plugin-generated target drops it, consistent with the NSE template.)
65
+ */
66
+ export function buildNCEInfoPlist(): string {
67
+ return `<?xml version="1.0" encoding="UTF-8"?>
68
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
69
+ <plist version="1.0">
70
+ <dict>
71
+ \t<key>CFBundleDevelopmentRegion</key>
72
+ \t<string>$(DEVELOPMENT_LANGUAGE)</string>
73
+ \t<key>CFBundleDisplayName</key>
74
+ \t<string>ConnectNCE</string>
75
+ \t<key>CFBundleExecutable</key>
76
+ \t<string>$(EXECUTABLE_NAME)</string>
77
+ \t<key>CFBundleIdentifier</key>
78
+ \t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
79
+ \t<key>CFBundleInfoDictionaryVersion</key>
80
+ \t<string>6.0</string>
81
+ \t<key>CFBundleName</key>
82
+ \t<string>$(PRODUCT_NAME)</string>
83
+ \t<key>CFBundlePackageType</key>
84
+ \t<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
85
+ \t<key>CFBundleShortVersionString</key>
86
+ \t<string>$(MARKETING_VERSION)</string>
87
+ \t<key>CFBundleVersion</key>
88
+ \t<string>$(CURRENT_PROJECT_VERSION)</string>
89
+ \t<key>NSExtension</key>
90
+ \t<dict>
91
+ \t\t<key>NSExtensionAttributes</key>
92
+ \t\t<dict>
93
+ \t\t\t<key>UNNotificationExtensionCategory</key>
94
+ \t\t\t<array>
95
+ \t\t\t\t<string>ACOUSTIC_RICH_NOTIFICATION</string>
96
+ \t\t\t\t<string>ACTIONABLE_NOTIFICATION</string>
97
+ \t\t\t</array>
98
+ \t\t\t<key>UNNotificationExtensionDefaultContentHidden</key>
99
+ \t\t\t<false/>
100
+ \t\t\t<key>UNNotificationExtensionInitialContentSizeRatio</key>
101
+ \t\t\t<real>1.0</real>
102
+ \t\t</dict>
103
+ \t\t<key>NSExtensionPointIdentifier</key>
104
+ \t\t<string>com.apple.usernotifications.content-extension</string>
105
+ \t\t<key>NSExtensionPrincipalClass</key>
106
+ \t\t<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
107
+ \t</dict>
108
+ </dict>
109
+ </plist>
110
+ `
111
+ }
112
+
113
+ /** Generates the entitlements plist content for the ConnectNCE target. */
114
+ export function buildNCEEntitlements(appGroupIdentifier: string): string {
115
+ return `<?xml version="1.0" encoding="UTF-8"?>
116
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
117
+ <plist version="1.0">
118
+ <dict>
119
+ \t<key>com.apple.security.application-groups</key>
120
+ \t<array>
121
+ \t\t<string>${appGroupIdentifier}</string>
122
+ \t</array>
123
+ </dict>
124
+ </plist>
125
+ `
126
+ }
127
+
128
+ // ─── Podfile injection ────────────────────────────────────────────────────────
129
+
130
+ const PODFILE_MARKER =
131
+ '# @generated ConnectNCE target (react-native-acoustic-connect-beta)'
132
+
133
+ /**
134
+ * Returns the Ruby snippet to inject into the Expo-generated Podfile for the
135
+ * ConnectNCE target. Thin wrapper over the shared
136
+ * {@link buildConnectPodTargetBlock} (single source of truth for the
137
+ * AcousticConnect pod-resolution helper), parameterised with the NCE marker,
138
+ * helper name, and target name.
139
+ */
140
+ export function buildPodfileBlock(): string {
141
+ return buildConnectPodTargetBlock(
142
+ PODFILE_MARKER,
143
+ 'acoustic_connect_pod_nce',
144
+ NCE_TARGET_NAME
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Injects the ConnectNCE Podfile block into `podfileContent` if not already
150
+ * present (idempotent, guarded by PODFILE_MARKER).
151
+ */
152
+ export function injectPodfileBlock(podfileContent: string): string {
153
+ if (podfileContent.includes(PODFILE_MARKER)) {
154
+ return podfileContent
155
+ }
156
+ return podfileContent + buildPodfileBlock()
157
+ }
158
+
159
+ // ─── Xcode project mod ───────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Adds the ConnectNCE Xcode target (app_extension) to the project.
163
+ *
164
+ * Sibling to withNSEXcodeProject; the only differences are the target name,
165
+ * the Swift source file (NotificationViewController.swift), the NCE Info.plist
166
+ * (content-extension NSExtension keys), and skipping BOTH extension targets'
167
+ * configs when mirroring host build settings.
168
+ *
169
+ * Idempotent: if a target named ConnectNCE already exists, skips target
170
+ * creation but still (re)writes the source files.
171
+ */
172
+ export function withNCEXcodeProject(
173
+ config: ExpoConfig,
174
+ appGroupIdentifier: string,
175
+ swiftContent: string
176
+ ): ExpoConfig {
177
+ return withXcodeProject(config, (c) => {
178
+ const xcodeProject: XcodeProject = c.modResults
179
+
180
+ // ios/ directory — modRequest.platformProjectRoot is the canonical
181
+ // native project root. (xcodeProject.filepath points INSIDE the
182
+ // .xcodeproj bundle, so deriving from it misplaces the files.)
183
+ const iosDir = c.modRequest.platformProjectRoot
184
+ const nceDir = path.join(iosDir, NCE_TARGET_NAME)
185
+
186
+ // Always write / overwrite the source files (idempotent)
187
+ fs.mkdirSync(nceDir, { recursive: true })
188
+
189
+ fs.writeFileSync(
190
+ path.join(nceDir, 'NotificationViewController.swift'),
191
+ swiftContent,
192
+ 'utf8'
193
+ )
194
+ fs.writeFileSync(
195
+ path.join(nceDir, 'Info.plist'),
196
+ buildNCEInfoPlist(),
197
+ 'utf8'
198
+ )
199
+ fs.writeFileSync(
200
+ path.join(nceDir, `${NCE_TARGET_NAME}.entitlements`),
201
+ buildNCEEntitlements(appGroupIdentifier),
202
+ 'utf8'
203
+ )
204
+
205
+ // Idempotency check — skip if target already registered in the pbxproj.
206
+ // Note: pbxTargetByName misses targets on a REPARSED project because the
207
+ // xcode lib stores written names with literal quotes ('"ConnectNCE"'),
208
+ // so compare quote-stripped names across the native-target section.
209
+ const nativeTargets = xcodeProject.pbxNativeTargetSection() as Record<
210
+ string,
211
+ { name?: string } | string
212
+ >
213
+ const targetExists = Object.values(nativeTargets).some(
214
+ (t) =>
215
+ typeof t === 'object' &&
216
+ typeof t.name === 'string' &&
217
+ t.name.replace(/"/g, '') === NCE_TARGET_NAME
218
+ )
219
+ if (targetExists) {
220
+ return c
221
+ }
222
+
223
+ // Derive the host bundle id — the NCE bundle id must be
224
+ // `<hostBundleId>.ConnectNCE`. Fail fast rather than emit a placeholder
225
+ // (a wrong bundle id breaks App Group pairing and App Store submission).
226
+ const hostBundleId = c.ios?.bundleIdentifier as string | undefined
227
+ if (!hostBundleId) {
228
+ throw new Error(
229
+ `[react-native-acoustic-connect-beta] ios.bundleIdentifier is not set.\n\n` +
230
+ `The ConnectNCE extension bundle id is derived as ` +
231
+ `"<ios.bundleIdentifier>.ConnectNCE", so the host bundle id must be ` +
232
+ `defined in app.json before prebuild:\n` +
233
+ ` { "expo": { "ios": { "bundleIdentifier": "com.example.app" } } }`
234
+ )
235
+ }
236
+
237
+ // Add the native target (app_extension)
238
+ const nceTarget = xcodeProject.addTarget(
239
+ NCE_TARGET_NAME,
240
+ 'app_extension',
241
+ NCE_TARGET_NAME,
242
+ `${hostBundleId}.ConnectNCE`
243
+ )
244
+
245
+ if (!nceTarget) {
246
+ throw new Error(
247
+ `[react-native-acoustic-connect-beta] Failed to add ConnectNCE Xcode target.`
248
+ )
249
+ }
250
+
251
+ // Add the build phase for Swift sources
252
+ xcodeProject.addBuildPhase(
253
+ ['NotificationViewController.swift'],
254
+ 'PBXSourcesBuildPhase',
255
+ 'Sources',
256
+ nceTarget.uuid
257
+ )
258
+
259
+ // Add Resources build phase (empty, but required by Xcode)
260
+ xcodeProject.addBuildPhase(
261
+ [],
262
+ 'PBXResourcesBuildPhase',
263
+ 'Resources',
264
+ nceTarget.uuid
265
+ )
266
+
267
+ // Add the Frameworks build phase, then explicitly link the two
268
+ // user-notifications system frameworks into the NCE target.
269
+ //
270
+ // The NCE principal class conforms to `UNNotificationContentExtension`,
271
+ // whose extension-point context class
272
+ // (`_UNNotificationContentExtensionVendorContext`) is vended by
273
+ // UserNotificationsUI.framework, and whose `didReceive(_:)` consumes
274
+ // `UNNotification` / `UNNotificationContent` from UserNotifications.framework.
275
+ // Without these explicit links the extension binary never loads them, so on
276
+ // a PHYSICAL DEVICE iOS logs "Unable to find NSExtensionContextClass … did
277
+ // you link the framework that declares the extension point?" and never calls
278
+ // `didReceive(_:)`. The custom expanded view (rich-media `expandedImage`)
279
+ // then silently never renders, while the NSE-produced thumbnail still shows.
280
+ // The iOS Simulator resolves these classes implicitly, masking the omission —
281
+ // so this only surfaces on device. Both are Apple SYSTEM frameworks, so
282
+ // CocoaPods does NOT add them; they must be linked here, mirroring the
283
+ // canonical Examples/bare-workflow ConnectNCE target (which also lists the
284
+ // auto-linked Foundation/UIKit — omitted here as Swift links them implicitly).
285
+ xcodeProject.addBuildPhase(
286
+ [],
287
+ 'PBXFrameworksBuildPhase',
288
+ 'Frameworks',
289
+ nceTarget.uuid
290
+ )
291
+ xcodeProject.addFramework(
292
+ 'System/Library/Frameworks/UserNotifications.framework',
293
+ { target: nceTarget.uuid }
294
+ )
295
+ xcodeProject.addFramework(
296
+ 'System/Library/Frameworks/UserNotificationsUI.framework',
297
+ { target: nceTarget.uuid }
298
+ )
299
+
300
+ // Make the host app target depend on ConnectNCE. CocoaPods resolves an
301
+ // extension's host target through PBXTargetDependency
302
+ // (xcodeproj's host_targets_for_embedded_target), NOT through the embed
303
+ // copy phase — without this edge `pod install` fails with "Unable to find
304
+ // host target(s) for ConnectNCE".
305
+ // The xcode lib SILENTLY no-ops addTargetDependency when these sections
306
+ // are absent from the parsed project — create them first. (The NSE mod
307
+ // already creates them when it runs before this; guarding here keeps the
308
+ // NCE mod correct if it ever runs first.)
309
+ const objects = xcodeProject.hash.project.objects
310
+ objects['PBXTargetDependency'] = objects['PBXTargetDependency'] ?? {}
311
+ objects['PBXContainerItemProxy'] = objects['PBXContainerItemProxy'] ?? {}
312
+ const hostTargetUuid = xcodeProject.getFirstTarget().uuid
313
+ xcodeProject.addTargetDependency(hostTargetUuid, [nceTarget.uuid])
314
+
315
+ // Create a PBXGroup for ConnectNCE source files
316
+ const nceGroup = xcodeProject.addPbxGroup(
317
+ [
318
+ 'NotificationViewController.swift',
319
+ 'Info.plist',
320
+ `${NCE_TARGET_NAME}.entitlements`,
321
+ ],
322
+ NCE_TARGET_NAME,
323
+ NCE_TARGET_NAME
324
+ )
325
+
326
+ // Add the group to the main project group
327
+ const mainGroup = xcodeProject.getFirstProject().firstProject.mainGroup
328
+ xcodeProject.addToPbxGroup(nceGroup.uuid, mainGroup)
329
+
330
+ // Set per-configuration build settings — mirror each host configuration
331
+ // individually so Debug gets Debug values and Release gets Release values.
332
+ const buildConfigurations = xcodeProject.pbxXCConfigurationList()
333
+ const nceConfigListUuid = nceTarget.pbxNativeTarget.buildConfigurationList
334
+
335
+ if (nceConfigListUuid) {
336
+ const configList = buildConfigurations[nceConfigListUuid]
337
+ if (
338
+ configList &&
339
+ Array.isArray(
340
+ (configList as { buildConfigurations?: unknown[] })
341
+ .buildConfigurations
342
+ )
343
+ ) {
344
+ const configs = xcodeProject.pbxXCBuildConfigurationSection()
345
+ for (const entry of (
346
+ configList as { buildConfigurations: Array<{ value: string }> }
347
+ ).buildConfigurations) {
348
+ const buildConfig = configs[entry.value]
349
+ if (buildConfig && buildConfig.buildSettings) {
350
+ // Determine which host config name to mirror (Debug → Debug, etc.)
351
+ const configName: string =
352
+ (buildConfig as { name?: string }).name ?? 'Debug'
353
+
354
+ // Mirror per-configuration values; fall back to the other config's
355
+ // value only when the matching one is absent. Skip BOTH extension
356
+ // targets so the NCE never mirrors the NSE target's settings.
357
+ const deploymentTarget = getHostBuildSettingForConfig(
358
+ xcodeProject,
359
+ 'IPHONEOS_DEPLOYMENT_TARGET',
360
+ configName,
361
+ // Matches the AcousticConnect podspec floor (iOS >= 15.1) so the
362
+ // NCE never demands a higher minimum than the host app.
363
+ '15.1',
364
+ EXTENSION_TARGET_NAMES
365
+ )
366
+ const swiftVersion = getHostBuildSettingForConfig(
367
+ xcodeProject,
368
+ 'SWIFT_VERSION',
369
+ configName,
370
+ '5.0',
371
+ EXTENSION_TARGET_NAMES
372
+ )
373
+ const marketingVersion = getHostBuildSettingForConfig(
374
+ xcodeProject,
375
+ 'MARKETING_VERSION',
376
+ configName,
377
+ '1.0',
378
+ EXTENSION_TARGET_NAMES
379
+ )
380
+ const currentProjectVersion = getHostBuildSettingForConfig(
381
+ xcodeProject,
382
+ 'CURRENT_PROJECT_VERSION',
383
+ configName,
384
+ '1',
385
+ EXTENSION_TARGET_NAMES
386
+ )
387
+
388
+ Object.assign(buildConfig.buildSettings, {
389
+ // Prevent Xcode from synthesising a second Info.plist that would
390
+ // shadow the NSExtension dictionary in ours.
391
+ GENERATE_INFOPLIST_FILE: 'NO',
392
+ INFOPLIST_FILE: `${NCE_TARGET_NAME}/Info.plist`,
393
+ // Extension must target the device SDK and restrict to
394
+ // extension-safe APIs (mirrors bare-workflow reference target).
395
+ SDKROOT: 'iphoneos',
396
+ APPLICATION_EXTENSION_API_ONLY: 'YES',
397
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
398
+ SWIFT_VERSION: swiftVersion,
399
+ MARKETING_VERSION: marketingVersion,
400
+ CURRENT_PROJECT_VERSION: currentProjectVersion,
401
+ CODE_SIGN_ENTITLEMENTS: `${NCE_TARGET_NAME}/${NCE_TARGET_NAME}.entitlements`,
402
+ PRODUCT_NAME: NCE_TARGET_NAME,
403
+ PRODUCT_BUNDLE_IDENTIFIER: `${hostBundleId}.ConnectNCE`,
404
+ TARGETED_DEVICE_FAMILY: '"1,2"',
405
+ CODE_SIGN_STYLE: 'Automatic',
406
+ })
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ return c
413
+ })
414
+ }
415
+
416
+ // ─── Podfile mod ─────────────────────────────────────────────────────────────
417
+
418
+ /**
419
+ * Injects the ConnectNCE Podfile target block via withDangerousMod.
420
+ * Guarded by a marker comment — re-runs are no-ops.
421
+ *
422
+ * Throws a clear, actionable error when the Podfile does not exist at
423
+ * mod-execution time, instead of silently returning — a silent skip would
424
+ * ship an NCE whose `import Connect` cannot resolve.
425
+ */
426
+ export function withNCEPodfile(config: ExpoConfig): ExpoConfig {
427
+ return withDangerousMod(config, [
428
+ 'ios',
429
+ async (c) => {
430
+ const podfilePath = path.join(c.modRequest.platformProjectRoot, 'Podfile')
431
+ if (!fs.existsSync(podfilePath)) {
432
+ throw new Error(
433
+ `[react-native-acoustic-connect-beta] ios/Podfile not found at ${podfilePath}.\n\n` +
434
+ `This mod runs after prebuild generates the ios/ directory. ` +
435
+ `If you are running this plugin outside of \`expo prebuild\`, ` +
436
+ `ensure the Podfile exists before the dangerous mod phase executes.\n` +
437
+ `Check prebuild ordering: withDangerousMod('ios') runs after ` +
438
+ `withXcodeProject, so the ios/ directory should already be present.`
439
+ )
440
+ }
441
+ const current = fs.readFileSync(podfilePath, 'utf8')
442
+ const updated = injectPodfileBlock(current)
443
+ if (updated !== current) {
444
+ fs.writeFileSync(podfilePath, updated, 'utf8')
445
+ }
446
+ return c
447
+ },
448
+ ])
449
+ }
450
+
451
+ // ─── Composed mod ─────────────────────────────────────────────────────────────
452
+
453
+ /**
454
+ * Expo Config Plugin mod that provisions a Notification Content Extension
455
+ * (NCE) Xcode target named `ConnectNCE`.
456
+ *
457
+ * Applies three mutations — all idempotent:
458
+ * 1. Host app entitlements: merges App Group into
459
+ * `com.apple.security.application-groups` (reuses the NSE host-entitlements
460
+ * mod — the host shares one App Group across app + NSE + NCE; the merge is
461
+ * a no-op when the NSE mod already added it).
462
+ * 2. Xcode project: adds ConnectNCE target + files (skips if already present).
463
+ * 3. Podfile: injects `target 'ConnectNCE'` block (guarded by marker comment).
464
+ *
465
+ * `config._internal.projectRoot` is required (injected by Expo CLI during
466
+ * `expo prebuild`); an actionable error is thrown if absent rather than
467
+ * silently falling back to `process.cwd()`.
468
+ *
469
+ * NSE coupling (intentional, tracked for refactor): this mod deliberately
470
+ * reuses three pieces of withConnectNSE — `withNSEEntitlements` (the host App
471
+ * Group is shared across app + NSE + NCE), `buildConnectPodTargetBlock` (one
472
+ * source for the pod-resolution helper), and `getHostBuildSettingForConfig`
473
+ * (the per-config mirroring logic). The composition always runs withConnectNSE
474
+ * before withConnectNCE, so the shared host entitlement and the
475
+ * PBXTargetDependency/PBXContainerItemProxy sections already exist when this
476
+ * runs. Extracting a shared `withConnectExtension` base for NSE + NCE is
477
+ * tracked as a follow-up refactor (see PR description) rather than done here,
478
+ * to keep the already-landed NSE mod stable.
479
+ */
480
+ export const withConnectNCE: ConfigPlugin<ConnectPluginProps> = (
481
+ config,
482
+ props = {}
483
+ ) => {
484
+ const projectRoot = config._internal?.projectRoot
485
+ if (!projectRoot) {
486
+ throw new Error(
487
+ `[react-native-acoustic-connect-beta] config._internal.projectRoot is not set.\n\n` +
488
+ `This value is injected by Expo CLI during \`expo prebuild\`. ` +
489
+ `If you are calling this plugin outside of Expo CLI (e.g. in a test or ` +
490
+ `custom script), set config._internal = { projectRoot: '/abs/path/to/project' } ` +
491
+ `before invoking the plugin.`
492
+ )
493
+ }
494
+
495
+ const appGroupIdentifier = resolveAppGroupIdentifier(projectRoot, props)
496
+
497
+ const swiftTemplatePath = path.join(
498
+ __dirname,
499
+ '..',
500
+ 'swift',
501
+ 'NotificationViewController.swift'
502
+ )
503
+ const swiftContent = substituteSwiftTemplate(
504
+ swiftTemplatePath,
505
+ appGroupIdentifier
506
+ )
507
+
508
+ let result = withNSEEntitlements(config, appGroupIdentifier)
509
+ result = withNCEXcodeProject(result, appGroupIdentifier, swiftContent)
510
+ result = withNCEPodfile(result)
511
+ return result
512
+ }
@@ -195,7 +195,11 @@ const PODFILE_MARKER =
195
195
  '# @generated ConnectNSE target (react-native-acoustic-connect-beta)'
196
196
 
197
197
  /**
198
- * Returns the Ruby snippet to inject into the Expo-generated Podfile.
198
+ * Builds the Ruby Podfile snippet shared by the NSE and NCE mods: a helper
199
+ * that resolves the AcousticConnect pod (name + version requirements) from
200
+ * ConnectConfig.json, plus an extension `target` that links it. Centralised
201
+ * here so the NSE and NCE blocks have a single source of truth for the
202
+ * resolution logic — they differ only in marker, helper name, and target name.
199
203
  *
200
204
  * Tolerates a missing ConnectConfig.json or a missing `Connect` key: when
201
205
  * the file or key is absent the block defaults to AcousticConnectDebug with
@@ -204,10 +208,14 @@ const PODFILE_MARKER =
204
208
  * JSON, `pod install` will print a warning and use the same defaults rather
205
209
  * than crashing.
206
210
  */
207
- export function buildPodfileBlock(): string {
211
+ export function buildConnectPodTargetBlock(
212
+ marker: string,
213
+ helperName: string,
214
+ targetName: string
215
+ ): string {
208
216
  return `
209
- ${PODFILE_MARKER}
210
- def acoustic_connect_pod_nse
217
+ ${marker}
218
+ def ${helperName}
211
219
  config_path = File.join(__dir__, '..', 'ConnectConfig.json')
212
220
  connect_config = {}
213
221
  if File.exist?(config_path)
@@ -226,14 +234,26 @@ def acoustic_connect_pod_nse
226
234
  [name, requirements]
227
235
  end
228
236
 
229
- target 'ConnectNSE' do
230
- connect_name, connect_requirements = acoustic_connect_pod_nse
237
+ target '${targetName}' do
238
+ connect_name, connect_requirements = ${helperName}
231
239
  pod connect_name, *connect_requirements
232
240
  end
233
- # @end ConnectNSE target (react-native-acoustic-connect-beta)
241
+ # @end ${targetName} target (react-native-acoustic-connect-beta)
234
242
  `
235
243
  }
236
244
 
245
+ /**
246
+ * Returns the Ruby snippet to inject into the Expo-generated Podfile for the
247
+ * ConnectNSE target. Thin wrapper over {@link buildConnectPodTargetBlock}.
248
+ */
249
+ export function buildPodfileBlock(): string {
250
+ return buildConnectPodTargetBlock(
251
+ PODFILE_MARKER,
252
+ 'acoustic_connect_pod_nse',
253
+ 'ConnectNSE'
254
+ )
255
+ }
256
+
237
257
  /**
238
258
  * Injects the ConnectNSE Podfile block into `podfileContent` if not already
239
259
  * present (idempotent, guarded by PODFILE_MARKER).
@@ -248,14 +268,25 @@ export function injectPodfileBlock(podfileContent: string): string {
248
268
  // ─── Entitlements mod ─────────────────────────────────────────────────────────
249
269
 
250
270
  /**
251
- * Adds the App Group to the HOST app's entitlements. Merges idempotently
252
- * — never clobbers an existing `com.apple.security.application-groups` array.
271
+ * Adds the HOST app's push entitlements. Merges idempotently:
272
+ * - `aps-environment` the APNs (Push Notifications) capability. Required for
273
+ * the SDK's automatic `registerForRemoteNotifications()` to receive a device
274
+ * token; without it iOS fails registration with "no valid aps-environment
275
+ * entitlement string found", so no token is ever sent to the collector.
276
+ * Mirrors the bare-workflow host entitlements. `development` targets the
277
+ * sandbox APNs used by dev/`expo run:ios`/EAS `development` builds;
278
+ * production/TestFlight builds need `production`.
279
+ * - `com.apple.security.application-groups` — shared App Group for the host app
280
+ * and the NSE/NCE extensions. Never clobbers an existing array.
253
281
  */
254
282
  export function withNSEEntitlements(
255
283
  config: ExpoConfig,
256
284
  appGroupIdentifier: string
257
285
  ): ExpoConfig {
258
286
  return withEntitlementsPlist(config, (c) => {
287
+ if (!c.modResults['aps-environment']) {
288
+ c.modResults['aps-environment'] = 'development'
289
+ }
259
290
  const existing: string[] =
260
291
  (c.modResults['com.apple.security.application-groups'] as
261
292
  | string[]
@@ -288,15 +319,19 @@ interface BuildSettings {
288
319
  * Falls back to the other host config if the named one is absent, then to the
289
320
  * hardcoded `fallback`.
290
321
  *
291
- * Scoped to host configs only: configs whose CODE_SIGN_ENTITLEMENTS contains
292
- * the ConnectNSE target name are skipped, so values are never mirrored from a
293
- * previously generated extension target.
322
+ * Scoped to host configs only: configs whose CODE_SIGN_ENTITLEMENTS references
323
+ * one of `skipTargetNames` (the extension targets this plugin generates) are
324
+ * skipped, so values are never mirrored from a previously generated extension
325
+ * target. Defaults to `[NSE_TARGET_NAME]`; the NCE mod (withConnectNCE) passes
326
+ * both ConnectNSE and ConnectNCE so a re-run that already wrote the NSE target
327
+ * never mirrors the sibling extension's settings.
294
328
  */
295
- function getHostBuildSettingForConfig(
329
+ export function getHostBuildSettingForConfig(
296
330
  xcodeProject: XcodeProject,
297
331
  setting: string,
298
332
  configName: string,
299
- fallback: string
333
+ fallback: string,
334
+ skipTargetNames: string[] = [NSE_TARGET_NAME]
300
335
  ): string {
301
336
  const allConfigs = xcodeProject.pbxXCBuildConfigurationSection() as Record<
302
337
  string,
@@ -322,7 +357,10 @@ function getHostBuildSettingForConfig(
322
357
  ) {
323
358
  // Skip NSE/NCE extension targets already written by this plugin
324
359
  const cse = entry.buildSettings['CODE_SIGN_ENTITLEMENTS']
325
- if (typeof cse === 'string' && cse.includes(NSE_TARGET_NAME)) {
360
+ if (
361
+ typeof cse === 'string' &&
362
+ skipTargetNames.some((name) => cse.includes(name))
363
+ ) {
326
364
  continue
327
365
  }
328
366
  const val = entry.buildSettings[setting]
@@ -332,12 +370,15 @@ function getHostBuildSettingForConfig(
332
370
  }
333
371
  }
334
372
 
335
- // Pass 2: sibling host config fallback (any name), skip NSE targets.
373
+ // Pass 2: sibling host config fallback (any name), skip NSE/NCE targets.
336
374
  for (const key of Object.keys(allConfigs)) {
337
375
  const entry = allConfigs[key]
338
376
  if (entry && entry.buildSettings) {
339
377
  const cse = entry.buildSettings['CODE_SIGN_ENTITLEMENTS']
340
- if (typeof cse === 'string' && cse.includes(NSE_TARGET_NAME)) {
378
+ if (
379
+ typeof cse === 'string' &&
380
+ skipTargetNames.some((name) => cse.includes(name))
381
+ ) {
341
382
  continue
342
383
  }
343
384
  const val = entry.buildSettings[setting]
@@ -0,0 +1,34 @@
1
+ //
2
+ // Copyright (C) 2026 Acoustic, L.P. All rights reserved.
3
+ //
4
+ // NOTICE: This file contains material that is confidential and proprietary to
5
+ // Acoustic, L.P. and/or other developers. No license is granted under any
6
+ // intellectual or industrial property rights of Acoustic, L.P. except as may
7
+ // be provided in an agreement with Acoustic, L.P. Any unauthorized copying or
8
+ // distribution of content from this file is prohibited.
9
+ //
10
+ // Generated by react-native-acoustic-connect-beta Expo Config Plugin.
11
+ // Canonical reference: Examples/bare-workflow/ios/ConnectNCE/NotificationViewController.swift
12
+ // The CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER token is replaced by the plugin
13
+ // with the resolved App Group identifier from ConnectConfig.json (or the
14
+ // iosAppGroupIdentifier plugin prop in app.json).
15
+ //
16
+
17
+ import Connect
18
+
19
+ // Notification Content Extension principal class — referenced by
20
+ // `NSExtensionPrincipalClass` in this target's Info.plist.
21
+ //
22
+ // The whole implementation is inherited from
23
+ // `ConnectNotificationContentExtension` (Connect SDK): it renders the rich
24
+ // expansion UI (media attachment and/or expanded body) below the system
25
+ // banner when the user long-presses / expands an Acoustic notification.
26
+ //
27
+ // As with the NSE, the only host responsibility is to point the extension at
28
+ // the SAME App Group as the host app and the NSE so all three processes
29
+ // read/write the same pending store. Mirrors the iOS-SDK NCE reference 1:1.
30
+ final class NotificationViewController: ConnectNotificationContentExtension {
31
+ override var appGroupIdentifier: String? {
32
+ "CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER"
33
+ }
34
+ }