@goliapkg/sentori-expo 7.0.0 → 7.0.2

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 (2) hide show
  1. package/app.plugin.js +162 -27
  2. package/package.json +1 -1
package/app.plugin.js CHANGED
@@ -39,10 +39,19 @@ const {
39
39
  withProjectBuildGradle,
40
40
  withAppBuildGradle,
41
41
  withDangerousMod,
42
+ withXcodeProject,
42
43
  AndroidConfig,
43
44
  withPlugins,
44
45
  } = require('@expo/config-plugins')
45
46
 
47
+ // NSE target wiring constants. Path is relative to the .xcodeproj — the
48
+ // `xcode` package's addBuildPhase resolves filePaths against the project
49
+ // root, not the target subfolder, so the NSE/ prefix is required here
50
+ // even though the source actually lives at ios/SentoriNSE/<basename>.
51
+ const NSE_TARGET = 'SentoriNSE'
52
+ const NSE_SOURCE_REL = 'SentoriNSE/SentoriNotificationServiceExtension.swift'
53
+ const NSE_PLIST_REL = 'SentoriNSE-Info.plist'
54
+
46
55
  const SENTORI_VERSION_KEY = 'SentoriSdkVersion'
47
56
  const FIREBASE_BOM_VERSION = '33.5.1'
48
57
  const GOOGLE_SERVICES_VERSION = '4.4.2'
@@ -95,9 +104,13 @@ const withSentoriPushIos = (config) => {
95
104
  */
96
105
  const withSentoriPushAndroidManifest = (config) => {
97
106
  return withAndroidManifest(config, (cfg) => {
98
- const manifest = cfg.modResults.manifest
107
+ // addPermission expects the AndroidManifest object (cfg.modResults);
108
+ // it dereferences `.manifest['uses-permission']` internally. Passing
109
+ // `cfg.modResults.manifest` makes that read fail with `Cannot read
110
+ // properties of undefined (reading 'uses-permission')` and crashes
111
+ // `expo prebuild`.
99
112
  AndroidConfig.Permissions.addPermission(
100
- manifest,
113
+ cfg.modResults,
101
114
  'android.permission.POST_NOTIFICATIONS'
102
115
  )
103
116
  return cfg
@@ -168,7 +181,7 @@ const withSentoriGoogleServicesJson = (config, props = {}) => {
168
181
  ])
169
182
  }
170
183
 
171
- // ── v2.28 iOS Notification Service Extension scaffolding ──────────
184
+ // ── v2.28 iOS Notification Service Extension ─────────────────────
172
185
  //
173
186
  // Rich-media notifications (images / future video) require an NSE
174
187
  // target on the iOS app. The Sentori NSE template downloads the URL
@@ -176,14 +189,48 @@ const withSentoriGoogleServicesJson = (config, props = {}) => {
176
189
  // displays the notification. APNs server side sets this key when
177
190
  // `richMedia.imageUrl` is on the send (v2.28+).
178
191
  //
179
- // v2.28 ships the source files via withDangerousMod. Adding the NSE
180
- // **target** to the Xcode project is a one-time manual step (5 clicks
181
- // in Xcode File New → Target → Notification Service Extension,
182
- // then drag in our Swift file). The recipe walks the developer through
183
- // it. v2.28.1 will auto-inject the target via withXcodeProject.
192
+ // As of 7.0.2 the wiring is fully automated across two plugins:
193
+ //
194
+ // - withSentoriNSE (this section) writes the template files
195
+ // into ios/SentoriNSE/ AND syncs the NSE
196
+ // Info.plist's CFBundleShortVersionString /
197
+ // CFBundleVersion to the host app's values.
198
+ // Apple's app-extension verifier rejects an
199
+ // .appex whose version keys do not match
200
+ // the parent app at signing time, so the
201
+ // sync is mandatory.
202
+ //
203
+ // - withSentoriNSETarget (further down) creates the actual Xcode
204
+ // target via withXcodeProject so signing +
205
+ // building succeed without any manual
206
+ // Xcode-UI step.
184
207
  //
185
- // Opt out with `{ ios: false }` (which also drops the rest of the iOS
186
- // push wiring) or with `{ nse: false }` for just this template.
208
+ // Opt out with `{ ios: false }` (which drops the rest of the iOS push
209
+ // wiring too) or with `{ nse: false }` for just NSE (template + target).
210
+
211
+ /**
212
+ * Rewrite the NSE Info.plist's marketing + build version strings to
213
+ * track the host app. Apple rejects an .appex at signing time when its
214
+ * CFBundleShortVersionString / CFBundleVersion don't match the parent
215
+ * app — present in every Sentori install that enabled NSE before 7.0.2.
216
+ *
217
+ * Pure / exported for unit-test coverage.
218
+ *
219
+ * @param {string} contents — current NSE-Info.plist contents
220
+ * @param {{ version: string, buildNumber: string }} ver
221
+ * @returns {string}
222
+ */
223
+ function syncNSEPlistVersion(contents, ver) {
224
+ return contents
225
+ .replace(
226
+ /(<key>CFBundleShortVersionString<\/key>\s*<string>)[^<]*(<\/string>)/,
227
+ `$1${ver.version}$2`
228
+ )
229
+ .replace(
230
+ /(<key>CFBundleVersion<\/key>\s*<string>)[^<]*(<\/string>)/,
231
+ `$1${ver.buildNumber}$2`
232
+ )
233
+ }
187
234
 
188
235
  /**
189
236
  * @param {import('@expo/config-plugins').ExpoConfig} config
@@ -193,7 +240,7 @@ const withSentoriNSE = (config) => {
193
240
  'ios',
194
241
  async (cfg) => {
195
242
  const platformRoot = cfg.modRequest.platformProjectRoot
196
- const destDir = path.join(platformRoot, 'SentoriNSE')
243
+ const destDir = path.join(platformRoot, NSE_TARGET)
197
244
  const templateDir = path.join(__dirname, 'templates', 'ios-nse')
198
245
  const swiftSrc = path.join(templateDir, 'SentoriNotificationServiceExtension.swift')
199
246
  const plistSrc = path.join(templateDir, 'SentoriNSE-Info.plist')
@@ -209,27 +256,109 @@ const withSentoriNSE = (config) => {
209
256
  swiftSrc,
210
257
  path.join(destDir, 'SentoriNotificationServiceExtension.swift')
211
258
  )
212
- fs.copyFileSync(plistSrc, path.join(destDir, 'SentoriNSE-Info.plist'))
213
- // One-time guidance for first-time setup. Idempotent — appears
214
- // on every prebuild until the target exists.
215
- const pbxproj = path.join(platformRoot, cfg.modRequest.projectName + '.xcodeproj', 'project.pbxproj')
216
- if (fs.existsSync(pbxproj)) {
217
- const proj = fs.readFileSync(pbxproj, 'utf8')
218
- if (!proj.includes('SentoriNSE')) {
219
- // eslint-disable-next-line no-console
220
- console.log(
221
- '\n[sentori-expo] iOS NSE template files copied to ios/SentoriNSE/.\n' +
222
- ' For rich-media (image) notifications to render, add a\n' +
223
- ' Notification Service Extension target via Xcode and link\n' +
224
- ' these files. Detailed steps in the recipe.\n'
225
- )
226
- }
259
+ const plistDest = path.join(destDir, NSE_PLIST_REL)
260
+ fs.copyFileSync(plistSrc, plistDest)
261
+
262
+ // Version-sync the freshly-copied plist in the same callback so
263
+ // the read+write is atomic with the copy (no dependency on
264
+ // Expo's cross-plugin dangerousMod ordering, which is LIFO and
265
+ // therefore brittle to reason about). Expo defaults the host
266
+ // app's CFBundleVersion to '1' when ios.buildNumber is unset.
267
+ const version = cfg.version
268
+ const buildNumber = cfg.ios?.buildNumber ?? '1'
269
+ if (version) {
270
+ const current = fs.readFileSync(plistDest, 'utf-8')
271
+ const updated = syncNSEPlistVersion(current, { buildNumber, version })
272
+ if (updated !== current) fs.writeFileSync(plistDest, updated)
227
273
  }
228
274
  return cfg
229
275
  },
230
276
  ])
231
277
  }
232
278
 
279
+ /**
280
+ * Pure pbxproj mutation. Adds the NSE target + build phases + build
281
+ * settings, or returns false when the target already exists.
282
+ *
283
+ * Exported for unit-test coverage.
284
+ *
285
+ * @param {object} pbxproj — `xcode` package's Pbxproj instance
286
+ * (cfg.modResults inside withXcodeProject)
287
+ * @param {{ mainBundleId: string, deploymentTarget: string }} opts
288
+ * @returns {boolean} — true when the target was added, false when no-op
289
+ */
290
+ function injectNSETarget(pbxproj, opts) {
291
+ if (pbxproj.pbxTargetByName(NSE_TARGET)) return false
292
+
293
+ const { deploymentTarget, mainBundleId } = opts
294
+ if (!mainBundleId) throw new Error('[sentori-expo] mainBundleId is required for NSE target')
295
+ if (!deploymentTarget) {
296
+ throw new Error('[sentori-expo] deploymentTarget is required for NSE target')
297
+ }
298
+
299
+ // addTarget creates the PBXNativeTarget + XCBuildConfigurationList with
300
+ // INFOPLIST_FILE / PRODUCT_NAME / SKIP_INSTALL pre-set, and wires the
301
+ // produced .appex into a "Copy Files" phase on the main app target —
302
+ // that is Xcode's "Embed App Extensions" phase under a different name.
303
+ const target = pbxproj.addTarget(
304
+ NSE_TARGET,
305
+ 'app_extension',
306
+ NSE_TARGET,
307
+ `${mainBundleId}.${NSE_TARGET}`
308
+ )
309
+
310
+ pbxproj.addBuildPhase([NSE_SOURCE_REL], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
311
+ pbxproj.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid)
312
+ pbxproj.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid)
313
+
314
+ // xcode 3.0.x stores native target names quoted (`'"SentoriNSE"'`), so
315
+ // `pbxTargetByName('SentoriNSE')` / `updateBuildProperty(..., 'SentoriNSE')`
316
+ // miss the target via string equality. Patch the buildSettings dictionary
317
+ // for the NSE build configurations directly off the project hash.
318
+ const settings = {
319
+ CLANG_ENABLE_MODULES: 'YES',
320
+ CODE_SIGN_STYLE: 'Automatic',
321
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
322
+ SWIFT_VERSION: '5.0',
323
+ TARGETED_DEVICE_FAMILY: '"1,2"',
324
+ }
325
+ const nseTarget = pbxproj.hash.project.objects.PBXNativeTarget[target.uuid]
326
+ const configListUuid = nseTarget.buildConfigurationList
327
+ const configList = pbxproj.hash.project.objects.XCConfigurationList[configListUuid]
328
+ for (const { value: configUuid } of configList.buildConfigurations) {
329
+ const config = pbxproj.hash.project.objects.XCBuildConfiguration[configUuid]
330
+ Object.assign(config.buildSettings, settings)
331
+ }
332
+
333
+ return true
334
+ }
335
+
336
+ /**
337
+ * @param {import('@expo/config-plugins').ExpoConfig} config
338
+ */
339
+ const withSentoriNSETarget = (config) =>
340
+ withXcodeProject(config, (cfg) => {
341
+ const mainBundleId = cfg.ios?.bundleIdentifier
342
+ if (!mainBundleId) {
343
+ // Skip silently rather than throw — a host without an iOS
344
+ // bundleIdentifier configured is mid-setup, not broken.
345
+ // eslint-disable-next-line no-console
346
+ console.warn(
347
+ '[sentori-expo] ios.bundleIdentifier not set; skipping NSE target injection. Add it to app.json and re-prebuild.'
348
+ )
349
+ return cfg
350
+ }
351
+ // Match the host app's deployment target so the NSE links against
352
+ // the same minimum iOS. Multi-source fallback because cfg.ios?.deploymentTarget
353
+ // is not reliably populated — Expo readers prefer the
354
+ // `expo-build-properties` config plugin's value or the Podfile,
355
+ // not the top-level ios field. 15.1 matches Expo SDK 55's default.
356
+ const deploymentTarget =
357
+ cfg.ios?.deploymentTarget ?? cfg.ios?.infoPlist?.MinimumOSVersion ?? '15.1'
358
+ injectNSETarget(cfg.modResults, { deploymentTarget, mainBundleId })
359
+ return cfg
360
+ })
361
+
233
362
  // ── Composer ───────────────────────────────────────────────────────
234
363
 
235
364
  /**
@@ -240,7 +369,9 @@ const withSentori = (config, props = {}) => {
240
369
  const plugins = [[withSentoriVersion, props]]
241
370
  if (props.ios !== false) {
242
371
  plugins.push([withSentoriPushIos, props])
243
- if (props.nse !== false) plugins.push([withSentoriNSE, props])
372
+ if (props.nse !== false) {
373
+ plugins.push([withSentoriNSE, props], [withSentoriNSETarget, props])
374
+ }
244
375
  }
245
376
  if (props.android !== false) {
246
377
  plugins.push(
@@ -253,3 +384,7 @@ const withSentori = (config, props = {}) => {
253
384
  }
254
385
 
255
386
  module.exports = withSentori
387
+ // Exported for unit-test coverage of the pure helpers.
388
+ module.exports.injectNSETarget = injectNSETarget
389
+ module.exports.syncNSEPlistVersion = syncNSEPlistVersion
390
+ module.exports.NSE_TARGET = NSE_TARGET
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-expo",
3
- "version": "7.0.0",
3
+ "version": "7.0.2",
4
4
  "description": "Expo adapter for Sentori — Config Plugin marker, expo-application auto-config, EAS post-build helper. Built on @goliapkg/sentori-react-native.",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",