@sigx/lynx-notifications 0.4.1 → 0.4.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.
@@ -0,0 +1,155 @@
1
+ import Foundation
2
+ import UIKit
3
+ import UserNotifications
4
+
5
+ /// AppDelegate hook for remote push.
6
+ ///
7
+ /// Discovered by the auto-linker via `signalx-module.json`'s
8
+ /// `ios.appDelegateHook` field; the generated `GeneratedAppDelegateHooks` calls
9
+ /// these static methods at the matching `UIApplicationDelegate` callbacks.
10
+ ///
11
+ /// Responsibilities:
12
+ /// - `didFinishLaunching` — install the singleton `UNUserNotificationCenter`
13
+ /// delegate so foreground banners and tap callbacks reach JS. Also captures
14
+ /// a cold-start notification payload (`launchOptions[.remoteNotification]`)
15
+ /// for `Notifications.getInitialNotification()`.
16
+ /// - `didRegisterForRemoteNotificationsWithDeviceToken` — converts the APNs
17
+ /// `Data` token to its canonical hex form and publishes via `PushEventBus`.
18
+ /// - `didFailToRegisterForRemoteNotificationsWithError` — surfaces the error
19
+ /// to JS so apps can show a "push unavailable" state instead of hanging.
20
+ @objc public class PushAppDelegateHook: NSObject {
21
+
22
+ @objc public static func didFinishLaunching(
23
+ _ application: UIApplication,
24
+ launchOptions: [UIApplication.LaunchOptionsKey: Any]?
25
+ ) {
26
+ // Take ownership of the notification-center delegate. Apps that need
27
+ // to interpose their own delegate should set it AFTER our hook runs
28
+ // and forward to PushNotificationDelegate.shared from their
29
+ // implementation. (Standard pattern for Firebase / OneSignal / etc.)
30
+ UNUserNotificationCenter.current().delegate = PushNotificationDelegate.shared
31
+
32
+ // Open the cold-start capture window. The first `didReceive` tap
33
+ // after launch (which is how local notifications launched from a
34
+ // terminated state arrive — they never appear in launchOptions) will
35
+ // be stashed as the initial payload instead of fired on the response
36
+ // channel. JS drains it via `getInitialNotification()`.
37
+ PushEventBus.shared.beginColdStartWindow()
38
+
39
+ // Cold-start REMOTE push: iOS also pre-populates
40
+ // `launchOptions[.remoteNotification]` for these. Capture the payload
41
+ // here too — same destination, just a different source.
42
+ if let userInfo = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
43
+ let (notificationId, data) = extractData(from: userInfo)
44
+ PushEventBus.shared.captureInitialResponse(
45
+ notificationId: notificationId,
46
+ data: data,
47
+ actionIdentifier: UNNotificationDefaultActionIdentifier
48
+ )
49
+ }
50
+ }
51
+
52
+ @objc public static func didRegisterForRemoteNotificationsWithDeviceToken(
53
+ _ application: UIApplication,
54
+ deviceToken: Data
55
+ ) {
56
+ let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
57
+ PushEventBus.shared.publishToken(hex, platform: "apns")
58
+ }
59
+
60
+ @objc public static func didFailToRegisterForRemoteNotificationsWithError(
61
+ _ application: UIApplication,
62
+ error: Error
63
+ ) {
64
+ PushEventBus.shared.publishTokenError(error.localizedDescription)
65
+ }
66
+
67
+ /// Pull a notification id and string-coerced data dict out of an APNs
68
+ /// userInfo payload. Notification id falls back to a generated UUID when
69
+ /// the payload doesn't carry one (APNs doesn't require it; some servers
70
+ /// set it as `apns-collapse-id` or in a custom field).
71
+ static func extractData(from userInfo: [AnyHashable: Any]) -> (String, [String: String]) {
72
+ var data: [String: String] = [:]
73
+ var notificationId = UUID().uuidString
74
+ for (k, v) in userInfo {
75
+ guard let key = k as? String else { continue }
76
+ // `aps` carries title/body/sound — strip it from the JS-visible
77
+ // data dict; the relevant fields are surfaced as title/body
78
+ // already by the foreground / response handlers.
79
+ if key == "aps" { continue }
80
+ if key == "notification_id" || key == "notificationId" {
81
+ if let s = v as? String { notificationId = s }
82
+ continue
83
+ }
84
+ data[key] = "\(v)"
85
+ }
86
+ return (notificationId, data)
87
+ }
88
+ }
89
+
90
+ /// Singleton `UNUserNotificationCenterDelegate`. Forwards foreground deliveries
91
+ /// (`willPresent`) and tap responses (`didReceive`) to `PushEventBus`. Owned by
92
+ /// `PushAppDelegateHook.didFinishLaunching`; the app keeps it as
93
+ /// `UNUserNotificationCenter.current().delegate`.
94
+ final class PushNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
95
+
96
+ static let shared = PushNotificationDelegate()
97
+
98
+ /// Foreground delivery. Pass `[.banner, .list, .sound, .badge]` so the OS
99
+ /// shows the banner even when the app is open — without this, iOS suppresses
100
+ /// foreground notifications entirely (the historical complaint in the
101
+ /// previous local-only README).
102
+ func userNotificationCenter(
103
+ _ center: UNUserNotificationCenter,
104
+ willPresent notification: UNNotification,
105
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
106
+ ) {
107
+ let content = notification.request.content
108
+ let (_, data) = PushAppDelegateHook.extractData(from: content.userInfo)
109
+ PushEventBus.shared.publishMessage(
110
+ title: content.title.isEmpty ? nil : content.title,
111
+ body: content.body.isEmpty ? nil : content.body,
112
+ data: data,
113
+ foreground: true
114
+ )
115
+ if #available(iOS 14.0, *) {
116
+ completionHandler([.banner, .list, .sound, .badge])
117
+ } else {
118
+ completionHandler([.alert, .sound, .badge])
119
+ }
120
+ }
121
+
122
+ /// User tapped a notification (remote OR local). This is the path the
123
+ /// previous README flagged as "Tap callbacks aren't surfaced in JS yet" —
124
+ /// now they are.
125
+ ///
126
+ /// If we're still in the cold-start window opened by
127
+ /// `didFinishLaunching`, the first tap is stashed as the initial payload
128
+ /// (drained later by `getInitialNotification()`) instead of fired on the
129
+ /// response channel — this covers local notifications launched from a
130
+ /// terminated state, where `launchOptions[.remoteNotification]` is empty.
131
+ func userNotificationCenter(
132
+ _ center: UNUserNotificationCenter,
133
+ didReceive response: UNNotificationResponse,
134
+ withCompletionHandler completionHandler: @escaping () -> Void
135
+ ) {
136
+ let content = response.notification.request.content
137
+ let (idFromPayload, data) = PushAppDelegateHook.extractData(from: content.userInfo)
138
+ let notificationId = response.notification.request.identifier.isEmpty
139
+ ? idFromPayload
140
+ : response.notification.request.identifier
141
+ let captured = PushEventBus.shared.captureInitialResponseIfColdStart(
142
+ notificationId: notificationId,
143
+ data: data,
144
+ actionIdentifier: response.actionIdentifier
145
+ )
146
+ if !captured {
147
+ PushEventBus.shared.publishResponse(
148
+ notificationId: notificationId,
149
+ data: data,
150
+ actionIdentifier: response.actionIdentifier
151
+ )
152
+ }
153
+ completionHandler()
154
+ }
155
+ }
@@ -0,0 +1,164 @@
1
+ import Foundation
2
+
3
+ /// Process-wide pub/sub bus that converts APNs + UNUserNotificationCenter
4
+ /// callbacks into JS-side event payloads. The AppDelegate hook and the
5
+ /// notification-center delegate write here; per-LynxView `PushPublisher`
6
+ /// instances read.
7
+ ///
8
+ /// Mirrors the `WebSocketEventBus` pattern. Native lifecycle is decoupled from
9
+ /// any specific LynxView so events that arrive before a LynxView is built
10
+ /// (cold-start payloads, token registrations) can be replayed to subscribers
11
+ /// when they attach.
12
+ final class PushEventBus {
13
+
14
+ static let shared = PushEventBus()
15
+
16
+ /// JSON-encodable payload. Wire shape matches the JS-side types in
17
+ /// `src/push.ts` (channel name carried as `__channel`).
18
+ typealias Payload = [String: Any]
19
+ typealias Listener = (String, Payload) -> Void
20
+
21
+ private let queue = DispatchQueue(label: "com.sigx.notifications.bus")
22
+ private var listeners: [(token: UUID, fn: Listener)] = []
23
+
24
+ /// Cached cold-start tap. Cleared after the JS shim retrieves it via
25
+ /// `Notifications.getInitialNotification()`. Stored regardless of whether
26
+ /// a LynxView has attached yet — the JS shim polls on first call.
27
+ private(set) var initialResponse: Payload?
28
+
29
+ /// Cached most-recent device token so a JS subscriber that attaches after
30
+ /// registration completes still receives it on subscribe. APNs returns
31
+ /// the same token across registrations, so caching is safe.
32
+ private(set) var lastToken: Payload?
33
+
34
+ /// `true` between `application(_:didFinishLaunchingWithOptions:)` and the
35
+ /// first time JS asks for the initial notification. While set, the FIRST
36
+ /// `didReceive` tap is also captured as the cold-start payload — covers
37
+ /// the local-notification cold-start path where there's no
38
+ /// `launchOptions[.remoteNotification]` to read.
39
+ private(set) var inColdStartWindow = false
40
+
41
+ @discardableResult
42
+ func addListener(_ fn: @escaping Listener) -> UUID {
43
+ let token = UUID()
44
+ // Snapshot the cached token in the same critical section as the
45
+ // listener append so we either replay-then-deliver or skip-then-deliver
46
+ // — no torn-state where a concurrent publishToken slips between the
47
+ // two and the listener sees the token twice. The replay itself runs
48
+ // OUTSIDE the queue so a listener that re-enters the bus (e.g.
49
+ // immediately subscribes a second listener, or calls publishToken
50
+ // recursively) doesn't deadlock on the serial queue.
51
+ let cached: Payload? = queue.sync {
52
+ listeners.append((token, fn))
53
+ return lastToken
54
+ }
55
+ if let cached = cached {
56
+ fn(PushEventChannel.token, cached)
57
+ }
58
+ return token
59
+ }
60
+
61
+ func removeListener(_ token: UUID) {
62
+ queue.sync { listeners.removeAll { $0.token == token } }
63
+ }
64
+
65
+ // MARK: - Publish
66
+
67
+ func publishToken(_ token: String, platform: String = "apns") {
68
+ let payload: Payload = ["token": token, "platform": platform]
69
+ queue.sync { lastToken = payload }
70
+ emit(channel: PushEventChannel.token, payload: payload)
71
+ }
72
+
73
+ func publishTokenError(_ message: String) {
74
+ emit(channel: PushEventChannel.tokenError, payload: ["error": message])
75
+ }
76
+
77
+ func publishMessage(title: String?, body: String?, data: [String: String], foreground: Bool) {
78
+ var payload: Payload = [
79
+ "data": data,
80
+ "foreground": foreground,
81
+ ]
82
+ if let title = title { payload["title"] = title }
83
+ if let body = body { payload["body"] = body }
84
+ emit(channel: PushEventChannel.message, payload: payload)
85
+ }
86
+
87
+ func publishResponse(notificationId: String, data: [String: String], actionIdentifier: String) {
88
+ let payload: Payload = [
89
+ "notificationId": notificationId,
90
+ "data": data,
91
+ "actionIdentifier": actionIdentifier,
92
+ ]
93
+ emit(channel: PushEventChannel.response, payload: payload)
94
+ }
95
+
96
+ /// Open the cold-start capture window. Called from
97
+ /// `application(_:didFinishLaunchingWithOptions:)`. The window stays
98
+ /// open until either the first `consumeInitialResponse()` call or a
99
+ /// successful capture — whichever comes first.
100
+ func beginColdStartWindow() {
101
+ queue.sync { inColdStartWindow = true }
102
+ }
103
+
104
+ /// Stash a cold-start tap. `application(_:didFinishLaunchingWithOptions:)`
105
+ /// runs before any LynxView exists; the payload is held until JS asks for
106
+ /// it via `getInitialNotification()`.
107
+ func captureInitialResponse(notificationId: String, data: [String: String], actionIdentifier: String) {
108
+ queue.sync {
109
+ initialResponse = [
110
+ "notificationId": notificationId,
111
+ "data": data,
112
+ "actionIdentifier": actionIdentifier,
113
+ ]
114
+ inColdStartWindow = false
115
+ }
116
+ }
117
+
118
+ /// Capture a tap as the cold-start payload IF we're still in the
119
+ /// cold-start window AND nothing's been captured yet. Returns `true` if
120
+ /// the call stashed the payload — the caller should NOT also publish the
121
+ /// same tap on the response channel (the JS shim drains it via
122
+ /// `getInitialNotification`).
123
+ func captureInitialResponseIfColdStart(
124
+ notificationId: String,
125
+ data: [String: String],
126
+ actionIdentifier: String,
127
+ ) -> Bool {
128
+ return queue.sync {
129
+ guard inColdStartWindow, initialResponse == nil else { return false }
130
+ initialResponse = [
131
+ "notificationId": notificationId,
132
+ "data": data,
133
+ "actionIdentifier": actionIdentifier,
134
+ ]
135
+ inColdStartWindow = false
136
+ return true
137
+ }
138
+ }
139
+
140
+ /// Drain and return the cold-start payload (one-shot — subsequent calls
141
+ /// return nil). Also closes the cold-start window so any subsequent tap
142
+ /// goes through the regular response channel.
143
+ func consumeInitialResponse() -> Payload? {
144
+ return queue.sync {
145
+ let p = initialResponse
146
+ initialResponse = nil
147
+ inColdStartWindow = false
148
+ return p
149
+ }
150
+ }
151
+
152
+ private func emit(channel: String, payload: Payload) {
153
+ let snapshot = queue.sync { listeners }
154
+ for (_, fn) in snapshot { fn(channel, payload) }
155
+ }
156
+ }
157
+
158
+ /// Stable event-channel names. These also appear in the JS shim — keep in sync.
159
+ enum PushEventChannel {
160
+ static let token = "__sigxPushToken"
161
+ static let tokenError = "__sigxPushTokenError"
162
+ static let message = "__sigxPushMessage"
163
+ static let response = "__sigxNotificationResponse"
164
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+ import Lynx
3
+
4
+ /// Per-`LynxView` publisher that pumps `PushEventBus` payloads into JS via
5
+ /// `LynxView.sendGlobalEvent(name, withParams:)`.
6
+ ///
7
+ /// One instance per LynxView; instantiated by the generated
8
+ /// `GeneratedLifecyclePublishers.attachAll(to:)` and retained for the
9
+ /// LynxView's lifetime. The bus is global so a remote push or tap that fires
10
+ /// before the LynxView's JS heap is ready will be replayed (for `__sigxPushToken`)
11
+ /// or delivered on the next message via cold-start retrieval.
12
+ final class PushPublisher {
13
+
14
+ private weak var lynxView: LynxView?
15
+ private var token: UUID?
16
+
17
+ init(lynxView: LynxView) {
18
+ self.lynxView = lynxView
19
+ self.token = PushEventBus.shared.addListener { [weak self] channel, payload in
20
+ guard let view = self?.lynxView else { return }
21
+ view.sendGlobalEvent(channel, withParams: [payload])
22
+ }
23
+ }
24
+
25
+ deinit {
26
+ if let token = token {
27
+ PushEventBus.shared.removeListener(token)
28
+ }
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigx/lynx-notifications",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Local push notifications for sigx-lynx",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,11 +19,12 @@
19
19
  "signalx-module.json"
20
20
  ],
21
21
  "dependencies": {
22
- "@sigx/lynx-core": "^0.4.1"
22
+ "@sigx/lynx-core": "^0.4.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@typescript/native-preview": "7.0.0-dev.20260511.1",
26
- "typescript": "^6.0.3"
25
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
26
+ "typescript": "^6.0.3",
27
+ "vitest": "^4.1.7"
27
28
  },
28
29
  "author": "Andreas Ekdahl",
29
30
  "license": "MIT",
@@ -51,6 +52,7 @@
51
52
  "scripts": {
52
53
  "build": "node ../../scripts/clean.mjs dist && tsgo",
53
54
  "dev": "tsgo --watch",
55
+ "test": "vitest run",
54
56
  "clean": "node ../../scripts/clean.mjs dist .turbo"
55
57
  }
56
58
  }
@@ -1,16 +1,54 @@
1
1
  {
2
2
  "name": "Notifications",
3
3
  "package": "@sigx/lynx-notifications",
4
- "description": "Local push notifications",
4
+ "description": "Local + remote push notifications (APNs / FCM)",
5
5
  "platforms": ["android", "ios"],
6
6
  "ios": {
7
7
  "moduleClass": "NotificationsModule",
8
+ "publisherClass": "PushPublisher",
9
+ "appDelegateHook": {
10
+ "class": "PushAppDelegateHook",
11
+ "methods": [
12
+ "didFinishLaunching",
13
+ "didRegisterForRemoteNotificationsWithDeviceToken",
14
+ "didFailToRegisterForRemoteNotificationsWithError"
15
+ ]
16
+ },
8
17
  "sourceDir": "ios",
9
- "methods": ["schedule","cancel","cancelAll","requestPermission","getPermissionStatus"]
18
+ "methods": [
19
+ "schedule",
20
+ "cancel",
21
+ "cancelAll",
22
+ "requestPermission",
23
+ "getPermissionStatus",
24
+ "registerForPushNotifications",
25
+ "unregisterForPushNotifications",
26
+ "setBadgeCount",
27
+ "getBadgeCount",
28
+ "getInitialNotification"
29
+ ],
30
+ "backgroundModes": ["remote-notification"]
10
31
  },
11
32
  "android": {
12
33
  "moduleClass": "com.sigx.notifications.NotificationsModule",
34
+ "publisherClass": "com.sigx.notifications.PushPublisher",
35
+ "activityHook": {
36
+ "class": "com.sigx.notifications.PushActivityHook",
37
+ "methods": ["onCreate", "onNewIntent"]
38
+ },
13
39
  "sourceDir": "android",
14
- "permissions": ["android.permission.POST_NOTIFICATIONS"]
40
+ "permissions": ["android.permission.POST_NOTIFICATIONS"],
41
+ "dependencies": [
42
+ "com.google.firebase:firebase-messaging:24.0.0",
43
+ "com.google.firebase:firebase-common-ktx:21.0.0",
44
+ "com.google.android.gms:play-services-base:18.5.0"
45
+ ],
46
+ "services": [
47
+ {
48
+ "name": "com.sigx.notifications.SigxFirebaseMessagingService",
49
+ "exported": false,
50
+ "actions": ["com.google.firebase.MESSAGING_EVENT"]
51
+ }
52
+ ]
15
53
  }
16
54
  }