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

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +2 -1
  2. package/android/src/main/assets/ConnectBasicConfig.properties +4 -4
  3. package/cli/doctor.mjs +294 -0
  4. package/cli/index.mjs +64 -0
  5. package/cli/ios/add_push_extensions.rb +192 -0
  6. package/cli/ios/connect_pods.rb +48 -0
  7. package/cli/ios/setup-push.mjs +196 -0
  8. package/cli/ios/templates/nce/ConnectNCE.entitlements +10 -0
  9. package/cli/ios/templates/nce/Info.plist +45 -0
  10. package/cli/ios/templates/nse/ConnectNSE.entitlements +10 -0
  11. package/cli/ios/templates/nse/Info.plist +33 -0
  12. package/cli/lib.mjs +240 -0
  13. package/ios/AcousticConnectRNConfig.json +7 -153
  14. package/ios/HybridAcousticConnectRN.swift +16 -0
  15. package/package.json +7 -3
  16. package/plugin/build/index.d.ts +16 -7
  17. package/plugin/build/index.d.ts.map +1 -1
  18. package/plugin/build/index.js +18 -7
  19. package/plugin/build/index.js.map +1 -1
  20. package/plugin/build/withConnectAndroidConfig.d.ts +20 -0
  21. package/plugin/build/withConnectAndroidConfig.d.ts.map +1 -0
  22. package/plugin/build/withConnectAndroidConfig.js +64 -0
  23. package/plugin/build/withConnectAndroidConfig.js.map +1 -0
  24. package/plugin/build/withConnectNCE.d.ts.map +1 -1
  25. package/plugin/build/withConnectNCE.js +20 -1
  26. package/plugin/build/withConnectNCE.js.map +1 -1
  27. package/plugin/build/withConnectNSE.d.ts +10 -2
  28. package/plugin/build/withConnectNSE.d.ts.map +1 -1
  29. package/plugin/build/withConnectNSE.js +13 -2
  30. package/plugin/build/withConnectNSE.js.map +1 -1
  31. package/plugin/src/index.ts +17 -7
  32. package/plugin/src/withConnectAndroidConfig.ts +70 -0
  33. package/plugin/src/withConnectNCE.ts +26 -1
  34. package/plugin/src/withConnectNSE.ts +13 -2
  35. package/scripts/postinstall.mjs +35 -0
  36. package/scripts/xmlparser.js +1 -1
  37. package/scripts/postInstallScripts.sh +0 -10
  38. package/scripts/verifyConnectSetup.js +0 -87
package/CHANGELOG.md CHANGED
@@ -1,4 +1,5 @@
1
- ## [18.0.36](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/18.0.35...18.0.36) (2026-06-17)
1
+ ## [18.0.38](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/18.0.37...18.0.38) (2026-06-22)
2
+ ## [18.0.37](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/18.0.36...18.0.37) (2026-06-22)
2
3
  ## [18.0.35](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/18.0.34...18.0.35) (2026-06-15)
3
4
  ## [18.0.34](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/18.0.33...18.0.34) (2026-06-15)
4
5
  ## [18.0.33](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/18.0.32...18.0.33) (2026-06-15)
@@ -1,13 +1,13 @@
1
- #Wed Jun 17 07:43:32 PDT 2026
1
+ #Mon Jun 22 06:57:38 PDT 2026
2
2
  UseWhiteList=true
3
3
  PrintScreen=3
4
4
  UseRandomSample=false
5
5
  CookieDomain=.straussandplesser.com
6
- PostMessageUrl=https\://lib-us-2.brilliantcollector.com/collector/collectorPost
6
+ PostMessageUrl=YOUR_POST_MESSAGE_URL_HERE
7
7
  SessionTimeout=30
8
8
  PercentToCompressImage=80
9
9
  CookieExpires=false
10
- AppKey=b6c3709b7a4c479bb4b5a9fb8fec324c
10
+ AppKey=YOUR_CONNECT_APP_KEY_HERE
11
11
  KillSwitchEnabled=false
12
12
  ScreenShotPixelDensity=1.5
13
13
  CookieExpiresFormat=ASCTIME
@@ -21,7 +21,7 @@ CookieUrl=http\://m.straussandplesser.com
21
21
  ScreenshotFormat=JPG
22
22
  Connection=3
23
23
  CookiePath=/
24
- KillSwitchUrl=https\://lib-us-2.brilliantcollector.com/collector/switch/b6c3709b7a4c479bb4b5a9fb8fec324c
24
+ KillSwitchUrl=YOUR_KILL_SWITCH_URL_HERE
25
25
  LogLocationEnabled=true
26
26
  LogLocationTimeout=30
27
27
  KillSwitchTimeInterval=5
package/cli/doctor.mjs ADDED
@@ -0,0 +1,294 @@
1
+ // `acoustic-connect doctor [dir]` — pre-build doctor + config scaffolder.
2
+ //
3
+ // One command that checks the prerequisites and validates the bits of Acoustic
4
+ // Connect integration that otherwise fail late and confusingly at build time:
5
+ // the App Group format, a Java-safe Android package, and the
6
+ // google-services.json package match. It also scaffolds the gitignored
7
+ // ConnectConfig.json from the committed example so a fresh clone has something
8
+ // to edit.
9
+ //
10
+ // Auto-detects the project shape:
11
+ // - Expo : a root app.json with an `expo` block (identifiers live there;
12
+ // google-services.json sits at the project root).
13
+ // - bare : an android/app/build.gradle (identifiers come from
14
+ // applicationId; google-services.json sits under android/app/).
15
+ //
16
+ // It is read-mostly: the only thing it writes is a missing ConnectConfig.json
17
+ // (copied from the example). It never touches native projects, installs
18
+ // dependencies, or creates Apple/Firebase resources — those are owned by the
19
+ // platform tooling and the developer's accounts.
20
+
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+
24
+ import {
25
+ Reporter,
26
+ color,
27
+ copyIfMissing,
28
+ fileExists,
29
+ readJson,
30
+ readText,
31
+ section,
32
+ } from './lib.mjs'
33
+
34
+ // App Group format per Apple + the Config Plugin's own validator
35
+ // (plugin/src/withConnectNSE.ts: APP_GROUP_PATTERN).
36
+ const APP_GROUP_PATTERN = /^group\.[A-Za-z0-9.-]+$/
37
+
38
+ // Java reserved words (+ literals) that cannot appear as a package segment.
39
+ // Expo uses android.package as the Java/Kotlin namespace, so any segment that
40
+ // is a keyword breaks the Gradle build ("not a valid Java package name").
41
+ const JAVA_KEYWORDS = new Set([
42
+ 'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char',
43
+ 'class', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum',
44
+ 'extends', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements',
45
+ 'import', 'instanceof', 'int', 'interface', 'long', 'native', 'new',
46
+ 'package', 'private', 'protected', 'public', 'return', 'short', 'static',
47
+ 'strictfp', 'super', 'switch', 'synchronized', 'this', 'throw', 'throws',
48
+ 'transient', 'try', 'void', 'volatile', 'while', 'true', 'false', 'null',
49
+ ])
50
+
51
+ // Decide whether `dir` is an Expo or a bare React Native project.
52
+ export function detectProjectType(dir) {
53
+ const appJson = readJson(path.join(dir, 'app.json'))
54
+ if (appJson && appJson.expo) return 'expo'
55
+ // A bare RN app always declares `react-native` in its manifest. Use that as
56
+ // the discriminator rather than a bare `android/` directory — any Flutter or
57
+ // native-Android project also has one, and would be misclassified as bare.
58
+ const pkg = readJson(path.join(dir, 'package.json'))
59
+ const deps = {...pkg?.dependencies, ...pkg?.devDependencies}
60
+ if (deps['react-native']) return 'bare'
61
+ return 'unknown'
62
+ }
63
+
64
+ function checkNode(reporter) {
65
+ const major = Number(process.versions.node.split('.')[0])
66
+ if (major >= 20) reporter.pass(`Node ${process.versions.node}`)
67
+ else
68
+ reporter.fail(
69
+ `Node ${process.versions.node}`,
70
+ 'Node >= 20 is required — upgrade Node and re-run',
71
+ )
72
+ }
73
+
74
+ // Scaffold the per-developer ConnectConfig.json from the committed example.
75
+ function ensureConnectConfig(reporter, dir) {
76
+ const dest = path.join(dir, 'ConnectConfig.json')
77
+ const src = path.join(dir, 'ConnectConfig.example.json')
78
+ const result = copyIfMissing(src, dest)
79
+ if (result === 'created')
80
+ reporter.warn(
81
+ 'ConnectConfig.json',
82
+ 'created from example — EDIT it: set AppKey + collector URLs (PostMessageUrl/KillSwitchUrl), and iOSAppGroupIdentifier for push',
83
+ )
84
+ else if (result === 'exists') reporter.pass('ConnectConfig.json present')
85
+ else reporter.fail('ConnectConfig.json', 'ConnectConfig.example.json missing')
86
+ }
87
+
88
+ // Validate the App Group identifier (when set). A bad value fails the Config
89
+ // Plugin during `expo prebuild` / the iOS extensions at build time, not here,
90
+ // so catch it early. Returns { appGroup, pushEnabled } for downstream checks.
91
+ function checkConnectConfigValues(reporter, dir) {
92
+ const cfg = readJson(path.join(dir, 'ConnectConfig.json'))
93
+ if (cfg === undefined) {
94
+ // Missing/failed was already reported by ensureConnectConfig; only flag a
95
+ // present-but-broken file here.
96
+ if (fileExists(path.join(dir, 'ConnectConfig.json')))
97
+ reporter.fail('ConnectConfig.json', 'present but not valid JSON')
98
+ return {}
99
+ }
100
+ const connect = (cfg && cfg.Connect) || {}
101
+ const appGroup = connect.iOSAppGroupIdentifier
102
+ const pushEnabled = connect.PushEnabled === true
103
+
104
+ if (appGroup) {
105
+ if (APP_GROUP_PATTERN.test(appGroup))
106
+ reporter.pass('iOSAppGroupIdentifier', appGroup)
107
+ else
108
+ reporter.fail(
109
+ 'iOSAppGroupIdentifier',
110
+ `"${appGroup}" is invalid — must match group.<reverse-dns> (letters/digits/dots/hyphens)`,
111
+ )
112
+ } else if (pushEnabled) {
113
+ reporter.warn(
114
+ 'iOSAppGroupIdentifier',
115
+ 'PushEnabled is true but no App Group set — required for the NSE/NCE rich-push extensions',
116
+ )
117
+ }
118
+ return {appGroup, pushEnabled}
119
+ }
120
+
121
+ function validateAndroidPackage(reporter, label, pkg) {
122
+ if (!pkg) {
123
+ reporter.warn(label, 'not set')
124
+ return
125
+ }
126
+ const badSegment = pkg.split('.').find((seg) => JAVA_KEYWORDS.has(seg))
127
+ if (badSegment)
128
+ reporter.fail(
129
+ label,
130
+ `"${pkg}" — segment "${badSegment}" is a Java keyword; the Android build will fail. Use a Java-safe package (the iOS bundle id may keep it).`,
131
+ )
132
+ else reporter.pass(label, pkg)
133
+ }
134
+
135
+ // Expo: identifiers live in app.json's expo block.
136
+ function checkAppJson(reporter, dir) {
137
+ const appJson = readJson(path.join(dir, 'app.json'))
138
+ if (!appJson || !appJson.expo) {
139
+ reporter.fail('app.json', 'missing or has no "expo" block')
140
+ return {}
141
+ }
142
+ const expo = appJson.expo
143
+ const androidPackage = expo.android && expo.android.package
144
+ const iosBundleId = expo.ios && expo.ios.bundleIdentifier
145
+ validateAndroidPackage(reporter, 'android.package', androidPackage)
146
+ if (iosBundleId) reporter.pass('ios.bundleIdentifier', iosBundleId)
147
+ else reporter.warn('ios.bundleIdentifier', 'not set in app.json')
148
+ return {androidPackage, iosBundleId}
149
+ }
150
+
151
+ // Bare: read identifiers from android/app/build.gradle. Two distinct fields:
152
+ // - `namespace` → the R-class / Java package; MUST be a valid Java
153
+ // package (keyword check applies here).
154
+ // - `applicationId` → the published app id; may contain segments that are
155
+ // not valid Java identifiers (e.g. `new`), so it is NOT
156
+ // keyword-checked. It is what FCM matches in
157
+ // google-services.json, so it's returned for that check.
158
+ // (Expo collapses both into android.package, which is why checkAppJson
159
+ // keyword-checks that single field.)
160
+ function checkBareAndroidId(reporter, dir) {
161
+ const gradle = readText(path.join(dir, 'android', 'app', 'build.gradle'))
162
+ const namespace = gradle?.match(/namespace\s+["']([^"']+)["']/)?.[1] || null
163
+ const appId = gradle?.match(/applicationId\s+["']([^"']+)["']/)?.[1] || null
164
+ validateAndroidPackage(reporter, 'namespace', namespace)
165
+ if (appId) reporter.pass('applicationId', appId)
166
+ else reporter.warn('applicationId', 'not set')
167
+ return {androidPackage: appId}
168
+ }
169
+
170
+ // google-services.json must contain a client whose package_name matches the
171
+ // Android package (FCM matches by package); the gradle plugin fails otherwise
172
+ // ("No matching client found for package name").
173
+ function checkGoogleServices(reporter, {gsPath, androidPackage, pushEnabled}) {
174
+ if (!fileExists(gsPath)) {
175
+ if (pushEnabled)
176
+ reporter.warn(
177
+ 'google-services.json',
178
+ 'missing — required for Android FCM builds (add the Firebase Android app, then download it here)',
179
+ )
180
+ else reporter.pass('google-services.json', 'not present (push disabled)')
181
+ return
182
+ }
183
+ const gs = readJson(gsPath)
184
+ if (gs === undefined) {
185
+ reporter.fail('google-services.json', 'present but not valid JSON')
186
+ return
187
+ }
188
+ const isPlaceholder =
189
+ gs.project_info?.project_id === 'your-firebase-project-id' ||
190
+ JSON.stringify(gs).includes('REPLACE_WITH_YOUR_FIREBASE')
191
+ const packages = (gs.client || [])
192
+ .map((c) => c.client_info?.android_client_info?.package_name)
193
+ .filter(Boolean)
194
+ if (isPlaceholder)
195
+ reporter.warn(
196
+ 'google-services.json',
197
+ `placeholder values — replace with your real Firebase config (register package '${androidPackage}')`,
198
+ )
199
+ else if (androidPackage && !packages.includes(androidPackage))
200
+ reporter.fail(
201
+ 'google-services.json',
202
+ `no client matches '${androidPackage}' (has: ${packages.join(', ') || 'none'}). Register that package in Firebase and re-download.`,
203
+ )
204
+ else reporter.pass('google-services.json', 'package match')
205
+ }
206
+
207
+ // Collect *.entitlements files at most one level deep under iosDir (host +
208
+ // extension target folders), skipping Pods/build.
209
+ function findEntitlements(iosDir) {
210
+ const out = []
211
+ let entries
212
+ try {
213
+ entries = fs.readdirSync(iosDir, {withFileTypes: true})
214
+ } catch {
215
+ return out
216
+ }
217
+ for (const e of entries) {
218
+ const p = path.join(iosDir, e.name)
219
+ if (e.isFile() && e.name.endsWith('.entitlements')) out.push(p)
220
+ else if (e.isDirectory() && e.name !== 'Pods' && e.name !== 'build') {
221
+ try {
222
+ for (const f of fs.readdirSync(p))
223
+ if (f.endsWith('.entitlements')) out.push(path.join(p, f))
224
+ } catch {
225
+ /* ignore unreadable subdir */
226
+ }
227
+ }
228
+ }
229
+ return out
230
+ }
231
+
232
+ // Bare iOS only: the host entitlements must declare aps-environment + an App
233
+ // Group (the Config Plugin generates these for Expo).
234
+ function checkBareIosEntitlements(reporter, dir) {
235
+ const iosDir = path.join(dir, 'ios')
236
+ if (!fileExists(iosDir)) {
237
+ reporter.warn('ios/', 'no ios/ directory found')
238
+ return
239
+ }
240
+ const hasBoth = findEntitlements(iosDir).some((p) => {
241
+ const t = readText(p) || ''
242
+ return t.includes('aps-environment') && t.includes('application-groups')
243
+ })
244
+ if (hasBoth) reporter.pass('App entitlements (aps-environment + App Group)')
245
+ else
246
+ reporter.warn(
247
+ 'App entitlements',
248
+ 'no *.entitlements with both aps-environment and application-groups — run `acoustic-connect setup-ios-push`',
249
+ )
250
+ }
251
+
252
+ // Run all relevant checks for `dir`. Returns the Reporter (caller decides how
253
+ // to print the summary / exit).
254
+ export function runDoctor(dir, {reporter = new Reporter()} = {}) {
255
+ const type = detectProjectType(dir)
256
+ console.log(
257
+ color.bold('\nAcoustic Connect doctor') +
258
+ color.dim(` (${type} project — ${dir})`),
259
+ )
260
+
261
+ section('Prerequisites')
262
+ checkNode(reporter)
263
+
264
+ section('Configuration')
265
+ ensureConnectConfig(reporter, dir)
266
+ const {pushEnabled} = checkConnectConfigValues(reporter, dir)
267
+
268
+ section('Identifiers')
269
+ let androidPackage
270
+ if (type === 'expo') {
271
+ ;({androidPackage} = checkAppJson(reporter, dir))
272
+ } else if (type === 'bare') {
273
+ ;({androidPackage} = checkBareAndroidId(reporter, dir))
274
+ } else {
275
+ reporter.warn(
276
+ 'project type',
277
+ 'could not detect Expo or bare RN layout — skipping identifier checks',
278
+ )
279
+ }
280
+
281
+ section('Android push (FCM)')
282
+ const gsPath =
283
+ type === 'expo'
284
+ ? path.join(dir, 'google-services.json')
285
+ : path.join(dir, 'android', 'app', 'google-services.json')
286
+ checkGoogleServices(reporter, {gsPath, androidPackage, pushEnabled})
287
+
288
+ if (type === 'bare') {
289
+ section('iOS push')
290
+ checkBareIosEntitlements(reporter, dir)
291
+ }
292
+
293
+ return reporter
294
+ }
package/cli/index.mjs ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // `acoustic-connect` — setup CLI for the React Native Acoustic Connect SDK.
3
+ //
4
+ // Ships in the published package so client apps don't have to re-implement the
5
+ // integration plumbing our own sample apps used to carry. Subcommands:
6
+ //
7
+ // acoustic-connect doctor [dir] Validate config + scaffold ConnectConfig.json
8
+ // acoustic-connect setup-ios-push [dir] Create/repair the iOS NSE + NCE push extensions
9
+ //
10
+ // `dir` defaults to the current working directory (npm passes $INIT_CWD when
11
+ // run as a script). All commands are idempotent and safe to re-run.
12
+
13
+ import path from 'node:path'
14
+
15
+ import {runDoctor} from './doctor.mjs'
16
+ import {setupIosPush} from './ios/setup-push.mjs'
17
+ import {Reporter, color} from './lib.mjs'
18
+
19
+ const USAGE = `acoustic-connect — React Native Acoustic Connect SDK setup
20
+
21
+ Usage:
22
+ acoustic-connect doctor [dir] Validate config + scaffold ConnectConfig.json
23
+ acoustic-connect setup-ios-push [dir] Create/repair the iOS NSE + NCE push extensions
24
+
25
+ dir defaults to the current directory.`
26
+
27
+ // Resolve the target directory: explicit arg > $INIT_CWD (npm) > cwd.
28
+ function targetDir(arg) {
29
+ if (arg && !arg.startsWith('-')) return path.resolve(arg)
30
+ if (process.env.INIT_CWD) return path.resolve(process.env.INIT_CWD)
31
+ return process.cwd()
32
+ }
33
+
34
+ async function main() {
35
+ const [command, maybeDir] = process.argv.slice(2)
36
+
37
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
38
+ console.log(USAGE)
39
+ process.exit(command ? 0 : 1)
40
+ }
41
+
42
+ const dir = targetDir(maybeDir)
43
+
44
+ switch (command) {
45
+ case 'doctor': {
46
+ const reporter = runDoctor(dir)
47
+ process.exit(reporter.summary())
48
+ }
49
+ case 'setup-ios-push': {
50
+ const reporter = new Reporter()
51
+ await setupIosPush(dir, {reporter})
52
+ process.exit(reporter.summary())
53
+ }
54
+ default:
55
+ console.error(color.red(`Unknown command: ${command}\n`))
56
+ console.log(USAGE)
57
+ process.exit(1)
58
+ }
59
+ }
60
+
61
+ main().catch((err) => {
62
+ console.error(color.red('\nacoustic-connect crashed:'), err)
63
+ process.exit(1)
64
+ })
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # add_push_extensions.rb (SDK-shipped, parameterized)
5
+ #
6
+ # Idempotently adds the two Acoustic Connect push extensions to a host app's
7
+ # Xcode project:
8
+ #
9
+ # * ConnectNSE — Notification Service Extension (rich-media download +
10
+ # PushReceived signal logging via the App Group pending store)
11
+ # * ConnectNCE — Notification Content Extension (rich expansion UI)
12
+ #
13
+ # Both subclass the Connect SDK base classes and share the host app's App
14
+ # Group. This is the MANUAL setup path for bare React Native projects; the Expo
15
+ # Config Plugin performs the equivalent target surgery automatically for Expo
16
+ # projects.
17
+ #
18
+ # This is the generalized version that ships inside the SDK package. It is
19
+ # normally invoked by `acoustic-connect setup-ios-push` (cli/ios/setup-push.mjs),
20
+ # which derives the parameters from the project and creates the per-extension
21
+ # source/plist/entitlements files from templates BEFORE running this — this
22
+ # script only performs the pbxproj surgery and assumes those files exist.
23
+ #
24
+ # Parameters come from the environment (the Node wrapper sets them):
25
+ # ACOUSTIC_PROJECT_PATH absolute path to the .xcodeproj (required)
26
+ # ACOUSTIC_APP_TARGET host app target name (required)
27
+ # ACOUSTIC_APP_BUNDLE_ID host app bundle identifier (optional —
28
+ # falls back to the app target's
29
+ # PRODUCT_BUNDLE_IDENTIFIER)
30
+ # ACOUSTIC_DEPLOYMENT_TARGET iOS deployment target (default 15.1)
31
+ # ACOUSTIC_SWIFT_VERSION Swift version (default 5.0)
32
+ #
33
+ # Requires the `xcodeproj` gem (ships with CocoaPods).
34
+
35
+ require 'xcodeproj'
36
+
37
+ def env!(key)
38
+ value = ENV[key]
39
+ raise "#{key} is required" if value.nil? || value.empty?
40
+
41
+ value
42
+ end
43
+
44
+ PROJECT_PATH = env!('ACOUSTIC_PROJECT_PATH')
45
+ APP_TARGET_NAME = env!('ACOUSTIC_APP_TARGET')
46
+ DEPLOYMENT_TARGET = ENV.fetch('ACOUSTIC_DEPLOYMENT_TARGET', '15.1')
47
+ SWIFT_VERSION = ENV.fetch('ACOUSTIC_SWIFT_VERSION', '5.0')
48
+
49
+ EXTENSIONS = [
50
+ {
51
+ name: 'ConnectNSE',
52
+ source: 'NotificationService.swift',
53
+ suffix: 'ConnectNSE',
54
+ # UserNotifications declares the service-extension point
55
+ # (com.apple.usernotifications.service / UNNotificationServiceExtension).
56
+ frameworks: %w[UserNotifications],
57
+ },
58
+ {
59
+ name: 'ConnectNCE',
60
+ source: 'NotificationViewController.swift',
61
+ suffix: 'ConnectNCE',
62
+ # UserNotificationsUI declares the content-extension point
63
+ # (com.apple.usernotifications.content-extension) and provides the
64
+ # UNNotificationContentExtension context class. WITHOUT it the extension
65
+ # traps on first connection — "Unable to find NSExtensionContextClass
66
+ # (_UNNotificationContentExtensionVendorContext) … did you link the
67
+ # framework that declares the extension point?" — and no custom rich UI
68
+ # renders. UIKit + UserNotifications back the view controller. The Xcode
69
+ # template links these automatically; this scaffolder must do the same.
70
+ frameworks: %w[UserNotificationsUI UserNotifications UIKit],
71
+ },
72
+ ].freeze
73
+
74
+ project = Xcodeproj::Project.open(PROJECT_PATH)
75
+ app_target = project.targets.find { |t| t.name == APP_TARGET_NAME }
76
+ raise "App target #{APP_TARGET_NAME} not found in #{PROJECT_PATH}" unless app_target
77
+
78
+ # Bundle id: explicit override, else read it off the host app target so the
79
+ # extension ids (<bundle>.ConnectNSE / .ConnectNCE) match the real app.
80
+ def resolve_bundle_id(app_target)
81
+ explicit = ENV['ACOUSTIC_APP_BUNDLE_ID']
82
+ return explicit unless explicit.nil? || explicit.empty?
83
+
84
+ app_target.build_configurations
85
+ .map { |c| c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] }
86
+ .compact
87
+ .find { |v| !v.include?('$(') }
88
+ end
89
+
90
+ APP_BUNDLE_ID = resolve_bundle_id(app_target)
91
+ raise 'Could not determine the host bundle id; pass ACOUSTIC_APP_BUNDLE_ID' unless APP_BUNDLE_ID
92
+
93
+ # 1. Wire the host app's entitlements (App Group + aps-environment) onto both
94
+ # build configurations. Safe to re-run.
95
+ app_target.build_configurations.each do |config|
96
+ config.build_settings['CODE_SIGN_ENTITLEMENTS'] =
97
+ "#{APP_TARGET_NAME}/#{APP_TARGET_NAME}.entitlements"
98
+ end
99
+
100
+ # Make the host entitlements file visible in the project navigator.
101
+ app_group = project.main_group.find_subpath(APP_TARGET_NAME, true)
102
+ ent_rel = "#{APP_TARGET_NAME}/#{APP_TARGET_NAME}.entitlements"
103
+ unless app_group.files.any? { |f| f.path == ent_rel }
104
+ app_group.new_reference(ent_rel)
105
+ end
106
+
107
+ # 2. Ensure there is an "Embed Foundation Extensions" copy-files phase on the
108
+ # app target (dstSubfolderSpec = PlugIns).
109
+ embed_phase = app_target.copy_files_build_phases.find do |p|
110
+ p.symbol_dst_subfolder_spec == :plug_ins
111
+ end
112
+ unless embed_phase
113
+ embed_phase = app_target.new_copy_files_build_phase('Embed Foundation Extensions')
114
+ embed_phase.symbol_dst_subfolder_spec = :plug_ins
115
+ end
116
+
117
+ EXTENSIONS.each do |ext|
118
+ if project.targets.any? { |t| t.name == ext[:name] }
119
+ puts "#{ext[:name]}: target already exists — skipping creation."
120
+ next
121
+ end
122
+
123
+ puts "#{ext[:name]}: creating app-extension target."
124
+ target = project.new_target(
125
+ :app_extension,
126
+ ext[:name],
127
+ :ios,
128
+ DEPLOYMENT_TARGET,
129
+ nil,
130
+ :swift
131
+ )
132
+
133
+ # Group + source file (added to the Compile Sources phase).
134
+ group = project.main_group.find_subpath(ext[:name], true)
135
+ source_ref = group.new_reference("#{ext[:name]}/#{ext[:source]}")
136
+ target.add_file_references([source_ref])
137
+ # Info.plist + entitlements are referenced via build settings only — add as
138
+ # plain references for navigator visibility (not compiled / copied).
139
+ group.new_reference("#{ext[:name]}/Info.plist")
140
+ group.new_reference("#{ext[:name]}/#{ext[:suffix]}.entitlements")
141
+
142
+ target.build_configurations.each do |config|
143
+ bs = config.build_settings
144
+ bs['PRODUCT_BUNDLE_IDENTIFIER'] = "#{APP_BUNDLE_ID}.#{ext[:suffix]}"
145
+ bs['PRODUCT_NAME'] = '$(TARGET_NAME)'
146
+ bs['INFOPLIST_FILE'] = "#{ext[:name]}/Info.plist"
147
+ bs['GENERATE_INFOPLIST_FILE'] = 'NO'
148
+ bs['CODE_SIGN_ENTITLEMENTS'] = "#{ext[:name]}/#{ext[:suffix]}.entitlements"
149
+ bs['CODE_SIGN_STYLE'] = 'Automatic'
150
+ bs['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET
151
+ bs['SWIFT_VERSION'] = SWIFT_VERSION
152
+ bs['SKIP_INSTALL'] = 'YES'
153
+ bs['APPLICATION_EXTENSION_API_ONLY'] = 'YES'
154
+ bs['CLANG_ENABLE_MODULES'] = 'YES'
155
+ bs['MARKETING_VERSION'] = '1.0'
156
+ bs['CURRENT_PROJECT_VERSION'] = '1'
157
+ bs['TARGETED_DEVICE_FAMILY'] = '1,2'
158
+ bs['LD_RUNPATH_SEARCH_PATHS'] = [
159
+ '$(inherited)',
160
+ '@executable_path/Frameworks',
161
+ '@executable_path/../../Frameworks',
162
+ ]
163
+ end
164
+
165
+ # App depends on the extension and embeds it in PlugIns.
166
+ app_target.add_dependency(target)
167
+ build_file = embed_phase.add_file_reference(target.product_reference)
168
+ build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
169
+ puts "#{ext[:name]}: target created, embedded, and added as app dependency."
170
+ end
171
+
172
+ # 3. Ensure each extension links the system frameworks that declare its
173
+ # extension point. Runs every time (also repairs pre-existing targets that
174
+ # were created before this step was added), and is idempotent — a framework
175
+ # already in the Link Binary phase is left untouched.
176
+ EXTENSIONS.each do |ext|
177
+ target = project.targets.find { |t| t.name == ext[:name] }
178
+ next unless target
179
+
180
+ linked = target.frameworks_build_phase.files.map(&:display_name)
181
+ Array(ext[:frameworks]).each do |fw|
182
+ if linked.include?("#{fw}.framework")
183
+ puts "#{ext[:name]}: #{fw}.framework already linked."
184
+ else
185
+ target.add_system_framework(fw)
186
+ puts "#{ext[:name]}: linked #{fw}.framework."
187
+ end
188
+ end
189
+ end
190
+
191
+ project.save
192
+ puts "Saved #{PROJECT_PATH}"
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # connect_pods.rb — shared CocoaPods helper for the Acoustic Connect SDK.
4
+ #
5
+ # Ships in the published package so consumer Podfiles don't have to re-implement
6
+ # the Connect SDK pod resolution. Mirrors AcousticConnectRN.podspec so the host
7
+ # app target and the push extensions (ConnectNSE / ConnectNCE) all link the SAME
8
+ # SDK build:
9
+ # - useRelease => 'AcousticConnect', otherwise 'AcousticConnectDebug'
10
+ # - floor '>= 2.1.12' (the push permission API floor)
11
+ # - optional exact pin via Connect.iOSVersion
12
+ #
13
+ # Usage from a Podfile (resolve the file the same way the RN template resolves
14
+ # react_native_pods.rb):
15
+ #
16
+ # require Pod::Executable.execute_command('node', ['-p',
17
+ # 'require.resolve("react-native-acoustic-connect-beta/cli/ios/connect_pods.rb", {paths: [process.argv[1]]})',
18
+ # __dir__]).strip
19
+ #
20
+ # target 'MyApp' do
21
+ # # ...
22
+ # pod *acoustic_connect_pod(__dir__)
23
+ # end
24
+ # target 'ConnectNSE' do
25
+ # pod *acoustic_connect_pod(__dir__)
26
+ # end
27
+ #
28
+ # ConnectConfig.json is resolved at <podfile_dir>/../ConnectConfig.json (the
29
+ # consumer app root), matching where the podspec and config.gradle read it.
30
+
31
+ require 'json'
32
+
33
+ # Returns [pod_name, requirements_array] for `pod *acoustic_connect_pod(__dir__)`.
34
+ #
35
+ # podfile_dir : the Podfile's own directory (pass `__dir__`). ConnectConfig.json
36
+ # is read from its parent, i.e. the app root.
37
+ # config_path : optional explicit path to ConnectConfig.json (overrides the
38
+ # default lookup).
39
+ def acoustic_connect_pod(podfile_dir, config_path: nil)
40
+ path = config_path || File.join(podfile_dir, '..', 'ConnectConfig.json')
41
+ connect_config = JSON.parse(File.read(path))['Connect'] || {}
42
+ use_release = connect_config['useRelease']
43
+ ios_version = connect_config['iOSVersion'].to_s
44
+ name = use_release ? 'AcousticConnect' : 'AcousticConnectDebug'
45
+ floor = '>= 2.1.12'
46
+ requirements = ios_version.empty? ? [floor] : [floor, ios_version]
47
+ [name, requirements]
48
+ end