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

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,659 @@
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
+ withEntitlementsPlist,
11
+ withXcodeProject,
12
+ withDangerousMod,
13
+ type ConfigPlugin,
14
+ } from '@expo/config-plugins'
15
+ import type { ExpoConfig } from '@expo/config-types'
16
+
17
+ // XcodeProject is declared in the `xcode` package which ships no TypeScript
18
+ // types of its own. We derive the type from the withXcodeProject callback
19
+ // so the compiler can still check our usage without requiring @types/xcode.
20
+ type XcodeProject = Parameters<
21
+ Parameters<typeof withXcodeProject>[1]
22
+ >[0]['modResults']
23
+ import * as fs from 'fs'
24
+ import * as path from 'path'
25
+
26
+ // ─── Types ────────────────────────────────────────────────────────────────────
27
+
28
+ export interface ConnectPluginProps {
29
+ /** Explicit App Group override. Wins over ConnectConfig.json when set. */
30
+ iosAppGroupIdentifier?: string
31
+ }
32
+
33
+ // ─── App Group resolution ─────────────────────────────────────────────────────
34
+
35
+ // Apple App Group identifiers are `group.` followed by a reverse-DNS string of
36
+ // alphanumerics, dots, and hyphens. Validating against this both catches typos
37
+ // early and guarantees the value is safe to interpolate into the entitlements
38
+ // XML (no `<`, `>`, `&`, or quotes can appear).
39
+ const APP_GROUP_PATTERN = /^group\.[A-Za-z0-9.-]+$/
40
+
41
+ /**
42
+ * Validates an App Group identifier against Apple's format and returns it.
43
+ * Throws an actionable error otherwise.
44
+ */
45
+ export function assertValidAppGroup(value: string, source: string): string {
46
+ if (!APP_GROUP_PATTERN.test(value)) {
47
+ throw new Error(
48
+ `[react-native-acoustic-connect-beta] Invalid iOS App Group identifier ` +
49
+ `"${value}" (from ${source}).\n\n` +
50
+ `It must start with "group." followed by letters, digits, dots, or ` +
51
+ `hyphens — e.g. "group.com.example.app".`
52
+ )
53
+ }
54
+ return value
55
+ }
56
+
57
+ /**
58
+ * Resolves the iOS App Group identifier used by ConnectNSE.
59
+ *
60
+ * Priority:
61
+ * 1. `iosAppGroupIdentifier` plugin prop in app.json (explicit override)
62
+ * 2. `Connect.iOSAppGroupIdentifier` in `<projectRoot>/ConnectConfig.json`
63
+ *
64
+ * The resolved value is validated against Apple's App Group format. Throws a
65
+ * clear, actionable error if neither source provides the value, or if the
66
+ * provided value is malformed.
67
+ */
68
+ export function resolveAppGroupIdentifier(
69
+ projectRoot: string,
70
+ props: ConnectPluginProps
71
+ ): string {
72
+ if (props.iosAppGroupIdentifier) {
73
+ return assertValidAppGroup(
74
+ props.iosAppGroupIdentifier,
75
+ 'app.json plugin prop'
76
+ )
77
+ }
78
+
79
+ const configPath = path.join(projectRoot, 'ConnectConfig.json')
80
+ if (fs.existsSync(configPath)) {
81
+ const raw = fs.readFileSync(configPath, 'utf8')
82
+ let parsed: Record<string, unknown>
83
+ try {
84
+ parsed = JSON.parse(raw) as Record<string, unknown>
85
+ } catch {
86
+ throw new Error(
87
+ `[react-native-acoustic-connect-beta] ConnectConfig.json at ${configPath} is not valid JSON.`
88
+ )
89
+ }
90
+ const connect = parsed['Connect'] as Record<string, unknown> | undefined
91
+ const groupId = connect?.['iOSAppGroupIdentifier']
92
+ if (typeof groupId === 'string' && groupId.length > 0) {
93
+ return assertValidAppGroup(
94
+ groupId,
95
+ 'Connect.iOSAppGroupIdentifier in ConnectConfig.json'
96
+ )
97
+ }
98
+ }
99
+
100
+ throw new Error(
101
+ `[react-native-acoustic-connect-beta] Could not resolve iOS App Group identifier.\n\n` +
102
+ `To fix this, do ONE of the following:\n` +
103
+ ` 1. (Preferred) Add "iOSAppGroupIdentifier" to the "Connect" object in ` +
104
+ `your project's ConnectConfig.json:\n` +
105
+ ` { "Connect": { "iOSAppGroupIdentifier": "group.com.example.app" } }\n` +
106
+ ` 2. Pass the identifier as a plugin prop in app.json:\n` +
107
+ ` ["react-native-acoustic-connect-beta", { "iosAppGroupIdentifier": "group.com.example.app" }]\n`
108
+ )
109
+ }
110
+
111
+ // ─── Swift template substitution ─────────────────────────────────────────────
112
+
113
+ const PLACEHOLDER = 'CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER'
114
+ const GENERATED_HEADER =
115
+ '// @generated by react-native-acoustic-connect-beta Expo Config Plugin — do not edit manually.\n'
116
+
117
+ /**
118
+ * Returns the content of `plugin/swift/NotificationService.swift` with the
119
+ * placeholder replaced by the resolved App Group identifier and a generated
120
+ * header prepended.
121
+ */
122
+ export function substituteSwiftTemplate(
123
+ templatePath: string,
124
+ appGroupIdentifier: string
125
+ ): string {
126
+ const template = fs.readFileSync(templatePath, 'utf8')
127
+ // split/join replaces EVERY occurrence — the template mentions the
128
+ // placeholder in a comment before the code occurrence, and a string
129
+ // pattern to .replace() would only substitute that first mention.
130
+ const substituted = template.split(PLACEHOLDER).join(appGroupIdentifier)
131
+ return GENERATED_HEADER + substituted
132
+ }
133
+
134
+ // ─── Plist helpers ────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Generates the Info.plist content for the ConnectNSE target.
138
+ *
139
+ * RCTNewArchEnabled is intentionally omitted: the NSE links the Connect SDK
140
+ * only — no React Native runtime is present in the extension process.
141
+ */
142
+ export function buildNSEInfoPlist(): string {
143
+ return `<?xml version="1.0" encoding="UTF-8"?>
144
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
145
+ <plist version="1.0">
146
+ <dict>
147
+ \t<key>CFBundleDevelopmentRegion</key>
148
+ \t<string>$(DEVELOPMENT_LANGUAGE)</string>
149
+ \t<key>CFBundleDisplayName</key>
150
+ \t<string>ConnectNSE</string>
151
+ \t<key>CFBundleExecutable</key>
152
+ \t<string>$(EXECUTABLE_NAME)</string>
153
+ \t<key>CFBundleIdentifier</key>
154
+ \t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
155
+ \t<key>CFBundleInfoDictionaryVersion</key>
156
+ \t<string>6.0</string>
157
+ \t<key>CFBundleName</key>
158
+ \t<string>$(PRODUCT_NAME)</string>
159
+ \t<key>CFBundlePackageType</key>
160
+ \t<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
161
+ \t<key>CFBundleShortVersionString</key>
162
+ \t<string>$(MARKETING_VERSION)</string>
163
+ \t<key>CFBundleVersion</key>
164
+ \t<string>$(CURRENT_PROJECT_VERSION)</string>
165
+ \t<key>NSExtension</key>
166
+ \t<dict>
167
+ \t\t<key>NSExtensionPointIdentifier</key>
168
+ \t\t<string>com.apple.usernotifications.service</string>
169
+ \t\t<key>NSExtensionPrincipalClass</key>
170
+ \t\t<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
171
+ \t</dict>
172
+ </dict>
173
+ </plist>
174
+ `
175
+ }
176
+
177
+ /** Generates the entitlements plist content for the ConnectNSE target. */
178
+ export function buildNSEEntitlements(appGroupIdentifier: string): string {
179
+ return `<?xml version="1.0" encoding="UTF-8"?>
180
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
181
+ <plist version="1.0">
182
+ <dict>
183
+ \t<key>com.apple.security.application-groups</key>
184
+ \t<array>
185
+ \t\t<string>${appGroupIdentifier}</string>
186
+ \t</array>
187
+ </dict>
188
+ </plist>
189
+ `
190
+ }
191
+
192
+ // ─── Podfile injection ────────────────────────────────────────────────────────
193
+
194
+ const PODFILE_MARKER =
195
+ '# @generated ConnectNSE target (react-native-acoustic-connect-beta)'
196
+
197
+ /**
198
+ * Returns the Ruby snippet to inject into the Expo-generated Podfile.
199
+ *
200
+ * Tolerates a missing ConnectConfig.json or a missing `Connect` key: when
201
+ * the file or key is absent the block defaults to AcousticConnectDebug with
202
+ * floor '>= 2.1.12' and no version pin, so prop-only setups (no
203
+ * ConnectConfig.json) keep working. If the file exists but contains invalid
204
+ * JSON, `pod install` will print a warning and use the same defaults rather
205
+ * than crashing.
206
+ */
207
+ export function buildPodfileBlock(): string {
208
+ return `
209
+ ${PODFILE_MARKER}
210
+ def acoustic_connect_pod_nse
211
+ config_path = File.join(__dir__, '..', 'ConnectConfig.json')
212
+ connect_config = {}
213
+ if File.exist?(config_path)
214
+ begin
215
+ parsed = JSON.parse(File.read(config_path))
216
+ connect_config = parsed['Connect'] || {}
217
+ rescue JSON::ParserError => e
218
+ warn "[react-native-acoustic-connect-beta] ConnectConfig.json is not valid JSON: #{e.message}. Using defaults."
219
+ end
220
+ end
221
+ use_release = connect_config['useRelease'] || false
222
+ ios_version = (connect_config['iOSVersion'] || '').to_s
223
+ name = use_release ? 'AcousticConnect' : 'AcousticConnectDebug'
224
+ floor = '>= 2.1.12'
225
+ requirements = ios_version.empty? ? [floor] : [floor, ios_version]
226
+ [name, requirements]
227
+ end
228
+
229
+ target 'ConnectNSE' do
230
+ connect_name, connect_requirements = acoustic_connect_pod_nse
231
+ pod connect_name, *connect_requirements
232
+ end
233
+ # @end ConnectNSE target (react-native-acoustic-connect-beta)
234
+ `
235
+ }
236
+
237
+ /**
238
+ * Injects the ConnectNSE Podfile block into `podfileContent` if not already
239
+ * present (idempotent, guarded by PODFILE_MARKER).
240
+ */
241
+ export function injectPodfileBlock(podfileContent: string): string {
242
+ if (podfileContent.includes(PODFILE_MARKER)) {
243
+ return podfileContent
244
+ }
245
+ return podfileContent + buildPodfileBlock()
246
+ }
247
+
248
+ // ─── Entitlements mod ─────────────────────────────────────────────────────────
249
+
250
+ /**
251
+ * Adds the App Group to the HOST app's entitlements. Merges idempotently
252
+ * — never clobbers an existing `com.apple.security.application-groups` array.
253
+ */
254
+ export function withNSEEntitlements(
255
+ config: ExpoConfig,
256
+ appGroupIdentifier: string
257
+ ): ExpoConfig {
258
+ return withEntitlementsPlist(config, (c) => {
259
+ const existing: string[] =
260
+ (c.modResults['com.apple.security.application-groups'] as
261
+ | string[]
262
+ | undefined) ?? []
263
+ if (!existing.includes(appGroupIdentifier)) {
264
+ c.modResults['com.apple.security.application-groups'] = [
265
+ ...existing,
266
+ appGroupIdentifier,
267
+ ]
268
+ }
269
+ return c
270
+ })
271
+ }
272
+
273
+ // ─── Xcode project mod ───────────────────────────────────────────────────────
274
+
275
+ const NSE_TARGET_NAME = 'ConnectNSE'
276
+
277
+ interface BuildSettings {
278
+ IPHONEOS_DEPLOYMENT_TARGET?: string
279
+ SWIFT_VERSION?: string
280
+ MARKETING_VERSION?: string
281
+ CURRENT_PROJECT_VERSION?: string
282
+ [key: string]: string | undefined
283
+ }
284
+
285
+ /**
286
+ * Extracts a build setting value scoped to the host native target's
287
+ * configuration list for the given `configName` (e.g. "Debug" or "Release").
288
+ * Falls back to the other host config if the named one is absent, then to the
289
+ * hardcoded `fallback`.
290
+ *
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.
294
+ */
295
+ function getHostBuildSettingForConfig(
296
+ xcodeProject: XcodeProject,
297
+ setting: string,
298
+ configName: string,
299
+ fallback: string
300
+ ): string {
301
+ const allConfigs = xcodeProject.pbxXCBuildConfigurationSection() as Record<
302
+ string,
303
+ { name?: string; buildSettings?: BuildSettings }
304
+ >
305
+
306
+ // Two-pass resolution, both passes skipping the plugin's own NSE configs
307
+ // (identified by CODE_SIGN_ENTITLEMENTS containing NSE_TARGET_NAME) so a
308
+ // re-run never mirrors values from a previously generated extension target:
309
+ // Pass 1 — exact match on the requested configName (Debug → Debug).
310
+ // Pass 2 — graceful fallback to the sibling host config's value when the
311
+ // matching config has no explicit setting; mirroring the host's
312
+ // other config beats dropping to the hardcoded default.
313
+
314
+ // Pass 1: exact configName match.
315
+ for (const key of Object.keys(allConfigs)) {
316
+ const entry = allConfigs[key]
317
+ if (
318
+ entry &&
319
+ typeof entry === 'object' &&
320
+ entry.name === configName &&
321
+ entry.buildSettings
322
+ ) {
323
+ // Skip NSE/NCE extension targets already written by this plugin
324
+ const cse = entry.buildSettings['CODE_SIGN_ENTITLEMENTS']
325
+ if (typeof cse === 'string' && cse.includes(NSE_TARGET_NAME)) {
326
+ continue
327
+ }
328
+ const val = entry.buildSettings[setting]
329
+ if (typeof val === 'string') {
330
+ return val
331
+ }
332
+ }
333
+ }
334
+
335
+ // Pass 2: sibling host config fallback (any name), skip NSE targets.
336
+ for (const key of Object.keys(allConfigs)) {
337
+ const entry = allConfigs[key]
338
+ if (entry && entry.buildSettings) {
339
+ const cse = entry.buildSettings['CODE_SIGN_ENTITLEMENTS']
340
+ if (typeof cse === 'string' && cse.includes(NSE_TARGET_NAME)) {
341
+ continue
342
+ }
343
+ const val = entry.buildSettings[setting]
344
+ if (typeof val === 'string') {
345
+ return val
346
+ }
347
+ }
348
+ }
349
+
350
+ return fallback
351
+ }
352
+
353
+ /**
354
+ * Adds the ConnectNSE Xcode target (app_extension) to the project.
355
+ *
356
+ * Follows the OneSignal plugin pattern (withOneSignalNSE.ts) using the
357
+ * `xcode` API exposed via `config.modResults`.
358
+ *
359
+ * Idempotent: if a target named ConnectNSE already exists, skips target
360
+ * creation but still (re)writes the source files.
361
+ */
362
+ export function withNSEXcodeProject(
363
+ config: ExpoConfig,
364
+ appGroupIdentifier: string,
365
+ swiftContent: string
366
+ ): ExpoConfig {
367
+ return withXcodeProject(config, (c) => {
368
+ const xcodeProject: XcodeProject = c.modResults
369
+
370
+ // ios/ directory — modRequest.platformProjectRoot is the canonical
371
+ // native project root. (xcodeProject.filepath points INSIDE the
372
+ // .xcodeproj bundle, so deriving from it misplaces the files.)
373
+ const iosDir = c.modRequest.platformProjectRoot
374
+ const nseDir = path.join(iosDir, NSE_TARGET_NAME)
375
+
376
+ // Always write / overwrite the source files (idempotent)
377
+ fs.mkdirSync(nseDir, { recursive: true })
378
+
379
+ fs.writeFileSync(
380
+ path.join(nseDir, 'NotificationService.swift'),
381
+ swiftContent,
382
+ 'utf8'
383
+ )
384
+ fs.writeFileSync(
385
+ path.join(nseDir, 'Info.plist'),
386
+ buildNSEInfoPlist(),
387
+ 'utf8'
388
+ )
389
+ fs.writeFileSync(
390
+ path.join(nseDir, `${NSE_TARGET_NAME}.entitlements`),
391
+ buildNSEEntitlements(appGroupIdentifier),
392
+ 'utf8'
393
+ )
394
+
395
+ // Idempotency check — skip if target already registered in the pbxproj.
396
+ // Note: pbxTargetByName misses targets on a REPARSED project because the
397
+ // xcode lib stores written names with literal quotes ('"ConnectNSE"'),
398
+ // so compare quote-stripped names across the native-target section.
399
+ const nativeTargets = xcodeProject.pbxNativeTargetSection() as Record<
400
+ string,
401
+ { name?: string } | string
402
+ >
403
+ const targetExists = Object.values(nativeTargets).some(
404
+ (t) =>
405
+ typeof t === 'object' &&
406
+ typeof t.name === 'string' &&
407
+ t.name.replace(/"/g, '') === NSE_TARGET_NAME
408
+ )
409
+ if (targetExists) {
410
+ return c
411
+ }
412
+
413
+ // Derive the host bundle id — the NSE bundle id must be
414
+ // `<hostBundleId>.ConnectNSE`. Fail fast rather than emit a placeholder
415
+ // (a wrong bundle id breaks App Group pairing and App Store submission).
416
+ const hostBundleId = c.ios?.bundleIdentifier as string | undefined
417
+ if (!hostBundleId) {
418
+ throw new Error(
419
+ `[react-native-acoustic-connect-beta] ios.bundleIdentifier is not set.\n\n` +
420
+ `The ConnectNSE extension bundle id is derived as ` +
421
+ `"<ios.bundleIdentifier>.ConnectNSE", so the host bundle id must be ` +
422
+ `defined in app.json before prebuild:\n` +
423
+ ` { "expo": { "ios": { "bundleIdentifier": "com.example.app" } } }`
424
+ )
425
+ }
426
+
427
+ // Add the native target (app_extension)
428
+ const nseTarget = xcodeProject.addTarget(
429
+ NSE_TARGET_NAME,
430
+ 'app_extension',
431
+ NSE_TARGET_NAME,
432
+ `${hostBundleId}.ConnectNSE`
433
+ )
434
+
435
+ if (!nseTarget) {
436
+ throw new Error(
437
+ `[react-native-acoustic-connect-beta] Failed to add ConnectNSE Xcode target.`
438
+ )
439
+ }
440
+
441
+ // Add the build phase for Swift sources
442
+ xcodeProject.addBuildPhase(
443
+ ['NotificationService.swift'],
444
+ 'PBXSourcesBuildPhase',
445
+ 'Sources',
446
+ nseTarget.uuid
447
+ )
448
+
449
+ // Add Resources build phase (empty, but required by Xcode)
450
+ xcodeProject.addBuildPhase(
451
+ [],
452
+ 'PBXResourcesBuildPhase',
453
+ 'Resources',
454
+ nseTarget.uuid
455
+ )
456
+
457
+ // Add Frameworks build phase (empty — CocoaPods populates it)
458
+ xcodeProject.addBuildPhase(
459
+ [],
460
+ 'PBXFrameworksBuildPhase',
461
+ 'Frameworks',
462
+ nseTarget.uuid
463
+ )
464
+
465
+ // Make the host app target depend on ConnectNSE. CocoaPods resolves an
466
+ // extension's host target through PBXTargetDependency (xcodeproj's
467
+ // host_targets_for_embedded_target), NOT through the embed copy phase —
468
+ // without this edge `pod install` fails with "Unable to find host
469
+ // target(s) for ConnectNSE".
470
+ // The xcode lib SILENTLY no-ops addTargetDependency when these sections
471
+ // are absent from the parsed project (Expo templates ship without any
472
+ // extension, so they are) — create them first.
473
+ const objects = xcodeProject.hash.project.objects
474
+ objects['PBXTargetDependency'] = objects['PBXTargetDependency'] ?? {}
475
+ objects['PBXContainerItemProxy'] = objects['PBXContainerItemProxy'] ?? {}
476
+ const hostTargetUuid = xcodeProject.getFirstTarget().uuid
477
+ xcodeProject.addTargetDependency(hostTargetUuid, [nseTarget.uuid])
478
+
479
+ // Create a PBXGroup for ConnectNSE source files
480
+ const nseGroup = xcodeProject.addPbxGroup(
481
+ [
482
+ 'NotificationService.swift',
483
+ 'Info.plist',
484
+ `${NSE_TARGET_NAME}.entitlements`,
485
+ ],
486
+ NSE_TARGET_NAME,
487
+ NSE_TARGET_NAME
488
+ )
489
+
490
+ // Add the group to the main project group
491
+ const mainGroup = xcodeProject.getFirstProject().firstProject.mainGroup
492
+ xcodeProject.addToPbxGroup(nseGroup.uuid, mainGroup)
493
+
494
+ // Set per-configuration build settings — mirror each host configuration
495
+ // individually so Debug gets Debug values and Release gets Release values.
496
+ const buildConfigurations = xcodeProject.pbxXCConfigurationList()
497
+ const nseConfigListUuid = nseTarget.pbxNativeTarget.buildConfigurationList
498
+
499
+ if (nseConfigListUuid) {
500
+ const configList = buildConfigurations[nseConfigListUuid]
501
+ if (
502
+ configList &&
503
+ Array.isArray(
504
+ (configList as { buildConfigurations?: unknown[] })
505
+ .buildConfigurations
506
+ )
507
+ ) {
508
+ const configs = xcodeProject.pbxXCBuildConfigurationSection()
509
+ for (const entry of (
510
+ configList as { buildConfigurations: Array<{ value: string }> }
511
+ ).buildConfigurations) {
512
+ const buildConfig = configs[entry.value]
513
+ if (buildConfig && buildConfig.buildSettings) {
514
+ // Determine which host config name to mirror (Debug → Debug, etc.)
515
+ const configName: string =
516
+ (buildConfig as { name?: string }).name ?? 'Debug'
517
+
518
+ // M2: mirror per-configuration values; fall back to the other
519
+ // config's value only when the matching one is absent.
520
+ const deploymentTarget = getHostBuildSettingForConfig(
521
+ xcodeProject,
522
+ 'IPHONEOS_DEPLOYMENT_TARGET',
523
+ configName,
524
+ // Matches the AcousticConnect podspec floor (iOS >= 15.1) so the
525
+ // NSE never demands a higher minimum than the host app.
526
+ '15.1'
527
+ )
528
+ const swiftVersion = getHostBuildSettingForConfig(
529
+ xcodeProject,
530
+ 'SWIFT_VERSION',
531
+ configName,
532
+ '5.0'
533
+ )
534
+ const marketingVersion = getHostBuildSettingForConfig(
535
+ xcodeProject,
536
+ 'MARKETING_VERSION',
537
+ configName,
538
+ '1.0'
539
+ )
540
+ const currentProjectVersion = getHostBuildSettingForConfig(
541
+ xcodeProject,
542
+ 'CURRENT_PROJECT_VERSION',
543
+ configName,
544
+ '1'
545
+ )
546
+
547
+ Object.assign(buildConfig.buildSettings, {
548
+ // C1: prevent Xcode from synthesising a second Info.plist that
549
+ // would shadow the NSExtension dictionary in ours.
550
+ GENERATE_INFOPLIST_FILE: 'NO',
551
+ INFOPLIST_FILE: `${NSE_TARGET_NAME}/Info.plist`,
552
+ // M1: extension must target the device SDK and restrict to
553
+ // extension-safe APIs (mirrors bare-workflow reference target).
554
+ SDKROOT: 'iphoneos',
555
+ APPLICATION_EXTENSION_API_ONLY: 'YES',
556
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
557
+ SWIFT_VERSION: swiftVersion,
558
+ MARKETING_VERSION: marketingVersion,
559
+ CURRENT_PROJECT_VERSION: currentProjectVersion,
560
+ CODE_SIGN_ENTITLEMENTS: `${NSE_TARGET_NAME}/${NSE_TARGET_NAME}.entitlements`,
561
+ PRODUCT_NAME: NSE_TARGET_NAME,
562
+ PRODUCT_BUNDLE_IDENTIFIER: `${hostBundleId}.ConnectNSE`,
563
+ TARGETED_DEVICE_FAMILY: '"1,2"',
564
+ CODE_SIGN_STYLE: 'Automatic',
565
+ })
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ return c
572
+ })
573
+ }
574
+
575
+ // ─── Podfile mod ─────────────────────────────────────────────────────────────
576
+
577
+ /**
578
+ * Injects the ConnectNSE Podfile target block via withDangerousMod.
579
+ * Guarded by a marker comment — re-runs are no-ops.
580
+ *
581
+ * M5: throws a clear, actionable error when the Podfile does not exist at
582
+ * mod-execution time, instead of silently returning — a silent skip would
583
+ * ship an NSE whose `import Connect` cannot resolve.
584
+ */
585
+ export function withNSEPodfile(config: ExpoConfig): ExpoConfig {
586
+ return withDangerousMod(config, [
587
+ 'ios',
588
+ async (c) => {
589
+ const podfilePath = path.join(c.modRequest.platformProjectRoot, 'Podfile')
590
+ if (!fs.existsSync(podfilePath)) {
591
+ throw new Error(
592
+ `[react-native-acoustic-connect-beta] ios/Podfile not found at ${podfilePath}.\n\n` +
593
+ `This mod runs after prebuild generates the ios/ directory. ` +
594
+ `If you are running this plugin outside of \`expo prebuild\`, ` +
595
+ `ensure the Podfile exists before the dangerous mod phase executes.\n` +
596
+ `Check prebuild ordering: withDangerousMod('ios') runs after ` +
597
+ `withXcodeProject, so the ios/ directory should already be present.`
598
+ )
599
+ }
600
+ const current = fs.readFileSync(podfilePath, 'utf8')
601
+ const updated = injectPodfileBlock(current)
602
+ if (updated !== current) {
603
+ fs.writeFileSync(podfilePath, updated, 'utf8')
604
+ }
605
+ return c
606
+ },
607
+ ])
608
+ }
609
+
610
+ // ─── Composed mod ─────────────────────────────────────────────────────────────
611
+
612
+ /**
613
+ * Expo Config Plugin mod that provisions a Notification Service Extension
614
+ * (NSE) Xcode target named `ConnectNSE`.
615
+ *
616
+ * Applies three mutations — all idempotent:
617
+ * 1. Host app entitlements: merges App Group into
618
+ * `com.apple.security.application-groups`.
619
+ * 2. Xcode project: adds ConnectNSE target + files (skips if already present).
620
+ * 3. Podfile: injects `target 'ConnectNSE'` block (guarded by marker comment).
621
+ *
622
+ * M4: `config._internal.projectRoot` is required. If absent (i.e. invoked
623
+ * outside Expo CLI), an actionable error is thrown rather than silently
624
+ * falling back to `process.cwd()`, which would misresolve ConnectConfig.json
625
+ * in monorepo setups.
626
+ */
627
+ export const withConnectNSE: ConfigPlugin<ConnectPluginProps> = (
628
+ config,
629
+ props = {}
630
+ ) => {
631
+ const projectRoot = config._internal?.projectRoot
632
+ if (!projectRoot) {
633
+ throw new Error(
634
+ `[react-native-acoustic-connect-beta] config._internal.projectRoot is not set.\n\n` +
635
+ `This value is injected by Expo CLI during \`expo prebuild\`. ` +
636
+ `If you are calling this plugin outside of Expo CLI (e.g. in a test or ` +
637
+ `custom script), set config._internal = { projectRoot: '/abs/path/to/project' } ` +
638
+ `before invoking the plugin.`
639
+ )
640
+ }
641
+
642
+ const appGroupIdentifier = resolveAppGroupIdentifier(projectRoot, props)
643
+
644
+ const swiftTemplatePath = path.join(
645
+ __dirname,
646
+ '..',
647
+ 'swift',
648
+ 'NotificationService.swift'
649
+ )
650
+ const swiftContent = substituteSwiftTemplate(
651
+ swiftTemplatePath,
652
+ appGroupIdentifier
653
+ )
654
+
655
+ let result = withNSEEntitlements(config, appGroupIdentifier)
656
+ result = withNSEXcodeProject(result, appGroupIdentifier, swiftContent)
657
+ result = withNSEPodfile(result)
658
+ return result
659
+ }
@@ -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/ConnectNSE/NotificationService.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 Service Extension principal class — referenced by
20
+ // `NSExtensionPrincipalClass` in this target's Info.plist.
21
+ //
22
+ // The whole implementation is inherited from `ConnectNotificationService`
23
+ // (Connect SDK): it downloads rich-media attachments, records the
24
+ // `PushReceived` signal into the App Group pending store, and flushes it to
25
+ // the Collector. The only host responsibility is to point the extension at
26
+ // the SAME App Group as the host app so the two processes share state.
27
+ //
28
+ // @unchecked Sendable: restates the inherited conformance from
29
+ // `ConnectNotificationService`.
30
+ final class NotificationService: ConnectNotificationService, @unchecked Sendable {
31
+ override var appGroupIdentifier: String? {
32
+ "CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER"
33
+ }
34
+ }