@tokamakdev/plugin-notifications 0.1.0-beta.50
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/README.md +206 -0
- package/android/AndroidManifest.xml +30 -0
- package/android/Content.kt +47 -0
- package/android/NotificationsPlugin.kt +287 -0
- package/android/Notifier.kt +91 -0
- package/android/Push.kt +87 -0
- package/android/Schedule.kt +77 -0
- package/apple/Info.plist +10 -0
- package/apple/NotificationContent.swift +151 -0
- package/apple/NotificationsPlugin.swift +381 -0
- package/package.json +29 -0
- package/service-worker/index.ts +142 -0
- package/src/index.ts +145 -0
- package/tokamak-plugin.json +39 -0
- package/web/index.ts +180 -0
- package/web/protocol.ts +43 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UserNotifications
|
|
3
|
+
|
|
4
|
+
#if os(iOS)
|
|
5
|
+
import UIKit
|
|
6
|
+
#else
|
|
7
|
+
import AppKit
|
|
8
|
+
#endif
|
|
9
|
+
|
|
10
|
+
private let subscriptionKey = "tokamak.notifications.subscription"
|
|
11
|
+
private let showInForegroundKey = "tokamak.notifications.show-in-foreground"
|
|
12
|
+
/// The system keeps only the soonest 64 pending requests.
|
|
13
|
+
private let pendingLimit = 64
|
|
14
|
+
/// The system allows about 30 seconds for a background remote notification.
|
|
15
|
+
private let pushTimeout: TimeInterval = 25
|
|
16
|
+
private let shown: UNNotificationPresentationOptions = [.banner, .list, .sound]
|
|
17
|
+
|
|
18
|
+
#if os(iOS)
|
|
19
|
+
private let pushEntitlement = "aps-environment"
|
|
20
|
+
#else
|
|
21
|
+
private let pushEntitlement = "com.apple.developer.aps-environment"
|
|
22
|
+
#endif
|
|
23
|
+
|
|
24
|
+
final class TokamakNotificationsPlugin: NSObject, TokamakPlugin,
|
|
25
|
+
UNUserNotificationCenterDelegate
|
|
26
|
+
{
|
|
27
|
+
let id = "notifications"
|
|
28
|
+
|
|
29
|
+
private let host: TokamakHost
|
|
30
|
+
private let center = UNUserNotificationCenter.current()
|
|
31
|
+
private let defaults = UserDefaults.standard
|
|
32
|
+
private var listeners: [String: [UUID: TokamakPluginReply]] = [:]
|
|
33
|
+
/// Notifications opened while no page listened for them.
|
|
34
|
+
private var heldOpened: [[String: Any]] = []
|
|
35
|
+
private var registering: [TokamakPluginReply] = []
|
|
36
|
+
|
|
37
|
+
/// Becomes the notification centre's delegate before launch finishes, which
|
|
38
|
+
/// the system requires to deliver the notification that launched the app.
|
|
39
|
+
init(host: TokamakHost) {
|
|
40
|
+
self.host = host
|
|
41
|
+
super.init()
|
|
42
|
+
center.delegate = self
|
|
43
|
+
if defaults.dictionary(forKey: subscriptionKey) != nil {
|
|
44
|
+
registerForRemoteNotifications()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func call(
|
|
49
|
+
method: String,
|
|
50
|
+
arguments: Any,
|
|
51
|
+
reply: @escaping TokamakPluginReply
|
|
52
|
+
) {
|
|
53
|
+
do throws(TokamakPluginError) {
|
|
54
|
+
switch method {
|
|
55
|
+
case "permission":
|
|
56
|
+
permission(reply)
|
|
57
|
+
case "requestPermission":
|
|
58
|
+
center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in
|
|
59
|
+
self.permission(reply)
|
|
60
|
+
}
|
|
61
|
+
case "show":
|
|
62
|
+
add(try TokamakNotificationRequest(arguments, scheduled: false), reply: reply)
|
|
63
|
+
case "schedule":
|
|
64
|
+
schedule(try TokamakNotificationRequest(arguments, scheduled: true), reply: reply)
|
|
65
|
+
case "getScheduled":
|
|
66
|
+
center.getPendingNotificationRequests { reply(.success($0.map(\.scheduled))) }
|
|
67
|
+
case "getDelivered":
|
|
68
|
+
center.getDeliveredNotifications {
|
|
69
|
+
reply(.success($0.map { TokamakNotificationFields($0).content }))
|
|
70
|
+
}
|
|
71
|
+
case "remove":
|
|
72
|
+
let id = try identifier(arguments)
|
|
73
|
+
center.removePendingNotificationRequests(withIdentifiers: [id])
|
|
74
|
+
center.removeDeliveredNotifications(withIdentifiers: [id])
|
|
75
|
+
reply(.success(nil))
|
|
76
|
+
case "subscribe":
|
|
77
|
+
try subscribeToPush(arguments, reply: reply)
|
|
78
|
+
case "getSubscription":
|
|
79
|
+
reply(.success(defaults.dictionary(forKey: subscriptionKey)))
|
|
80
|
+
case "unsubscribe":
|
|
81
|
+
unsubscribe()
|
|
82
|
+
reply(.success(nil))
|
|
83
|
+
default:
|
|
84
|
+
throw .notSupported("\(id).\(method) is not supported")
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
reply(.failure(error))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func subscribe(
|
|
92
|
+
method: String,
|
|
93
|
+
arguments: Any,
|
|
94
|
+
reply: @escaping TokamakPluginReply
|
|
95
|
+
) -> (() -> Void) {
|
|
96
|
+
guard ["onMessage", "onNotificationOpened", "onSubscriptionChange"].contains(method) else {
|
|
97
|
+
reply(.failure(.notSupported("\(id).\(method) is not supported")))
|
|
98
|
+
return {}
|
|
99
|
+
}
|
|
100
|
+
let key = UUID()
|
|
101
|
+
listeners[method, default: [:]][key] = reply
|
|
102
|
+
if method == "onNotificationOpened" {
|
|
103
|
+
let held = heldOpened
|
|
104
|
+
heldOpened.removeAll()
|
|
105
|
+
for opened in held {
|
|
106
|
+
reply(.success(opened))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { [weak self] in
|
|
110
|
+
self?.listeners[method]?.removeValue(forKey: key)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
|
115
|
+
let previous = defaults.dictionary(forKey: subscriptionKey)
|
|
116
|
+
guard previous != nil || !registering.isEmpty else { return }
|
|
117
|
+
let subscription: [String: Any] = [
|
|
118
|
+
"service": "apns",
|
|
119
|
+
"token": deviceToken.map { String(format: "%02x", $0) }.joined(),
|
|
120
|
+
"environment": Self.apsEnvironment ?? "development",
|
|
121
|
+
]
|
|
122
|
+
defaults.set(subscription, forKey: subscriptionKey)
|
|
123
|
+
let replies = registering
|
|
124
|
+
registering.removeAll()
|
|
125
|
+
for reply in replies {
|
|
126
|
+
reply(.success(subscription))
|
|
127
|
+
}
|
|
128
|
+
if let previous, previous["token"] as? String != subscription["token"] as? String {
|
|
129
|
+
emit("onSubscriptionChange", subscription)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func didFailToRegisterForRemoteNotifications(error: Error) {
|
|
134
|
+
let replies = registering
|
|
135
|
+
registering.removeAll()
|
|
136
|
+
for reply in replies {
|
|
137
|
+
reply(.failure(Self.registrationError(error)))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Runs the Worker's `push` handler for a data-only message, showing the
|
|
142
|
+
/// notification it returns.
|
|
143
|
+
func didReceiveRemoteNotification(
|
|
144
|
+
_ userInfo: [AnyHashable: Any],
|
|
145
|
+
completion: @escaping (TokamakBackgroundResult) -> Void
|
|
146
|
+
) {
|
|
147
|
+
let fields = TokamakNotificationFields(remoteNotification: userInfo)
|
|
148
|
+
guard fields.title == nil, fields.body == nil else {
|
|
149
|
+
completion(.noData)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
emit("onMessage", fields.message)
|
|
153
|
+
host.dispatch(event: "push", payload: fields.message, timeout: pushTimeout) { result in
|
|
154
|
+
switch result {
|
|
155
|
+
case .success(.handled(let returned)):
|
|
156
|
+
self.show(returned) { completion(.newData) }
|
|
157
|
+
case .success(.unhandled):
|
|
158
|
+
completion(.noData)
|
|
159
|
+
case .failure(let error):
|
|
160
|
+
print("tokamak push handler failed: \(error)")
|
|
161
|
+
completion(.failed)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func userNotificationCenter(
|
|
167
|
+
_ center: UNUserNotificationCenter,
|
|
168
|
+
willPresent notification: UNNotification,
|
|
169
|
+
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
170
|
+
) {
|
|
171
|
+
let fields = TokamakNotificationFields(notification)
|
|
172
|
+
DispatchQueue.main.async {
|
|
173
|
+
guard fields.isPush else {
|
|
174
|
+
completionHandler(shown)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
self.emit("onMessage", fields.message)
|
|
178
|
+
completionHandler(self.defaults.bool(forKey: showInForegroundKey) ? shown : [])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func userNotificationCenter(
|
|
183
|
+
_ center: UNUserNotificationCenter,
|
|
184
|
+
didReceive response: UNNotificationResponse,
|
|
185
|
+
withCompletionHandler completionHandler: @escaping () -> Void
|
|
186
|
+
) {
|
|
187
|
+
let opened = TokamakNotificationFields(response.notification).opened
|
|
188
|
+
DispatchQueue.main.async {
|
|
189
|
+
self.emit("onNotificationOpened", opened)
|
|
190
|
+
completionHandler()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func emit(_ method: String, _ value: [String: Any]) {
|
|
195
|
+
let current = listeners[method] ?? [:]
|
|
196
|
+
if method == "onNotificationOpened" && current.isEmpty {
|
|
197
|
+
heldOpened.append(value)
|
|
198
|
+
}
|
|
199
|
+
for reply in current.values {
|
|
200
|
+
reply(.success(value))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private func permission(_ reply: @escaping TokamakPluginReply) {
|
|
205
|
+
center.getNotificationSettings { settings in
|
|
206
|
+
reply(.success(Self.permission(settings.authorizationStatus)))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private static func permission(_ status: UNAuthorizationStatus) -> String {
|
|
211
|
+
switch status {
|
|
212
|
+
case .denied: return "denied"
|
|
213
|
+
case .notDetermined: return "prompt"
|
|
214
|
+
default: return "granted"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private func schedule(_ request: TokamakNotificationRequest, reply: @escaping TokamakPluginReply)
|
|
219
|
+
{
|
|
220
|
+
center.getPendingNotificationRequests { pending in
|
|
221
|
+
let replaces = pending.contains { $0.identifier == request.id }
|
|
222
|
+
guard replaces || pending.count < pendingLimit else {
|
|
223
|
+
reply(
|
|
224
|
+
.failure(
|
|
225
|
+
TokamakPluginError(
|
|
226
|
+
name: "QuotaExceededError",
|
|
227
|
+
message: "The app has \(pendingLimit) scheduled notifications, the most allowed"
|
|
228
|
+
)))
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
self.add(request, reply: reply)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private func add(_ request: TokamakNotificationRequest, reply: @escaping TokamakPluginReply) {
|
|
236
|
+
let notification: UNNotificationRequest
|
|
237
|
+
do {
|
|
238
|
+
notification = try request.request()
|
|
239
|
+
} catch {
|
|
240
|
+
reply(.failure(error))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
center.getNotificationSettings { settings in
|
|
244
|
+
guard Self.permission(settings.authorizationStatus) == "granted" else {
|
|
245
|
+
reply(
|
|
246
|
+
.failure(
|
|
247
|
+
TokamakPluginError(
|
|
248
|
+
name: "NotAllowedError",
|
|
249
|
+
message: "Notification permission has not been granted"
|
|
250
|
+
)))
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
self.center.add(notification) { error in
|
|
254
|
+
reply(error.map { .failure(Self.operationError($0)) } ?? .success(nil))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Shows a notification a Worker handler returned, then calls `completion`.
|
|
260
|
+
private func show(_ returned: Any, completion: @escaping () -> Void) {
|
|
261
|
+
guard !(returned is NSNull) else {
|
|
262
|
+
completion()
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
guard let request = try? TokamakNotificationRequest(returned, scheduled: false) else {
|
|
266
|
+
print("tokamak push handler returned an invalid notification")
|
|
267
|
+
completion()
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
add(request) { _ in completion() }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func subscribeToPush(
|
|
274
|
+
_ arguments: Any,
|
|
275
|
+
reply: @escaping TokamakPluginReply
|
|
276
|
+
) throws(TokamakPluginError) {
|
|
277
|
+
#if os(macOS)
|
|
278
|
+
guard Self.entitlement("com.apple.application-identifier") != nil else {
|
|
279
|
+
throw .notSupported("Push requires a team-signed build")
|
|
280
|
+
}
|
|
281
|
+
#endif
|
|
282
|
+
#if !targetEnvironment(simulator)
|
|
283
|
+
guard Self.apsEnvironment != nil else {
|
|
284
|
+
throw TokamakPluginError(
|
|
285
|
+
name: "InvalidStateError",
|
|
286
|
+
message: "Push requires the app to declare the \(pushEntitlement) entitlement"
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
#endif
|
|
290
|
+
let options = arguments as? [String: Any] ?? [:]
|
|
291
|
+
defaults.set(options["showInForeground"] as? Bool ?? false, forKey: showInForegroundKey)
|
|
292
|
+
registering.append(reply)
|
|
293
|
+
registerForRemoteNotifications()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Stops push delivery, failing a `subscribe` still waiting for its token.
|
|
297
|
+
private func unsubscribe() {
|
|
298
|
+
unregisterForRemoteNotifications()
|
|
299
|
+
defaults.removeObject(forKey: subscriptionKey)
|
|
300
|
+
defaults.removeObject(forKey: showInForegroundKey)
|
|
301
|
+
let replies = registering
|
|
302
|
+
registering.removeAll()
|
|
303
|
+
for reply in replies {
|
|
304
|
+
reply(.failure(TokamakPluginError(name: "AbortError", message: "unsubscribe was called")))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private func registerForRemoteNotifications() {
|
|
309
|
+
#if os(iOS)
|
|
310
|
+
UIApplication.shared.registerForRemoteNotifications()
|
|
311
|
+
#else
|
|
312
|
+
NSApplication.shared.registerForRemoteNotifications()
|
|
313
|
+
#endif
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private func unregisterForRemoteNotifications() {
|
|
317
|
+
#if os(iOS)
|
|
318
|
+
UIApplication.shared.unregisterForRemoteNotifications()
|
|
319
|
+
#else
|
|
320
|
+
NSApplication.shared.unregisterForRemoteNotifications()
|
|
321
|
+
#endif
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func identifier(_ arguments: Any) throws(TokamakPluginError) -> String {
|
|
325
|
+
guard let id = (arguments as? [String: Any])?["id"] as? String, !id.isEmpty else {
|
|
326
|
+
throw TokamakPluginError(name: "TypeError", message: "id must be a non-empty string")
|
|
327
|
+
}
|
|
328
|
+
return id
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/// The APNs environment the app is signed for, or nil without the push entitlement.
|
|
332
|
+
private static let apsEnvironment: String? = {
|
|
333
|
+
#if targetEnvironment(simulator)
|
|
334
|
+
return "development"
|
|
335
|
+
#elseif os(iOS)
|
|
336
|
+
return profileEntitlement(pushEntitlement)
|
|
337
|
+
#else
|
|
338
|
+
return entitlement(pushEntitlement) as? String
|
|
339
|
+
#endif
|
|
340
|
+
}()
|
|
341
|
+
|
|
342
|
+
#if os(macOS)
|
|
343
|
+
private static func entitlement(_ key: String) -> Any? {
|
|
344
|
+
guard let task = SecTaskCreateFromSelf(nil) else { return nil }
|
|
345
|
+
return SecTaskCopyValueForEntitlement(task, key as CFString, nil)
|
|
346
|
+
}
|
|
347
|
+
#endif
|
|
348
|
+
|
|
349
|
+
#if os(iOS) && !targetEnvironment(simulator)
|
|
350
|
+
/// An entitlement in the embedded provisioning profile, which signing takes
|
|
351
|
+
/// the push environment from.
|
|
352
|
+
private static func profileEntitlement(_ key: String) -> String? {
|
|
353
|
+
guard
|
|
354
|
+
let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
|
|
355
|
+
let profile = try? Data(contentsOf: url),
|
|
356
|
+
let start = profile.range(of: Data("<?xml".utf8)),
|
|
357
|
+
let end = profile.range(of: Data("</plist>".utf8), in: start.lowerBound..<profile.endIndex),
|
|
358
|
+
let plist = try? PropertyListSerialization.propertyList(
|
|
359
|
+
from: profile[start.lowerBound..<end.upperBound],
|
|
360
|
+
format: nil
|
|
361
|
+
) as? [String: Any]
|
|
362
|
+
else {
|
|
363
|
+
return nil
|
|
364
|
+
}
|
|
365
|
+
return (plist["Entitlements"] as? [String: Any])?[key] as? String
|
|
366
|
+
}
|
|
367
|
+
#endif
|
|
368
|
+
|
|
369
|
+
private static func registrationError(_ error: Error) -> TokamakPluginError {
|
|
370
|
+
// Cocoa reports a missing aps-environment entitlement as error 3000.
|
|
371
|
+
let error = error as NSError
|
|
372
|
+
if error.domain == NSCocoaErrorDomain && error.code == 3000 {
|
|
373
|
+
return TokamakPluginError(name: "InvalidStateError", message: error.localizedDescription)
|
|
374
|
+
}
|
|
375
|
+
return operationError(error)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private static func operationError(_ error: Error) -> TokamakPluginError {
|
|
379
|
+
TokamakPluginError(name: "OperationError", message: error.localizedDescription)
|
|
380
|
+
}
|
|
381
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tokamakdev/plugin-notifications",
|
|
3
|
+
"version": "0.1.0-beta.50",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/mantty/tokamak.git",
|
|
7
|
+
"directory": "plugins/notifications"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./service-worker": "./service-worker/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@tokamakdev/plugin": "0.1.0-beta.50"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"android",
|
|
19
|
+
"apple",
|
|
20
|
+
"service-worker",
|
|
21
|
+
"src",
|
|
22
|
+
"web",
|
|
23
|
+
"tokamak-plugin.json",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Message, OpenedNotification } from "../src/index.js";
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE,
|
|
4
|
+
OPENED,
|
|
5
|
+
OPENED_CACHE,
|
|
6
|
+
SUBSCRIPTION,
|
|
7
|
+
type ServiceWorkerMessage,
|
|
8
|
+
notificationData,
|
|
9
|
+
pluginData,
|
|
10
|
+
webPushSubscription,
|
|
11
|
+
} from "../web/protocol.js";
|
|
12
|
+
|
|
13
|
+
/** The parts of the service worker events and global scope the helpers use. */
|
|
14
|
+
interface ExtendableEvent {
|
|
15
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PushEvent extends ExtendableEvent {
|
|
19
|
+
readonly data: { text(): string } | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface NotificationEvent extends ExtendableEvent {
|
|
23
|
+
readonly notification: Notification;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PushSubscriptionChangeEvent extends ExtendableEvent {
|
|
27
|
+
readonly newSubscription?: PushSubscription | null;
|
|
28
|
+
readonly oldSubscription?: PushSubscription | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface WindowClient {
|
|
32
|
+
readonly url: string;
|
|
33
|
+
focus(): Promise<WindowClient>;
|
|
34
|
+
postMessage(message: ServiceWorkerMessage): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ServiceWorkerScope {
|
|
38
|
+
readonly registration: ServiceWorkerRegistration;
|
|
39
|
+
readonly clients: {
|
|
40
|
+
matchAll(options: { type: "window"; includeUncontrolled: boolean }): Promise<WindowClient[]>;
|
|
41
|
+
openWindow(url: string): Promise<WindowClient | null>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const scope = globalThis as unknown as ServiceWorkerScope;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Shows the notification in a push message's `{ id, title, body, data }` JSON payload and
|
|
49
|
+
* forwards the message to open pages.
|
|
50
|
+
*/
|
|
51
|
+
export function handlePush(event: PushEvent): void {
|
|
52
|
+
event.waitUntil(showAndForward(message(payload(event.data?.text()))));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Handles a click on a notification the plugin showed: holds it for the page's
|
|
57
|
+
* `onNotificationOpened`, then focuses an open page or opens `data.url` (default `/`).
|
|
58
|
+
* Returns false for other notifications.
|
|
59
|
+
*/
|
|
60
|
+
export function handleNotificationClick(event: NotificationEvent): boolean {
|
|
61
|
+
const data = pluginData(event.notification.data);
|
|
62
|
+
if (!data) return false;
|
|
63
|
+
event.notification.close();
|
|
64
|
+
const opened: OpenedNotification = {
|
|
65
|
+
id: event.notification.tag,
|
|
66
|
+
title: event.notification.title,
|
|
67
|
+
body: event.notification.body || null,
|
|
68
|
+
data: data.data,
|
|
69
|
+
source: data.source,
|
|
70
|
+
};
|
|
71
|
+
event.waitUntil(open(opened));
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Forwards the replacement subscription to open pages when the push service changes it. */
|
|
76
|
+
export function handleSubscriptionChange(event: PushSubscriptionChangeEvent): void {
|
|
77
|
+
event.waitUntil(forwardSubscription(event));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function showAndForward(pushed: Message): Promise<void> {
|
|
81
|
+
await scope.registration.showNotification(pushed.title ?? "", {
|
|
82
|
+
body: pushed.body ?? undefined,
|
|
83
|
+
tag: pushed.id,
|
|
84
|
+
data: notificationData("push", pushed.data),
|
|
85
|
+
});
|
|
86
|
+
await post({ type: MESSAGE, message: pushed });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function open(opened: OpenedNotification): Promise<void> {
|
|
90
|
+
const cache = await caches.open(OPENED_CACHE);
|
|
91
|
+
await cache.put(`/__tokamak/notifications/opened/${crypto.randomUUID()}`, Response.json(opened));
|
|
92
|
+
const client = (await windows()).at(0);
|
|
93
|
+
if (client) {
|
|
94
|
+
await client.focus();
|
|
95
|
+
} else {
|
|
96
|
+
await scope.clients.openWindow(typeof opened.data.url === "string" ? opened.data.url : "/");
|
|
97
|
+
}
|
|
98
|
+
await post({ type: OPENED });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function forwardSubscription(event: PushSubscriptionChangeEvent): Promise<void> {
|
|
102
|
+
const options = event.oldSubscription?.options;
|
|
103
|
+
const subscription =
|
|
104
|
+
event.newSubscription ??
|
|
105
|
+
(options ? await scope.registration.pushManager.subscribe(options) : null);
|
|
106
|
+
if (subscription) {
|
|
107
|
+
await post({ type: SUBSCRIPTION, subscription: webPushSubscription(subscription) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function post(message: ServiceWorkerMessage): Promise<void> {
|
|
112
|
+
for (const client of await windows()) client.postMessage(message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function windows(): Promise<WindowClient[]> {
|
|
116
|
+
return scope.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A push payload's JSON, or its text as the body when it is not JSON. */
|
|
120
|
+
function payload(text: string | undefined): unknown {
|
|
121
|
+
if (!text) return undefined;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
return { body: text };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The message in a push payload; every field is optional. */
|
|
130
|
+
function message(payload: unknown): Message {
|
|
131
|
+
const fields = (payload ?? {}) as Partial<Record<keyof Message, unknown>>;
|
|
132
|
+
return {
|
|
133
|
+
id: typeof fields.id === "string" ? fields.id : crypto.randomUUID(),
|
|
134
|
+
title: typeof fields.title === "string" ? fields.title : null,
|
|
135
|
+
body: typeof fields.body === "string" ? fields.body : null,
|
|
136
|
+
data: isRecord(fields.data) ? fields.data : {},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
141
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
142
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { FrontendPlugin } from "@tokamakdev/plugin";
|
|
2
|
+
import * as web from "../web/index.js";
|
|
3
|
+
|
|
4
|
+
/** Whether the app may show notifications. */
|
|
5
|
+
export type Permission = "granted" | "denied" | "prompt";
|
|
6
|
+
|
|
7
|
+
export interface NotificationContent {
|
|
8
|
+
/** Showing or scheduling another notification with the same id replaces this one. */
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly title: string;
|
|
11
|
+
readonly body?: string;
|
|
12
|
+
readonly data?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ScheduledNotification extends NotificationContent {
|
|
16
|
+
/** Milliseconds since the epoch; the earliest time the notification is shown. */
|
|
17
|
+
readonly at: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A received push message. A data-only message has no title or body. */
|
|
21
|
+
export interface Message {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly title: string | null;
|
|
24
|
+
readonly body: string | null;
|
|
25
|
+
readonly data: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OpenedNotification extends Message {
|
|
29
|
+
readonly source: "local" | "push";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where the app's server sends push messages for this installation. */
|
|
33
|
+
export type Subscription =
|
|
34
|
+
| {
|
|
35
|
+
readonly service: "apns";
|
|
36
|
+
readonly token: string;
|
|
37
|
+
readonly environment: "development" | "production";
|
|
38
|
+
}
|
|
39
|
+
| { readonly service: "fcm"; readonly token: string }
|
|
40
|
+
| {
|
|
41
|
+
readonly service: "webpush";
|
|
42
|
+
readonly endpoint: string;
|
|
43
|
+
readonly keys: { readonly p256dh: string; readonly auth: string };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface SubscribeOptions {
|
|
47
|
+
/** Web Push only: the app's VAPID public key, base64url-encoded. */
|
|
48
|
+
readonly applicationServerKey?: string;
|
|
49
|
+
/** Also show push notifications that arrive while the page is visible. Defaults to false. */
|
|
50
|
+
readonly showInForeground?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Listener<T> = (value: T) => void;
|
|
54
|
+
export type ErrorListener = (error: DOMException) => void;
|
|
55
|
+
|
|
56
|
+
class Notifications extends FrontendPlugin {
|
|
57
|
+
constructor() {
|
|
58
|
+
super("notifications");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
permission(): Promise<Permission> {
|
|
62
|
+
return this.hasNativeTransport ? this.call("permission") : web.permission();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Asks the user for permission to show notifications. */
|
|
66
|
+
requestPermission(): Promise<Permission> {
|
|
67
|
+
return this.hasNativeTransport ? this.call("requestPermission") : web.requestPermission();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Shows a notification now, even while the page is visible. */
|
|
71
|
+
show(notification: NotificationContent): Promise<void> {
|
|
72
|
+
return this.hasNativeTransport ? this.call("show", notification) : web.show(notification);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shows a notification at `at`, or now when `at` has passed. Not supported on the web. */
|
|
76
|
+
schedule(notification: ScheduledNotification): Promise<void> {
|
|
77
|
+
return this.hasNativeTransport ? this.call("schedule", notification) : web.schedule();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Scheduled notifications not yet shown. */
|
|
81
|
+
getScheduled(): Promise<ScheduledNotification[]> {
|
|
82
|
+
return this.hasNativeTransport ? this.call("getScheduled") : Promise.resolve([]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Shown notifications still in the notification centre. */
|
|
86
|
+
getDelivered(): Promise<NotificationContent[]> {
|
|
87
|
+
return this.hasNativeTransport ? this.call("getDelivered") : web.getDelivered();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Removes a scheduled or shown notification. */
|
|
91
|
+
remove(id: string): Promise<void> {
|
|
92
|
+
return this.hasNativeTransport ? this.call("remove", { id }) : web.remove(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Subscribes to push messages, returning the existing subscription when there is one. */
|
|
96
|
+
subscribe(options: SubscribeOptions = {}): Promise<Subscription> {
|
|
97
|
+
if (!this.hasNativeTransport) return web.subscribe(options);
|
|
98
|
+
return this.call("subscribe", { showInForeground: options.showInForeground ?? false });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The current push subscription, or null. */
|
|
102
|
+
getSubscription(): Promise<Subscription | null> {
|
|
103
|
+
return this.hasNativeTransport ? this.call("getSubscription") : web.getSubscription();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
unsubscribe(): Promise<void> {
|
|
107
|
+
return this.hasNativeTransport ? this.call("unsubscribe") : web.unsubscribe();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Receives push messages that arrive while the page is loaded. */
|
|
111
|
+
onMessage(listener: Listener<Message>, error: ErrorListener = report): () => void {
|
|
112
|
+
if (!this.hasNativeTransport) return web.onMessage(listener);
|
|
113
|
+
return this.listen("onMessage", (value) => {
|
|
114
|
+
listener(value as Message);
|
|
115
|
+
}, error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Receives opened notifications, local or push, including the one that opened the app. */
|
|
119
|
+
onNotificationOpened(
|
|
120
|
+
listener: Listener<OpenedNotification>,
|
|
121
|
+
error: ErrorListener = report,
|
|
122
|
+
): () => void {
|
|
123
|
+
if (!this.hasNativeTransport) return web.onNotificationOpened(listener);
|
|
124
|
+
return this.listen("onNotificationOpened", (value) => {
|
|
125
|
+
listener(value as OpenedNotification);
|
|
126
|
+
}, error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Receives the replacement when the push service changes the subscription. */
|
|
130
|
+
onSubscriptionChange(
|
|
131
|
+
listener: Listener<Subscription>,
|
|
132
|
+
error: ErrorListener = report,
|
|
133
|
+
): () => void {
|
|
134
|
+
if (!this.hasNativeTransport) return web.onSubscriptionChange(listener);
|
|
135
|
+
return this.listen("onSubscriptionChange", (value) => {
|
|
136
|
+
listener(value as Subscription);
|
|
137
|
+
}, error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function report(error: DOMException): void {
|
|
142
|
+
console.error(error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const notifications = new Notifications();
|