react-native-acoustic-connect-beta 18.0.34 → 18.0.36

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,689 @@
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
+ * 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.
203
+ *
204
+ * Tolerates a missing ConnectConfig.json or a missing `Connect` key: when
205
+ * the file or key is absent the block defaults to AcousticConnectDebug with
206
+ * floor '>= 2.1.12' and no version pin, so prop-only setups (no
207
+ * ConnectConfig.json) keep working. If the file exists but contains invalid
208
+ * JSON, `pod install` will print a warning and use the same defaults rather
209
+ * than crashing.
210
+ */
211
+ export function buildConnectPodTargetBlock(
212
+ marker: string,
213
+ helperName: string,
214
+ targetName: string
215
+ ): string {
216
+ return `
217
+ ${marker}
218
+ def ${helperName}
219
+ config_path = File.join(__dir__, '..', 'ConnectConfig.json')
220
+ connect_config = {}
221
+ if File.exist?(config_path)
222
+ begin
223
+ parsed = JSON.parse(File.read(config_path))
224
+ connect_config = parsed['Connect'] || {}
225
+ rescue JSON::ParserError => e
226
+ warn "[react-native-acoustic-connect-beta] ConnectConfig.json is not valid JSON: #{e.message}. Using defaults."
227
+ end
228
+ end
229
+ use_release = connect_config['useRelease'] || false
230
+ ios_version = (connect_config['iOSVersion'] || '').to_s
231
+ name = use_release ? 'AcousticConnect' : 'AcousticConnectDebug'
232
+ floor = '>= 2.1.12'
233
+ requirements = ios_version.empty? ? [floor] : [floor, ios_version]
234
+ [name, requirements]
235
+ end
236
+
237
+ target '${targetName}' do
238
+ connect_name, connect_requirements = ${helperName}
239
+ pod connect_name, *connect_requirements
240
+ end
241
+ # @end ${targetName} target (react-native-acoustic-connect-beta)
242
+ `
243
+ }
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
+
257
+ /**
258
+ * Injects the ConnectNSE Podfile block into `podfileContent` if not already
259
+ * present (idempotent, guarded by PODFILE_MARKER).
260
+ */
261
+ export function injectPodfileBlock(podfileContent: string): string {
262
+ if (podfileContent.includes(PODFILE_MARKER)) {
263
+ return podfileContent
264
+ }
265
+ return podfileContent + buildPodfileBlock()
266
+ }
267
+
268
+ // ─── Entitlements mod ─────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Adds the App Group to the HOST app's entitlements. Merges idempotently
272
+ * — never clobbers an existing `com.apple.security.application-groups` array.
273
+ */
274
+ export function withNSEEntitlements(
275
+ config: ExpoConfig,
276
+ appGroupIdentifier: string
277
+ ): ExpoConfig {
278
+ return withEntitlementsPlist(config, (c) => {
279
+ const existing: string[] =
280
+ (c.modResults['com.apple.security.application-groups'] as
281
+ | string[]
282
+ | undefined) ?? []
283
+ if (!existing.includes(appGroupIdentifier)) {
284
+ c.modResults['com.apple.security.application-groups'] = [
285
+ ...existing,
286
+ appGroupIdentifier,
287
+ ]
288
+ }
289
+ return c
290
+ })
291
+ }
292
+
293
+ // ─── Xcode project mod ───────────────────────────────────────────────────────
294
+
295
+ const NSE_TARGET_NAME = 'ConnectNSE'
296
+
297
+ interface BuildSettings {
298
+ IPHONEOS_DEPLOYMENT_TARGET?: string
299
+ SWIFT_VERSION?: string
300
+ MARKETING_VERSION?: string
301
+ CURRENT_PROJECT_VERSION?: string
302
+ [key: string]: string | undefined
303
+ }
304
+
305
+ /**
306
+ * Extracts a build setting value scoped to the host native target's
307
+ * configuration list for the given `configName` (e.g. "Debug" or "Release").
308
+ * Falls back to the other host config if the named one is absent, then to the
309
+ * hardcoded `fallback`.
310
+ *
311
+ * Scoped to host configs only: configs whose CODE_SIGN_ENTITLEMENTS references
312
+ * one of `skipTargetNames` (the extension targets this plugin generates) are
313
+ * skipped, so values are never mirrored from a previously generated extension
314
+ * target. Defaults to `[NSE_TARGET_NAME]`; the NCE mod (withConnectNCE) passes
315
+ * both ConnectNSE and ConnectNCE so a re-run that already wrote the NSE target
316
+ * never mirrors the sibling extension's settings.
317
+ */
318
+ export function getHostBuildSettingForConfig(
319
+ xcodeProject: XcodeProject,
320
+ setting: string,
321
+ configName: string,
322
+ fallback: string,
323
+ skipTargetNames: string[] = [NSE_TARGET_NAME]
324
+ ): string {
325
+ const allConfigs = xcodeProject.pbxXCBuildConfigurationSection() as Record<
326
+ string,
327
+ { name?: string; buildSettings?: BuildSettings }
328
+ >
329
+
330
+ // Two-pass resolution, both passes skipping the plugin's own NSE configs
331
+ // (identified by CODE_SIGN_ENTITLEMENTS containing NSE_TARGET_NAME) so a
332
+ // re-run never mirrors values from a previously generated extension target:
333
+ // Pass 1 — exact match on the requested configName (Debug → Debug).
334
+ // Pass 2 — graceful fallback to the sibling host config's value when the
335
+ // matching config has no explicit setting; mirroring the host's
336
+ // other config beats dropping to the hardcoded default.
337
+
338
+ // Pass 1: exact configName match.
339
+ for (const key of Object.keys(allConfigs)) {
340
+ const entry = allConfigs[key]
341
+ if (
342
+ entry &&
343
+ typeof entry === 'object' &&
344
+ entry.name === configName &&
345
+ entry.buildSettings
346
+ ) {
347
+ // Skip NSE/NCE extension targets already written by this plugin
348
+ const cse = entry.buildSettings['CODE_SIGN_ENTITLEMENTS']
349
+ if (
350
+ typeof cse === 'string' &&
351
+ skipTargetNames.some((name) => cse.includes(name))
352
+ ) {
353
+ continue
354
+ }
355
+ const val = entry.buildSettings[setting]
356
+ if (typeof val === 'string') {
357
+ return val
358
+ }
359
+ }
360
+ }
361
+
362
+ // Pass 2: sibling host config fallback (any name), skip NSE/NCE targets.
363
+ for (const key of Object.keys(allConfigs)) {
364
+ const entry = allConfigs[key]
365
+ if (entry && entry.buildSettings) {
366
+ const cse = entry.buildSettings['CODE_SIGN_ENTITLEMENTS']
367
+ if (
368
+ typeof cse === 'string' &&
369
+ skipTargetNames.some((name) => cse.includes(name))
370
+ ) {
371
+ continue
372
+ }
373
+ const val = entry.buildSettings[setting]
374
+ if (typeof val === 'string') {
375
+ return val
376
+ }
377
+ }
378
+ }
379
+
380
+ return fallback
381
+ }
382
+
383
+ /**
384
+ * Adds the ConnectNSE Xcode target (app_extension) to the project.
385
+ *
386
+ * Follows the OneSignal plugin pattern (withOneSignalNSE.ts) using the
387
+ * `xcode` API exposed via `config.modResults`.
388
+ *
389
+ * Idempotent: if a target named ConnectNSE already exists, skips target
390
+ * creation but still (re)writes the source files.
391
+ */
392
+ export function withNSEXcodeProject(
393
+ config: ExpoConfig,
394
+ appGroupIdentifier: string,
395
+ swiftContent: string
396
+ ): ExpoConfig {
397
+ return withXcodeProject(config, (c) => {
398
+ const xcodeProject: XcodeProject = c.modResults
399
+
400
+ // ios/ directory — modRequest.platformProjectRoot is the canonical
401
+ // native project root. (xcodeProject.filepath points INSIDE the
402
+ // .xcodeproj bundle, so deriving from it misplaces the files.)
403
+ const iosDir = c.modRequest.platformProjectRoot
404
+ const nseDir = path.join(iosDir, NSE_TARGET_NAME)
405
+
406
+ // Always write / overwrite the source files (idempotent)
407
+ fs.mkdirSync(nseDir, { recursive: true })
408
+
409
+ fs.writeFileSync(
410
+ path.join(nseDir, 'NotificationService.swift'),
411
+ swiftContent,
412
+ 'utf8'
413
+ )
414
+ fs.writeFileSync(
415
+ path.join(nseDir, 'Info.plist'),
416
+ buildNSEInfoPlist(),
417
+ 'utf8'
418
+ )
419
+ fs.writeFileSync(
420
+ path.join(nseDir, `${NSE_TARGET_NAME}.entitlements`),
421
+ buildNSEEntitlements(appGroupIdentifier),
422
+ 'utf8'
423
+ )
424
+
425
+ // Idempotency check — skip if target already registered in the pbxproj.
426
+ // Note: pbxTargetByName misses targets on a REPARSED project because the
427
+ // xcode lib stores written names with literal quotes ('"ConnectNSE"'),
428
+ // so compare quote-stripped names across the native-target section.
429
+ const nativeTargets = xcodeProject.pbxNativeTargetSection() as Record<
430
+ string,
431
+ { name?: string } | string
432
+ >
433
+ const targetExists = Object.values(nativeTargets).some(
434
+ (t) =>
435
+ typeof t === 'object' &&
436
+ typeof t.name === 'string' &&
437
+ t.name.replace(/"/g, '') === NSE_TARGET_NAME
438
+ )
439
+ if (targetExists) {
440
+ return c
441
+ }
442
+
443
+ // Derive the host bundle id — the NSE bundle id must be
444
+ // `<hostBundleId>.ConnectNSE`. Fail fast rather than emit a placeholder
445
+ // (a wrong bundle id breaks App Group pairing and App Store submission).
446
+ const hostBundleId = c.ios?.bundleIdentifier as string | undefined
447
+ if (!hostBundleId) {
448
+ throw new Error(
449
+ `[react-native-acoustic-connect-beta] ios.bundleIdentifier is not set.\n\n` +
450
+ `The ConnectNSE extension bundle id is derived as ` +
451
+ `"<ios.bundleIdentifier>.ConnectNSE", so the host bundle id must be ` +
452
+ `defined in app.json before prebuild:\n` +
453
+ ` { "expo": { "ios": { "bundleIdentifier": "com.example.app" } } }`
454
+ )
455
+ }
456
+
457
+ // Add the native target (app_extension)
458
+ const nseTarget = xcodeProject.addTarget(
459
+ NSE_TARGET_NAME,
460
+ 'app_extension',
461
+ NSE_TARGET_NAME,
462
+ `${hostBundleId}.ConnectNSE`
463
+ )
464
+
465
+ if (!nseTarget) {
466
+ throw new Error(
467
+ `[react-native-acoustic-connect-beta] Failed to add ConnectNSE Xcode target.`
468
+ )
469
+ }
470
+
471
+ // Add the build phase for Swift sources
472
+ xcodeProject.addBuildPhase(
473
+ ['NotificationService.swift'],
474
+ 'PBXSourcesBuildPhase',
475
+ 'Sources',
476
+ nseTarget.uuid
477
+ )
478
+
479
+ // Add Resources build phase (empty, but required by Xcode)
480
+ xcodeProject.addBuildPhase(
481
+ [],
482
+ 'PBXResourcesBuildPhase',
483
+ 'Resources',
484
+ nseTarget.uuid
485
+ )
486
+
487
+ // Add Frameworks build phase (empty — CocoaPods populates it)
488
+ xcodeProject.addBuildPhase(
489
+ [],
490
+ 'PBXFrameworksBuildPhase',
491
+ 'Frameworks',
492
+ nseTarget.uuid
493
+ )
494
+
495
+ // Make the host app target depend on ConnectNSE. CocoaPods resolves an
496
+ // extension's host target through PBXTargetDependency (xcodeproj's
497
+ // host_targets_for_embedded_target), NOT through the embed copy phase —
498
+ // without this edge `pod install` fails with "Unable to find host
499
+ // target(s) for ConnectNSE".
500
+ // The xcode lib SILENTLY no-ops addTargetDependency when these sections
501
+ // are absent from the parsed project (Expo templates ship without any
502
+ // extension, so they are) — create them first.
503
+ const objects = xcodeProject.hash.project.objects
504
+ objects['PBXTargetDependency'] = objects['PBXTargetDependency'] ?? {}
505
+ objects['PBXContainerItemProxy'] = objects['PBXContainerItemProxy'] ?? {}
506
+ const hostTargetUuid = xcodeProject.getFirstTarget().uuid
507
+ xcodeProject.addTargetDependency(hostTargetUuid, [nseTarget.uuid])
508
+
509
+ // Create a PBXGroup for ConnectNSE source files
510
+ const nseGroup = xcodeProject.addPbxGroup(
511
+ [
512
+ 'NotificationService.swift',
513
+ 'Info.plist',
514
+ `${NSE_TARGET_NAME}.entitlements`,
515
+ ],
516
+ NSE_TARGET_NAME,
517
+ NSE_TARGET_NAME
518
+ )
519
+
520
+ // Add the group to the main project group
521
+ const mainGroup = xcodeProject.getFirstProject().firstProject.mainGroup
522
+ xcodeProject.addToPbxGroup(nseGroup.uuid, mainGroup)
523
+
524
+ // Set per-configuration build settings — mirror each host configuration
525
+ // individually so Debug gets Debug values and Release gets Release values.
526
+ const buildConfigurations = xcodeProject.pbxXCConfigurationList()
527
+ const nseConfigListUuid = nseTarget.pbxNativeTarget.buildConfigurationList
528
+
529
+ if (nseConfigListUuid) {
530
+ const configList = buildConfigurations[nseConfigListUuid]
531
+ if (
532
+ configList &&
533
+ Array.isArray(
534
+ (configList as { buildConfigurations?: unknown[] })
535
+ .buildConfigurations
536
+ )
537
+ ) {
538
+ const configs = xcodeProject.pbxXCBuildConfigurationSection()
539
+ for (const entry of (
540
+ configList as { buildConfigurations: Array<{ value: string }> }
541
+ ).buildConfigurations) {
542
+ const buildConfig = configs[entry.value]
543
+ if (buildConfig && buildConfig.buildSettings) {
544
+ // Determine which host config name to mirror (Debug → Debug, etc.)
545
+ const configName: string =
546
+ (buildConfig as { name?: string }).name ?? 'Debug'
547
+
548
+ // M2: mirror per-configuration values; fall back to the other
549
+ // config's value only when the matching one is absent.
550
+ const deploymentTarget = getHostBuildSettingForConfig(
551
+ xcodeProject,
552
+ 'IPHONEOS_DEPLOYMENT_TARGET',
553
+ configName,
554
+ // Matches the AcousticConnect podspec floor (iOS >= 15.1) so the
555
+ // NSE never demands a higher minimum than the host app.
556
+ '15.1'
557
+ )
558
+ const swiftVersion = getHostBuildSettingForConfig(
559
+ xcodeProject,
560
+ 'SWIFT_VERSION',
561
+ configName,
562
+ '5.0'
563
+ )
564
+ const marketingVersion = getHostBuildSettingForConfig(
565
+ xcodeProject,
566
+ 'MARKETING_VERSION',
567
+ configName,
568
+ '1.0'
569
+ )
570
+ const currentProjectVersion = getHostBuildSettingForConfig(
571
+ xcodeProject,
572
+ 'CURRENT_PROJECT_VERSION',
573
+ configName,
574
+ '1'
575
+ )
576
+
577
+ Object.assign(buildConfig.buildSettings, {
578
+ // C1: prevent Xcode from synthesising a second Info.plist that
579
+ // would shadow the NSExtension dictionary in ours.
580
+ GENERATE_INFOPLIST_FILE: 'NO',
581
+ INFOPLIST_FILE: `${NSE_TARGET_NAME}/Info.plist`,
582
+ // M1: extension must target the device SDK and restrict to
583
+ // extension-safe APIs (mirrors bare-workflow reference target).
584
+ SDKROOT: 'iphoneos',
585
+ APPLICATION_EXTENSION_API_ONLY: 'YES',
586
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
587
+ SWIFT_VERSION: swiftVersion,
588
+ MARKETING_VERSION: marketingVersion,
589
+ CURRENT_PROJECT_VERSION: currentProjectVersion,
590
+ CODE_SIGN_ENTITLEMENTS: `${NSE_TARGET_NAME}/${NSE_TARGET_NAME}.entitlements`,
591
+ PRODUCT_NAME: NSE_TARGET_NAME,
592
+ PRODUCT_BUNDLE_IDENTIFIER: `${hostBundleId}.ConnectNSE`,
593
+ TARGETED_DEVICE_FAMILY: '"1,2"',
594
+ CODE_SIGN_STYLE: 'Automatic',
595
+ })
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ return c
602
+ })
603
+ }
604
+
605
+ // ─── Podfile mod ─────────────────────────────────────────────────────────────
606
+
607
+ /**
608
+ * Injects the ConnectNSE Podfile target block via withDangerousMod.
609
+ * Guarded by a marker comment — re-runs are no-ops.
610
+ *
611
+ * M5: throws a clear, actionable error when the Podfile does not exist at
612
+ * mod-execution time, instead of silently returning — a silent skip would
613
+ * ship an NSE whose `import Connect` cannot resolve.
614
+ */
615
+ export function withNSEPodfile(config: ExpoConfig): ExpoConfig {
616
+ return withDangerousMod(config, [
617
+ 'ios',
618
+ async (c) => {
619
+ const podfilePath = path.join(c.modRequest.platformProjectRoot, 'Podfile')
620
+ if (!fs.existsSync(podfilePath)) {
621
+ throw new Error(
622
+ `[react-native-acoustic-connect-beta] ios/Podfile not found at ${podfilePath}.\n\n` +
623
+ `This mod runs after prebuild generates the ios/ directory. ` +
624
+ `If you are running this plugin outside of \`expo prebuild\`, ` +
625
+ `ensure the Podfile exists before the dangerous mod phase executes.\n` +
626
+ `Check prebuild ordering: withDangerousMod('ios') runs after ` +
627
+ `withXcodeProject, so the ios/ directory should already be present.`
628
+ )
629
+ }
630
+ const current = fs.readFileSync(podfilePath, 'utf8')
631
+ const updated = injectPodfileBlock(current)
632
+ if (updated !== current) {
633
+ fs.writeFileSync(podfilePath, updated, 'utf8')
634
+ }
635
+ return c
636
+ },
637
+ ])
638
+ }
639
+
640
+ // ─── Composed mod ─────────────────────────────────────────────────────────────
641
+
642
+ /**
643
+ * Expo Config Plugin mod that provisions a Notification Service Extension
644
+ * (NSE) Xcode target named `ConnectNSE`.
645
+ *
646
+ * Applies three mutations — all idempotent:
647
+ * 1. Host app entitlements: merges App Group into
648
+ * `com.apple.security.application-groups`.
649
+ * 2. Xcode project: adds ConnectNSE target + files (skips if already present).
650
+ * 3. Podfile: injects `target 'ConnectNSE'` block (guarded by marker comment).
651
+ *
652
+ * M4: `config._internal.projectRoot` is required. If absent (i.e. invoked
653
+ * outside Expo CLI), an actionable error is thrown rather than silently
654
+ * falling back to `process.cwd()`, which would misresolve ConnectConfig.json
655
+ * in monorepo setups.
656
+ */
657
+ export const withConnectNSE: ConfigPlugin<ConnectPluginProps> = (
658
+ config,
659
+ props = {}
660
+ ) => {
661
+ const projectRoot = config._internal?.projectRoot
662
+ if (!projectRoot) {
663
+ throw new Error(
664
+ `[react-native-acoustic-connect-beta] config._internal.projectRoot is not set.\n\n` +
665
+ `This value is injected by Expo CLI during \`expo prebuild\`. ` +
666
+ `If you are calling this plugin outside of Expo CLI (e.g. in a test or ` +
667
+ `custom script), set config._internal = { projectRoot: '/abs/path/to/project' } ` +
668
+ `before invoking the plugin.`
669
+ )
670
+ }
671
+
672
+ const appGroupIdentifier = resolveAppGroupIdentifier(projectRoot, props)
673
+
674
+ const swiftTemplatePath = path.join(
675
+ __dirname,
676
+ '..',
677
+ 'swift',
678
+ 'NotificationService.swift'
679
+ )
680
+ const swiftContent = substituteSwiftTemplate(
681
+ swiftTemplatePath,
682
+ appGroupIdentifier
683
+ )
684
+
685
+ let result = withNSEEntitlements(config, appGroupIdentifier)
686
+ result = withNSEXcodeProject(result, appGroupIdentifier, swiftContent)
687
+ result = withNSEPodfile(result)
688
+ return result
689
+ }