@goliapkg/sentori-expo 7.0.1 → 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 +156 -25
  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'
@@ -172,7 +181,7 @@ const withSentoriGoogleServicesJson = (config, props = {}) => {
172
181
  ])
173
182
  }
174
183
 
175
- // ── v2.28 iOS Notification Service Extension scaffolding ──────────
184
+ // ── v2.28 iOS Notification Service Extension ─────────────────────
176
185
  //
177
186
  // Rich-media notifications (images / future video) require an NSE
178
187
  // target on the iOS app. The Sentori NSE template downloads the URL
@@ -180,14 +189,48 @@ const withSentoriGoogleServicesJson = (config, props = {}) => {
180
189
  // displays the notification. APNs server side sets this key when
181
190
  // `richMedia.imageUrl` is on the send (v2.28+).
182
191
  //
183
- // v2.28 ships the source files via withDangerousMod. Adding the NSE
184
- // **target** to the Xcode project is a one-time manual step (5 clicks
185
- // in Xcode File New → Target → Notification Service Extension,
186
- // then drag in our Swift file). The recipe walks the developer through
187
- // 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.
188
207
  //
189
- // Opt out with `{ ios: false }` (which also drops the rest of the iOS
190
- // 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
+ }
191
234
 
192
235
  /**
193
236
  * @param {import('@expo/config-plugins').ExpoConfig} config
@@ -197,7 +240,7 @@ const withSentoriNSE = (config) => {
197
240
  'ios',
198
241
  async (cfg) => {
199
242
  const platformRoot = cfg.modRequest.platformProjectRoot
200
- const destDir = path.join(platformRoot, 'SentoriNSE')
243
+ const destDir = path.join(platformRoot, NSE_TARGET)
201
244
  const templateDir = path.join(__dirname, 'templates', 'ios-nse')
202
245
  const swiftSrc = path.join(templateDir, 'SentoriNotificationServiceExtension.swift')
203
246
  const plistSrc = path.join(templateDir, 'SentoriNSE-Info.plist')
@@ -213,27 +256,109 @@ const withSentoriNSE = (config) => {
213
256
  swiftSrc,
214
257
  path.join(destDir, 'SentoriNotificationServiceExtension.swift')
215
258
  )
216
- fs.copyFileSync(plistSrc, path.join(destDir, 'SentoriNSE-Info.plist'))
217
- // One-time guidance for first-time setup. Idempotent — appears
218
- // on every prebuild until the target exists.
219
- const pbxproj = path.join(platformRoot, cfg.modRequest.projectName + '.xcodeproj', 'project.pbxproj')
220
- if (fs.existsSync(pbxproj)) {
221
- const proj = fs.readFileSync(pbxproj, 'utf8')
222
- if (!proj.includes('SentoriNSE')) {
223
- // eslint-disable-next-line no-console
224
- console.log(
225
- '\n[sentori-expo] iOS NSE template files copied to ios/SentoriNSE/.\n' +
226
- ' For rich-media (image) notifications to render, add a\n' +
227
- ' Notification Service Extension target via Xcode and link\n' +
228
- ' these files. Detailed steps in the recipe.\n'
229
- )
230
- }
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)
231
273
  }
232
274
  return cfg
233
275
  },
234
276
  ])
235
277
  }
236
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
+
237
362
  // ── Composer ───────────────────────────────────────────────────────
238
363
 
239
364
  /**
@@ -244,7 +369,9 @@ const withSentori = (config, props = {}) => {
244
369
  const plugins = [[withSentoriVersion, props]]
245
370
  if (props.ios !== false) {
246
371
  plugins.push([withSentoriPushIos, props])
247
- if (props.nse !== false) plugins.push([withSentoriNSE, props])
372
+ if (props.nse !== false) {
373
+ plugins.push([withSentoriNSE, props], [withSentoriNSETarget, props])
374
+ }
248
375
  }
249
376
  if (props.android !== false) {
250
377
  plugins.push(
@@ -257,3 +384,7 @@ const withSentori = (config, props = {}) => {
257
384
  }
258
385
 
259
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.1",
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)",