├── .github
└── workflows
│ ├── api.yml
│ └── test.yml
├── .gitignore
├── .spi.yml
├── LICENSE
├── Package.swift
├── README.md
├── Sources
├── WebPush
│ ├── Errors
│ │ ├── BadSubscriberError.swift
│ │ ├── Base64URLDecodingError.swift
│ │ ├── MessageTooLargeError.swift
│ │ ├── PushServiceError.swift
│ │ ├── UserAgentKeyMaterialError.swift
│ │ └── VAPIDConfigurationError.swift
│ ├── Helpers
│ │ ├── DataProtocol+Base64URLCoding.swift
│ │ ├── FixedWidthInteger+BigEndienBytes.swift
│ │ ├── HTTPClientProtocol.swift
│ │ ├── PrintLogHandler.swift
│ │ ├── StringProtocol+UTF8Bytes.swift
│ │ └── URL+Origin.swift
│ ├── Push Message
│ │ ├── Notification.swift
│ │ └── PushMessage.swift
│ ├── Subscriber.swift
│ ├── Topic.swift
│ ├── VAPID
│ │ ├── VAPID.swift
│ │ ├── VAPIDConfiguration.swift
│ │ ├── VAPIDKey.swift
│ │ └── VAPIDToken.swift
│ └── WebPushManager.swift
└── WebPushTesting
│ ├── Subscriber+Testing.swift
│ ├── VAPIDConfiguration+Testing.swift
│ ├── VAPIDKey+Testing.swift
│ └── WebPushManager+Testing.swift
├── Tests
└── WebPushTests
│ ├── Base64URLCodingTests.swift
│ ├── BytesTests.swift
│ ├── ErrorTests.swift
│ ├── Helpers
│ ├── MockHTTPClient.swift
│ └── VAPIDConfiguration+Testing.swift
│ ├── MessageSizeTests.swift
│ ├── NeverTests.swift
│ ├── NotificationTests.swift
│ ├── SubscriberTests.swift
│ ├── TopicTests.swift
│ ├── URLOriginTests.swift
│ ├── VAPIDConfigurationTests.swift
│ ├── VAPIDKeyTests.swift
│ ├── VAPIDTokenTests.swift
│ └── WebPushManagerTests.swift
└── vapid-key-generator
├── Package.swift
└── Sources
└── VAPIDKeyGenerator.swift
/.github/workflows/api.yml:
--------------------------------------------------------------------------------
1 | name: API Check
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | API-Check:
10 | name: Diagnose API Breaking Changes
11 | runs-on: ubuntu-latest
12 | timeout-minutes: 10
13 | steps:
14 | - name: Checkout Source
15 | uses: actions/checkout@v3
16 | with:
17 | fetch-depth: 0
18 | - name: Mark Workspace As Safe
19 | # https://github.com/actions/checkout/issues/766
20 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE}
21 | - name: Diagnose API Breaking Changes
22 | run: |
23 | swift package diagnose-api-breaking-changes origin/main --products WebPush
24 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test WebPush
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | push:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | Test-Release:
13 | name: "Test Release"
14 | runs-on: ubuntu-latest
15 | timeout-minutes: 10
16 | steps:
17 | - name: Checkout Source
18 | uses: actions/checkout@v3
19 | - name: Build
20 | run: |
21 | swift build --build-tests --configuration release -Xswiftc -enable-testing -Xswiftc -warnings-as-errors -Xcc -Werror
22 | - name: Run Tests
23 | run: |
24 | swift test --skip-build --configuration release
25 |
26 | Test-Debug:
27 | name: "Test Debug"
28 | runs-on: ubuntu-latest
29 | timeout-minutes: 10
30 | steps:
31 | - name: Checkout Source
32 | uses: actions/checkout@v3
33 | - name: Build
34 | run: |
35 | swift build --build-tests --configuration debug -Xswiftc -enable-testing -Xswiftc -warnings-as-errors -Xcc -Werror
36 | - name: Run Tests
37 | run: |
38 | swift test --skip-build --configuration debug
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 | Package.resolved
10 |
11 | /vapid-key-generator/.build
12 | /vapid-key-generator/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
13 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [WebPush, WebPushTesting]
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Mochi Development, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "swift-webpush",
8 | platforms: [
9 | .macOS(.v13),
10 | .iOS(.v15),
11 | .tvOS(.v15),
12 | .watchOS(.v8),
13 | ],
14 | products: [
15 | .library(name: "WebPush", targets: ["WebPush"]),
16 | .library(name: "WebPushTesting", targets: ["WebPush", "WebPushTesting"]),
17 | ],
18 | dependencies: [
19 | /// Core dependency that allows us to sign Authorization tokens and encrypt push messages per subscriber before delivery.
20 | .package(url: "https://github.com/apple/swift-crypto.git", "3.10.0"..<"5.0.0"),
21 | /// Logging integration allowing runtime API missuse warnings and push status tracing.
22 | .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
23 | /// Service lifecycle integration for clean shutdowns in a server environment.
24 | .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.2"),
25 | /// Internal dependency allowing push message delivery over HTTP/2.
26 | .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.24.0"),
27 | /// Internal dependency for event loop coordination and shared HTTP types.
28 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.77.0"),
29 | ],
30 | targets: [
31 | .target(
32 | name: "WebPush",
33 | dependencies: [
34 | .product(name: "AsyncHTTPClient", package: "async-http-client"),
35 | .product(name: "Crypto", package: "swift-crypto"),
36 | .product(name: "Logging", package: "swift-log"),
37 | .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
38 | .product(name: "NIOCore", package: "swift-nio"),
39 | .product(name: "NIOHTTP1", package: "swift-nio"),
40 | ]
41 | ),
42 | .target(
43 | name: "WebPushTesting",
44 | dependencies: [
45 | .product(name: "Crypto", package: "swift-crypto"),
46 | .product(name: "Logging", package: "swift-log"),
47 | .target(name: "WebPush"),
48 | ]
49 | ),
50 | .testTarget(name: "WebPushTests", dependencies: [
51 | .product(name: "AsyncHTTPClient", package: "async-http-client"),
52 | .product(name: "Crypto", package: "swift-crypto"),
53 | .product(name: "Logging", package: "swift-log"),
54 | .product(name: "NIOCore", package: "swift-nio"),
55 | .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
56 | .target(name: "WebPush"),
57 | .target(name: "WebPushTesting"),
58 | ]),
59 | ]
60 | )
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Swift WebPush
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | A server-side Swift implementation of the WebPush standard.
16 |
17 | ## Quick Links
18 |
19 | - [Documentation](https://swiftpackageindex.com/mochidev/swift-webpush/documentation)
20 | - [Symbol Exploration](https://swiftinit.org/docs/mochidev.swift-webpush)
21 | - [Updates on Mastodon](https://mastodon.social/tags/SwiftWebPush)
22 |
23 | ## Installation
24 |
25 | Add `WebPush` as a dependency in your `Package.swift` file to start using it. Then, add `import WebPush` to any file you wish to use the library in.
26 |
27 | Please check the [releases](https://github.com/mochidev/swift-webpush/releases) for recommended versions.
28 |
29 | ```swift
30 | dependencies: [
31 | .package(
32 | url: "https://github.com/mochidev/swift-webpush.git",
33 | .upToNextMinor(from: "0.4.1")
34 | ),
35 | ],
36 | ...
37 | targets: [
38 | .target(
39 | name: "MyPackage",
40 | dependencies: [
41 | .product(name: "WebPush", package: "swift-webpush"),
42 | ...
43 | ]
44 | ),
45 | .testTarget(
46 | name: "MyPackageTests",
47 | dependencies: [
48 | .product(name: "WebPushTesting", package: "swift-webpush"),
49 | ...
50 | ]
51 | ),
52 | ]
53 | ```
54 |
55 | ## Usage
56 |
57 | ### Terminology and Core Concepts
58 |
59 | If you are unfamiliar with the WebPush standard, we suggest you first familiarize yourself with the following core concepts:
60 |
61 |
62 | Subscriber
63 |
64 | A **Subscriber** represents a device that has opted in to receive push messages from your service.
65 |
66 | > [!IMPORTANT]
67 | > A subscriber should not be conflated with a user — a single user may be logged in on multiple devices, while a subscriber may be shared by multiple users on a single device. It is up to you to manage this complexity and ensure user information remains secure across session boundaries by registering, unregistering, and updating the subscriber when a user logs in or out.
68 |
69 |
70 |
71 |
72 | Application Server
73 |
74 | The **Application Server** is a server you run to manage subscriptions and send push notifications. The actual servers that perform these roles may be different, but they must all use the same VAPID keys to function correctly.
75 |
76 | > [!CAUTION]
77 | > Using a VAPID key that wasn't registered with a subscription will result in push messages failing to reach their subscribers.
78 |
79 |
80 |
81 |
82 | VAPID Key
83 |
84 | **VAPID**, or _Voluntary Application Server Identification_, describes a standard for letting your application server introduce itself at time of subscription registration so that the subscription returned back to you may only be used by your service, and can't be shared with other unrelated services.
85 |
86 | This is made possible by generating a VAPID key pair to represent your server with. At time of registration, the public key is shared with the browser, and the subscription that is returned is locked to this key. When sending a push message, the private key is used to identify your application server to the push service so that it knows who you are before forwarding messages to subscribers.
87 |
88 | > [!CAUTION]
89 | > It is important to note that you should strive to use the same key for as long as possible for a given subscriber — you won't be able to send messages to existing subscribers if you ever regenerate this key, so keep it secure!
90 |
91 |
92 |
93 |
94 | Push Service
95 |
96 | A **Push Service** is run by browsers to coordinate delivery of messages to subscribers on your behalf.
97 |
98 |
99 |
100 |
101 | ### Generating Keys
102 |
103 | Before integrating WebPush into your server, you must generate one time VAPID keys to identify your server to push services with. To help we this, we provide `vapid-key-generator`, which you can install and use as needed:
104 | ```zsh
105 | % git clone https://github.com/mochidev/swift-webpush.git
106 | % cd swift-webpush/vapid-key-generator
107 | % swift package experimental-install
108 | ```
109 |
110 | To uninstall the generator:
111 | ```zsh
112 | % swift package experimental-uninstall vapid-key-generator
113 | ```
114 |
115 | To update the generator, uninstall it and re-install it after pulling from main:
116 | ```zsh
117 | % swift package experimental-uninstall vapid-key-generator
118 | % swift package experimental-install
119 | ```
120 |
121 | Once installed, a new configuration can be generated as needed. Here, we generate a configuration with `https://example.com` as our support URL for push service administrators to use to contact us when issues occur:
122 | ```
123 | % ~/.swiftpm/bin/vapid-key-generator https://example.com
124 | VAPID.Configuration: {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000}
125 |
126 |
127 | Example Usage:
128 | // TODO: Load this data from .env or from file system
129 | let configurationData = Data(#" {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000} "#.utf8)
130 | let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData)
131 | ```
132 |
133 | Once generated, the configuration JSON should be added to your deployment's `.env` and kept secure so it can be accessed at runtime by your application server, and _only_ by your application server. Make sure this key does not leak and is not stored alongside subscriber information.
134 |
135 | > [!NOTE]
136 | > You can specify either a support URL or an email for administrators of push services to contact you with if problems are encountered, or you can generate keys only if you prefer to configure contact information at runtime:
137 |
138 | ```zsh
139 | % ~/.swiftpm/bin/vapid-key-generator -h
140 | OVERVIEW: Generate VAPID Keys.
141 |
142 | Generates VAPID keys and configurations suitable for use on your server. Keys should generally only be generated once
143 | and kept secure.
144 |
145 | USAGE: vapid-key-generator
146 | vapid-key-generator --email
147 | vapid-key-generator --key-only
148 |
149 | ARGUMENTS:
150 | The fully-qualified HTTPS support URL administrators of push services may contact you at:
151 | https://example.com/support
152 |
153 | OPTIONS:
154 | -k, --key-only Only generate a VAPID key.
155 | -s, --silent Output raw JSON only so this tool can be piped with others in scripts.
156 | -p, --pretty Output JSON with spacing. Has no effect when generating keys only.
157 | --email Parse the input as an email address.
158 | -h, --help Show help information.
159 | ```
160 |
161 | > [!IMPORTANT]
162 | > If you only need to change the contact information, you can do so in the JSON directly — a key should _not_ be regenerated when doing this as it will invalidate all existing subscriptions.
163 |
164 | > [!TIP]
165 | > If you prefer, you can also generate keys in your own code by calling `VAPID.Key()`, but remember, the key should be persisted and re-used from that point forward!
166 |
167 |
168 | ### Setup
169 |
170 | During the setup stage of your application server, decode the VAPID configuration you created above and initialize a `WebPushManager` with it:
171 |
172 | ```swift
173 | import WebPush
174 |
175 | ...
176 |
177 | guard
178 | let rawVAPIDConfiguration = ProcessInfo.processInfo.environment["VAPID-CONFIG"],
179 | let vapidConfiguration = try? JSONDecoder().decode(VAPID.Configuration.self, from: Data(rawVAPIDConfiguration.utf8))
180 | else { fatalError("VAPID keys are unavailable, please generate one and add it to the environment.") }
181 |
182 | let manager = WebPushManager(
183 | vapidConfiguration: vapidConfiguration,
184 | backgroundActivityLogger: logger
185 | /// If you customized the event loop group your app uses, you can set it here:
186 | // eventLoopGroupProvider: .shared(app.eventLoopGroup)
187 | )
188 |
189 | try await ServiceGroup(
190 | services: [
191 | /// Your other services here
192 | manager
193 | ],
194 | gracefulShutdownSignals: [.sigint],
195 | logger: logger
196 | ).run()
197 | ```
198 |
199 | If you are not yet using [Swift Service Lifecycle](https://github.com/swift-server/swift-service-lifecycle), you can skip adding it to the service group, and it'll shut down on deinit instead. This however may be too late to finish sending over all in-flight messages, so prefer to use a ServiceGroup for all your services if you can.
200 |
201 | You'll also want to serve a `serviceWorker.mjs` file at the root of your server (it can be anywhere, but there are scoping restrictions that are simplified by serving it at the root) to handle incoming notifications:
202 |
203 | ```js
204 | self.addEventListener('push', function(event) {
205 | const data = event.data?.json() ?? {};
206 | event.waitUntil((async () => {
207 | const notification = data?.notification ?? {}
208 | /// Try parsing the data, otherwise use fallback content. DO NOT skip sending the notification, as you must display one for every push message that is received or your subscription will be dropped.
209 | const title = notification.title ?? "Your App Name";
210 | const body = notification.body ?? "New Content Available!";
211 |
212 | await self.registration.showNotification(title, {
213 | body,
214 | requireInteraction: notification.require_interaction ?? false,
215 | ...notification,
216 | });
217 | })());
218 | });
219 | ```
220 |
221 | In the example above, we are using the new **Declarative Push Notification** format for our message payload so the browser can automatically skip requiring the service worker, but you can send send any data payload and interpret it in your service worker should you choose. Note that doing so will require more resources on your user's devices when Declarative Push Notifications are otherwise supported.
222 |
223 | > [!NOTE]
224 | > `.mjs` here allows your code to import other js modules as needed. If you are not using Vapor, please make sure your server uses the correct mime type for this file extension.
225 |
226 |
227 | ### Registering Subscribers
228 |
229 | To register a subscriber, you'll need backend code to provide your VAPID key, and frontend code to ask the browser for a subscription on behalf of the user.
230 |
231 | On the backend (we are assuming Vapor here), register a route that returns your VAPID public key:
232 |
233 | ```swift
234 | import WebPush
235 |
236 | ...
237 |
238 | /// Listen somewhere for a VAPID key request. This path can be anything you want, and should be available to all parties you with to serve push messages to.
239 | app.get("vapidKey", use: loadVapidKey)
240 |
241 | ...
242 |
243 | /// A wrapper for the VAPID key that Vapor can encode.
244 | struct WebPushOptions: Codable, Content, Hashable, Sendable {
245 | static let defaultContentType = HTTPMediaType(type: "application", subType: "webpush-options+json")
246 |
247 | var vapid: VAPID.Key.ID
248 | }
249 |
250 | /// The route handler, usually part of a route controller.
251 | @Sendable func loadVapidKey(request: Request) async throws -> WebPushOptions {
252 | WebPushOptions(vapid: manager.nextVAPIDKeyID)
253 | }
254 | ```
255 |
256 | Also register a route for persisting `Subscriber`'s:
257 |
258 | ```swift
259 | import WebPush
260 |
261 | ...
262 |
263 | /// Listen somewhere for new registrations. This path can be anything you want, and should be available to all parties you with to serve push messages to.
264 | app.get("registerSubscription", use: registerSubscription)
265 |
266 | ...
267 |
268 | /// A custom type for communicating the status of your subscription. Fill this out with any options you'd like to communicate back to the user.
269 | struct SubscriptionStatus: Codable, Content, Hashable, Sendable {
270 | var subscribed = true
271 | }
272 |
273 | /// The route handler, usually part of a route controller.
274 | @Sendable func registerSubscription(request: Request) async throws -> SubscriptionStatus {
275 | let subscriptionRequest = try request.content.decode(Subscriber.self, as: .jsonAPI)
276 |
277 | // TODO: Persist subscriptionRequest!
278 |
279 | return SubscriptionStatus()
280 | }
281 | ```
282 |
283 | > [!NOTE]
284 | > `WebPushManager` (`manager` here) is fully sendable, and should be shared with your controllers using dependency injection. This allows you to fully test your application server by relying on the provided `WebPushTesting` library in your unit tests to mock keys, verify delivery, and simulate errors.
285 |
286 | On the frontend, register your service worker, fetch your vapid key, and subscribe on behalf of the user:
287 |
288 | ```js
289 | const serviceRegistration = await navigator.serviceWorker?.register("/serviceWorker.mjs", { type: "module" });
290 | let subscription = await registration?.pushManager?.getSubscription();
291 |
292 | /// Wait for the user to interact with the page to request a subscription.
293 | document.getElementById("notificationsSwitch").addEventListener("click", async ({ currentTarget }) => {
294 | try {
295 | /// If we couldn't load a subscription, now's the time to ask for one.
296 | if (!subscription) {
297 | const applicationServerKey = await loadVAPIDKey();
298 | subscription = await serviceRegistration.pushManager.subscribe({
299 | userVisibleOnly: true,
300 | applicationServerKey,
301 | });
302 | }
303 |
304 | /// It is safe to re-register the same subscription.
305 | const subscriptionStatusResponse = await registerSubscription(subscription);
306 |
307 | /// Do something with your registration. Some may use it to store notification settings and display those back to the user.
308 | ...
309 | } catch (error) {
310 | /// Tell the user something went wrong here.
311 | console.error(error);
312 | }
313 | });
314 |
315 | ...
316 |
317 | async function loadVAPIDKey() {
318 | /// Make sure this is the same route used above.
319 | const httpResponse = await fetch(`/vapidKey`);
320 |
321 | const webPushOptions = await httpResponse.json();
322 | if (httpResponse.status != 200) throw new Error(webPushOptions.reason);
323 |
324 | return webPushOptions.vapid;
325 | }
326 |
327 | export async function registerSubscription(subscription) {
328 | /// Make sure this is the same route used above.
329 | const subscriptionStatusResponse = await fetch(`/registerSubscription`, {
330 | method: "POST",
331 | body: {
332 | ...subscription.toJSON(),
333 | /// It is good practice to provide the applicationServerKey back here so we can track which one was used if multiple were provided during configuration.
334 | applicationServerKey: subscription.options.applicationServerKey,
335 | },
336 | });
337 |
338 | /// Do something with your registration. Some may use it to store notification settings and display those back to the user.
339 | ...
340 | }
341 | ```
342 |
343 |
344 | ### Sending Messages
345 |
346 | To send a message, call one of the `send()` methods on `WebPushManager` with a `Subscriber`:
347 |
348 | ```swift
349 | import WebPush
350 |
351 | ...
352 |
353 | do {
354 | try await manager.send(
355 | /// We use a declarative push notification to allow newer browsers to deliver the notification to users on our behalf.
356 | notification: PushMessage.Notification(
357 | destination: URL(string: "https://example.com/notificationDetails")!,
358 | title: "Test Notification",
359 | body: "Hello, World!"
360 | ),
361 | to: subscriber
362 | /// If sent from a request, pass the request's logger here to maintain its metadata.
363 | // logger: request.logger
364 | )
365 | } catch is BadSubscriberError {
366 | /// The subscription is no longer valid and should be removed.
367 | } catch is MessageTooLargeError {
368 | /// The message was too long and should be shortened.
369 | } catch let error as PushServiceError {
370 | /// The push service ran into trouble. error.response may help here.
371 | } catch {
372 | /// An unknown error occurred.
373 | }
374 | ```
375 |
376 | Your service worker will receive this message, decode it, and present it to the user.
377 |
378 | You can also send JSON (`send(json: ...)`), data (`send(data: ...)`), or text (`send(string: ...)`) and have your service worker interpret the payload as it sees fit.
379 |
380 | > [!NOTE]
381 | > Although the spec supports it, most browsers do not support silent notifications, and will drop a subscription if they are used.
382 |
383 |
384 | ### Testing
385 |
386 | The `WebPushTesting` module can be used to obtain a mocked `WebPushManager` instance that allows you to capture all messages that are sent out, or throw your own errors to validate your code functions appropriately.
387 |
388 | > [!IMPORTANT]
389 | > Only import `WebPushTesting` in your testing targets.
390 |
391 | ```swift
392 | import Testing
393 | import WebPushTesting
394 |
395 | @Test func sendSuccessfulNotifications() async throws {
396 | try await confirmation { requestWasMade in
397 | let mockedManager = WebPushManager.makeMockedManager { message, subscriber, topic, expiration, urgency in
398 | #expect(message.string == "hello")
399 | #expect(subscriber.endpoint.absoluteString == "https://example.com/expectedSubscriber")
400 | #expect(subscriber.vapidKeyID == .mockedKeyID1)
401 | #expect(topic == nil)
402 | #expect(expiration == .recommendedMaximum)
403 | #expect(urgency == .high)
404 | requestWasMade()
405 | }
406 |
407 | let myController = MyController(pushManager: mockedManager)
408 | try await myController.sendNotifications()
409 | }
410 | }
411 |
412 | @Test func catchBadSubscriptions() async throws {
413 | /// Mocked managers accept multiple handlers, and will cycle through them each time a push message is sent:
414 | let mockedManager = WebPushManager.makeMockedManager(messageHandlers:
415 | { _, _, _, _, _ in throw BadSubscriberError() },
416 | { _, _, _, _, _ in },
417 | { _, _, _, _, _ in throw BadSubscriberError() },
418 | )
419 |
420 | let myController = MyController(pushManager: mockedManager)
421 | #expect(myController.subscribers.count == 3)
422 | try await myController.sendNotifications()
423 | #expect(myController.subscribers.count == 1)
424 | }
425 | ```
426 |
427 | ## Specifications
428 |
429 | ### RFC Standards
430 |
431 | - [RFC 6454 — The Web Origin Concept](https://datatracker.ietf.org/doc/html/rfc6454)
432 | - [RFC 7515 — JSON Web Signature (JWS)](https://datatracker.ietf.org/doc/html/rfc7515)
433 | - [RFC 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
434 | - [RFC 8030 — Generic Event Delivery Using HTTP Push](https://datatracker.ietf.org/doc/html/rfc8030)
435 | - [RFC 8188 — Encrypted Content-Encoding for HTTP](https://datatracker.ietf.org/doc/html/rfc8188)
436 | - [RFC 8291 — Message Encryption for Web Push](https://datatracker.ietf.org/doc/html/rfc8291)
437 | - [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)
438 |
439 | ### W3C Standards
440 |
441 | - [Push API Working Draft](https://www.w3.org/TR/push-api/)
442 | - [Push API Editor's Draft — `declarative-push` branch](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html)
443 |
444 | ### WHATWG Standards
445 |
446 | - [Notifications API — Living Standard](https://notifications.spec.whatwg.org/)
447 | - [Notifications API — PR #213 — Allow notifications and actions to specify a navigable URL](https://whatpr.org/notifications/213.html)
448 |
449 | ## Other Resources
450 |
451 | - [Apple Developer — Sending web push notifications in web apps and browsers](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers)
452 | - [WWDC22 — Meet Web Push for Safari](https://developer.apple.com/videos/play/wwdc2022/10098/)
453 | - [WebKit — Meet Web Push](https://webkit.org/blog/12945/meet-web-push/)
454 | - [WebKit — Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)
455 | - [MDN — Notification](https://developer.mozilla.org/en-US/docs/Web/API/Notification)
456 | - [MDN — Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
457 | - [MDN — Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
458 | - [web.dev — The Web Push Protocol](https://web.dev/articles/push-notifications-web-push-protocol)
459 | - [Sample Code — ServiceWorker Cookbook](https://github.com/mdn/serviceworker-cookbook/tree/master/push-simple)
460 | - [Web Push: Data Encryption Test Page](https://mozilla-services.github.io/WebPushDataTestPage/)
461 |
462 | ## Contributing
463 |
464 | Contribution is welcome! Please take a look at the issues already available, or start a new discussion to propose a new feature. Although guarantees can't be made regarding feature requests, PRs that fit within the goals of the project and that have been discussed beforehand are more than welcome!
465 |
466 | Please make sure that all submissions have clean commit histories, are well documented, and thoroughly tested. **Please rebase your PR** before submission rather than merge in `main`. Linear histories are required, so merge commits in PRs will not be accepted.
467 |
468 | ## Support
469 |
470 | To support this project, consider following [@dimitribouniol](https://mastodon.social/@dimitribouniol) on Mastodon, listening to Spencer and Dimitri on [Code Completion](https://mastodon.social/@codecompletion), or downloading Linh and Dimitri's apps:
471 | - [Not Phở](https://notpho.app/)
472 | - [Jiiiii](https://jiiiii.moe/)
473 |
--------------------------------------------------------------------------------
/Sources/WebPush/Errors/BadSubscriberError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BadSubscriberError.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-13.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | /// The subscription is no longer valid and should be removed and re-registered.
16 | ///
17 | /// - Warning: Do not continue to send notifications to invalid subscriptions or you'll risk being rate limited by push services.
18 | public struct BadSubscriberError: LocalizedError, Hashable, Sendable {
19 | /// Create a new bad subscriber error.
20 | public init() {}
21 |
22 | public var errorDescription: String? {
23 | "The subscription is no longer valid."
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/WebPush/Errors/Base64URLDecodingError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Base64URLDecodingError.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-13.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | /// An error encountered while decoding Base64 data.
16 | public struct Base64URLDecodingError: LocalizedError, Hashable, Sendable {
17 | /// Create a new base 64 decoding error.
18 | public init() {}
19 |
20 | public var errorDescription: String? {
21 | "The Base64 data could not be decoded."
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/WebPush/Errors/MessageTooLargeError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MessageTooLargeError.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-13.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | /// The message was too large, and could not be delivered to the push service.
16 | ///
17 | /// - SeeAlso: ``WebPushManager/maximumMessageSize``
18 | public struct MessageTooLargeError: LocalizedError, Hashable, Sendable {
19 | /// Create a new message too large error.
20 | public init() {}
21 |
22 | public var errorDescription: String? {
23 | "The message was too large, and could not be delivered to the push service."
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/WebPush/Errors/PushServiceError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PushServiceError.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-13.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import AsyncHTTPClient
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 |
16 | /// An unknown Push Service error was encountered.
17 | ///
18 | /// - SeeAlso: [RFC 8030 — Generic Event Delivery Using HTTP Push](https://datatracker.ietf.org/doc/html/rfc8030)
19 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)
20 | /// - SeeAlso: [Sending web push notifications in web apps and browsers — Review responses for push notification errors](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers#Review-responses-for-push-notification-errors)
21 | public struct PushServiceError: LocalizedError, Sendable {
22 | /// The HTTP response that was returned from the push service..
23 | public let response: HTTPClientResponse
24 |
25 | /// A cached description from the response that won't change over the lifetime of the error.
26 | let capturedResponseDescription: String
27 |
28 | /// Create a new http error.
29 | public init(response: HTTPClientResponse) {
30 | self.response = response
31 | self.capturedResponseDescription = "\(response)"
32 | }
33 |
34 | public var errorDescription: String? {
35 | "A \(response.status) Push Service error was encountered: \(capturedResponseDescription)."
36 | }
37 | }
38 |
39 | extension PushServiceError: Hashable {
40 | public static func == (lhs: Self, rhs: Self) -> Bool {
41 | "\(lhs.capturedResponseDescription)" == "\(rhs.capturedResponseDescription)"
42 | }
43 |
44 | public func hash(into hasher: inout Hasher) {
45 | hasher.combine("\(capturedResponseDescription)")
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserAgentKeyMaterialError.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-13.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | /// An error encountered during ``VAPID/Configuration`` initialization or decoding.
16 | public struct UserAgentKeyMaterialError: LocalizedError, Sendable {
17 | /// The kind of error that occured.
18 | enum Kind {
19 | /// The public key was invalid.
20 | case invalidPublicKey
21 | /// The authentication secret was invalid.
22 | case invalidAuthenticationSecret
23 | }
24 |
25 | /// The kind of error that occured.
26 | var kind: Kind
27 |
28 | /// The underlying error that caused this one.
29 | public var underlyingError: any Error
30 |
31 | /// The public key was invalid.
32 | public static func invalidPublicKey(underlyingError: Error) -> Self {
33 | Self(kind: .invalidPublicKey, underlyingError: underlyingError)
34 | }
35 |
36 | /// The authentication secret was invalid.
37 | public static func invalidAuthenticationSecret(underlyingError: Error) -> Self {
38 | Self(kind: .invalidAuthenticationSecret, underlyingError: underlyingError)
39 | }
40 |
41 | public var errorDescription: String? {
42 | switch kind {
43 | case .invalidPublicKey:
44 | "Subscriber Public Key (`\(UserAgentKeyMaterial.CodingKeys.publicKey.stringValue)`) was invalid: \(underlyingError.localizedDescription)"
45 | case .invalidAuthenticationSecret:
46 | "Subscriber Authentication Secret (`\(UserAgentKeyMaterial.CodingKeys.authenticationSecret.stringValue)`) was invalid: \(underlyingError.localizedDescription)"
47 | }
48 | }
49 | }
50 |
51 | extension UserAgentKeyMaterialError: Hashable {
52 | public static func == (lhs: Self, rhs: Self) -> Bool {
53 | lhs.kind == rhs.kind && lhs.underlyingError.localizedDescription == rhs.underlyingError.localizedDescription
54 | }
55 |
56 | public func hash(into hasher: inout Hasher) {
57 | hasher.combine(kind)
58 | hasher.combine(underlyingError.localizedDescription)
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Sources/WebPush/Errors/VAPIDConfigurationError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDConfigurationError.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-13.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | extension VAPID {
16 | /// An error encountered during ``VAPID/Configuration`` initialization or decoding.
17 | public struct ConfigurationError: LocalizedError, Hashable, Sendable {
18 | /// The kind of error that occured.
19 | enum Kind {
20 | /// VAPID keys not found during initialization.
21 | case keysNotProvided
22 | /// A VAPID key for the subscriber was not found.
23 | case matchingKeyNotFound
24 | }
25 |
26 | /// The kind of error that occured.
27 | var kind: Kind
28 |
29 | /// VAPID keys not found during initialization.
30 | public static let keysNotProvided = Self(kind: .keysNotProvided)
31 |
32 | /// A VAPID key for the subscriber was not found.
33 | public static let matchingKeyNotFound = Self(kind: .matchingKeyNotFound)
34 |
35 | public var errorDescription: String? {
36 | switch kind {
37 | case .keysNotProvided:
38 | "VAPID keys not found during initialization."
39 | case .matchingKeyNotFound:
40 | "A VAPID key for the subscriber was not found."
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataProtocol+Base64URLCoding.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-06.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | extension DataProtocol {
16 | /// The receiver as a Base64 URL encoded string.
17 | @_disfavoredOverload
18 | @usableFromInline
19 | func base64URLEncodedString() -> String {
20 | Data(self)
21 | .base64EncodedString()
22 | .transformToBase64URLEncoding()
23 | }
24 | }
25 |
26 | extension String {
27 | /// Transform a regular Base64 encoded string to a Base64URL encoded one.
28 | @usableFromInline
29 | func transformToBase64URLEncoding() -> String {
30 | self.replacingOccurrences(of: "+", with: "-")
31 | .replacingOccurrences(of: "/", with: "_")
32 | .replacingOccurrences(of: "=", with: "")
33 | }
34 | }
35 |
36 | extension ContiguousBytes {
37 | /// The receiver as a Base64 URL encoded string.
38 | @usableFromInline
39 | func base64URLEncodedString() -> String {
40 | withUnsafeBytes { bytes in
41 | (bytes as any DataProtocol).base64URLEncodedString()
42 | }
43 | }
44 | }
45 |
46 | extension DataProtocol where Self: RangeReplaceableCollection {
47 | /// Initialize data using a Base64 URL encoded string.
48 | @usableFromInline
49 | init?(base64URLEncoded string: some StringProtocol) {
50 | var base64String = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
51 | while base64String.count % 4 != 0 {
52 | base64String = base64String.appending("=")
53 | }
54 |
55 | guard let decodedData = Data(base64Encoded: base64String)
56 | else { return nil }
57 |
58 | self = Self(decodedData)
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Sources/WebPush/Helpers/FixedWidthInteger+BigEndienBytes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FixedWidthInteger+BigEndienBytes.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-11.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | extension FixedWidthInteger {
10 | /// The big endian representation of the integer.
11 | @usableFromInline
12 | var bigEndianBytes: [UInt8] {
13 | withUnsafeBytes(of: self.bigEndian) { Array($0) }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/WebPush/Helpers/HTTPClientProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPClientProtocol.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-11.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import AsyncHTTPClient
10 | import Logging
11 | import NIOCore
12 |
13 | /// A protocol abstracting HTTP request execution.
14 | package protocol HTTPClientProtocol: Sendable {
15 | /// Execute the request.
16 | func execute(
17 | _ request: HTTPClientRequest,
18 | deadline: NIODeadline,
19 | logger: Logger?
20 | ) async throws -> HTTPClientResponse
21 |
22 | /// Shuts down the client.
23 | func syncShutdown() throws
24 | }
25 |
26 | extension HTTPClient: HTTPClientProtocol {}
27 |
--------------------------------------------------------------------------------
/Sources/WebPush/Helpers/PrintLogHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrintLogHandler.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-06.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Logging
15 |
16 | /// A simple log handler that uses formatted print statements.
17 | package struct PrintLogHandler: LogHandler {
18 | private let label: String
19 |
20 | package var logLevel: Logger.Level = .info
21 | package var metadataProvider: Logger.MetadataProvider?
22 |
23 | package init(
24 | label: String,
25 | logLevel: Logger.Level = .info,
26 | metadataProvider: Logger.MetadataProvider? = nil
27 | ) {
28 | self.label = label
29 | self.logLevel = logLevel
30 | self.metadataProvider = metadataProvider
31 | }
32 |
33 | private var prettyMetadata: String?
34 | package var metadata = Logger.Metadata() {
35 | didSet {
36 | self.prettyMetadata = self.prettify(self.metadata)
37 | }
38 | }
39 |
40 | package subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? {
41 | get {
42 | self.metadata[metadataKey]
43 | }
44 | set {
45 | self.metadata[metadataKey] = newValue
46 | }
47 | }
48 |
49 | package func log(
50 | level: Logger.Level,
51 | message: Logger.Message,
52 | metadata explicitMetadata: Logger.Metadata?,
53 | source: String,
54 | file: String,
55 | function: String,
56 | line: UInt
57 | ) {
58 | let effectiveMetadata = Self.prepareMetadata(
59 | base: self.metadata,
60 | provider: self.metadataProvider,
61 | explicit: explicitMetadata
62 | )
63 |
64 | let prettyMetadata: String?
65 | if let effectiveMetadata = effectiveMetadata {
66 | prettyMetadata = self.prettify(effectiveMetadata)
67 | } else {
68 | prettyMetadata = self.prettyMetadata
69 | }
70 |
71 | print("\(self.timestamp()) [\(level)] \(self.label):\(prettyMetadata.map { " \($0)" } ?? "") [\(source)] \(message)")
72 | }
73 |
74 | private static func prepareMetadata(
75 | base: Logger.Metadata,
76 | provider: Logger.MetadataProvider?,
77 | explicit: Logger.Metadata?
78 | ) -> Logger.Metadata? {
79 | var metadata = base
80 |
81 | let provided = provider?.get() ?? [:]
82 |
83 | guard !provided.isEmpty || !((explicit ?? [:]).isEmpty) else {
84 | // all per-log-statement values are empty
85 | return nil
86 | }
87 |
88 | if !provided.isEmpty {
89 | metadata.merge(provided, uniquingKeysWith: { _, provided in provided })
90 | }
91 |
92 | if let explicit = explicit, !explicit.isEmpty {
93 | metadata.merge(explicit, uniquingKeysWith: { _, explicit in explicit })
94 | }
95 |
96 | return metadata
97 | }
98 |
99 | private func prettify(_ metadata: Logger.Metadata) -> String? {
100 | if metadata.isEmpty {
101 | return nil
102 | } else {
103 | return metadata.lazy.sorted(by: { $0.key < $1.key }).map { "\($0)=\($1)" }.joined(separator: " ")
104 | }
105 | }
106 |
107 | private func timestamp() -> String {
108 | Date().formatted(date: .numeric, time: .complete)
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/Sources/WebPush/Helpers/StringProtocol+UTF8Bytes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StringProtocol+UTF8Bytes.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-11.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | extension String {
10 | /// The UTF8 byte representation of the string.
11 | @usableFromInline
12 | var utf8Bytes: [UInt8] {
13 | var string = self
14 | return string.withUTF8 { Array($0) }
15 | }
16 | }
17 |
18 | extension Substring {
19 | /// The UTF8 byte representation of the string.
20 | @usableFromInline
21 | var utf8Bytes: [UInt8] {
22 | var string = self
23 | return string.withUTF8 { Array($0) }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/WebPush/Helpers/URL+Origin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URL+Origin.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-09.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | extension URL {
16 | /// Returns the origin for the receiving URL, as defined for use in signing headers for VAPID.
17 | ///
18 | /// This implementation is similar to the [WHATWG Standard](https://url.spec.whatwg.org/#concept-url-origin), except that it uses the unicode form of the host, and is limited to HTTP and HTTPS schemas.
19 | ///
20 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
21 | /// - SeeAlso: [RFC 6454 — The Web Origin Concept §6.1. Unicode Serialization of an Origin](https://datatracker.ietf.org/doc/html/rfc6454#section-6.1)
22 | var origin: String {
23 | /// Note that we need the unicode variant, which only URLComponents provides.
24 | let components = URLComponents(url: self, resolvingAgainstBaseURL: true)
25 | guard
26 | let scheme = components?.scheme?.lowercased(),
27 | let host = components?.host
28 | else { return "null" }
29 |
30 | switch scheme {
31 | case "http":
32 | let port = components?.port ?? 80
33 | return "http://" + host + (port != 80 ? ":\(port)" : "")
34 | case "https":
35 | let port = components?.port ?? 443
36 | return "https://" + host + (port != 443 ? ":\(port)" : "")
37 | default: return "null"
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/WebPush/Push Message/Notification.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Notification.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2025-02-26.
6 | // Copyright © 2024-25 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | // MARK: - Notification
16 |
17 | extension PushMessage {
18 | /// A Declarative Push Notification.
19 | ///
20 | /// Declarative push notifications don't require a service worker to be running for a notification to be displayed, simplifying deployment on supported browsers.
21 | ///
22 | /// - Important: As of 2025-02-28, declarative notifications are experimental and supported only in [Safari 18.4 Beta](https://developer.apple.com/documentation/safari-release-notes/safari-18_4-release-notes).
23 | ///
24 | /// - Note: Support for Declarative Push Notifications is currently experimental in [WebKit and Safari betas](https://developer.apple.com/documentation/safari-release-notes/safari-18_4-release-notes) only, but falls back gracefully to a service worker implementation if unsupported. It is therefore required that you still deploy and register a service worker for push notifications to be successfully delivered to most subscribers.
25 | public struct Notification: Sendable {
26 | /// The kind of notification to deliver.
27 | ///
28 | /// Defaults to ``PushMessage/NotificationKind/declarative``.
29 | ///
30 | /// - Note: This property is encoded as `web_push` in JSON.
31 | ///
32 | /// - Important: As of 2025-02-28, declarative notifications are experimental and supported only in [Safari 18.4 Beta](https://developer.apple.com/documentation/safari-release-notes/safari-18_4-release-notes).
33 | ///
34 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
35 | public var kind: NotificationKind
36 |
37 |
38 | /// The destination URL that should be opened when the user interacts with the notification.
39 | ///
40 | /// - Note: This property is encoded as `navigate` in JSON.
41 | ///
42 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
43 | /// - SeeAlso: [WHATWG Notifications API — PR #213 — §2. Notifications](https://whatpr.org/notifications/213.html#notification-navigation-url)
44 | /// - SeeAlso: [WHATWG Notifications API — PR #213 — §2.7. Activating a notification](https://whatpr.org/notifications/213.html#activating-a-notification)
45 | public var destination: URL
46 |
47 | /// The notification's title.
48 | ///
49 | /// - SeeAlso: [MDN Notifications — Notification: `title` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/title)
50 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#concept-title)
51 | public var title: String
52 |
53 | /// The notification's body text.
54 | ///
55 | /// Defaults to `nil`.
56 | ///
57 | /// - SeeAlso: [MDN Notifications — Notification: `body` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/body)
58 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#body)
59 | public var body: String?
60 |
61 | /// The image to be displayed in the notification.
62 | ///
63 | /// - SeeAlso: [MDN Notifications — Notification: `image` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/image)
64 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#image-resource)
65 | /// - SeeAlso: [WHATWG Notifications API — §2.5. Resources](https://notifications.spec.whatwg.org/#resources)
66 | public var image: URL?
67 |
68 | /// The actions available on the notification for a user to interact with.
69 | ///
70 | /// Defaults to an empty array, which means the notification will only support its default ``destination``.
71 | ///
72 | /// - Important: Different browser implementations handle provided actions differently — some may limit their number or omit them completely. You are encouraged to provide an interface to handle all these options as a fallback for such scenarios.
73 | ///
74 | /// - SeeAlso: [MDN Notifications — Notification: `actions` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/actions)
75 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#actions)
76 | public var actions: [NotificationAction]
77 |
78 |
79 | /// The date and time that should be attached to a notification.
80 | ///
81 | /// Defaults to the time the notification was sent. However, a time in the past may be used for an event that already happened, or a time in the future may be used for an event that is planned but did not start yet.
82 | ///
83 | /// - Important: Timezone data is not communicated with the standard, and the subscriber's default timezone will always be used instead.
84 | ///
85 | /// - SeeAlso: [MDN Notifications — Notification: `timestamp` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/timestamp)
86 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#timestamp)
87 | public var timestamp: Date?
88 |
89 | /// Optional data to associate with a notification for when a service worker processes it.
90 | ///
91 | /// Associating data with a notification does not guarantee a service worker will be available to process it; the ``isMutable`` preference must still be set to true. If you need to guarantee a message that contains data is processed on the client side by a service worker, you can instead choose to send a non-declarative message, but note that the notification will only be delivered if a service worker is still running on the user's device.
92 | ///
93 | /// - SeeAlso: [MDN Notifications — Notification: `data` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/data)
94 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#data)
95 | public var data: Contents?
96 |
97 |
98 | /// The badge count to display on a PWA's app icon.
99 | ///
100 | /// Defaults to `nil`, indicating no badge should be shown.
101 | ///
102 | /// - Note: This property is encoded as `app_badge` in JSON.
103 | ///
104 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
105 | public var appBadgeCount: Int?
106 |
107 | /// A preference indicating if a notification can be mutated by a service worker running on the subscriber's device.
108 | ///
109 | /// Defaults to `false`. Setting this to `true` requires a service worker be registered to handle capturing the notification in order to mutate it before presentation. Note that the service worker may be skipped if it is not running, and the notification will be presented as is.
110 | ///
111 | /// - Note: This property is encoded as `mutable` in JSON.
112 | ///
113 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
114 | public var isMutable: Bool
115 |
116 | /// Additional options and configuration for a notification.
117 | ///
118 | /// Defaults to an empty ``PushMessage/NotificationOptions`` configuration of options.
119 | ///
120 | /// - SeeAlso: [MDN Notifications — Notification](https://developer.mozilla.org/en-US/docs/Web/API/Notification)
121 | public var options: NotificationOptions
122 |
123 |
124 | /// Initialize a new Declarative Push Notification.
125 | ///
126 | /// - Important: The entire notification must fit within ``WebPushManager/maximumMessageSize`` once encoded, or sending the notification will fail. Keep this in mind when specifying data to be sent along with the notification.
127 | ///
128 | /// - Parameters:
129 | /// - kind: The kind of notification to send. Defaults to ``PushMessage/NotificationKind/declarative``.
130 | /// - destination: The destination URL that should be opened when the user interacts with the notification.
131 | /// - title: The notification's title text.
132 | /// - body: The notification's body text. Defaults to `nil`.
133 | /// - image: A URL for the image to display in a notification. Defaults to `nil`.
134 | /// - actions: A list of actions to display alongside the notification. Defaults to an empty array.
135 | /// - timestamp: The timestamp to attach to the notification. Defaults to `.now`.
136 | /// - data: Optional data to associate with the notification for when a service worker is used. Defaults to `nil`.
137 | /// - appBadgeCount: The badge numeral to use for a PWA's app icon. Defaults to `nil`.
138 | /// - isMutable: A preference indicating the notification should first be processed by a service worker. Defaults to `false`.
139 | /// - options: Notification options to use for additional configuration. See ``PushMessage/NotificationOptions``.
140 | public init(
141 | kind: NotificationKind = .declarative,
142 | destination: URL,
143 | title: String,
144 | body: String? = nil,
145 | image: URL? = nil,
146 | actions: [NotificationAction] = [],
147 | timestamp: Date? = .now,
148 | data: Contents?,
149 | appBadgeCount: Int? = nil,
150 | isMutable: Bool = false,
151 | options: NotificationOptions = NotificationOptions()
152 | ) {
153 | self.kind = kind
154 | self.destination = destination
155 | self.title = title
156 | self.body = body
157 | self.image = image
158 | self.actions = actions
159 | self.timestamp = timestamp
160 | self.data = data
161 | self.appBadgeCount = appBadgeCount
162 | self.isMutable = isMutable
163 | self.options = options
164 | }
165 | }
166 | }
167 |
168 | extension PushMessage.Notification where Contents == Never {
169 | /// Initialize a new Declarative Push Notification.
170 | ///
171 | /// - Important: The entire notification must fit within ``WebPushManager/maximumMessageSize`` once encoded, or sending the notification will fail. Keep this in mind when specifying data to be sent along with the notification.
172 | ///
173 | /// - Parameters:
174 | /// - kind: The kind of notification to send. Defaults to ``PushMessage/NotificationKind/declarative``.
175 | /// - destination: The destination URL that should be opened when the user interacts with the notification.
176 | /// - title: The notification's title text.
177 | /// - body: The notification's body text. Defaults to `nil`.
178 | /// - image: A URL for the image to display in a notification. Defaults to `nil`.
179 | /// - actions: A list of actions to display alongside the notification. Defaults to an empty array.
180 | /// - timestamp: The timestamp to attach to the notification. Defaults to `.now`.
181 | /// - data: Optional data to associate with the notification for when a service worker is used. Defaults to `nil`.
182 | /// - appBadge: The badge numeral to use for a PWA's app icon. Defaults to `nil`.
183 | /// - isMutable: A preference indicating the notification should first be processed by a service worker. Defaults to `false`.
184 | /// - options: Notification options to use for additional configuration. See ``PushMessage/NotificationOptions``.
185 | public init(
186 | kind: PushMessage.NotificationKind = .declarative,
187 | destination: URL,
188 | title: String,
189 | body: String? = nil,
190 | image: URL? = nil,
191 | actions: [PushMessage.NotificationAction] = [],
192 | timestamp: Date? = .now,
193 | appBadgeCount: Int? = nil,
194 | isMutable: Bool = false,
195 | options: PushMessage.NotificationOptions = PushMessage.NotificationOptions()
196 | ) where Contents == Never {
197 | self.kind = kind
198 | self.destination = destination
199 | self.title = title
200 | self.body = body
201 | self.image = image
202 | self.actions = actions
203 | self.timestamp = timestamp
204 | self.data = nil
205 | self.appBadgeCount = appBadgeCount
206 | self.isMutable = isMutable
207 | self.options = options
208 | }
209 | }
210 |
211 | extension PushMessage {
212 | /// A declarative push notification with no data associated with it.
213 | ///
214 | /// This should only be used when decoding a notification you know has no custom ``PushMessage/Notification/data`` associated with it, though decoding will fail if it does.
215 | public typealias SimpleNotification = Notification
216 | }
217 |
218 | extension PushMessage.Notification: Encodable {
219 | /// The keys used when encoding a top-level ``PushMessage/Notification``.
220 | ///
221 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
222 | public enum MessageCodingKeys: String, CodingKey {
223 | case webPushIdentifier = "web_push"
224 | case notification
225 | case appBadgeCount = "app_badge"
226 | case isMutable = "mutable"
227 | }
228 |
229 | /// The keys used when encoding a ``PushMessage/Notification`` as a ``PushMessage/Notification/MessageCodingKeys/notification``.
230 | ///
231 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
232 | public enum NotificationCodingKeys: String, CodingKey {
233 | case title
234 | case direction = "dir"
235 | case language = "lang"
236 | case body
237 | case destination = "navigate"
238 | case tag
239 | case image
240 | case icon
241 | case badgeIcon = "badge"
242 | case vibrate
243 | case timestamp
244 | case shouldRenotify = "renotify"
245 | case isSilent = "silent"
246 | case requiresInteraction = "require_interaction"
247 | case data
248 | case actions
249 | }
250 |
251 | public func encode(to encoder: any Encoder) throws {
252 | var messageContainer = encoder.container(keyedBy: MessageCodingKeys.self)
253 |
254 | switch kind {
255 | case .declarative:
256 | try messageContainer.encode(PushMessage.declarativePushMessageIdentifier, forKey: .webPushIdentifier)
257 | case .legacy: break
258 | }
259 |
260 | var notificationContainer = messageContainer.nestedContainer(keyedBy: NotificationCodingKeys.self, forKey: .notification)
261 | try notificationContainer.encode(title, forKey: .title)
262 | if options.direction != .auto { try notificationContainer.encode(options.direction, forKey: .direction) }
263 | try notificationContainer.encodeIfPresent(options.language, forKey: .language)
264 | try notificationContainer.encodeIfPresent(body, forKey: .body)
265 | try notificationContainer.encode(destination, forKey: .destination)
266 | try notificationContainer.encodeIfPresent(options.tag, forKey: .tag)
267 | try notificationContainer.encodeIfPresent(image, forKey: .image)
268 | try notificationContainer.encodeIfPresent(options.icon, forKey: .icon)
269 | try notificationContainer.encodeIfPresent(options.badgeIcon, forKey: .badgeIcon)
270 | if !options.vibrate.isEmpty { try notificationContainer.encode(options.vibrate, forKey: .vibrate) }
271 | try notificationContainer.encodeIfPresent(timestamp.map { Int($0.timeIntervalSince1970*1000) }, forKey: .timestamp)
272 | if options.shouldRenotify { try notificationContainer.encode(true, forKey: .shouldRenotify) }
273 | if options.isSilent { try notificationContainer.encode(true, forKey: .isSilent) }
274 | if options.requiresInteraction { try notificationContainer.encode(true, forKey: .requiresInteraction) }
275 | try notificationContainer.encodeIfPresent(data, forKey: .data)
276 | if !actions.isEmpty { try notificationContainer.encode(actions, forKey: .actions) }
277 |
278 | try messageContainer.encodeIfPresent(appBadgeCount, forKey: .appBadgeCount)
279 | if isMutable { try messageContainer.encode(isMutable, forKey: .isMutable) }
280 | }
281 |
282 | /// Check to see if a notification is potentially too large to be sent to a push service.
283 | ///
284 | /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. For these reasons, unless you are sending the same message to multiple subscribers, it's often faster to just try sending the message rather than checking before sending.
285 | ///
286 | /// - Throws: ``MessageTooLargeError`` if the message is too large. Throws another error if encoding fails.
287 | @inlinable
288 | public func checkMessageSize() throws {
289 | guard try WebPushManager.messageEncoder.encode(self).count <= WebPushManager.maximumMessageSize
290 | else { throw MessageTooLargeError() }
291 | }
292 | }
293 |
294 | extension PushMessage.Notification: Decodable where Contents: Decodable {
295 | public init(from decoder: any Decoder) throws {
296 | let messageContainer = try decoder.container(keyedBy: MessageCodingKeys.self)
297 |
298 | self.kind = if let webPushIdentifier = try messageContainer.decodeIfPresent(Int.self, forKey: .webPushIdentifier),
299 | webPushIdentifier == PushMessage.declarativePushMessageIdentifier
300 | {
301 | .declarative
302 | } else {
303 | .legacy
304 | }
305 |
306 | let notificationContainer = try messageContainer.nestedContainer(keyedBy: NotificationCodingKeys.self, forKey: .notification)
307 | self.title = try notificationContainer.decode(String.self, forKey: .title)
308 | self.body = try notificationContainer.decodeIfPresent(String.self, forKey: .body)
309 | self.destination = try notificationContainer.decode(URL.self, forKey: .destination)
310 | self.image = try notificationContainer.decodeIfPresent(URL.self, forKey: .image)
311 | self.timestamp = try notificationContainer.decodeIfPresent(Double.self, forKey: .timestamp).map { Date(timeIntervalSince1970: $0/1000) }
312 | self.data = try notificationContainer.decodeIfPresent(Contents.self, forKey: .data)
313 | self.actions = try notificationContainer.decodeIfPresent([PushMessage.NotificationAction].self, forKey: .actions) ?? []
314 | self.options = PushMessage.NotificationOptions(
315 | direction: try notificationContainer.decodeIfPresent(PushMessage.NotificationOptions.Direction.self, forKey: .direction) ?? .auto,
316 | language: try notificationContainer.decodeIfPresent(String.self, forKey: .language),
317 | tag: try notificationContainer.decodeIfPresent(String.self, forKey: .tag),
318 | icon: try notificationContainer.decodeIfPresent(URL.self, forKey: .icon),
319 | badgeIcon: try notificationContainer.decodeIfPresent(URL.self, forKey: .badgeIcon),
320 | vibrate: try notificationContainer.decodeIfPresent([Int].self, forKey: .vibrate) ?? [],
321 | shouldRenotify: try notificationContainer.decodeIfPresent(Bool.self, forKey: .shouldRenotify) ?? false,
322 | isSilent: try notificationContainer.decodeIfPresent(Bool.self, forKey: .isSilent) ?? false,
323 | requiresInteraction: try notificationContainer.decodeIfPresent(Bool.self, forKey: .requiresInteraction) ?? false
324 | )
325 |
326 | self.appBadgeCount = try messageContainer.decodeIfPresent(Int.self, forKey: .appBadgeCount)
327 | self.isMutable = try messageContainer.decodeIfPresent(Bool.self, forKey: .isMutable) ?? false
328 | }
329 | }
330 |
331 | extension PushMessage.Notification: Equatable where Contents: Equatable {}
332 | extension PushMessage.Notification: Hashable where Contents: Hashable {}
333 |
334 | // MARK: - NotificationKind
335 |
336 | extension PushMessage {
337 | /// The type of notification to encode.
338 | public enum NotificationKind: Hashable, Sendable {
339 | /// A declarative notification that a browser can display independently without a service worker.
340 | ///
341 | /// This sets ``PushMessage/Notification/MessageCodingKeys/webPushIdentifier`` key (`web_push`) to ``PushMessage/declarativePushMessageIdentifier`` (`8030`).
342 | ///
343 | /// - Important: As of 2025-02-28, declarative notifications are experimental and supported only in [Safari 18.4 Beta](https://developer.apple.com/documentation/safari-release-notes/safari-18_4-release-notes).
344 | case declarative
345 |
346 | /// A legacy push message that a service worker must transform before displaying manually.
347 | ///
348 | /// This omits the ``PushMessage/Notification/MessageCodingKeys/webPushIdentifier`` key (`web_push`).
349 | case legacy
350 | }
351 | }
352 |
353 | // MARK: - NotificationOptions
354 |
355 | extension PushMessage {
356 | /// Additional options and configuration to use when presenting a notification.
357 | ///
358 | /// - SeeAlso: [MDN Notifications — Notification](https://developer.mozilla.org/en-US/docs/Web/API/Notification)
359 | public struct NotificationOptions: Hashable, Sendable {
360 | /// The language direction for the notification's title, body, action titles, and order of actions.
361 | ///
362 | /// Defaults to ``Direction-swift.enum/auto``.
363 | ///
364 | /// - Note: This property is encoded as `dir` in JSON.
365 | ///
366 | /// - SeeAlso: [MDN Notifications — Notification: `dir` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir)
367 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#concept-direction)
368 | /// - SeeAlso: [WHATWG Notifications API — §2.3. Direction](https://notifications.spec.whatwg.org/#direction)
369 | public var direction: Direction
370 |
371 | /// The notification's language.
372 | ///
373 | /// - Note: This property is encoded as `lang` in JSON.
374 | ///
375 | /// - SeeAlso: [MDN Notifications — Notification: `lang` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/lang)
376 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#concept-language)
377 | /// - SeeAlso: [WHATWG Notifications API — §2.4. Language](https://notifications.spec.whatwg.org/#language)
378 | public var language: String?
379 |
380 |
381 | /// A tag to use to de-duplicate or replace notifications before they are presented to the user.
382 | ///
383 | /// Defaults to `nil`, indicating all notifications should be presented in isolation from one another.
384 | ///
385 | /// - Note: This is similar to providing a ``Topic`` when submitting the message, however the tag is used _after_ the message is delivered to the browser, while the topic is used before the browser connects to the push service to retrieve notifications for a subscriber.
386 | ///
387 | /// - SeeAlso: [MDN Notifications — Notification: `tag` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/tag)
388 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#tag)
389 | /// - SeeAlso: [WHATWG Notifications API — §3.5.3. Using the tag member for multiple instances](https://notifications.spec.whatwg.org/#using-the-tag-member-for-a-single-instance)
390 | public var tag: String?
391 |
392 |
393 | /// The icon to be displayed alongside the notification.
394 | ///
395 | /// If unspecified, the site's icon will be used instead.
396 | ///
397 | /// - SeeAlso: [MDN Notifications — Notification: `icon` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/icon)
398 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#icon-resource)
399 | /// - SeeAlso: [WHATWG Notifications API — §2.5. Resources](https://notifications.spec.whatwg.org/#resources)
400 | public var icon: URL?
401 |
402 | /// The badge icon image to represent the notification when there is not enough space to display the notification itself such as for example, the Android Notification Bar.
403 | ///
404 | /// Defaults to `nil`, indicating the site's icon should be used.
405 | ///
406 | /// - Note: This property is encoded as `badge` in JSON.
407 | ///
408 | /// - SeeAlso: [MDN Notifications — Notification: `badge` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/badge)
409 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#badge-resource)
410 | /// - SeeAlso: [WHATWG Notifications API — §2.5. Resources](https://notifications.spec.whatwg.org/#resources)
411 | public var badgeIcon: URL?
412 |
413 |
414 | /// The vibration pattern to use when alerting the user.
415 | ///
416 | /// Defaults to an empty array, indicating the notification should follow subscriber preferences.
417 | ///
418 | /// The sequence of numbers represents the amount of time in milliseconds to alternatively vibrate and pause. For instance, `[200]` will vibrate for 0.2s, while `[200, 100, 200]` will vibrate for 0.2s, pause for 0.1s, and vibrate again for 0.2s.
419 | ///
420 | /// - SeeAlso: [MDN Notifications — Notification: `vibrate` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate)
421 | /// - SeeAlso: [MDN Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API)
422 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#vibration-pattern)
423 | /// - SeeAlso: [WHATWG Notifications API — §2.9. Alerting the end user](https://notifications.spec.whatwg.org/#alerting-the-user)
424 | public var vibrate: [Int]
425 |
426 |
427 | /// A preference indicating if the user should be alerted again after the initial notification was presented when another notification with the same ``tag`` is sent.
428 | ///
429 | /// Defaults to `false`, indicating the second notification should be ignored.
430 | ///
431 | /// - Note: This property is encoded as `renotify` in JSON.
432 | ///
433 | /// - SeeAlso: [MDN Notifications — Notification: `renotify` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/renotify)
434 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#renotify-preference-flag)
435 | /// - SeeAlso: [WHATWG Notifications API — §2.6. Showing a notification](https://notifications.spec.whatwg.org/#showing-a-notification)
436 | public var shouldRenotify: Bool
437 |
438 | /// A preference indicating if the notification should be presented without sounds or vibrations.
439 | ///
440 | /// Defaults to `false`, indicating the notification should follow subscriber preferences.
441 | ///
442 | /// - Note: This property is encoded as `silent` in JSON.
443 | ///
444 | /// - SeeAlso: [MDN Notifications — Notification: `silent` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent)
445 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#silent-preference-flag)
446 | public var isSilent: Bool
447 |
448 | /// For devices with sufficiently large screens (ie. a laptop or desktop), a preference indicating if the notification should stay on screen until the user interacts with it rather than dismiss automatically.
449 | ///
450 | /// Defaults to `false`, indicating the notification should follow subscriber preferences.
451 | ///
452 | /// - Note: This property is encoded as `requires_interaction` in JSON.
453 | ///
454 | /// - SeeAlso: [MDN Notifications — Notification: `requireInteraction` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/requireInteraction)
455 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#require-interaction-preference-flag)
456 | public var requiresInteraction: Bool
457 |
458 |
459 | /// Initialize notification options.
460 | /// - Parameters:
461 | /// - direction: The language direction for the notification. Defaults to ``Direction-swift.enum/auto``.
462 | /// - language: The language tag for the notification. Defaults to `nil`.
463 | /// - tag: The tag to deduplicate or replace presentation of the notification. Defaults to `nil`.
464 | /// - icon: A URL for the icon the notification should use. Defaults to `nil`.
465 | /// - badgeIcon: A URL for the badge icon the notification should use. Defaults to `nil`.
466 | /// - vibrate: A vibration pattern the notification should use. Defaults to `nil`.
467 | /// - shouldRenotify: A preference indicating if the notification with the same tag should be re-presented. Defaults to `false`.
468 | /// - isSilent: A preference indicating if the notification should be presented without sound or vibrations. Defaults to `false`.
469 | /// - requiresInteraction: A preference indicating if the notification stays on screen until the user interacts with it. Defaults to `false`.
470 | public init(
471 | direction: Direction = .auto,
472 | language: String? = nil,
473 | tag: String? = nil,
474 | icon: URL? = nil,
475 | badgeIcon: URL? = nil,
476 | vibrate: [Int] = [],
477 | shouldRenotify: Bool = false,
478 | isSilent: Bool = false,
479 | requiresInteraction: Bool = false
480 | ) {
481 | self.direction = direction
482 | self.language = language
483 | self.tag = tag
484 | self.icon = icon
485 | self.badgeIcon = badgeIcon
486 | self.vibrate = vibrate
487 | self.shouldRenotify = shouldRenotify
488 | self.isSilent = isSilent
489 | self.requiresInteraction = requiresInteraction
490 | }
491 | }
492 | }
493 |
494 | // MARK: - NotificationOptions.Direction
495 |
496 | extension PushMessage.NotificationOptions {
497 | /// The language direction for the notification's title, body, action titles, and order of actions.
498 | ///
499 | /// - SeeAlso: [MDN Notifications — Notification: `dir` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir)
500 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#concept-direction)
501 | /// - SeeAlso: [WHATWG Notifications API — §2.3. Direction](https://notifications.spec.whatwg.org/#direction)
502 | public enum Direction: String, Hashable, Codable, Sendable {
503 | /// Use the browser's language defaults.
504 | ///
505 | /// - SeeAlso: [MDN Notifications — Notification: `dir` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir#auto)
506 | case auto
507 |
508 | /// The notification should be presented left-to-right.
509 | ///
510 | /// - SeeAlso: [MDN Notifications — Notification: `dir` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir#ltr)
511 | case leftToRight = "ltr"
512 |
513 | /// The notification should be presented right-to-left.
514 | ///
515 | /// - SeeAlso: [MDN Notifications — Notification: `dir` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir#rtl)
516 | case rightToLeft = "rtf"
517 | }
518 | }
519 |
520 | // MARK: - NotificationAction
521 |
522 | extension PushMessage {
523 | /// An associated action for a notification when it is displayed to the user.
524 | ///
525 | /// - Note: Not all browsers support displaying actions.
526 | ///
527 | /// - SeeAlso: [MDN Notifications — Notification: `actions` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/actions)
528 | /// - SeeAlso: [WHATWG Notifications API — §3. API — `NotificationAction`](https://notifications.spec.whatwg.org/#dictdef-notificationaction)
529 | public struct NotificationAction: Hashable, Codable, Sendable, Identifiable {
530 | /// The action's identifier.
531 | ///
532 | /// This can be used when handling an action from a service worker directly.
533 | ///
534 | /// - Note: This property is encoded as `action` in JSON.
535 | ///
536 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#action-name)
537 | /// - SeeAlso: [MDN Notifications — Notification: `actions` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/actions#action)
538 | /// - SeeAlso: [WHATWG Notifications API — §3.5.2. Using actions from a service worker](https://notifications.spec.whatwg.org/#using-actions)
539 | public var id: String
540 |
541 | /// The action button's label.
542 | ///
543 | /// - Note: This property is encoded as `title` in JSON.
544 | ///
545 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#action-title)
546 | /// - SeeAlso: [MDN Notifications — Notification: `actions` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/actions#title)
547 | public var label: String
548 |
549 | /// The destination that will be opened when the action's button is pressed.
550 | ///
551 | /// - Note: This property is encoded as `navigation` in JSON.
552 | ///
553 | /// - SeeAlso: [WHATWG Notifications API — PR #213 — §2. Notifications](https://whatpr.org/notifications/213.html#notification-action-navigation-url)
554 | /// - SeeAlso: [WHATWG Notifications API — PR #213 — §2.7. Activating a notification](https://whatpr.org/notifications/213.html#activating-a-notification)
555 | public var destination: URL
556 |
557 | /// The URL of an icon to display with the action.
558 | ///
559 | /// - SeeAlso: [WHATWG Notifications API — §2. Notifications](https://notifications.spec.whatwg.org/#action-icon)
560 | /// - SeeAlso: [MDN Notifications — Notification: `actions` property](https://developer.mozilla.org/en-US/docs/Web/API/Notification/actions#icon)
561 | public var icon: URL?
562 |
563 | /// The keys used when encoding ``PushMessage/NotificationAction``.
564 | public enum CodingKeys: String, CodingKey {
565 | case id = "action"
566 | case label = "title"
567 | case destination = "navigate"
568 | case icon
569 | }
570 | }
571 | }
572 |
573 | // MARK: - Constants
574 |
575 | extension PushMessage {
576 | /// An integer that must be `8030`. Used to disambiguate a declarative push message from other JSON documents.
577 | ///
578 | /// - SeeAlso: [Push API Editor's Draft — §3.3.1. Members](https://raw.githubusercontent.com/w3c/push-api/refs/heads/declarative-push/index.html#members)
579 | public static let declarativePushMessageIdentifier = 8030
580 | }
581 |
582 | // MARK: - Additional Conformances
583 |
584 | #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS)
585 | extension Never: @retroactive Codable {
586 | /// A default implementation for Never for macOS 13, since official support was added in macOS 14.
587 | ///
588 | /// - SeeAlso: https://github.com/swiftlang/swift/blob/af3e7e765549c0397288e60983c96d81639287ed/stdlib/public/core/Policy.swift#L81-L86
589 | public func encode(to encoder: any Encoder) throws {}
590 |
591 | /// A default implementation for Never for macOS 13, since official support was added in macOS 14.
592 | ///
593 | /// - SeeAlso: https://github.com/swiftlang/swift/blob/af3e7e765549c0397288e60983c96d81639287ed/stdlib/public/core/Policy.swift#L88-L98
594 | public init(from decoder: any Decoder) throws {
595 | let context = DecodingError.Context(
596 | codingPath: decoder.codingPath,
597 | debugDescription: "Unable to decode an instance of Never."
598 | )
599 | throw DecodingError.typeMismatch(Never.self, context)
600 | }
601 | }
602 | #endif
603 |
--------------------------------------------------------------------------------
/Sources/WebPush/Push Message/PushMessage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PushMessage.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2025-02-26.
6 | // Copyright © 2024-25 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | /// Common Push Message types.
10 | ///
11 | /// Currently, only ``PushMessage/Notification`` is defined.
12 | public enum PushMessage: Sendable {}
13 |
--------------------------------------------------------------------------------
/Sources/WebPush/Subscriber.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Subscriber.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-10.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | @preconcurrency import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 |
16 | /// Represents a subscriber registration from the browser.
17 | ///
18 | /// Prefer to use ``Subscriber`` directly when possible.
19 | ///
20 | /// - SeeAlso: [Push API Working Draft §8. `PushSubscription` interface](https://www.w3.org/TR/push-api/#pushsubscription-interface). Note that the VAPID Key ID must be manually added to the structure supplied by the spec.
21 | public protocol SubscriberProtocol: Sendable {
22 | /// The endpoint representing the subscriber on their push registration service of choice.
23 | var endpoint: URL { get }
24 |
25 | /// The key material supplied by the user agent.
26 | var userAgentKeyMaterial: UserAgentKeyMaterial { get }
27 |
28 | /// The preferred VAPID Key ID to use, if available.
29 | ///
30 | /// If unknown, use the key set to ``VAPID/Configuration/primaryKey``, but be aware that this may be different from the key originally used at time of subscription, and if it is, push messages will be rejected.
31 | ///
32 | /// - Important: It is highly recommended to store the VAPID Key ID used at time of registration with the subscriber, and always supply the key itself to the manager. If you are phasing out the key and don't want new subscribers registered against it, store the key in ``VAPID/Configuration/deprecatedKeys``, otherwise store it in ``VAPID/Configuration/keys``.
33 | var vapidKeyID: VAPID.Key.ID { get }
34 | }
35 |
36 | /// The set of cryptographic secrets shared by the browser (is. user agent) along with a subscription.
37 | ///
38 | /// - SeeAlso: [RFC 8291 — Message Encryption for Web Push §2.1. Key and Secret Distribution](https://datatracker.ietf.org/doc/html/rfc8291#section-2.1)
39 | public struct UserAgentKeyMaterial: Sendable {
40 | /// The underlying type of an authentication secret.
41 | public typealias Salt = Data
42 |
43 | /// The public key a shared secret can be derived from for message encryption.
44 | ///
45 | /// - SeeAlso: [Push API Working Draft §8.1. `PushEncryptionKeyName` enumeration — `p256dh`](https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh)
46 | public var publicKey: P256.KeyAgreement.PublicKey
47 |
48 | /// The authentication secret to validate our ability to send a subscriber push messages.
49 | ///
50 | /// - SeeAlso: [Push API Working Draft §8.1. `PushEncryptionKeyName` enumeration — `auth`](https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-auth)
51 | public var authenticationSecret: Salt
52 |
53 | /// Initialize key material with a public key and authentication secret from a user agent.
54 | ///
55 | /// - Parameters:
56 | /// - publicKey: The public key a shared secret can be derived from for message encryption.
57 | /// - authenticationSecret: The authentication secret to validate our ability to send a subscriber push messages.
58 | public init(
59 | publicKey: P256.KeyAgreement.PublicKey,
60 | authenticationSecret: Salt
61 | ) {
62 | self.publicKey = publicKey
63 | self.authenticationSecret = authenticationSecret
64 | }
65 |
66 | /// Initialize key material with a public key and authentication secret from a user agent.
67 | ///
68 | /// - Parameters:
69 | /// - publicKey: The public key a shared secret can be derived from for message encryption.
70 | /// - authenticationSecret: The authentication secret to validate our ability to send a subscriber push messages.
71 | public init(
72 | publicKey: String,
73 | authenticationSecret: String
74 | ) throws(UserAgentKeyMaterialError) {
75 | guard let publicKeyData = Data(base64URLEncoded: publicKey)
76 | else { throw .invalidPublicKey(underlyingError: Base64URLDecodingError()) }
77 | do {
78 | self.publicKey = try P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
79 | } catch { throw .invalidPublicKey(underlyingError: error) }
80 |
81 | guard let authenticationSecretData = Data(base64URLEncoded: authenticationSecret)
82 | else { throw .invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()) }
83 |
84 | self.authenticationSecret = authenticationSecretData
85 | }
86 | }
87 |
88 | extension UserAgentKeyMaterial: Hashable {
89 | public static func == (lhs: UserAgentKeyMaterial, rhs: UserAgentKeyMaterial) -> Bool {
90 | lhs.publicKey.x963Representation == rhs.publicKey.x963Representation
91 | && lhs.authenticationSecret == rhs.authenticationSecret
92 | }
93 |
94 | public func hash(into hasher: inout Hasher) {
95 | hasher.combine(publicKey.x963Representation)
96 | hasher.combine(authenticationSecret)
97 | }
98 | }
99 |
100 | extension UserAgentKeyMaterial: Codable {
101 | /// The encoded representation of a subscriber's key material.
102 | ///
103 | /// - SeeAlso: [Push API Working Draft §8.1. `PushEncryptionKeyName` enumeration](https://www.w3.org/TR/push-api/#pushencryptionkeyname-enumeration)
104 | public enum CodingKeys: String, CodingKey {
105 | case publicKey = "p256dh"
106 | case authenticationSecret = "auth"
107 | }
108 |
109 | public init(from decoder: Decoder) throws {
110 | let container = try decoder.container(keyedBy: CodingKeys.self)
111 |
112 | let publicKeyString = try container.decode(String.self, forKey: .publicKey)
113 | let authenticationSecretString = try container.decode(String.self, forKey: .authenticationSecret)
114 | try self.init(publicKey: publicKeyString, authenticationSecret: authenticationSecretString)
115 | }
116 |
117 | public func encode(to encoder: any Encoder) throws {
118 | var container = encoder.container(keyedBy: CodingKeys.self)
119 | try container.encode(publicKey.x963Representation.base64URLEncodedString(), forKey: .publicKey)
120 | try container.encode(authenticationSecret.base64URLEncodedString(), forKey: .authenticationSecret)
121 | }
122 | }
123 |
124 | /// A default subscriber implementation that can be used to decode subscriptions encoded by client-side JavaScript directly.
125 | ///
126 | /// Note that this object requires the VAPID key (`applicationServerKey` in JavaScript) that was supplied during registration, which is not provided by default by [`PushSubscription.toJSON()`](https://www.w3.org/TR/push-api/#dom-pushsubscription-tojson):
127 | /// ```js
128 | /// const subscriptionStatusResponse = await fetch(`/registerSubscription`, {
129 | /// method: "POST",
130 | /// body: {
131 | /// ...subscription.toJSON(),
132 | /// applicationServerKey: subscription.options.applicationServerKey,
133 | /// }
134 | /// });
135 | /// ```
136 | ///
137 | /// If you cannot provide this for whatever reason, opt to decode the object using your own type, and conform to ``SubscriberProtocol`` instead.
138 | public struct Subscriber: SubscriberProtocol, Codable, Hashable, Sendable {
139 | /// The encoded representation of a subscriber.
140 | ///
141 | /// - Note: The VAPID Key ID must be manually added to the structure supplied by the spec.
142 | /// - SeeAlso: [Push API Working Draft §8. `PushSubscription` interface](https://www.w3.org/TR/push-api/#pushsubscription-interface).
143 | public enum CodingKeys: String, CodingKey {
144 | case endpoint = "endpoint"
145 | case userAgentKeyMaterial = "keys"
146 | case vapidKeyID = "applicationServerKey"
147 | }
148 |
149 | /// The push endpoint associated with the push subscription.
150 | ///
151 | /// - SeeAlso: [Push API Working Draft §8. `PushSubscription` interface — `endpoint`](https://www.w3.org/TR/push-api/#dfn-getting-the-endpoint-attribute)
152 | public var endpoint: URL
153 |
154 | /// The key material provided by the user agent to encrupt push data with.
155 | ///
156 | /// - SeeAlso: [Push API Working Draft §8. `PushSubscription` interface — `getKey`](https://www.w3.org/TR/push-api/#dom-pushsubscription-getkey)
157 | public var userAgentKeyMaterial: UserAgentKeyMaterial
158 |
159 | /// The VAPID Key ID used to register the subscription, that identifies the application server with the push service.
160 | ///
161 | /// - SeeAlso: [Push API Working Draft §8. `PushSubscription` interface — `options`](https://www.w3.org/TR/push-api/#dom-pushsubscription-options)
162 | public var vapidKeyID: VAPID.Key.ID
163 |
164 | /// Initialize a new subscriber manually.
165 | ///
166 | /// Prefer decoding a subscription directly with the results of the subscription directly:
167 | /// ```js
168 | /// const subscriptionStatusResponse = await fetch(`/registerSubscription`, {
169 | /// method: "POST",
170 | /// body: {
171 | /// ...subscription.toJSON(),
172 | /// applicationServerKey: subscription.options.applicationServerKey,
173 | /// }
174 | /// });
175 | /// ```
176 | public init(
177 | endpoint: URL,
178 | userAgentKeyMaterial: UserAgentKeyMaterial,
179 | vapidKeyID: VAPID.Key.ID
180 | ) {
181 | self.endpoint = endpoint
182 | self.userAgentKeyMaterial = userAgentKeyMaterial
183 | self.vapidKeyID = vapidKeyID
184 | }
185 |
186 | /// Cast an object that conforms to ``SubscriberProtocol`` to a ``Subscriber``.
187 | public init(_ subscriber: some SubscriberProtocol) {
188 | self.init(
189 | endpoint: subscriber.endpoint,
190 | userAgentKeyMaterial: subscriber.userAgentKeyMaterial,
191 | vapidKeyID: subscriber.vapidKeyID
192 | )
193 | }
194 | }
195 |
196 | extension Subscriber: Identifiable {
197 | /// A safe identifier to use for the subscriber without exposing key material.
198 | public var id: String { endpoint.absoluteString }
199 | }
200 |
--------------------------------------------------------------------------------
/Sources/WebPush/Topic.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Topic.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-24.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | @preconcurrency import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 |
16 | /// Topics are used to de-duplicate and overwrite messages on push services before they are delivered to a subscriber.
17 | ///
18 | /// The topic is never delivered to your service worker, though is seen in plain text by the Push Service, so this type encodes it first to prevent leaking any information about the messages you are sending or your subscribers.
19 | ///
20 | /// - Important: Since topics are sent in the clear to push services, they must be securely hashed. You must use a stable random value for this, such as the subscriber's ``UserAgentKeyMaterial/authenticationSecret``. This is fine for most applications, though you may wish to use a different key if your application requires it.
21 | ///
22 | /// - SeeAlso: [RFC 8030 — Generic Event Delivery Using HTTP §5.4. Replacing Push Messages](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4)
23 | public struct Topic: Hashable, Sendable, CustomStringConvertible {
24 | /// The topic value to use.
25 | public let topic: String
26 |
27 | /// Create a new topic from encodable data and a salt.
28 | ///
29 | /// - Important: Since topics are sent in the clear to push services, they must be securely hashed. You must use a stable random value for this, such as the subscriber's ``UserAgentKeyMaterial/authenticationSecret``. This is fine for most applications, though you may wish to use a different key if your application requires it.
30 | ///
31 | /// - Parameters:
32 | /// - encodableTopic: The encodable data that represents a stable topic. This can be a string, identifier, or any other token that can be encoded.
33 | /// - salt: The salt that should be used when encoding the topic.
34 | public init(
35 | encodableTopic: some Encodable,
36 | salt: some DataProtocol
37 | ) throws {
38 | /// First, turn the topic into a byte stream.
39 | let encoder = JSONEncoder()
40 | encoder.outputFormatting = [.sortedKeys]
41 | let encodedTopic = try encoder.encode(encodableTopic)
42 |
43 | /// Next, hash the topic using the provided salt, some info, and cut to length at 24 bytes.
44 | let hashedTopic = HKDF.deriveKey(
45 | inputKeyMaterial: SymmetricKey(data: encodedTopic),
46 | salt: salt,
47 | info: "WebPush Topic".utf8Bytes,
48 | outputByteCount: 24
49 | )
50 |
51 | /// Transform these 24 bytes into 32 Base64 URL-safe characters.
52 | self.topic = hashedTopic.base64URLEncodedString()
53 | }
54 |
55 | /// Create a new random topic.
56 | ///
57 | /// Create a topic with a random identifier to save it in your own data stores, and re-use it as needed.
58 | public init() {
59 | /// Generate a 24-byte topic.
60 | var topicBytes: [UInt8] = Array(repeating: 0, count: 24)
61 | for index in topicBytes.indices { topicBytes[index] = .random(in: .min ... .max) }
62 | self.topic = topicBytes.base64URLEncodedString()
63 | }
64 |
65 | /// Initialize a topic with an unchecked string.
66 | ///
67 | /// Prefer to use ``init(encodableTopic:salt:)`` when possible.
68 | ///
69 | /// - Warning: This may be rejected by a Push Service if it is not 32 Base64 URL-safe characters, and will not be encrypted. Expect to handle a ``PushServiceError`` with a ``PushServiceError/response`` status code of `400 Bad Request` when it does.
70 | public init(unsafeTopic: String) {
71 | topic = unsafeTopic
72 | }
73 |
74 | public var description: String {
75 | topic
76 | }
77 | }
78 |
79 | extension Topic: Codable {
80 | public init(from decoder: any Decoder) throws {
81 | let container = try decoder.singleValueContainer()
82 | topic = try container.decode(String.self)
83 | }
84 |
85 | public func encode(to encoder: any Encoder) throws {
86 | var container = encoder.singleValueContainer()
87 | try container.encode(topic)
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Sources/WebPush/VAPID/VAPID.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDKey.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-03.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | /// The fully qualified name for VAPID.
16 | public typealias VoluntaryApplicationServerIdentification = VAPID
17 |
18 | /// A set of types for Voluntary Application Server Identification, also known as VAPID.
19 | ///
20 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)
21 | public enum VAPID: Sendable {}
22 |
--------------------------------------------------------------------------------
/Sources/WebPush/VAPID/VAPIDConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDConfiguration.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-04.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 |
15 | extension VoluntaryApplicationServerIdentification {
16 | /// A configuration object specifying the contact information along with the keys that your application server identifies itself with.
17 | ///
18 | /// The ``primaryKey``, when priovided, will always be used for new subscriptions when ``WebPushManager/nextVAPIDKeyID`` is called. If omitted, one of the keys in ``keys`` will be randomely chosen instead.
19 | ///
20 | /// ``deprecatedKeys`` that you must stull support for older subscribers, but don't wish to use when registering new subscribers, may also be specified.
21 | ///
22 | /// To reduce implementation complexity, it is recommended to only use a single ``primaryKey``, though this key should be stored with subscribers as ``Subscriber`` encourages you to do so that you can deprecate it in the future should it ever leak.
23 | ///
24 | /// ## Codable
25 | ///
26 | /// VAPID configurations should ideally be generated a single time and shared across all instances of your application server, across runs. To facilitate this, you can encode and decode a configuration to load it at runtime rather than instanciate a new one every time:
27 | /// ```swift
28 | /// // TODO: Load this data from .env or from file system
29 | /// let configurationData = Data(#" {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000} "#.utf8)
30 | /// let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData)
31 | /// ```
32 | ///
33 | /// - SeeAlso: [Generating Keys](https://github.com/mochidev/swift-webpush?tab=readme-ov-file#generating-keys): Keys can also be generated by our `vapid-key-generator` helper tool.
34 | public struct Configuration: Hashable, Sendable {
35 | /// The VAPID key that identifies the push service to subscribers.
36 | ///
37 | /// If not provided, a key from ``keys`` will be used instead.
38 | /// - SeeAlso: ``VAPID/Configuration``
39 | public private(set) var primaryKey: Key?
40 |
41 | /// The set of valid keys to choose from when identifying the applications erver to new registrations.
42 | public private(set) var keys: Set
43 |
44 | /// The set of deprecated keys to continue to support when signing push messages, but shouldn't be used for new registrations.
45 | ///
46 | /// This set can be interogated via ``WebPushManager/`` to determine if a subscriber should be re-registered against a new key or not:
47 | /// ```swift
48 | /// webPushManager.keyStatus(for: subscriber.vapidKeyID) == .deprecated
49 | /// ```
50 | public private(set) var deprecatedKeys: Set?
51 |
52 | /// The contact information an administrator of a push service may use to contact you in the case of an issue.
53 | public var contactInformation: ContactInformation
54 |
55 | /// The number of seconds before a cached authentication header signed by this configuration fully expires.
56 | ///
57 | /// This value must be 24 hours or less, and it conservatively set to 22 hours by default to account for clock drift between your applications erver and push services.
58 | public var expirationDuration: Duration
59 |
60 | /// The number of seconds before a cached authentication header signed by this configuration is renewed.
61 | ///
62 | /// This valus must be less than ``expirationDuration``, and is set to 20 hours by default as an adequate compromise between re-usability and key over-use.
63 | public var validityDuration: Duration
64 |
65 | /// Initialize a configuration with a single primary key.
66 | /// - Parameters:
67 | /// - key: The primary key to use when introducing your application server during registration.
68 | /// - deprecatedKeys: Suppoted, but deprecated, keys to use during push delivery if a subscriber requires them.
69 | /// - contactInformation: The contact information an administrator of a push service may use to contact you in the case of an issue.
70 | /// - expirationDuration: The number of seconds before a cached authentication header signed by this configuration fully expires.
71 | /// - validityDuration: The number of seconds before a cached authentication header signed by this configuration is renewed.
72 | public init(
73 | key: Key,
74 | deprecatedKeys: Set? = nil,
75 | contactInformation: ContactInformation,
76 | expirationDuration: Duration = .hours(22),
77 | validityDuration: Duration = .hours(20)
78 | ) {
79 | self.primaryKey = key
80 | self.keys = [key]
81 | var deprecatedKeys = deprecatedKeys ?? []
82 | deprecatedKeys.remove(key)
83 | self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
84 | self.contactInformation = contactInformation
85 | self.expirationDuration = expirationDuration
86 | self.validityDuration = validityDuration
87 | }
88 |
89 | /// Initialize a configuration with a multiple VAPID keys.
90 | ///
91 | /// Use this initializer _only_ if you wish to implement more complicated key rotation if you believe keys may be leaked at a higher rate than usual. In all other cases, it is highly recommended to use ``init(key:deprecatedKeys:contactInformation:expirationDuration:validityDuration:)`` instead to supply a singly primary key and keep it secure.
92 | /// - Parameters:
93 | /// - primaryKey: The optional primary key to use when introducing your application server during registration.
94 | /// - keys: The set of valid keys to choose from when identifying the applications erver to new registrations.
95 | /// - deprecatedKeys: Suppoted, but deprecated, keys to use during push delivery if a subscriber requires them.
96 | /// - contactInformation: The contact information an administrator of a push service may use to contact you in the case of an issue.
97 | /// - expirationDuration: The number of seconds before a cached authentication header signed by this configuration fully expires.
98 | /// - validityDuration: The number of seconds before a cached authentication header signed by this configuration is renewed.
99 | public init(
100 | primaryKey: Key?,
101 | keys: Set,
102 | deprecatedKeys: Set? = nil,
103 | contactInformation: ContactInformation,
104 | expirationDuration: Duration = .hours(22),
105 | validityDuration: Duration = .hours(20)
106 | ) throws(ConfigurationError) {
107 | self.primaryKey = primaryKey
108 | var keys = keys
109 | if let primaryKey {
110 | keys.insert(primaryKey)
111 | }
112 | guard !keys.isEmpty
113 | else { throw .keysNotProvided }
114 |
115 | self.keys = keys
116 | var deprecatedKeys = deprecatedKeys ?? []
117 | deprecatedKeys.subtract(keys)
118 | self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
119 | self.contactInformation = contactInformation
120 | self.expirationDuration = expirationDuration
121 | self.validityDuration = validityDuration
122 | }
123 |
124 | /// Update the keys that this configuration represents.
125 | ///
126 | /// At least one non-deprecated key must be specified, whether it is a primary key or specified in the list of keys, or this method will throw.
127 | /// - Parameters:
128 | /// - primaryKey: The primary key to use when registering a new subscriber.
129 | /// - keys: A list of valid, non deprecated keys to cycle through if a primary key is not specified.
130 | /// - deprecatedKeys: A list of deprecated keys to use for signing if a subscriber requires it, but won't be used for new registrations.
131 | public mutating func updateKeys(
132 | primaryKey: Key?,
133 | keys: Set,
134 | deprecatedKeys: Set? = nil
135 | ) throws(ConfigurationError) {
136 | self.primaryKey = primaryKey
137 | var keys = keys
138 | if let primaryKey {
139 | keys.insert(primaryKey)
140 | }
141 | guard !keys.isEmpty
142 | else { throw .keysNotProvided }
143 |
144 | self.keys = keys
145 | var deprecatedKeys = deprecatedKeys ?? []
146 | deprecatedKeys.subtract(keys)
147 | self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
148 | }
149 |
150 | /// Internal method to set invalid state for validation that other components are resiliant to these configurations.
151 | mutating func unsafeUpdateKeys(
152 | primaryKey: Key? = nil,
153 | keys: Set,
154 | deprecatedKeys: Set? = nil
155 | ) {
156 | self.primaryKey = primaryKey
157 | self.keys = keys
158 | self.deprecatedKeys = deprecatedKeys
159 | }
160 | }
161 | }
162 |
163 | extension VAPID.Configuration: Codable {
164 | /// The coding keys used to encode a VAPID configuration.
165 | public enum CodingKeys: CodingKey {
166 | case primaryKey
167 | case keys
168 | case deprecatedKeys
169 | case contactInformation
170 | case expirationDuration
171 | case validityDuration
172 | }
173 |
174 | public init(from decoder: any Decoder) throws {
175 | let container = try decoder.container(keyedBy: CodingKeys.self)
176 |
177 | let primaryKey = try container.decodeIfPresent(VAPID.Key.self, forKey: CodingKeys.primaryKey)
178 | let keys = try container.decodeIfPresent(Set.self, forKey: CodingKeys.keys) ?? []
179 | let deprecatedKeys = try container.decodeIfPresent(Set.self, forKey: CodingKeys.deprecatedKeys)
180 | let contactInformation = try container.decode(ContactInformation.self, forKey: CodingKeys.contactInformation)
181 | let expirationDuration = try container.decode(Duration.self, forKey: CodingKeys.expirationDuration)
182 | let validityDuration = try container.decode(Duration.self, forKey: CodingKeys.validityDuration)
183 |
184 | try self.init(
185 | primaryKey: primaryKey,
186 | keys: keys,
187 | deprecatedKeys: deprecatedKeys,
188 | contactInformation: contactInformation,
189 | expirationDuration: expirationDuration,
190 | validityDuration: validityDuration
191 | )
192 | }
193 |
194 | public func encode(to encoder: any Encoder) throws {
195 | var container = encoder.container(keyedBy: CodingKeys.self)
196 |
197 | /// Remove the primary key from the list so it's not listed twice
198 | var keys: Set? = self.keys
199 | if let primaryKey {
200 | keys?.remove(primaryKey)
201 | }
202 | if keys?.isEmpty == true {
203 | keys = nil
204 | }
205 |
206 | try container.encodeIfPresent(primaryKey, forKey: .primaryKey)
207 | try container.encodeIfPresent(keys, forKey: .keys)
208 | try container.encodeIfPresent(deprecatedKeys, forKey: .deprecatedKeys)
209 | try container.encode(contactInformation, forKey: .contactInformation)
210 | try container.encode(expirationDuration, forKey: .expirationDuration)
211 | try container.encode(validityDuration, forKey: .validityDuration)
212 | }
213 | }
214 |
215 | extension VAPID.Configuration {
216 | /// The contact information for the push service.
217 | ///
218 | /// This allows administrators of push services to contact you should an issue arise with your application server.
219 | ///
220 | /// - Note: Although the specification notes that this field is optional, some push services may refuse connection from serers without contact information.
221 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2.1. Application Server Contact Information](https://datatracker.ietf.org/doc/html/rfc8292#section-2.1)
222 | public enum ContactInformation: Hashable, Codable, Sendable {
223 | /// A URL-based contact method, such as a support page on your website.
224 | case url(URL)
225 | /// An email-based contact method.
226 | case email(String)
227 |
228 | /// The string that representa the contact information as a fully-qualified URL.
229 | var urlString: String {
230 | switch self {
231 | case .url(let url): url.absoluteURL.absoluteString
232 | case .email(let email): "mailto:\(email)"
233 | }
234 | }
235 |
236 | public init(from decoder: any Decoder) throws {
237 | let container = try decoder.singleValueContainer()
238 | let url = try container.decode(URL.self)
239 |
240 | switch url.scheme?.lowercased() {
241 | case "mailto":
242 | let email = String(url.absoluteString.dropFirst("mailto:".count))
243 | if !email.isEmpty {
244 | self = .email(email)
245 | } else {
246 | throw DecodingError.typeMismatch(URL.self, .init(codingPath: decoder.codingPath, debugDescription: "Found a mailto URL with no email."))
247 | }
248 | case "http", "https":
249 | self = .url(url)
250 | default:
251 | throw DecodingError.typeMismatch(URL.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected a mailto or http(s) URL, but found neither."))
252 | }
253 | }
254 |
255 | public func encode(to encoder: any Encoder) throws {
256 | var container = encoder.singleValueContainer()
257 | try container.encode(urlString)
258 | }
259 | }
260 |
261 | /// The satus of a key as it relates to a configuration.
262 | ///
263 | /// - SeeAlso: ``WebPushManager/keyStatus(for:)``
264 | public enum KeyStatus: Sendable, Hashable {
265 | /// The key is valid and should continue to be used.
266 | case valid
267 |
268 | /// The key had been deprecated.
269 | ///
270 | /// The user should be encouraged to re-register using a new key.
271 | case deprecated
272 |
273 | /// The key is unknown to the configuration.
274 | ///
275 | /// The configuration should be investigated as all keys should be accounted for.
276 | case unknown
277 | }
278 |
279 | /// A duration in seconds used to express when VAPID tokens will expire.
280 | public struct Duration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable {
281 | /// The number of seconds represented by this duration.
282 | public let seconds: Int
283 |
284 | /// Initialize a duration with a number of seconds.
285 | @inlinable
286 | public init(seconds: Int) {
287 | self.seconds = seconds
288 | }
289 |
290 | @inlinable
291 | public static func < (lhs: Self, rhs: Self) -> Bool {
292 | lhs.seconds < rhs.seconds
293 | }
294 |
295 | public init(from decoder: Decoder) throws {
296 | let container = try decoder.singleValueContainer()
297 | self.seconds = try container.decode(Int.self)
298 | }
299 |
300 | public func encode(to encoder: any Encoder) throws {
301 | var container = encoder.singleValueContainer()
302 | try container.encode(self.seconds)
303 | }
304 |
305 | @inlinable
306 | public init(integerLiteral value: Int) {
307 | self.seconds = value
308 | }
309 |
310 | @inlinable
311 | public static func - (lhs: Self, rhs: Self) -> Self {
312 | Self(seconds: lhs.seconds - rhs.seconds)
313 | }
314 |
315 | @inlinable
316 | public static func + (lhs: Self, rhs: Self) -> Self {
317 | Self(seconds: lhs.seconds + rhs.seconds)
318 | }
319 |
320 | /// Make a duration with a number of seconds.
321 | @inlinable
322 | public static func seconds(_ seconds: Int) -> Self {
323 | Self(seconds: seconds)
324 | }
325 |
326 | /// Make a duration with a number of minutes.
327 | @inlinable
328 | public static func minutes(_ minutes: Int) -> Self {
329 | .seconds(minutes*60)
330 | }
331 |
332 | /// Make a duration with a number of hours.
333 | @inlinable
334 | public static func hours(_ hours: Int) -> Self {
335 | .minutes(hours*60)
336 | }
337 |
338 | /// Make a duration with a number of days.
339 | @inlinable
340 | public static func days(_ days: Int) -> Self {
341 | .hours(days*24)
342 | }
343 | }
344 | }
345 |
346 | extension Date {
347 | /// Helper to add a duration to a date.
348 | func adding(_ duration: VAPID.Configuration.Duration) -> Self {
349 | addingTimeInterval(TimeInterval(duration.seconds))
350 | }
351 | }
352 |
--------------------------------------------------------------------------------
/Sources/WebPush/VAPID/VAPIDKey.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDKey.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-04.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | @preconcurrency import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 |
16 | extension VoluntaryApplicationServerIdentification {
17 | /// Represents the application server's identification key that is used to confirm to a push service that the server connecting to it is the same one that was subscribed to.
18 | ///
19 | /// When sharing with the browser, ``VAPID/Key/ID`` can be used.
20 | public struct Key: Sendable {
21 | private var privateKey: P256.Signing.PrivateKey
22 |
23 | /// Create a brand new VAPID signing key.
24 | ///
25 | /// - Note: You must persist this key somehow if you are creating it yourself.
26 | public init() {
27 | privateKey = P256.Signing.PrivateKey(compactRepresentable: false)
28 | }
29 |
30 | /// Initialize a key from a P256 SIgning Private Key.
31 | ///
32 | /// - Warning: Do not re-use this key for any other purpose other than VAPID authorization!
33 | public init(privateKey: P256.Signing.PrivateKey) {
34 | self.privateKey = privateKey
35 | }
36 |
37 | /// Decode a key directly from a Base 64 (URL) encoded string, or throw an error if decoding failed.
38 | public init(base64URLEncoded: String) throws {
39 | guard let data = Data(base64URLEncoded: base64URLEncoded)
40 | else { throw Base64URLDecodingError() }
41 | privateKey = try P256.Signing.PrivateKey(rawRepresentation: data)
42 | }
43 | }
44 | }
45 |
46 | extension VAPID.Key: Hashable {
47 | public static func == (lhs: Self, rhs: Self) -> Bool {
48 | lhs.privateKey.rawRepresentation == rhs.privateKey.rawRepresentation
49 | }
50 |
51 | public func hash(into hasher: inout Hasher) {
52 | hasher.combine(privateKey.rawRepresentation)
53 | }
54 | }
55 |
56 | extension VAPID.Key: Codable {
57 | public init(from decoder: any Decoder) throws {
58 | let container = try decoder.singleValueContainer()
59 | privateKey = try P256.Signing.PrivateKey(rawRepresentation: container.decode(Data.self))
60 | }
61 |
62 | public func encode(to encoder: any Encoder) throws {
63 | var container = encoder.singleValueContainer()
64 | try container.encode(privateKey.rawRepresentation)
65 | }
66 | }
67 |
68 | extension VAPID.Key: Identifiable {
69 | /// The identifier for a private ``VAPID/Key``'s public key.
70 | ///
71 | /// This value can be shared as is with a subscription registration as the `applicationServerKey` key in JavaScript.
72 | ///
73 | /// - SeeAlso: [Push API Working Draft §7.2. `PushSubscriptionOptions` Interface](https://www.w3.org/TR/push-api/#pushsubscriptionoptions-interface)
74 | public struct ID: Hashable, Comparable, Codable, Sendable, CustomStringConvertible {
75 | /// The raw string that represents the ID.
76 | private var rawValue: String
77 |
78 | /// Initialize an ID with a raw string.
79 | init(_ rawValue: String) {
80 | self.rawValue = rawValue
81 | }
82 |
83 | public static func < (lhs: Self, rhs: Self) -> Bool {
84 | lhs.rawValue < rhs.rawValue
85 | }
86 |
87 | public init(from decoder: any Decoder) throws {
88 | let container = try decoder.singleValueContainer()
89 | self.rawValue = try container.decode(String.self).transformToBase64URLEncoding()
90 | }
91 |
92 | public func encode(to encoder: any Encoder) throws {
93 | var container = encoder.singleValueContainer()
94 | try container.encode(self.rawValue)
95 | }
96 |
97 | public var description: String {
98 | self.rawValue
99 | }
100 | }
101 |
102 | /// The public key component in a format suitable for user agents to consume.
103 | ///
104 | /// - SeeAlso: [Push API Working Draft §7.2. `PushSubscriptionOptions` Interface](https://www.w3.org/TR/push-api/#dom-pushsubscriptionoptions-applicationserverkey)
105 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §3.2. Public Key Parameter ("k")](https://datatracker.ietf.org/doc/html/rfc8292#section-3.2)
106 | public var id: ID {
107 | ID(privateKey.publicKey.x963Representation.base64URLEncodedString())
108 | }
109 | }
110 |
111 | extension VAPID.Key: VAPIDKeyProtocol {
112 | func signature(for message: some DataProtocol) throws -> P256.Signing.ECDSASignature {
113 | try privateKey.signature(for: SHA256.hash(data: message))
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/Sources/WebPush/VAPID/VAPIDToken.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDToken.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-07.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | @preconcurrency import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 |
16 | extension VAPID {
17 | /// An internal representation the token and authorization headers used self-identification.
18 | ///
19 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
20 | /// - SeeAlso: [RFC 7515 — JSON Web Signature (JWS)](https://datatracker.ietf.org/doc/html/rfc7515)
21 | ///- SeeAlso: [RFC 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
22 | struct Token: Hashable, Codable, Sendable {
23 | /// The coding keys used to encode the token.
24 | enum CodingKeys: String, CodingKey {
25 | case audience = "aud"
26 | case subject = "sub"
27 | case expiration = "exp"
28 | }
29 |
30 | /// The audience claim, which encodes the origin of the ``Subscriber/endpoint``
31 | ///
32 | /// - SeeAlso: ``/Foundation/URL/origin``
33 | /// - SeeAlso: [RFC 7519 — JSON Web Token (JWT) §4.1.3. "aud" (Audience) Claim](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3)
34 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
35 | var audience: String
36 |
37 | /// The subject claim, which encodes contact information for the application server.
38 | ///
39 | /// - SeeAlso: [RFC 7519 — JSON Web Token (JWT) §4.1.2. "sub" (Subject) Claim](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2)
40 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2.1. Application Server Contact Information](https://datatracker.ietf.org/doc/html/rfc8292#section-2.1)
41 | var subject: Configuration.ContactInformation
42 |
43 | /// The expiry claim, which encodes the number of seconds after 1970/01/01 when the token expires.
44 | ///
45 | /// - SeeAlso: [RFC 7519 — JSON Web Token (JWT) §4.1.4. "exp" (Expiration Time) Claim](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4)
46 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
47 | var expiration: Int
48 |
49 | /// The standard header including the type and algorithm.
50 | ///
51 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
52 | static let jwtHeader = Array(#"{"typ":"JWT","alg":"ES256"}"#.utf8).base64URLEncodedString()
53 |
54 | /// Initialize a token with the specified claims.
55 | init(
56 | origin: String,
57 | contactInformation: Configuration.ContactInformation,
58 | expiration: Date
59 | ) {
60 | self.audience = origin
61 | self.subject = contactInformation
62 | self.expiration = Int(expiration.timeIntervalSince1970)
63 | }
64 |
65 | /// Initialize a token with the specified claims.
66 | init(
67 | origin: String,
68 | contactInformation: Configuration.ContactInformation,
69 | expiresIn: Configuration.Duration
70 | ) {
71 | audience = origin
72 | subject = contactInformation
73 | expiration = Int(Date.now.timeIntervalSince1970) + expiresIn.seconds
74 | }
75 |
76 | /// Initialize a token from a VAPID `Authorization` header's values.
77 | init?(token: String, key: String) {
78 | let components = token.split(separator: ".")
79 |
80 | guard
81 | components.count == 3,
82 | components[0] == Self.jwtHeader,
83 | let bodyBytes = Data(base64URLEncoded: components[1]),
84 | let signatureBytes = Data(base64URLEncoded: components[2]),
85 | let publicKeyBytes = Data(base64URLEncoded: key)
86 | else { return nil }
87 |
88 | let message = Data("\(components[0]).\(components[1])".utf8)
89 | let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyBytes)
90 | let isValid = try? publicKey?.isValidSignature(.init(rawRepresentation: signatureBytes), for: SHA256.hash(data: message))
91 |
92 | guard
93 | isValid == true,
94 | let token = try? JSONDecoder().decode(Self.self, from: bodyBytes)
95 | else { return nil }
96 |
97 | self = token
98 | }
99 |
100 | /// - SeeAlso: [RFC 7515 — JSON Web Signature (JWS) §3. JSON Web Signature (JWS) Overview](https://datatracker.ietf.org/doc/html/rfc7515#section-3)
101 | func generateJWT(signedBy signingKey: some VAPIDKeyProtocol) throws -> String {
102 | let header = Self.jwtHeader
103 |
104 | let encoder = JSONEncoder()
105 | encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
106 | let body = try encoder.encode(self).base64URLEncodedString()
107 |
108 | var message = "\(header).\(body)"
109 | let signature = try message.withUTF8 { try signingKey.signature(for: $0) }.base64URLEncodedString()
110 | return "\(message).\(signature)"
111 | }
112 |
113 | /// Generate an `Authorization` header.
114 | ///
115 | /// - SeeAlso: [RFC 8292 — Voluntary Application Server Identification (VAPID) for Web Push §3. VAPID Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc8292#section-3)
116 | func generateAuthorization(signedBy signingKey: some VAPIDKeyProtocol) throws -> String {
117 | let token = try generateJWT(signedBy: signingKey)
118 | let key = signingKey.id
119 |
120 | return "vapid t=\(token), k=\(key)"
121 | }
122 | }
123 | }
124 |
125 | protocol VAPIDKeyProtocol: Identifiable, Sendable {
126 | /// The signature type used by this key.
127 | associatedtype Signature: ContiguousBytes
128 |
129 | /// Returns a JWS signature for the message.
130 | /// - SeeAlso: [RFC 7515 — JSON Web Signature (JWS)](https://datatracker.ietf.org/doc/html/rfc7515)
131 | func signature(for message: some DataProtocol) throws -> Signature
132 | }
133 |
--------------------------------------------------------------------------------
/Sources/WebPushTesting/Subscriber+Testing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Subscriber+Testing.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-20.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | @preconcurrency import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import WebPush
16 |
17 | extension Subscriber {
18 | /// A mocked subscriber to send messages to.
19 | public static let mockedSubscriber = Subscriber(
20 | endpoint: URL(string: "https://example.com/subscriber")!,
21 | userAgentKeyMaterial: .mockedKeyMaterial,
22 | vapidKeyID: .mockedKeyID1
23 | )
24 |
25 | /// Make a mocked subscriber with a unique private key and salt.
26 | static func makeMockedSubscriber(endpoint: URL = URL(string: "https://example.com/subscriber")!) -> (subscriber: Subscriber, privateKey: P256.KeyAgreement.PrivateKey) {
27 | let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
28 | var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
29 | for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }
30 |
31 | let subscriber = Subscriber(
32 | endpoint: endpoint,
33 | userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
34 | vapidKeyID: .mockedKeyID1
35 | )
36 |
37 | return (subscriber, subscriberPrivateKey)
38 | }
39 | }
40 |
41 | extension SubscriberProtocol where Self == Subscriber {
42 | /// A mocked subscriber to send messages to.
43 | public static func mockedSubscriber() -> Subscriber {
44 | .mockedSubscriber
45 | }
46 | }
47 |
48 | extension UserAgentKeyMaterial {
49 | /// The private key component of ``mockedKeyMaterial``.
50 | public static let mockedKeyMaterialPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: "BS2nTTf5wAdVvi5Om3AjSmlsCpz91XgK+uCLaIJ0T/M=")!)
51 |
52 | /// A mocked user-agent-key material to attach to a subscriber.
53 | public static let mockedKeyMaterial = try! UserAgentKeyMaterial(
54 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
55 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDConfiguration+Testing.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-12.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | @preconcurrency import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import WebPush
16 |
17 | extension VAPID.Configuration {
18 | /// A mocked configuration useful when testing with the library, since the mocked manager doesn't make use of it anyways.
19 | public static let mockedConfiguration = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@example.com"))
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/WebPushTesting/VAPIDKey+Testing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDKey+Testing.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-18.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import WebPush
10 |
11 | extension VAPID.Key {
12 | /// A mocked key guaranteed to not conflict with ``mockedKey2``, ``mockedKey3``, and ``mockedKey4``.
13 | public static let mockedKey1 = try! VAPID.Key(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=")
14 | /// A mocked key guaranteed to not conflict with ``mockedKey1``, ``mockedKey3``, and ``mockedKey4``.
15 | public static let mockedKey2 = try! VAPID.Key(base64URLEncoded: "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk=")
16 | /// A mocked key guaranteed to not conflict with ``mockedKey1``, ``mockedKey2``, and ``mockedKey4``.
17 | public static let mockedKey3 = try! VAPID.Key(base64URLEncoded: "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE=")
18 | /// A mocked key guaranteed to not conflict with ``mockedKey1``, ``mockedKey2``, and ``mockedKey3``.
19 | public static let mockedKey4 = try! VAPID.Key(base64URLEncoded: "BGEhWik09/s/JNkl0OAcTIdRTb7AoLRZQQG4C96Ohlc=")
20 | }
21 |
22 | extension VAPID.Key.ID {
23 | /// A mocked key ID that matches ``/VAPID/Key/mockedKey1``.
24 | public static let mockedKeyID1 = VAPID.Key.mockedKey1.id
25 | /// A mocked key ID that matches ``/VAPID/Key/mockedKey2``.
26 | public static let mockedKeyID2 = VAPID.Key.mockedKey2.id
27 | /// A mocked key ID that matches ``/VAPID/Key/mockedKey3``.
28 | public static let mockedKeyID3 = VAPID.Key.mockedKey3.id
29 | /// A mocked key ID that matches ``/VAPID/Key/mockedKey4``.
30 | public static let mockedKeyID4 = VAPID.Key.mockedKey4.id
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/WebPushTesting/WebPushManager+Testing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebPushManager+Testing.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-12.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Logging
10 | import WebPush
11 | import Synchronization
12 |
13 | extension WebPushManager {
14 | /// A push message in its original form, either ``/Foundation/Data``, ``/Swift/String``, or ``/Foundation/Encodable``.
15 | /// - Warning: Never switch on the message type, as values may be added to it over time.
16 | public typealias Message = _Message
17 |
18 | public typealias MessageHandler = @Sendable (
19 | _ message: Message,
20 | _ subscriber: Subscriber,
21 | _ topic: Topic?,
22 | _ expiration: Expiration,
23 | _ urgency: Urgency
24 | ) async throws -> Void
25 |
26 | /// Create a mocked web push manager.
27 | ///
28 | /// The mocked manager will forward all messages as is to its message handler so that you may either verify that a push was sent, or inspect the contents of the message that was sent.
29 | ///
30 | /// - SeeAlso: ``makeMockedManager(vapidConfiguration:backgroundActivityLogger:messageHandlers:_:)``
31 | ///
32 | /// - Parameters:
33 | /// - vapidConfiguration: A VAPID configuration, though the mocked manager doesn't make use of it.
34 | /// - logger: An optional logger.
35 | /// - messageHandler: A handler to receive messages or throw errors.
36 | /// - Returns: A new manager suitable for mocking.
37 | public static func makeMockedManager(
38 | vapidConfiguration: VAPID.Configuration = .mockedConfiguration,
39 | // TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
40 | backgroundActivityLogger: Logger? = .defaultWebPushPrintLogger,
41 | messageHandler: @escaping MessageHandler = { _, _, _, _, _ in }
42 | ) -> WebPushManager {
43 | let backgroundActivityLogger = backgroundActivityLogger ?? .defaultWebPushNoOpLogger
44 |
45 | return WebPushManager(
46 | vapidConfiguration: vapidConfiguration,
47 | backgroundActivityLogger: backgroundActivityLogger,
48 | executor: .handler(messageHandler)
49 | )
50 | }
51 |
52 | /// Create a mocked web push manager.
53 | ///
54 | /// The mocked manager will forward all messages as is to its message handlers so that you may either verify that a push was sent, or inspect the contents of the message that was sent. Assign multiple handlers here to have each message that comes in rotate through the handlers, looping when they are exausted.
55 | ///
56 | /// - Parameters:
57 | /// - vapidConfiguration: A VAPID configuration, though the mocked manager doesn't make use of it.
58 | /// - logger: An optional logger.
59 | /// - messageHandlers: A list of handlers to receive messages or throw errors.
60 | /// - Returns: A new manager suitable for mocking.
61 | @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
62 | @_disfavoredOverload
63 | public static func makeMockedManager(
64 | vapidConfiguration: VAPID.Configuration = .mockedConfiguration,
65 | // TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
66 | backgroundActivityLogger: Logger? = .defaultWebPushPrintLogger,
67 | messageHandlers: @escaping MessageHandler,
68 | _ otherHandlers: MessageHandler...
69 | ) -> WebPushManager {
70 | let backgroundActivityLogger = backgroundActivityLogger ?? .defaultWebPushNoOpLogger
71 | let index = Mutex(0)
72 | let allHandlers = [messageHandlers] + otherHandlers
73 |
74 | return WebPushManager(
75 | vapidConfiguration: vapidConfiguration,
76 | backgroundActivityLogger: backgroundActivityLogger,
77 | executor: .handler({ message, subscriber, topic, expiration, urgency in
78 | let currentIndex = index.withLock { index in
79 | let current = index
80 | index = (index + 1) % allHandlers.count
81 | return current
82 | }
83 | return try await allHandlers[currentIndex](message, subscriber, topic, expiration, urgency)
84 | })
85 | )
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/Base64URLCodingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Base64URLCodingTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-06.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Testing
15 | @testable import WebPush
16 |
17 | @Suite("Base 64 URL Coding")
18 | struct Base64URLCoding {
19 | @Test func base64URLDecoding() async throws {
20 | let string = ">>> Hello, swift-webpush world??? 🎉"
21 | let base64Encoded = "Pj4+IEhlbGxvLCBzd2lmdC13ZWJwdXNoIHdvcmxkPz8/IPCfjok="
22 | let base64URLEncoded = "Pj4-IEhlbGxvLCBzd2lmdC13ZWJwdXNoIHdvcmxkPz8_IPCfjok"
23 | #expect(String(decoding: Data(base64URLEncoded: base64Encoded)!, as: UTF8.self) == string)
24 | #expect(String(decoding: Data(base64URLEncoded: base64URLEncoded)!, as: UTF8.self) == string)
25 | #expect(String(decoding: [UInt8](base64URLEncoded: base64Encoded)!, as: UTF8.self) == string)
26 | #expect(String(decoding: [UInt8](base64URLEncoded: base64URLEncoded)!, as: UTF8.self) == string)
27 | }
28 |
29 | @Test func invalidBase64URLDecoding() async throws {
30 | #expect(Data(base64URLEncoded: " ") == nil)
31 | }
32 |
33 | @Test func base64URLEncoding() async throws {
34 | let string = ">>> Hello, swift-webpush world??? 🎉"
35 | let base64URLEncoded = "Pj4-IEhlbGxvLCBzd2lmdC13ZWJwdXNoIHdvcmxkPz8_IPCfjok"
36 | #expect([UInt8](string.utf8).base64URLEncodedString() == base64URLEncoded)
37 | #expect(Data(string.utf8).base64URLEncodedString() == base64URLEncoded)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/BytesTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BytesTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-22.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Testing
15 | @testable import WebPush
16 |
17 | @Suite struct BytesTests {
18 | @Test func stringBytes() {
19 | #expect("hello".utf8Bytes == [0x68, 0x65, 0x6c, 0x6c, 0x6f])
20 | #expect("hello"[...].utf8Bytes == [0x68, 0x65, 0x6c, 0x6c, 0x6f])
21 | }
22 |
23 | @Test func integerBytes() {
24 | #expect(UInt8(0b11110000).bigEndianBytes == [0b11110000])
25 | #expect(UInt16(0b1111000010100101).bigEndianBytes == [0b11110000, 0b10100101])
26 | #expect(UInt32(0b11110000101001010000111101011010).bigEndianBytes == [0b11110000, 0b10100101, 0b000001111, 0b01011010])
27 | #expect(UInt64(0b1111000010100101000011110101101011001100100011110011001101110000).bigEndianBytes == [0b11110000, 0b10100101, 0b000001111, 0b01011010, 0b11001100, 0b10001111, 0b00110011, 0b01110000])
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/ErrorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ErrorTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-21.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import AsyncHTTPClient
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import Testing
16 | @testable import WebPush
17 |
18 | @Suite struct ErrorTests {
19 | @Test func badSubscriberError() {
20 | #expect(BadSubscriberError() == BadSubscriberError())
21 | #expect("\(BadSubscriberError().localizedDescription)" == "The subscription is no longer valid.")
22 | }
23 |
24 | @Test func base64URLDecodingError() {
25 | #expect(Base64URLDecodingError() == Base64URLDecodingError())
26 | #expect("\(Base64URLDecodingError().localizedDescription)" == "The Base64 data could not be decoded.")
27 | }
28 |
29 | @Test func pushServiceError() {
30 | let response = HTTPClientResponse(status: .notFound)
31 | #expect(PushServiceError(response: response) == PushServiceError(response: response))
32 | #expect(PushServiceError(response: response).hashValue == PushServiceError(response: response).hashValue)
33 | #expect(PushServiceError(response: response) != PushServiceError(response: HTTPClientResponse(status: .internalServerError)))
34 | #expect("\(PushServiceError(response: response).localizedDescription)" == "A 404 Not Found Push Service error was encountered: \(response).")
35 | }
36 |
37 | @Test func messageTooLargeError() {
38 | #expect(MessageTooLargeError() == MessageTooLargeError())
39 | #expect("\(MessageTooLargeError().localizedDescription)" == "The message was too large, and could not be delivered to the push service.")
40 | }
41 |
42 | @Test func userAgentKeyMaterialError() {
43 | #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()) == .invalidPublicKey(underlyingError: Base64URLDecodingError()))
44 | #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()).hashValue == UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()).hashValue)
45 | #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()) != .invalidPublicKey(underlyingError: BadSubscriberError()))
46 | #expect(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()) == .invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()))
47 | #expect(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()).hashValue == UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()).hashValue)
48 | #expect(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()) != .invalidAuthenticationSecret(underlyingError: BadSubscriberError()))
49 | #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()) != .invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()))
50 |
51 | #expect("\(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()).localizedDescription)" == "Subscriber Public Key (`p256dh`) was invalid: The Base64 data could not be decoded.")
52 | #expect("\(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()).localizedDescription)" == "Subscriber Authentication Secret (`auth`) was invalid: The Base64 data could not be decoded.")
53 | }
54 |
55 | @Test func vapidConfigurationError() {
56 | #expect(VAPID.ConfigurationError.keysNotProvided == .keysNotProvided)
57 | #expect(VAPID.ConfigurationError.matchingKeyNotFound == .matchingKeyNotFound)
58 | #expect(VAPID.ConfigurationError.keysNotProvided != .matchingKeyNotFound)
59 | #expect("\(VAPID.ConfigurationError.keysNotProvided.localizedDescription)" == "VAPID keys not found during initialization.")
60 | #expect("\(VAPID.ConfigurationError.matchingKeyNotFound.localizedDescription)" == "A VAPID key for the subscriber was not found.")
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/Helpers/MockHTTPClient.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockHTTPClient.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-11.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import AsyncHTTPClient
10 | import Logging
11 | import NIOCore
12 | @testable import WebPush
13 |
14 | actor MockHTTPClient: HTTPClientProtocol {
15 | typealias Handler = (HTTPClientRequest) async throws -> HTTPClientResponse
16 | var handlers: [Handler]
17 | var index = 0
18 |
19 | init(_ requestHandler: Handler...) {
20 | self.handlers = requestHandler
21 | }
22 |
23 | func execute(
24 | _ request: HTTPClientRequest,
25 | deadline: NIODeadline,
26 | logger: Logger?
27 | ) async throws -> HTTPClientResponse {
28 | let currentHandler = handlers[index]
29 | index = (index + 1) % handlers.count
30 | guard deadline >= .now() else { throw HTTPClientError.deadlineExceeded }
31 | return try await currentHandler(request)
32 | }
33 |
34 | nonisolated func syncShutdown() throws {}
35 | }
36 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/Helpers/VAPIDConfiguration+Testing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDConfiguration+Testing.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-06.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import WebPush
15 |
16 | extension VAPID.Configuration {
17 | /// Make a new configuration useful for testing against.
18 | static func makeTesting() -> VAPID.Configuration {
19 | VAPID.Configuration(
20 | key: VAPID.Key(),
21 | contactInformation: .url(URL(string: "https://example.com/contact")!)
22 | )
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/MessageSizeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MessageSizeTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2025-03-02.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Testing
15 | @testable import WebPush
16 | @testable import WebPushTesting
17 |
18 | @Suite("Message Size Tetss")
19 | struct MessageSizeTests {
20 | @Test func dataMessages() async throws {
21 | let webPushManager = WebPushManager.makeMockedManager()
22 | try webPushManager.checkMessageSize(data: Data(repeating: 0, count: 42))
23 | try webPushManager.checkMessageSize(data: Data(repeating: 0, count: 3993))
24 | #expect(throws: MessageTooLargeError()) {
25 | try webPushManager.checkMessageSize(data: Data(repeating: 0, count: 3994))
26 | }
27 |
28 | try WebPushManager._Message.data(Data(repeating: 0, count: 42)).checkMessageSize()
29 | try WebPushManager._Message.data(Data(repeating: 0, count: 3993)).checkMessageSize()
30 | #expect(throws: MessageTooLargeError()) {
31 | try WebPushManager._Message.data(Data(repeating: 0, count: 3994)).checkMessageSize()
32 | }
33 | }
34 |
35 | @Test func stringMessages() async throws {
36 | let webPushManager = WebPushManager.makeMockedManager()
37 | try webPushManager.checkMessageSize(string: String(repeating: "A", count: 42))
38 | try webPushManager.checkMessageSize(string: String(repeating: "A", count: 3993))
39 | #expect(throws: MessageTooLargeError()) {
40 | try webPushManager.checkMessageSize(string: String(repeating: "A", count: 3994))
41 | }
42 |
43 | try WebPushManager._Message.string(String(repeating: "A", count: 42)).checkMessageSize()
44 | try WebPushManager._Message.string(String(repeating: "A", count: 3993)).checkMessageSize()
45 | #expect(throws: MessageTooLargeError()) {
46 | try WebPushManager._Message.string(String(repeating: "A", count: 3994)).checkMessageSize()
47 | }
48 | }
49 |
50 | @Test func jsonMessages() async throws {
51 | let webPushManager = WebPushManager.makeMockedManager()
52 | try webPushManager.checkMessageSize(json: ["key" : String(repeating: "A", count: 42)])
53 | try webPushManager.checkMessageSize(json: ["key" : String(repeating: "A", count: 3983)])
54 | #expect(throws: MessageTooLargeError()) {
55 | try webPushManager.checkMessageSize(json: ["key" : String(repeating: "A", count: 3984)])
56 | }
57 |
58 | try WebPushManager._Message.json(["key" : String(repeating: "A", count: 42)]).checkMessageSize()
59 | try WebPushManager._Message.json(["key" : String(repeating: "A", count: 3983)]).checkMessageSize()
60 | #expect(throws: MessageTooLargeError()) {
61 | try WebPushManager._Message.json(["key" : String(repeating: "A", count: 3984)]).checkMessageSize()
62 | }
63 | }
64 |
65 | @Test func notificationMessages() async throws {
66 | let webPushManager = WebPushManager.makeMockedManager()
67 | try webPushManager.checkMessageSize(notification: PushMessage.Notification(
68 | destination: URL(string: "https://example.com")!,
69 | title: String(repeating: "A", count: 42),
70 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
71 | ))
72 | try webPushManager.checkMessageSize(notification: PushMessage.Notification(
73 | destination: URL(string: "https://example.com")!,
74 | title: String(repeating: "A", count: 3889),
75 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
76 | ))
77 | #expect(throws: MessageTooLargeError()) {
78 | try webPushManager.checkMessageSize(notification: PushMessage.Notification(
79 | destination: URL(string: "https://example.com")!,
80 | title: String(repeating: "A", count: 3890),
81 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
82 | ))
83 | }
84 |
85 | try PushMessage.Notification(
86 | destination: URL(string: "https://example.com")!,
87 | title: String(repeating: "A", count: 42),
88 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
89 | ).checkMessageSize()
90 | try PushMessage.Notification(
91 | destination: URL(string: "https://example.com")!,
92 | title: String(repeating: "A", count: 3889),
93 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
94 | ).checkMessageSize()
95 | #expect(throws: MessageTooLargeError()) {
96 | try PushMessage.Notification(
97 | destination: URL(string: "https://example.com")!,
98 | title: String(repeating: "A", count: 3890),
99 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
100 | ).checkMessageSize()
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/NeverTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NeverTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2025-03-01.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Testing
15 | @testable import WebPush
16 |
17 | @Suite("Never Tests")
18 | struct NeverTests {
19 | @Test func retroactiveCodableWorks() async throws {
20 | #expect(throws: DecodingError.self, performing: {
21 | try JSONDecoder().decode(Never.self, from: Data("null".utf8))
22 | })
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/NotificationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotificationTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2025-03-01.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Testing
15 | @testable import WebPush
16 |
17 | @Suite("Push Message Notification")
18 | struct NotificationTests {
19 | @Test func simpleNotificationEncodesProperly() async throws {
20 | let notification = PushMessage.Notification(
21 | destination: URL(string: "https://jiiiii.moe")!,
22 | title: "New Anime",
23 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
24 | )
25 |
26 | let encoder = JSONEncoder()
27 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
28 |
29 | let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self)
30 | #expect(encodedString == """
31 | {
32 | "notification" : {
33 | "navigate" : "https://jiiiii.moe",
34 | "timestamp" : 1000000000000,
35 | "title" : "New Anime"
36 | },
37 | "web_push" : 8030
38 | }
39 | """)
40 |
41 | let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(encodedString.utf8))
42 | #expect(decodedNotification == notification)
43 | }
44 |
45 | @Test func legacyNotificationEncodesProperly() async throws {
46 | let notification = PushMessage.Notification(
47 | kind: .legacy,
48 | destination: URL(string: "https://jiiiii.moe")!,
49 | title: "New Anime",
50 | timestamp: Date(timeIntervalSince1970: 1_000_000_000)
51 | )
52 |
53 | let encoder = JSONEncoder()
54 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
55 |
56 | let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self)
57 | #expect(encodedString == """
58 | {
59 | "notification" : {
60 | "navigate" : "https://jiiiii.moe",
61 | "timestamp" : 1000000000000,
62 | "title" : "New Anime"
63 | }
64 | }
65 | """)
66 |
67 | let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(encodedString.utf8))
68 | #expect(decodedNotification == notification)
69 | }
70 |
71 | @Test func completeNotificationEncodesProperly() async throws {
72 | let notification = PushMessage.Notification(
73 | kind: .declarative,
74 | destination: URL(string: "https://jiiiii.moe")!,
75 | title: "New Anime",
76 | body: "New anime is available!",
77 | image: URL(string: "https://jiiiii.moe/animeImage")!,
78 | actions: [
79 | PushMessage.NotificationAction(
80 | id: "ok",
81 | label: "OK",
82 | destination: URL(string: "https://jiiiii.moe/ok")!,
83 | icon: URL(string: "https://jiiiii.moe/okIcon")
84 | ),
85 | PushMessage.NotificationAction(
86 | id: "cancel",
87 | label: "Cancel",
88 | destination: URL(string: "https://jiiiii.moe/cancel")!,
89 | icon: URL(string: "https://jiiiii.moe/cancelIcon")
90 | ),
91 | ],
92 | timestamp: Date(timeIntervalSince1970: 1_000_000_000),
93 | appBadgeCount: 0,
94 | isMutable: true,
95 | options: PushMessage.NotificationOptions(
96 | direction: .rightToLeft,
97 | language: "jp",
98 | tag: "new-anime",
99 | icon: URL(string: "https://jiiiii.moe/icon")!,
100 | badgeIcon: URL(string: "https://jiiiii.moe/badgeIcon")!,
101 | vibrate: [200, 100, 200],
102 | shouldRenotify: true,
103 | isSilent: true,
104 | requiresInteraction: true
105 | )
106 | )
107 |
108 | let encoder = JSONEncoder()
109 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
110 |
111 | let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self)
112 | #expect(encodedString == """
113 | {
114 | "app_badge" : 0,
115 | "mutable" : true,
116 | "notification" : {
117 | "actions" : [
118 | {
119 | "action" : "ok",
120 | "icon" : "https://jiiiii.moe/okIcon",
121 | "navigate" : "https://jiiiii.moe/ok",
122 | "title" : "OK"
123 | },
124 | {
125 | "action" : "cancel",
126 | "icon" : "https://jiiiii.moe/cancelIcon",
127 | "navigate" : "https://jiiiii.moe/cancel",
128 | "title" : "Cancel"
129 | }
130 | ],
131 | "badge" : "https://jiiiii.moe/badgeIcon",
132 | "body" : "New anime is available!",
133 | "dir" : "rtf",
134 | "icon" : "https://jiiiii.moe/icon",
135 | "image" : "https://jiiiii.moe/animeImage",
136 | "lang" : "jp",
137 | "navigate" : "https://jiiiii.moe",
138 | "renotify" : true,
139 | "require_interaction" : true,
140 | "silent" : true,
141 | "tag" : "new-anime",
142 | "timestamp" : 1000000000000,
143 | "title" : "New Anime",
144 | "vibrate" : [
145 | 200,
146 | 100,
147 | 200
148 | ]
149 | },
150 | "web_push" : 8030
151 | }
152 | """)
153 |
154 | let decodedNotification = try JSONDecoder().decode(PushMessage.SimpleNotification.self, from: Data(encodedString.utf8))
155 | #expect(decodedNotification == notification)
156 | }
157 |
158 | @Test func customNotificationEncodesProperly() async throws {
159 | let notification = PushMessage.Notification(
160 | destination: URL(string: "https://jiiiii.moe")!,
161 | title: "New Anime",
162 | timestamp: Date(timeIntervalSince1970: 1_000_000_000),
163 | data: ["episodeID": "123"]
164 | )
165 |
166 | let encoder = JSONEncoder()
167 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
168 |
169 | let encodedString = String(decoding: try encoder.encode(notification), as: UTF8.self)
170 | #expect(encodedString == """
171 | {
172 | "notification" : {
173 | "data" : {
174 | "episodeID" : "123"
175 | },
176 | "navigate" : "https://jiiiii.moe",
177 | "timestamp" : 1000000000000,
178 | "title" : "New Anime"
179 | },
180 | "web_push" : 8030
181 | }
182 | """)
183 |
184 | let decodedNotification = try JSONDecoder().decode(type(of: notification), from: Data(encodedString.utf8))
185 | #expect(decodedNotification == notification)
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/SubscriberTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SubscriberTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-21.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import Testing
16 | @testable import WebPush
17 | import WebPushTesting
18 |
19 | @Suite struct SubscriberTests {
20 | @Suite struct Initialization {
21 | @Test func fromKeyMaterial() {
22 | let privateKey = P256.KeyAgreement.PrivateKey()
23 | let subscriber = Subscriber(
24 | endpoint: URL(string: "https://example.com/subscriber")!,
25 | userAgentKeyMaterial: UserAgentKeyMaterial(
26 | publicKey: privateKey.publicKey,
27 | authenticationSecret: Data()
28 | ),
29 | vapidKeyID: .mockedKeyID1
30 | )
31 | #expect(subscriber.endpoint == URL(string: "https://example.com/subscriber")!)
32 | #expect(subscriber.userAgentKeyMaterial == UserAgentKeyMaterial(
33 | publicKey: privateKey.publicKey,
34 | authenticationSecret: Data()
35 | ))
36 | #expect(subscriber.vapidKeyID == .mockedKeyID1)
37 | }
38 |
39 | @Test func fromOtherSubscriber() {
40 | let subscriber = Subscriber(.mockedSubscriber())
41 | #expect(subscriber == .mockedSubscriber)
42 | }
43 |
44 | @Test func identifiable() {
45 | let subscriber = Subscriber.mockedSubscriber
46 | #expect(subscriber.id == "https://example.com/subscriber")
47 | }
48 | }
49 |
50 | @Suite struct UserAgentKeyMaterialTests {
51 | @Suite struct Initialization {
52 | @Test func actualKeys() {
53 | let privateKey = P256.KeyAgreement.PrivateKey()
54 | let keyMaterial = UserAgentKeyMaterial(
55 | publicKey: privateKey.publicKey,
56 | authenticationSecret: Data()
57 | )
58 | #expect(keyMaterial == UserAgentKeyMaterial(
59 | publicKey: privateKey.publicKey,
60 | authenticationSecret: Data()
61 | ))
62 | }
63 |
64 | @Test func strings() throws {
65 | let privateKey = UserAgentKeyMaterial.mockedKeyMaterialPrivateKey
66 | let keyMaterial = try UserAgentKeyMaterial(
67 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
68 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
69 | )
70 |
71 | #expect(keyMaterial == UserAgentKeyMaterial(
72 | publicKey: privateKey.publicKey,
73 | authenticationSecret: keyMaterial.authenticationSecret
74 | ))
75 |
76 | #expect(throws: UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError())) {
77 | try UserAgentKeyMaterial(
78 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
79 | authenticationSecret: "()"
80 | )
81 | }
82 |
83 | #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) {
84 | try UserAgentKeyMaterial(
85 | publicKey: "()",
86 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
87 | )
88 | }
89 |
90 | #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) {
91 | try UserAgentKeyMaterial(
92 | publicKey: "()",
93 | authenticationSecret: "()"
94 | )
95 | }
96 |
97 | #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: CryptoKitError.incorrectParameterSize)) {
98 | try UserAgentKeyMaterial(
99 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYM",
100 | authenticationSecret: "()"
101 | )
102 | }
103 | }
104 |
105 | @Test func hashes() throws {
106 | let keyMaterial1 = try UserAgentKeyMaterial(
107 | publicKey: "BPgjN_Qet3SrCclnXNri-jEHu31CsdeZmNH9xkNskR58jBpxcqXJFspAPBeahlvNqUVXvorTn9RKcXag_esAmG0",
108 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
109 | )
110 | let keyMaterial2 = try UserAgentKeyMaterial(
111 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
112 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
113 | )
114 | var set: Set = [keyMaterial1, keyMaterial2]
115 | #expect(set.count == 2)
116 | set.insert(try UserAgentKeyMaterial(
117 | publicKey: "BPgjN_Qet3SrCclnXNri-jEHu31CsdeZmNH9xkNskR58jBpxcqXJFspAPBeahlvNqUVXvorTn9RKcXag_esAmG0",
118 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
119 | ))
120 | #expect(set.count == 2)
121 | set.insert(try UserAgentKeyMaterial(
122 | publicKey: "BPgjN_Qet3SrCclnXNri-jEHu31CsdeZmNH9xkNskR58jBpxcqXJFspAPBeahlvNqUVXvorTn9RKcXag_esAmG0",
123 | authenticationSecret: "AzODAQZN6BbGvmm7vWQJXg" // first character: A
124 | ))
125 | #expect(set.count == 3)
126 | }
127 | }
128 |
129 | @Suite struct Coding {
130 | @Test func encodes() async throws {
131 | let encoder = JSONEncoder()
132 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
133 | let result = String(
134 | decoding: try encoder.encode(UserAgentKeyMaterial(
135 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
136 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
137 | )),
138 | as: UTF8.self
139 | )
140 |
141 | #expect(result == """
142 | {
143 | "auth" : "IzODAQZN6BbGvmm7vWQJXg",
144 | "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M"
145 | }
146 | """)
147 | }
148 |
149 | @Test func decodes() async throws {
150 | #expect(
151 | try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data("""
152 | {
153 | "auth" : "IzODAQZN6BbGvmm7vWQJXg",
154 | "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M"
155 | }
156 | """.utf8
157 | )) ==
158 | UserAgentKeyMaterial(
159 | publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
160 | authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
161 | )
162 | )
163 |
164 | #expect(throws: UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError())) {
165 | try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data("""
166 | {
167 | "auth" : "()",
168 | "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M"
169 | }
170 | """.utf8
171 | ))
172 | }
173 |
174 | #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) {
175 | try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data("""
176 | {
177 | "auth" : "IzODAQZN6BbGvmm7vWQJXg",
178 | "p256dh" : "()"
179 | }
180 | """.utf8
181 | ))
182 | }
183 |
184 | #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) {
185 | try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data("""
186 | {
187 | "auth" : "()",
188 | "p256dh" : "()"
189 | }
190 | """.utf8
191 | ))
192 | }
193 |
194 | /// `UserAgentKeyMaterialError.invalidPublicKey(underlyingError: CryptoKitError.incorrectParameterSize)` on macOS, `UserAgentKeyMaterialError.invalidPublicKey(underlyingError: CryptoKitError.underlyingCoreCryptoError(error: 251658360))` on Linux
195 | #expect(throws: UserAgentKeyMaterialError.self) {
196 | try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data("""
197 | {
198 | "auth" : "IzODAQZN6BbGvmm7vWQJXg",
199 | "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1A"
200 | }
201 | """.utf8
202 | ))
203 | }
204 | }
205 | }
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/TopicTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TopicTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-24.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 |
10 | import Crypto
11 | #if canImport(FoundationEssentials)
12 | import FoundationEssentials
13 | #else
14 | import Foundation
15 | #endif
16 | import Testing
17 | @testable import WebPush
18 |
19 | extension Character {
20 | var isBase64URLSafe: Bool {
21 | self.isASCII && (
22 | self.isLetter
23 | || self.isNumber
24 | || self == "-"
25 | || self == "_"
26 | )
27 | }
28 | }
29 |
30 | @Suite struct TopicTests {
31 | @Test func topicIsValid() throws {
32 | func checkValidity(_ topic: String) {
33 | #expect(topic.count == 32)
34 | let allSafeCharacters = topic.allSatisfy(\.isBase64URLSafe)
35 | #expect(allSafeCharacters)
36 | }
37 | checkValidity(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes).topic)
38 | checkValidity(try Topic(encodableTopic: "", salt: "Salty".utf8Bytes).topic)
39 | checkValidity(try Topic(encodableTopic: "", salt: "".utf8Bytes).topic)
40 | checkValidity(try Topic(encodableTopic: ["A", "B", "C"], salt: "SecretSalt".utf8Bytes).topic)
41 | checkValidity(try Topic(encodableTopic: ["a" : "b"], salt: "SecretSalt".utf8Bytes).topic)
42 | checkValidity(try Topic(encodableTopic: UUID(), salt: "SecretSalt".utf8Bytes).topic)
43 | checkValidity(Topic().topic)
44 |
45 | struct ComplexTopic: Codable {
46 | var user = "Dimitri"
47 | var app = "Jiiiii"
48 | var id = UUID()
49 | var secretNumber = 42
50 | }
51 | checkValidity(try Topic(encodableTopic: ComplexTopic(), salt: "SecretSalt".utf8Bytes).topic)
52 |
53 | do {
54 | let unsafeTopic = Topic(unsafeTopic: "test")
55 | #expect(unsafeTopic.topic.count != 32)
56 | let allSafeCharacters = unsafeTopic.topic.allSatisfy(\.isBase64URLSafe)
57 | #expect(allSafeCharacters)
58 | }
59 | do {
60 | let unsafeTopic = Topic(unsafeTopic: "()")
61 | #expect(unsafeTopic.topic.count != 32)
62 | let allSafeCharacters = unsafeTopic.topic.allSatisfy(\.isBase64URLSafe)
63 | #expect(!allSafeCharacters)
64 | }
65 | }
66 |
67 | @Test func topicIsTransformed() throws {
68 | #expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes).topic == "mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-")
69 | #expect(Topic(unsafeTopic: "test").topic == "test")
70 | #expect(Topic(unsafeTopic: "A really long test (with unsafe characters to boot ふふふ!)").topic == "A really long test (with unsafe characters to boot ふふふ!)")
71 | }
72 |
73 | @Test func topicIsDescribable() throws {
74 | #expect("\(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes))" == "mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-")
75 | #expect("\(Topic(unsafeTopic: "test"))" == "test")
76 | #expect("\(Topic(unsafeTopic: "A really long test (with unsafe characters to boot ふふふ!)"))" == "A really long test (with unsafe characters to boot ふふふ!)")
77 | }
78 |
79 | @Test func transformsDeterministically() throws {
80 | #expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes) == Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes))
81 | #expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes) != Topic(encodableTopic: "Hello", salt: "NotSalty".utf8Bytes))
82 | #expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes) != Topic(encodableTopic: "Hello, World", salt: "Salty".utf8Bytes))
83 | }
84 |
85 | @Suite struct Coding {
86 | @Test func encoding() throws {
87 | #expect(String(decoding: try JSONEncoder().encode(Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes)), as: UTF8.self) == "\"mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-\"")
88 | }
89 |
90 | @Test func decoding() throws {
91 | #expect(try JSONDecoder().decode(Topic.self, from: Data("\"mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-\"".utf8)) == Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes))
92 |
93 | #expect(try JSONDecoder().decode(Topic.self, from: Data("\"test\"".utf8)) == Topic(unsafeTopic: "test"))
94 |
95 | #expect(try JSONDecoder().decode(Topic.self, from: Data("\"A really long test (with unsafe characters to boot ふふふ!)\"".utf8)) == Topic(unsafeTopic: "A really long test (with unsafe characters to boot ふふふ!)"))
96 |
97 | #expect(throws: DecodingError.self) {
98 | try JSONDecoder().decode(Topic.self, from: Data("{}".utf8))
99 | }
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/URLOriginTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLOriginTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-22.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(FoundationEssentials)
10 | import FoundationEssentials
11 | #else
12 | import Foundation
13 | #endif
14 | import Testing
15 | @testable import WebPush
16 |
17 | @Suite struct URLOriginTests {
18 | @Test func httpURLs() {
19 | #expect(URL(string: "http://example.com/subscriber")?.origin == "http://example.com")
20 | #expect(URL(string: "http://example.com/")?.origin == "http://example.com")
21 | #expect(URL(string: "http://example.com")?.origin == "http://example.com")
22 | #expect(URL(string: "HtTp://Example.com/")?.origin == "http://Example.com")
23 | #expect(URL(string: "http://example.com:80/")?.origin == "http://example.com")
24 | #expect(URL(string: "http://example.com:8081/")?.origin == "http://example.com:8081")
25 | #expect(URL(string: "http://example.com:443/")?.origin == "http://example.com:443")
26 | #expect(URL(string: "http://host/")?.origin == "http://host")
27 | #expect(URL(string: "http://user:pass@host/")?.origin == "http://host")
28 | #expect(URL(string: "http://")?.origin == "http://")
29 | #expect(URL(string: "http:///")?.origin == "http://")
30 | #expect(URL(string: "http://じぃ.app/")?.origin == "http://じぃ.app")
31 | #expect(URL(string: "http://xn--m8jyb.app/")?.origin == "http://じぃ.app")
32 | }
33 |
34 | @Test func httpsURLs() {
35 | #expect(URL(string: "https://example.com/subscriber")?.origin == "https://example.com")
36 | #expect(URL(string: "https://example.com/")?.origin == "https://example.com")
37 | #expect(URL(string: "https://example.com")?.origin == "https://example.com")
38 | #expect(URL(string: "HtTps://Example.com/")?.origin == "https://Example.com")
39 | #expect(URL(string: "https://example.com:443/")?.origin == "https://example.com")
40 | #expect(URL(string: "https://example.com:4443/")?.origin == "https://example.com:4443")
41 | #expect(URL(string: "https://example.com:80/")?.origin == "https://example.com:80")
42 | #expect(URL(string: "https://host/")?.origin == "https://host")
43 | #expect(URL(string: "https://user:pass@host/")?.origin == "https://host")
44 | #expect(URL(string: "https://")?.origin == "https://")
45 | #expect(URL(string: "https:///")?.origin == "https://")
46 | #expect(URL(string: "https://じぃ.app/")?.origin == "https://じぃ.app")
47 | #expect(URL(string: "https://xn--m8jyb.app/")?.origin == "https://じぃ.app")
48 | }
49 |
50 | @Test func otherURLs() {
51 | #expect(URL(string: "file://example.com/subscriber")?.origin == "null")
52 | #expect(URL(string: "ftp://example.com/")?.origin == "null")
53 | #expect(URL(string: "blob:example.com")?.origin == "null")
54 | #expect(URL(string: "mailto:test@example.com")?.origin == "null")
55 | #expect(URL(string: "example.com")?.origin == "null")
56 | #expect(URL(string: "otherFile.html")?.origin == "null")
57 | #expect(URL(string: "/subscriber")?.origin == "null")
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/VAPIDConfigurationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDConfigurationTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-15.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import Testing
16 | @testable import WebPush
17 | import WebPushTesting
18 |
19 | @Suite("VAPID Configuration Tests")
20 | struct VAPIDConfigurationTests {
21 | @Suite
22 | struct Initialization {
23 | @Test func primaryKeyOnly() {
24 | let config = VAPID.Configuration(
25 | key: .mockedKey1,
26 | contactInformation: .email("test@email.com")
27 | )
28 | #expect(config.primaryKey == .mockedKey1)
29 | #expect(config.keys == [.mockedKey1])
30 | #expect(config.deprecatedKeys == nil)
31 | #expect(config.contactInformation == .email("test@email.com"))
32 | #expect(config.expirationDuration == .hours(22))
33 | #expect(config.validityDuration == .hours(20))
34 | }
35 |
36 | @Test func emptyDeprecatedKeys() {
37 | let config = VAPID.Configuration(
38 | key: .mockedKey1,
39 | deprecatedKeys: [],
40 | contactInformation: .url(URL(string: "https://example.com")!),
41 | expirationDuration: .hours(24),
42 | validityDuration: .hours(12)
43 | )
44 | #expect(config.primaryKey == .mockedKey1)
45 | #expect(config.keys == [.mockedKey1])
46 | #expect(config.deprecatedKeys == nil)
47 | #expect(config.contactInformation == .url(URL(string: "https://example.com")!))
48 | #expect(config.expirationDuration == .hours(24))
49 | #expect(config.validityDuration == .hours(12))
50 | }
51 |
52 | @Test func deprecatedKeys() {
53 | let config = VAPID.Configuration(
54 | key: .mockedKey1,
55 | deprecatedKeys: [.mockedKey2, .mockedKey3],
56 | contactInformation: .email("test@email.com")
57 | )
58 | #expect(config.primaryKey == .mockedKey1)
59 | #expect(config.keys == [.mockedKey1])
60 | #expect(config.deprecatedKeys == [.mockedKey2, .mockedKey3])
61 | #expect(config.contactInformation == .email("test@email.com"))
62 | #expect(config.expirationDuration == .hours(22))
63 | #expect(config.validityDuration == .hours(20))
64 | }
65 |
66 | @Test func deprecatedAndPrimaryKeys() {
67 | let config = VAPID.Configuration(
68 | key: .mockedKey1,
69 | deprecatedKeys: [.mockedKey2, .mockedKey3, .mockedKey1],
70 | contactInformation: .url(URL(string: "https://example.com")!),
71 | expirationDuration: .hours(24),
72 | validityDuration: .hours(12)
73 | )
74 | #expect(config.primaryKey == .mockedKey1)
75 | #expect(config.keys == [.mockedKey1])
76 | #expect(config.deprecatedKeys == [.mockedKey2, .mockedKey3])
77 | #expect(config.contactInformation == .url(URL(string: "https://example.com")!))
78 | #expect(config.expirationDuration == .hours(24))
79 | #expect(config.validityDuration == .hours(12))
80 | }
81 |
82 | @Test func multipleKeys() throws {
83 | let config = try VAPID.Configuration(
84 | primaryKey: nil,
85 | keys: [.mockedKey1, .mockedKey2],
86 | deprecatedKeys: nil,
87 | contactInformation: .email("test@email.com")
88 | )
89 | #expect(config.primaryKey == nil)
90 | #expect(config.keys == [.mockedKey1, .mockedKey2])
91 | #expect(config.deprecatedKeys == nil)
92 | #expect(config.contactInformation == .email("test@email.com"))
93 | #expect(config.expirationDuration == .hours(22))
94 | #expect(config.validityDuration == .hours(20))
95 | }
96 |
97 | @Test func noKeys() throws {
98 | #expect(throws: VAPID.ConfigurationError.keysNotProvided) {
99 | try VAPID.Configuration(
100 | primaryKey: nil,
101 | keys: [],
102 | deprecatedKeys: [.mockedKey2, .mockedKey3],
103 | contactInformation: .email("test@email.com")
104 | )
105 | }
106 | }
107 |
108 | @Test func multipleAndDeprecatedKeys() throws {
109 | let config = try VAPID.Configuration(
110 | primaryKey: nil,
111 | keys: [.mockedKey1, .mockedKey2],
112 | deprecatedKeys: [.mockedKey2],
113 | contactInformation: .email("test@email.com")
114 | )
115 | #expect(config.primaryKey == nil)
116 | #expect(config.keys == [.mockedKey1, .mockedKey2])
117 | #expect(config.deprecatedKeys == nil)
118 | #expect(config.contactInformation == .email("test@email.com"))
119 | #expect(config.expirationDuration == .hours(22))
120 | #expect(config.validityDuration == .hours(20))
121 | }
122 |
123 | @Test func multipleAndPrimaryKeys() throws {
124 | let config = try VAPID.Configuration(
125 | primaryKey: .mockedKey1,
126 | keys: [.mockedKey2],
127 | deprecatedKeys: [.mockedKey2, .mockedKey3, .mockedKey1],
128 | contactInformation: .url(URL(string: "https://example.com")!),
129 | expirationDuration: .hours(24),
130 | validityDuration: .hours(12)
131 | )
132 | #expect(config.primaryKey == .mockedKey1)
133 | #expect(config.keys == [.mockedKey1, .mockedKey2])
134 | #expect(config.deprecatedKeys == [.mockedKey3])
135 | #expect(config.contactInformation == .url(URL(string: "https://example.com")!))
136 | #expect(config.expirationDuration == .hours(24))
137 | #expect(config.validityDuration == .hours(12))
138 | }
139 | }
140 |
141 | @Suite
142 | struct Updates {
143 | @Test func primaryKeyOnly() throws {
144 | var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com"))
145 |
146 | try config.updateKeys(primaryKey: .mockedKey2, keys: [], deprecatedKeys: nil)
147 | #expect(config.primaryKey == .mockedKey2)
148 | #expect(config.keys == [.mockedKey2])
149 | #expect(config.deprecatedKeys == nil)
150 | }
151 |
152 | @Test func noKeys() throws {
153 | var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com"))
154 | #expect(throws: VAPID.ConfigurationError.keysNotProvided) {
155 | try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: nil)
156 | }
157 | #expect(throws: VAPID.ConfigurationError.keysNotProvided) {
158 | try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: [])
159 | }
160 | #expect(throws: VAPID.ConfigurationError.keysNotProvided) {
161 | try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: [.mockedKey1])
162 | }
163 | }
164 |
165 | @Test func multipleKeys() throws {
166 | var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com"))
167 |
168 | try config.updateKeys(primaryKey: nil, keys: [.mockedKey2], deprecatedKeys: nil)
169 | #expect(config.primaryKey == nil)
170 | #expect(config.keys == [.mockedKey2])
171 | #expect(config.deprecatedKeys == nil)
172 |
173 | try config.updateKeys(primaryKey: nil, keys: [.mockedKey2, .mockedKey3], deprecatedKeys: nil)
174 | #expect(config.primaryKey == nil)
175 | #expect(config.keys == [.mockedKey2, .mockedKey3])
176 | #expect(config.deprecatedKeys == nil)
177 | }
178 |
179 | @Test func multipleAndDeprecatedKeys() throws {
180 | var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com"))
181 |
182 | try config.updateKeys(primaryKey: nil, keys: [.mockedKey2], deprecatedKeys: [.mockedKey2, .mockedKey3])
183 | #expect(config.primaryKey == nil)
184 | #expect(config.keys == [.mockedKey2])
185 | #expect(config.deprecatedKeys == [.mockedKey3])
186 |
187 | try config.updateKeys(primaryKey: nil, keys: [.mockedKey2, .mockedKey3], deprecatedKeys: [.mockedKey2, .mockedKey3])
188 | #expect(config.primaryKey == nil)
189 | #expect(config.keys == [.mockedKey2, .mockedKey3])
190 | #expect(config.deprecatedKeys == nil)
191 | }
192 |
193 | @Test func multipleAndPrimaryKeys() throws {
194 | var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com"))
195 |
196 | try config.updateKeys(primaryKey: .mockedKey2, keys: [.mockedKey3], deprecatedKeys: [.mockedKey1, .mockedKey2, .mockedKey3])
197 | #expect(config.primaryKey == .mockedKey2)
198 | #expect(config.keys == [.mockedKey2, .mockedKey3])
199 | #expect(config.deprecatedKeys == [.mockedKey1])
200 |
201 | try config.updateKeys(primaryKey: .mockedKey2, keys: [.mockedKey3], deprecatedKeys: [.mockedKey2, .mockedKey3])
202 | #expect(config.primaryKey == .mockedKey2)
203 | #expect(config.keys == [.mockedKey2, .mockedKey3])
204 | #expect(config.deprecatedKeys == nil)
205 | }
206 | }
207 |
208 | @Suite
209 | struct Coding {
210 | func encode(_ configuration: VAPID.Configuration) throws -> String {
211 | let encoder = JSONEncoder()
212 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
213 | return String(decoding: try encoder.encode(configuration), as: UTF8.self)
214 | }
215 |
216 | @Test func encodesPrimaryKeyOnly() async throws {
217 | #expect(
218 | try encode(.init(key: .mockedKey1, contactInformation: .email("test@example.com"))) ==
219 | """
220 | {
221 | "contactInformation" : "mailto:test@example.com",
222 | "expirationDuration" : 79200,
223 | "primaryKey" : "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=",
224 | "validityDuration" : 72000
225 | }
226 | """
227 | )
228 | }
229 |
230 | @Test func encodesMultipleKeysWithoutDuplicates() async throws {
231 | #expect(
232 | try encode(.init(
233 | primaryKey: .mockedKey1,
234 | keys: [.mockedKey2],
235 | deprecatedKeys: [.mockedKey1, .mockedKey2, .mockedKey3],
236 | contactInformation: .email("test@example.com"),
237 | expirationDuration: .hours(1),
238 | validityDuration: .hours(10)
239 | )) ==
240 | """
241 | {
242 | "contactInformation" : "mailto:test@example.com",
243 | "deprecatedKeys" : [
244 | "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE="
245 | ],
246 | "expirationDuration" : 3600,
247 | "keys" : [
248 | "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk="
249 | ],
250 | "primaryKey" : "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=",
251 | "validityDuration" : 36000
252 | }
253 | """
254 | )
255 | }
256 |
257 | @Test func decodesIncompleteConfiguration() throws {
258 | #expect(
259 | try JSONDecoder().decode(VAPID.Configuration.self, from: Data(
260 | """
261 | {
262 | "contactInformation" : "mailto:test@example.com",
263 | "expirationDuration" : 79200,
264 | "primaryKey" : "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=",
265 | "validityDuration" : 72000
266 | }
267 | """.utf8
268 | )) ==
269 | VAPID.Configuration(
270 | key: .mockedKey1,
271 | contactInformation: .email("test@example.com")
272 | )
273 | )
274 | }
275 |
276 | @Test func decodesWholeConfiguration() throws {
277 | #expect(
278 | try JSONDecoder().decode(VAPID.Configuration.self, from: Data(
279 | """
280 | {
281 | "contactInformation" : "mailto:test@example.com",
282 | "deprecatedKeys" : [
283 | "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE="
284 | ],
285 | "expirationDuration" : 3600,
286 | "keys" : [
287 | "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk="
288 | ],
289 | "primaryKey" : "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=",
290 | "validityDuration" : 36000
291 | }
292 | """.utf8
293 | )) ==
294 | VAPID.Configuration(
295 | primaryKey: .mockedKey1,
296 | keys: [.mockedKey2],
297 | deprecatedKeys: [.mockedKey1, .mockedKey2, .mockedKey3],
298 | contactInformation: .email("test@example.com"),
299 | expirationDuration: .hours(1),
300 | validityDuration: .hours(10)
301 | )
302 | )
303 | }
304 | }
305 |
306 | @Suite
307 | struct Duration {
308 | @Test func makingDurations() {
309 | #expect(VAPID.Configuration.Duration.zero.seconds == 0)
310 |
311 | #expect(VAPID.Configuration.Duration(seconds: 15).seconds == 15)
312 | #expect(VAPID.Configuration.Duration(seconds: -15).seconds == -15)
313 |
314 | #expect((15 as VAPID.Configuration.Duration).seconds == 15)
315 | #expect((-15 as VAPID.Configuration.Duration).seconds == -15)
316 |
317 | #expect(VAPID.Configuration.Duration.seconds(15).seconds == 15)
318 | #expect(VAPID.Configuration.Duration.seconds(-15).seconds == -15)
319 |
320 | #expect(VAPID.Configuration.Duration.minutes(15).seconds == 900)
321 | #expect(VAPID.Configuration.Duration.minutes(-15).seconds == -900)
322 |
323 | #expect(VAPID.Configuration.Duration.hours(15).seconds == 54_000)
324 | #expect(VAPID.Configuration.Duration.hours(-15).seconds == -54_000)
325 |
326 | #expect(VAPID.Configuration.Duration.days(15).seconds == 1_296_000)
327 | #expect(VAPID.Configuration.Duration.days(-15).seconds == -1_296_000)
328 | }
329 |
330 | @Test func arithmatic() {
331 | let base: VAPID.Configuration.Duration = 15
332 | #expect((base + 15).seconds == 30)
333 | #expect((base - 15).seconds == 0)
334 |
335 | #expect((base - .seconds(30)) == -15)
336 | #expect((base + .minutes(2)) == 135)
337 | #expect((base + .minutes(2) + .hours(1)) == 3_735)
338 | #expect((base + .minutes(2) + .hours(1) + .days(2)) == 176_535)
339 | #expect((base + .seconds(45) + .minutes(59)) == .hours(1))
340 | }
341 |
342 | @Test func comparison() {
343 | #expect(VAPID.Configuration.Duration.seconds(75) < VAPID.Configuration.Duration.minutes(2))
344 | #expect(VAPID.Configuration.Duration.seconds(175) > VAPID.Configuration.Duration.minutes(2))
345 | }
346 |
347 | @Test func addingToDates() {
348 | let now = Date()
349 | #expect(now.adding(.seconds(5)) == now.addingTimeInterval(5))
350 | }
351 |
352 | @Test func coding() throws {
353 | #expect(String(decoding: try JSONEncoder().encode(VAPID.Configuration.Duration(60)), as: UTF8.self) == "60")
354 |
355 | #expect(try JSONDecoder().decode(VAPID.Configuration.Duration.self, from: Data("60".utf8)) == .minutes(1))
356 | }
357 | }
358 | }
359 |
360 | @Suite("Contact Information Coding")
361 | struct ContactInformationCoding {
362 | @Test func encodesToString() async throws {
363 | func encode(_ contactInformation: VAPID.Configuration.ContactInformation) throws -> String {
364 | String(decoding: try JSONEncoder().encode(contactInformation), as: UTF8.self)
365 | }
366 | #expect(try encode(.email("test@example.com")) == "\"mailto:test@example.com\"")
367 | #expect(try encode(.email("junk")) == "\"mailto:junk\"")
368 | #expect(try encode(.email("")) == "\"mailto:\"")
369 | #expect(try encode(.url(URL(string: "https://example.com")!)) == "\"https:\\/\\/example.com\"")
370 | #expect(try encode(.url(URL(string: "junk")!)) == "\"junk\"")
371 | }
372 |
373 | @Test func decodesFromString() async throws {
374 | func decode(_ string: String) throws -> VAPID.Configuration.ContactInformation {
375 | try JSONDecoder().decode(VAPID.Configuration.ContactInformation.self, from: Data(string.utf8))
376 | }
377 | #expect(try decode("\"mailto:test@example.com\"") == .email("test@example.com"))
378 | #expect(try decode("\"mailto:junk\"") == .email("junk"))
379 | #expect(try decode("\"https://example.com\"") == .url(URL(string: "https://example.com")!))
380 | #expect(try decode("\"HTTP://example.com\"") == .url(URL(string: "HTTP://example.com")!))
381 |
382 | #expect(throws: DecodingError.self) {
383 | try decode("\"\"")
384 | }
385 |
386 | #expect(throws: DecodingError.self) {
387 | try decode("\"junk\"")
388 | }
389 |
390 | #expect(throws: DecodingError.self) {
391 | try decode("\"file:///Users/you/Library\"")
392 | }
393 |
394 | #expect(throws: DecodingError.self) {
395 | try decode("\"mailto:\"")
396 | }
397 | }
398 | }
399 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/VAPIDKeyTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDKeyTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-22.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import Testing
16 | @testable import WebPush
17 | import WebPushTesting
18 |
19 | @Suite("VAPID Key Tests") struct VAPIDKeyTests {
20 | @Suite struct Initialization {
21 | @Test func createNew() {
22 | let key = VAPID.Key()
23 | #expect(!key.id.description.isEmpty)
24 | }
25 |
26 | @Test func privateKey() {
27 | let privateKey = P256.Signing.PrivateKey()
28 | let key = VAPID.Key(privateKey: privateKey)
29 | #expect(key.id.description == privateKey.publicKey.x963Representation.base64URLEncodedString())
30 | }
31 |
32 | @Test func base64Representation() throws {
33 | let key = try VAPID.Key(base64URLEncoded: "6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=")
34 | #expect(key.id.description == "BKO3ND8PZ4w3TMdjUE-VFLmwKoawWnfU_fHtp2G55mgOQdCY9sf2b9LjVbmItinpRPMC4qv_9GE9bSDYJ0jaErE")
35 |
36 | #expect(throws: Base64URLDecodingError()) {
37 | try VAPID.Key(base64URLEncoded: "()")
38 | }
39 |
40 | #expect(throws: CryptoKitError.self) {
41 | try VAPID.Key(base64URLEncoded: "AAAA")
42 | }
43 | }
44 | }
45 |
46 | @Test func equality() throws {
47 | let key1 = VAPID.Key.mockedKey1
48 | let key2 = VAPID.Key.mockedKey2
49 | let key3 = VAPID.Key(privateKey: try .init(rawRepresentation: Data(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=")!))
50 |
51 | #expect(key1 != key2)
52 | #expect(key1 == .mockedKey1)
53 | #expect(key1 == key3)
54 | #expect(key1.hashValue == key3.hashValue)
55 | }
56 |
57 | @Suite struct Coding {
58 | @Test func encoding() throws {
59 | #expect(String(decoding: try JSONEncoder().encode(VAPID.Key.mockedKey1), as: UTF8.self) == "\"FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=\"")
60 | }
61 |
62 | @Test func decoding() throws {
63 | #expect(try JSONDecoder().decode(VAPID.Key.self, from: Data("\"FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=\"".utf8)) == .mockedKey1)
64 |
65 | #expect(throws: DecodingError.self) {
66 | try JSONDecoder().decode(VAPID.Key.self, from: Data("{}".utf8))
67 | }
68 |
69 | #expect(throws: DecodingError.self) {
70 | try JSONDecoder().decode(VAPID.Key.self, from: Data("\"()\"".utf8))
71 | }
72 |
73 | #expect(throws: CryptoKitError.self) {
74 | try JSONDecoder().decode(VAPID.Key.self, from: Data("\"\"".utf8))
75 | }
76 |
77 | #expect(throws: CryptoKitError.self) {
78 | try JSONDecoder().decode(VAPID.Key.self, from: Data("\"AAAA\"".utf8))
79 | }
80 | }
81 | }
82 |
83 | @Suite struct Identification {
84 | @Test func comparable() {
85 | #expect([
86 | VAPID.Key.ID.mockedKeyID1,
87 | VAPID.Key.ID.mockedKeyID2,
88 | VAPID.Key.ID.mockedKeyID3,
89 | VAPID.Key.ID.mockedKeyID4,
90 | ].sorted() == [
91 | VAPID.Key.ID.mockedKeyID2,
92 | VAPID.Key.ID.mockedKeyID4,
93 | VAPID.Key.ID.mockedKeyID1,
94 | VAPID.Key.ID.mockedKeyID3,
95 | ])
96 | }
97 |
98 | @Test func encoding() throws {
99 | #expect(String(decoding: try JSONEncoder().encode(VAPID.Key.ID.mockedKeyID1), as: UTF8.self) == "\"BLf3RZAljlexEovBgfZgFTjcEVUKBDr3lIH8quJioMdX4FweRdId_P72h613ptxtU-qSAyW3Tbt_3WgwGhOUxrs\"")
100 | }
101 |
102 | @Test func decoding() throws {
103 | #expect(try JSONDecoder().decode(VAPID.Key.ID.self, from: Data("\"BLf3RZAljlexEovBgfZgFTjcEVUKBDr3lIH8quJioMdX4FweRdId_P72h613ptxtU-qSAyW3Tbt_3WgwGhOUxrs\"".utf8)) == .mockedKeyID1)
104 | #expect(try JSONDecoder().decode(VAPID.Key.ID.self, from: Data("\"BLf3RZAljlexEovBgfZgFTjcEVUKBDr3lIH8quJioMdX4FweRdId/P72h613ptxtU+qSAyW3Tbt/3WgwGhOUxrs=\"".utf8)) == .mockedKeyID1)
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Tests/WebPushTests/VAPIDTokenTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDTokenTests.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-07.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Crypto
10 | #if canImport(FoundationEssentials)
11 | import FoundationEssentials
12 | #else
13 | import Foundation
14 | #endif
15 | import Testing
16 | @testable import WebPush
17 |
18 | struct MockVAPIDKey: VAPIDKeyProtocol {
19 | var id: String
20 | var signature: Bytes
21 |
22 | func signature(for message: some DataProtocol) throws -> Bytes {
23 | signature
24 | }
25 | }
26 |
27 | @Suite struct VAPIDTokenTests {
28 | @Test func expirationProperlyConfigured() throws {
29 | let date = Date(timeIntervalSince1970: 1_234_567)
30 | let token = VAPID.Token(
31 | origin: "https://push.example.net",
32 | contactInformation: .email("push@example.com"),
33 | expiration: date
34 | )
35 |
36 | #expect(token.expiration == 1_234_567)
37 | }
38 |
39 | @Test func generatesValidSignedToken() throws {
40 | let key = VAPID.Key()
41 |
42 | let token = VAPID.Token(
43 | origin: "https://push.example.net",
44 | contactInformation: .email("push@example.com"),
45 | expiresIn: .hours(22)
46 | )
47 |
48 | let signedJWT = try token.generateJWT(signedBy: key)
49 | #expect(VAPID.Token(token: signedJWT, key: "\(key.id)") == token)
50 | }
51 |
52 | /// Make sure we can decode the example from https://datatracker.ietf.org/doc/html/rfc8292#section-2.4, as we use the same decoding logic to self-verify our own signing proceedure.
53 | @Test func tokenVerificationMatchesSpec() throws {
54 | var expectedToken = VAPID.Token(
55 | origin: "https://push.example.net",
56 | contactInformation: .email("push@example.com"),
57 | expiresIn: 0
58 | )
59 | expectedToken.expiration = 1453523768
60 |
61 | let receivedToken = VAPID.Token(
62 | token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA",
63 | key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs"
64 | )
65 | #expect(receivedToken == expectedToken)
66 | }
67 |
68 | @Test func authorizationHeaderGeneration() throws {
69 | var expectedToken = VAPID.Token(
70 | origin: "https://push.example.net",
71 | contactInformation: .email("push@example.com"),
72 | expiresIn: 0
73 | )
74 | expectedToken.expiration = 1453523768
75 |
76 | let mockKey = MockVAPIDKey(
77 | id: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs",
78 | signature: Data(base64URLEncoded: "i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA")!
79 | )
80 |
81 | let generatedHeader = try expectedToken.generateAuthorization(signedBy: mockKey)
82 | #expect(generatedHeader == "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
83 | }
84 |
85 | @Test func invalidTokenInitialization() {
86 | let invalidToken = VAPID.Token(token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", key: "")
87 | #expect(invalidToken == nil)
88 |
89 | let incompleteToken = VAPID.Token(token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBs", key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
90 | #expect(incompleteToken == nil)
91 |
92 | let invalidTokenHeader = VAPID.Token(token: "eyJ0eXAiOiJKV1QiL CJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
93 | #expect(invalidTokenHeader == nil)
94 |
95 | let invalidTokenBody = VAPID.Token(token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHA iOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
96 | #expect(invalidTokenBody == nil)
97 |
98 | let invalidTokenSignature = VAPID.Token(token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK 9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
99 | #expect(invalidTokenSignature == nil)
100 |
101 | let invalidTokenKey = VAPID.Token(token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6 YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
102 | #expect(invalidTokenKey == nil)
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/vapid-key-generator/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let tool = Package(
7 | name: "vapid-key-generator",
8 | platforms: [
9 | .macOS(.v13),
10 | .iOS(.v15),
11 | .tvOS(.v15),
12 | .watchOS(.v8),
13 | ],
14 | products: [
15 | .executable(name: "vapid-key-generator", targets: ["VAPIDKeyGenerator"]),
16 | ],
17 | dependencies: [
18 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
19 | .package(path: "../"),
20 | ],
21 | targets: [
22 | .executableTarget(
23 | name: "VAPIDKeyGenerator",
24 | dependencies: [
25 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
26 | .product(name: "WebPush", package: "swift-webpush"),
27 | ]
28 | ),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/vapid-key-generator/Sources/VAPIDKeyGenerator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VAPIDKeyGenerator.swift
3 | // swift-webpush
4 | //
5 | // Created by Dimitri Bouniol on 2024-12-14.
6 | // Copyright © 2024 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import ArgumentParser
10 | import Foundation
11 | import WebPush
12 |
13 | @main
14 | struct MyCoolerTool: ParsableCommand {
15 | static let configuration = CommandConfiguration(
16 | abstract: "Generate VAPID Keys.",
17 | usage: """
18 | vapid-key-generator
19 | vapid-key-generator --email
20 | vapid-key-generator --key-only
21 | """,
22 | discussion: """
23 | Generates VAPID keys and configurations suitable for use on your server. Keys should generally only be generated once and kept secure.
24 | """
25 | )
26 |
27 | @Flag(name: [.long, .customShort("k")], help: "Only generate a VAPID key.")
28 | var keyOnly = false
29 |
30 | @Flag(name: [.long, .customShort("s")], help: "Output raw JSON only so this tool can be piped with others in scripts.")
31 | var silent = false
32 |
33 | @Flag(name: [.long, .customShort("p")], help: "Output JSON with spacing. Has no effect when generating keys only.")
34 | var pretty = false
35 |
36 | @Option(name: [.long], help: "Parse the input as an email address.")
37 | var email: String?
38 |
39 | @Argument(help: "The fully-qualified HTTPS support URL administrators of push services may contact you at: https://example.com/support") var supportURL: URL?
40 |
41 | mutating func run() throws {
42 | let key = VAPID.Key()
43 | let encoder = JSONEncoder()
44 | if pretty {
45 | encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys, .prettyPrinted]
46 | } else {
47 | encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys]
48 | }
49 |
50 | if keyOnly, supportURL == nil, email == nil {
51 | let json = String(decoding: try encoder.encode(key), as: UTF8.self)
52 |
53 | if silent {
54 | print("\(json)")
55 | } else {
56 | print("VAPID.Key: \(json)\n\n")
57 | print("Example Usage:")
58 | print(" // TODO: Load this data from .env or from file system")
59 | print(" let keyData = Data(\(json).utf8)")
60 | print(" let vapidKey = try VAPID.Key(base64URLEncoded: keyData)")
61 | }
62 | } else if !keyOnly {
63 | let contactInformation = if let supportURL, email == nil {
64 | VAPID.Configuration.ContactInformation.url(supportURL)
65 | } else if supportURL == nil, let email {
66 | VAPID.Configuration.ContactInformation.email(email)
67 | } else if supportURL != nil, email != nil {
68 | throw UnknownError(reason: "Only one of an email or a support-url may be specified.")
69 | } else {
70 | throw UnknownError(reason: "A valid support-url must be specified.")
71 | }
72 | if let supportURL {
73 | guard let scheme = supportURL.scheme?.lowercased(), scheme == "http" || scheme == "https"
74 | else { throw UnknownError(reason: "support-url must be an HTTP or HTTPS.") }
75 | }
76 |
77 | let configuration = VAPID.Configuration(key: key, contactInformation: contactInformation)
78 |
79 | let json = String(decoding: try encoder.encode(configuration), as: UTF8.self)
80 |
81 | if silent {
82 | print("\(json)")
83 | } else {
84 | var exampleJSON = ""
85 | if pretty {
86 | print("VAPID.Configuration:\n\(json)\n\n")
87 | exampleJSON = json
88 | exampleJSON.replace("\n", with: "\n ")
89 | exampleJSON = "#\"\"\"\n \(exampleJSON)\n \"\"\"#"
90 | } else {
91 | print("VAPID.Configuration: \(json)\n\n")
92 | exampleJSON = "#\" \(json) \"#"
93 | }
94 | print("Example Usage:")
95 | print(" // TODO: Load this data from .env or from file system")
96 | print(" let configurationData = Data(\(exampleJSON).utf8)")
97 | print(" let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData)")
98 | }
99 | } else {
100 | if email != nil {
101 | throw UnknownError(reason: "An email cannot be specified if only keys are being generated.")
102 | } else {
103 | throw UnknownError(reason: "A support-url cannot be specified if only keys are being generated.")
104 | }
105 | }
106 | }
107 | }
108 |
109 | struct UnknownError: LocalizedError {
110 | var reason: String
111 |
112 | var errorDescription: String? { reason }
113 | }
114 |
115 | extension URL: @retroactive ExpressibleByArgument {
116 | public init?(argument: String) {
117 | self.init(string: argument)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------