@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.
@@ -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();