├── .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 | Test Status 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 | --------------------------------------------------------------------------------