@goliapkg/sentori-expo 6.0.0 → 7.0.1

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.
package/app.plugin.js CHANGED
@@ -95,9 +95,13 @@ const withSentoriPushIos = (config) => {
95
95
  */
96
96
  const withSentoriPushAndroidManifest = (config) => {
97
97
  return withAndroidManifest(config, (cfg) => {
98
- const manifest = cfg.modResults.manifest
98
+ // addPermission expects the AndroidManifest object (cfg.modResults);
99
+ // it dereferences `.manifest['uses-permission']` internally. Passing
100
+ // `cfg.modResults.manifest` makes that read fail with `Cannot read
101
+ // properties of undefined (reading 'uses-permission')` and crashes
102
+ // `expo prebuild`.
99
103
  AndroidConfig.Permissions.addPermission(
100
- manifest,
104
+ cfg.modResults,
101
105
  'android.permission.POST_NOTIFICATIONS'
102
106
  )
103
107
  return cfg
@@ -168,15 +172,80 @@ const withSentoriGoogleServicesJson = (config, props = {}) => {
168
172
  ])
169
173
  }
170
174
 
175
+ // ── v2.28 iOS Notification Service Extension scaffolding ──────────
176
+ //
177
+ // Rich-media notifications (images / future video) require an NSE
178
+ // target on the iOS app. The Sentori NSE template downloads the URL
179
+ // at `userInfo.sentori_attachment_url` and attaches it before iOS
180
+ // displays the notification. APNs server side sets this key when
181
+ // `richMedia.imageUrl` is on the send (v2.28+).
182
+ //
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.
188
+ //
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.
191
+
192
+ /**
193
+ * @param {import('@expo/config-plugins').ExpoConfig} config
194
+ */
195
+ const withSentoriNSE = (config) => {
196
+ return withDangerousMod(config, [
197
+ 'ios',
198
+ async (cfg) => {
199
+ const platformRoot = cfg.modRequest.platformProjectRoot
200
+ const destDir = path.join(platformRoot, 'SentoriNSE')
201
+ const templateDir = path.join(__dirname, 'templates', 'ios-nse')
202
+ const swiftSrc = path.join(templateDir, 'SentoriNotificationServiceExtension.swift')
203
+ const plistSrc = path.join(templateDir, 'SentoriNSE-Info.plist')
204
+ if (!fs.existsSync(swiftSrc) || !fs.existsSync(plistSrc)) {
205
+ // eslint-disable-next-line no-console
206
+ console.warn(
207
+ '[sentori-expo] NSE templates missing; skipping. Reinstall the package to restore.'
208
+ )
209
+ return cfg
210
+ }
211
+ fs.mkdirSync(destDir, { recursive: true })
212
+ fs.copyFileSync(
213
+ swiftSrc,
214
+ path.join(destDir, 'SentoriNotificationServiceExtension.swift')
215
+ )
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
+ }
231
+ }
232
+ return cfg
233
+ },
234
+ ])
235
+ }
236
+
171
237
  // ── Composer ───────────────────────────────────────────────────────
172
238
 
173
239
  /**
174
240
  * @param {import('@expo/config-plugins').ExpoConfig} config
175
- * @param {{ sdkVersion?: string, ios?: boolean, android?: boolean, googleServicesFile?: string }} [props]
241
+ * @param {{ sdkVersion?: string, ios?: boolean, android?: boolean, nse?: boolean, googleServicesFile?: string }} [props]
176
242
  */
177
243
  const withSentori = (config, props = {}) => {
178
244
  const plugins = [[withSentoriVersion, props]]
179
- if (props.ios !== false) plugins.push([withSentoriPushIos, props])
245
+ if (props.ios !== false) {
246
+ plugins.push([withSentoriPushIos, props])
247
+ if (props.nse !== false) plugins.push([withSentoriNSE, props])
248
+ }
180
249
  if (props.android !== false) {
181
250
  plugins.push(
182
251
  [withSentoriPushAndroidManifest, props],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-expo",
3
- "version": "6.0.0",
3
+ "version": "7.0.1",
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)",
@@ -36,6 +36,7 @@
36
36
  "src/",
37
37
  "app.plugin.js",
38
38
  "scripts/",
39
+ "templates/",
39
40
  "README.md"
40
41
  ],
41
42
  "scripts": {
@@ -45,7 +46,7 @@
45
46
  "prepack": "bun run build"
46
47
  },
47
48
  "peerDependencies": {
48
- "@goliapkg/sentori-react-native": ">=3.0.0",
49
+ "@goliapkg/sentori-react-native": ">=3.1.0",
49
50
  "expo": ">=55.0.0 <57.0.0",
50
51
  "expo-application": ">=55.0.0 <57.0.0",
51
52
  "react-native": ">=0.81.0"
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>SentoriNSE</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>$(EXECUTABLE_NAME)</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+ <key>CFBundleInfoDictionaryVersion</key>
14
+ <string>6.0</string>
15
+ <key>CFBundleName</key>
16
+ <string>$(PRODUCT_NAME)</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>1.0</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>1</string>
23
+ <key>NSExtension</key>
24
+ <dict>
25
+ <key>NSExtensionPointIdentifier</key>
26
+ <string>com.apple.usernotifications.service</string>
27
+ <key>NSExtensionPrincipalClass</key>
28
+ <string>$(PRODUCT_MODULE_NAME).SentoriNotificationServiceExtension</string>
29
+ </dict>
30
+ </dict>
31
+ </plist>
@@ -0,0 +1,77 @@
1
+ // Sentori Notification Service Extension — v2.28.
2
+ //
3
+ // When an APNs payload arrives with `mutable-content: 1` AND a custom
4
+ // `sentori_attachment_url` field, this extension downloads the image
5
+ // and attaches it to the notification before iOS displays it.
6
+ //
7
+ // Lifecycle:
8
+ // - serviceExtensionTimeWillExpire is called ~30 s after delivery.
9
+ // We fall back to the unaltered notification at that point.
10
+ // - All work runs off the main thread; the OS gives this extension
11
+ // up to ~30 s with a hard CPU budget. The download is on a
12
+ // background URLSession with 5 s timeout so a slow CDN does not
13
+ // burn the budget.
14
+ //
15
+ // This file is written by `@goliapkg/sentori-expo` v2.28+'s
16
+ // `withSentoriNSE` config plugin into `ios/SentoriNSE/` on every
17
+ // `expo prebuild`. The one-time Xcode target wiring is documented in
18
+ // the recipe (auto-injection lands in v2.28.1).
19
+
20
+ import UserNotifications
21
+
22
+ final class SentoriNotificationServiceExtension: UNNotificationServiceExtension {
23
+ private var contentHandler: ((UNNotificationContent) -> Void)?
24
+ private var bestAttempt: UNMutableNotificationContent?
25
+
26
+ override func didReceive(
27
+ _ request: UNNotificationRequest,
28
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
29
+ ) {
30
+ self.contentHandler = contentHandler
31
+ self.bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent)
32
+ guard let bestAttempt = self.bestAttempt else {
33
+ contentHandler(request.content)
34
+ return
35
+ }
36
+
37
+ // Extract the Sentori-reserved attachment URL. v2.28 server
38
+ // emits this as a top-level custom-data key when the send's
39
+ // `richMedia.imageUrl` is set.
40
+ guard
41
+ let raw = bestAttempt.userInfo["sentori_attachment_url"] as? String,
42
+ let url = URL(string: raw)
43
+ else {
44
+ contentHandler(bestAttempt)
45
+ return
46
+ }
47
+
48
+ // Bounded-time download to a temp file. URLSession.shared
49
+ // honours the request's timeout; we set it to 5 s so a stalled
50
+ // CDN does not eat the ~30 s extension budget.
51
+ var request = URLRequest(url: url)
52
+ request.timeoutInterval = 5
53
+ let task = URLSession.shared.downloadTask(with: request) { tempURL, _, _ in
54
+ defer { contentHandler(bestAttempt) }
55
+ guard let tempURL = tempURL else { return }
56
+ // Move to a guessed-extension destination so iOS picks a
57
+ // sensible content type.
58
+ let ext = url.pathExtension.isEmpty ? "img" : url.pathExtension
59
+ let dest = tempURL.deletingPathExtension().appendingPathExtension(ext)
60
+ try? FileManager.default.moveItem(at: tempURL, to: dest)
61
+ if let attachment = try? UNNotificationAttachment(
62
+ identifier: "sentori-image",
63
+ url: dest,
64
+ options: nil
65
+ ) {
66
+ bestAttempt.attachments = [attachment]
67
+ }
68
+ }
69
+ task.resume()
70
+ }
71
+
72
+ override func serviceExtensionTimeWillExpire() {
73
+ if let contentHandler = contentHandler, let bestAttempt = bestAttempt {
74
+ contentHandler(bestAttempt)
75
+ }
76
+ }
77
+ }