Swift WebPush

Test Status

WebPush 标准的服务器端 Swift 实现。

快速链接

安装

在您的 Package.swift 文件中添加 WebPush 作为依赖项即可开始使用。然后,在任何您希望使用该库的文件中添加 import WebPush

请查看版本发布以获取推荐版本。

dependencies: [
    .package(
        url: "https://github.com/mochidev/swift-webpush.git", 
        .upToNextMinor(from: "0.4.1")
    ),
],
...
targets: [
    .target(
        name: "MyPackage",
        dependencies: [
            .product(name: "WebPush", package: "swift-webpush"),
            ...
        ]
    ),
    .testTarget(
        name: "MyPackageTests",
        dependencies: [
            .product(name: "WebPushTesting", package: "swift-webpush"),
            ...
        ]
    ),
]

用法

术语和核心概念

如果您不熟悉 WebPush 标准,我们建议您首先熟悉以下核心概念

订阅者

订阅者代表已选择接收来自您服务的推送消息的设备。

[!IMPORTANT] 不应将订阅者与用户混淆——单个用户可能在多个设备上登录,而单个设备上的多个用户可能共享一个订阅者。您需要管理这种复杂性,并通过在用户登录或注销时注册、注销和更新订阅者来确保用户信息在会话边界之间保持安全。

应用服务器

应用服务器是您运行的服务器,用于管理订阅和发送推送通知。执行这些角色的实际服务器可能不同,但它们都必须使用相同的 VAPID 密钥才能正常运行。

[!CAUTION] 使用未向订阅注册的 VAPID 密钥导致推送消息无法到达其订阅者。

VAPID 密钥

VAPID,或称自愿应用服务器识别,描述了一种标准,用于让您的应用服务器在订阅注册时进行自我介绍,以便返回给您的订阅只能由您的服务使用,而不能与其他无关服务共享。

这通过生成一个 VAPID 密钥对来代表您的服务器来实现。在注册时,公钥与浏览器共享,并且返回的订阅被锁定到此密钥。发送推送消息时,私钥用于向推送服务标识您的应用服务器,以便推送服务在将消息转发给订阅者之前知道您的身份。

[!CAUTION] 重要的是要注意,您应该努力为给定的订阅者尽可能长时间地使用相同的密钥——如果您重新生成此密钥,您将无法向现有订阅者发送消息,因此请确保其安全!

推送服务

推送服务由浏览器运行,以协调代表您向订阅者传递消息。

生成密钥

在将 WebPush 集成到您的服务器之前,您必须生成一次性 VAPID 密钥,以向推送服务标识您的服务器。为了帮助您做到这一点,我们提供了 vapid-key-generator,您可以根据需要安装和使用它

% git clone https://github.com/mochidev/swift-webpush.git
% cd swift-webpush/vapid-key-generator
% swift package experimental-install

卸载生成器

% swift package experimental-uninstall vapid-key-generator

要更新生成器,请卸载它并在从 main 拉取后重新安装它

% swift package experimental-uninstall vapid-key-generator
% swift package experimental-install

安装完成后,可以根据需要生成新的配置。在这里,我们生成一个配置,其中 https://example.com 作为我们的支持 URL,供推送服务管理员在出现问题时联系我们

% ~/.swiftpm/bin/vapid-key-generator https://example.com
VAPID.Configuration: {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000}


Example Usage:
    // TODO: Load this data from .env or from file system
    let configurationData = Data(#" {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000} "#.utf8)
    let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData)

生成后,应将配置 JSON 添加到您的部署的 .env 中并保持安全,以便您的应用服务器可以在运行时访问它,并且供您的应用服务器访问。确保此密钥不会泄露,并且不会与订阅者信息一起存储。

注意

您可以指定支持 URL 或电子邮件,供推送服务管理员在遇到问题时与您联系,或者如果您希望在运行时配置联系信息,则可以仅生成密钥

% ~/.swiftpm/bin/vapid-key-generator -h
OVERVIEW: Generate VAPID Keys.

Generates VAPID keys and configurations suitable for use on your server. Keys should generally only be generated once
and kept secure.

USAGE: vapid-key-generator <support-url>
       vapid-key-generator --email <email>
       vapid-key-generator --key-only

ARGUMENTS:
  <support-url>           The fully-qualified HTTPS support URL administrators of push services may contact you at:
                          https://example.com/support

OPTIONS:
  -k, --key-only          Only generate a VAPID key.
  -s, --silent            Output raw JSON only so this tool can be piped with others in scripts.
  -p, --pretty            Output JSON with spacing. Has no effect when generating keys only.
  --email <email>         Parse the input as an email address.
  -h, --help              Show help information.

重要提示

如果您只需要更改联系信息,您可以直接在 JSON 中进行更改——这样做时不应重新生成密钥,因为它会使所有现有订阅失效。

提示

如果您愿意,您也可以通过调用 VAPID.Key() 在您自己的代码中生成密钥,但请记住,密钥应从那时起持久化并重复使用!

设置

在您的应用服务器的设置阶段,解码您上面创建的 VAPID 配置,并使用它初始化 WebPushManager

import WebPush

...

guard
    let rawVAPIDConfiguration = ProcessInfo.processInfo.environment["VAPID-CONFIG"],
    let vapidConfiguration = try? JSONDecoder().decode(VAPID.Configuration.self, from: Data(rawVAPIDConfiguration.utf8))
else { fatalError("VAPID keys are unavailable, please generate one and add it to the environment.") }

let manager = WebPushManager(
    vapidConfiguration: vapidConfiguration,
    backgroundActivityLogger: logger
    /// If you customized the event loop group your app uses, you can set it here:
    // eventLoopGroupProvider: .shared(app.eventLoopGroup)
)

try await ServiceGroup(
    services: [
        /// Your other services here
        manager
    ],
    gracefulShutdownSignals: [.sigint],
    logger: logger
).run()

如果您尚未使用 Swift Service Lifecycle,您可以跳过将其添加到服务组,它会在 deinit 时关闭。然而,这可能太晚以至于无法完成发送所有正在进行中的消息,因此如果可以,最好为您的所有服务使用 ServiceGroup。

您还需要在服务器根目录(它可以位于任何位置,但通过在根目录提供服务可以简化范围限制)提供一个 serviceWorker.mjs 文件,以处理传入的通知

self.addEventListener('push', function(event) {
    const data = event.data?.json() ?? {};
    event.waitUntil((async () => {
        /// 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.
        let title = data?.title ?? "Your App Name";
        const body = data?.body ?? "New Content Available!";
        
        await self.registration.showNotification(title, { 
            body, 
            icon: "/notification-icon.png", /// Only some browsers support this.
            data
        });
    })());
});

注意

此处的 .mjs 允许您的代码根据需要导入其他 js 模块。如果您未使用 Vapor,请确保您的服务器为此文件扩展名使用正确的 mime 类型。

注册订阅者

要注册订阅者,您需要后端代码来提供您的 VAPID 密钥,以及前端代码来代表用户向浏览器请求订阅。

在后端(我们在这里假设是 Vapor),注册一个返回您的 VAPID 公钥的路由

import WebPush

...

/// 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.
app.get("vapidKey", use: loadVapidKey)

...

/// A wrapper for the VAPID key that Vapor can encode.
struct WebPushOptions: Codable, Content, Hashable, Sendable {
    static let defaultContentType = HTTPMediaType(type: "application", subType: "webpush-options+json")

    var vapid: VAPID.Key.ID
}

/// The route handler, usually part of a route controller.
@Sendable func loadVapidKey(request: Request) async throws -> WebPushOptions {
    WebPushOptions(vapid: manager.nextVAPIDKeyID)
}

还要注册一个用于持久化 Subscriber 的路由

import WebPush

...

/// 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.
app.get("registerSubscription", use: registerSubscription)

...

/// 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.
struct SubscriptionStatus: Codable, Content, Hashable, Sendable {
    var subscribed = true
}

/// The route handler, usually part of a route controller.
@Sendable func registerSubscription(request: Request) async throws -> SubscriptionStatus {
    let subscriptionRequest = try request.content.decode(Subscriber.self, as: .jsonAPI)
    
    // TODO: Persist subscriptionRequest!
    
    return SubscriptionStatus()
}

注意

WebPushManager(此处的 manager)是完全可发送的,应使用依赖注入与您的控制器共享。这使您可以通过依赖单元测试中提供的 WebPushTesting 库来模拟密钥、验证交付和模拟错误,从而完全测试您的应用程序服务器。

在前端,注册您的 service worker,获取您的 vapid 密钥,并代表用户订阅

const serviceRegistration = await navigator.serviceWorker?.register("/serviceWorker.mjs", { type: "module" });
let subscription = await registration?.pushManager?.getSubscription();

/// Wait for the user to interact with the page to request a subscription.
document.getElementById("notificationsSwitch").addEventListener("click", async ({ currentTarget }) => {
    try {
        /// If we couldn't load a subscription, now's the time to ask for one.
        if (!subscription) {
            const applicationServerKey = await loadVAPIDKey();
            subscription = await serviceRegistration.pushManager.subscribe({
                userVisibleOnly: true,
                applicationServerKey,
            });
        }
        
        /// It is safe to re-register the same subscription.
        const subscriptionStatusResponse = await registerSubscription(subscription);
        
        /// Do something with your registration. Some may use it to store notification settings and display those back to the user.
        ...
    } catch (error) {
        /// Tell the user something went wrong here.
        console.error(error);
    }
});

...

async function loadVAPIDKey() {
    /// Make sure this is the same route used above.
    const httpResponse = await fetch(`/vapidKey`);

    const webPushOptions = await httpResponse.json();
    if (httpResponse.status != 200) throw new Error(webPushOptions.reason);

    return webPushOptions.vapid;
}

export async function registerSubscription(subscription) {
    /// Make sure this is the same route used above.
    const subscriptionStatusResponse = await fetch(`/registerSubscription`, {
        method: "POST",
        body: {
            ...subscription.toJSON(),
            /// It is good practice to provide the applicationServerKey back here so we can track which one was used if multiple were provided during configuration.
            applicationServerKey: subscription.options.applicationServerKey,
        },
    });
    
    /// Do something with your registration. Some may use it to store notification settings and display those back to the user.
    ...
}

发送消息

要发送消息,请使用 SubscriberWebPushManager 上调用 send() 方法之一

import WebPush

...

do {
    try await manager.send(
        json: ["title": "Test Notification", "body": "Hello, World!"],
        to: subscriber
        /// If sent from a request, pass the request's logger here to maintain its metadata.
        // logger: request.logger
    )
} catch is BadSubscriberError {
    /// The subscription is no longer valid and should be removed.
} catch is MessageTooLargeError {
    /// The message was too long and should be shortened.
} catch let error as PushServiceError {
    /// The push service ran into trouble. error.response may help here.
} catch {
    /// An unknown error occurred.
}

您的 service worker 将接收此消息,对其进行解码,并将其呈现给用户。

注意

尽管规范支持,但大多数浏览器不支持静默通知,如果使用静默通知,浏览器将删除订阅。

测试

WebPushTesting 模块可用于获取模拟的 WebPushManager 实例,该实例允许您捕获所有发送出去的消息,或抛出您自己的错误以验证您的代码功能是否正常。

重要提示

仅在您的测试目标中导入 WebPushTesting

import Testing
import WebPushTesting

@Test func sendSuccessfulNotifications() async throws {
    try await confirmation { requestWasMade in
        let mockedManager = WebPushManager.makeMockedManager { message, subscriber, topic, expiration, urgency in
            #expect(message.string == "hello")
            #expect(subscriber.endpoint.absoluteString == "https://example.com/expectedSubscriber")
            #expect(subscriber.vapidKeyID == .mockedKeyID1)
            #expect(topic == nil)
            #expect(expiration == .recommendedMaximum)
            #expect(urgency == .high)
            requestWasMade()
        }
        
        let myController = MyController(pushManager: mockedManager)
        try await myController.sendNotifications()
    }
}

@Test func catchBadSubscriptions() async throws {
    /// Mocked managers accept multiple handlers, and will cycle through them each time a push message is sent:
    let mockedManager = WebPushManager.makeMockedManager(messageHandlers:
        { _, _, _, _, _ in throw BadSubscriberError() },
        { _, _, _, _, _ in },
        { _, _, _, _, _ in throw BadSubscriberError() },
    )
    
    let myController = MyController(pushManager: mockedManager)
    #expect(myController.subscribers.count == 3)
    try await myController.sendNotifications()
    #expect(myController.subscribers.count == 1)
}

规范

其他资源

贡献

欢迎贡献!请查看已有的 issue,或发起新的讨论以提出新功能。虽然不能保证功能请求一定会被采纳,但符合项目目标并在事先讨论过的 PR 非常受欢迎!

请确保所有提交都有清晰的提交历史记录,文档完善且经过全面测试。请在提交之前 rebase 您的 PR,而不是合并 main。需要线性历史记录,因此 PR 中的合并提交将不被接受。

支持

为了支持本项目,请考虑在 Mastodon 上关注 @dimitribouniol,在 Code Completion 上收听 Spencer 和 Dimitri 的节目,或下载 Linh 和 Dimitri 的应用程序