diff --git a/src/platform/web/config.ts b/src/platform/web/config.ts index 06a09e847..b0e7493c0 100644 --- a/src/platform/web/config.ts +++ b/src/platform/web/config.ts @@ -1,5 +1,8 @@ import { IPlatformConfig } from '../../common/types/IPlatformConfig'; import * as Utils from 'common/lib/util/utils'; +import { DeviceFormFactor, DevicePlatform } from 'common/lib/types/devicedetails'; +import webstorage from './lib/util/webstorage'; +import { getW3CPushDeviceDetails } from './lib/transport/push'; // Workaround for salesforce lightning locker compat const globalObject = Utils.getGlobalObject(); @@ -79,6 +82,12 @@ const Config: IPlatformConfig = { return byteArray.buffer; }, isWebworker: isWebWorkerContext(), + push: { + platform: DevicePlatform.Browser, + formFactor: DeviceFormFactor.Desktop, + storage: webstorage, + getPushDeviceDetails: getW3CPushDeviceDetails, + }, }; export default Config; diff --git a/src/platform/web/lib/transport/push.ts b/src/platform/web/lib/transport/push.ts new file mode 100644 index 000000000..ff28a3a40 --- /dev/null +++ b/src/platform/web/lib/transport/push.ts @@ -0,0 +1,87 @@ +import { ActivationStateMachine, LocalDevice } from 'plugins/push/pushactivation'; +import Resource from 'common/lib/client/resource'; +import ErrorInfo from 'common/lib/types/errorinfo'; +import Defaults from 'common/lib/util/defaults'; + +function toBase64Url(arrayBuffer: ArrayBuffer) { + const buffer = new Uint8Array(arrayBuffer.slice(0, arrayBuffer.byteLength)); + return btoa(String.fromCharCode.apply(null, Array.from(buffer))); +} + +function urlBase64ToUint8Array(base64String: string) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const rawDataChars = []; + for (let i = 0; i < rawData.length; i++) { + rawDataChars.push(rawData[i].charCodeAt(0)); + } + return Uint8Array.from(rawDataChars); +} + +export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { + const GettingPushDeviceDetailsFailed = machine.GettingPushDeviceDetailsFailed; + const GotPushDeviceDetails = machine.GotPushDeviceDetails; + + const permission = await Notification.requestPermission(); + + if (permission !== 'granted') { + machine.handleEvent( + new GettingPushDeviceDetailsFailed(new ErrorInfo(`user denied permission to send notifications.`, 400, 40000)), + ); + return; + } + + const swUrl = machine.rest.options.pushServiceWorkerUrl; + if (!swUrl) { + machine.handleEvent( + new GettingPushDeviceDetailsFailed(new ErrorInfo('missing ClientOptions.pushServiceWorkerUrl', 400, 40000)), + ); + return; + } + + try { + const worker = await navigator.serviceWorker.register(swUrl); + + machine.pushManager = worker.pushManager; + + const headers = Defaults.defaultGetHeaders(machine.rest.options, { format: 'text' }); + const appServerKey = (await Resource.get(machine.rest, '/push/publicVapidKey', headers, {}, null, true)) + .body as string; + + if (!worker.active) { + await navigator.serviceWorker.ready; + } + + const subscription = await worker.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(appServerKey), + }); + + const endpoint = subscription.endpoint; + + const [p256dh, auth] = [subscription.getKey('p256dh'), subscription.getKey('auth')]; + + if (!p256dh || !auth) { + throw new ErrorInfo('Public key not found', 50000, 500); + } + + const key = [p256dh, auth].map(toBase64Url).join(':'); + + const device = machine.rest.device as LocalDevice; + device.push.recipient = { + transportType: 'web', + targetUrl: btoa(endpoint), + encryptionKey: key, + }; + device.persist(); + + machine.handleEvent(new GotPushDeviceDetails()); + } catch (err) { + machine.handleEvent( + new GettingPushDeviceDetailsFailed( + new ErrorInfo('failed to register service worker', 50000, 500, err as Error | ErrorInfo), + ), + ); + } +} diff --git a/test/browser/push.test.js b/test/browser/push.test.js new file mode 100644 index 000000000..fd6516943 --- /dev/null +++ b/test/browser/push.test.js @@ -0,0 +1,83 @@ +'use strict'; + +define(['ably', 'shared_helper', 'chai', 'push'], function (Ably, helper, chai, PushPlugin) { + const expect = chai.expect; + const whenPromiseSettles = helper.whenPromiseSettles; + const swUrl = '/push_sw.js'; + let rest; + + const persistKeys = { + deviceId: 'ably.push.deviceId', + deviceSecret: 'ably.push.deviceSecret', + deviceIdentityToken: 'ably.push.deviceIdentityToken', + pushRecipient: 'ably.push.pushRecipient', + activationState: 'ably.push.activationState', + }; + + const messageChannel = new MessageChannel(); + + describe('browser/push', function () { + this.timeout(60 * 1000); + + before(function (done) { + helper.setupApp(function () { + done(); + }); + + rest = helper.AblyRest({ + pushServiceWorkerUrl: swUrl, + plugins: { push: PushPlugin }, + }); + }); + + beforeEach(async function () { + Object.values(persistKeys).forEach((key) => { + Ably.Realtime.Platform.Config.push.storage.remove(key); + }); + + const worker = await navigator.serviceWorker.getRegistration(swUrl); + + if (worker) { + await worker.unregister(); + } + }); + + afterEach(async function () { + await rest.push.deactivate(); + }); + + it('push_activation_succeeds', async function () { + await rest.push.activate(); + expect(rest.device.deviceIdentityToken).to.be.ok; + }); + + it('direct_publish_device_id', async function () { + await rest.push.activate(); + + const pushRecipient = { + deviceId: rest.device.id, + }; + + const pushPayload = { + notification: { title: 'Test message', body: 'Test message body' }, + data: { foo: 'bar' }, + }; + + const sw = await navigator.serviceWorker.getRegistration(swUrl); + + sw.active.postMessage({ type: 'INIT_PORT' }, [messageChannel.port2]); + + const receivedPushPayload = await new Promise((resolve, reject) => { + messageChannel.port1.onmessage = function (event) { + resolve(event.data.payload); + }; + + rest.push.admin.publish(pushRecipient, pushPayload).catch(reject); + }); + + expect(receivedPushPayload.data).to.deep.equal(pushPayload.data); + expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title); + expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body); + }); + }); +}); diff --git a/test/support/browser_file_list.js b/test/support/browser_file_list.js index 4b2c4df0c..48535e6cf 100644 --- a/test/support/browser_file_list.js +++ b/test/support/browser_file_list.js @@ -40,6 +40,7 @@ window.__testFiles__.files = { 'test/realtime/init.test.js': true, 'test/realtime/message.test.js': true, 'test/realtime/presence.test.js': true, + 'test/realtime/push.test.js': true, 'test/realtime/reauth.test.js': true, 'test/realtime/resume.test.js': true, 'test/realtime/sync.test.js': true, @@ -61,6 +62,7 @@ window.__testFiles__.files = { 'test/browser/connection.test.js': true, 'test/browser/simple.test.js': true, 'test/browser/http.test.js': true, + 'test/browser/push.test.js': true, 'test/rest/status.test.js': true, 'test/rest/batch.test.js': true, }; diff --git a/test/support/push_sw.js b/test/support/push_sw.js new file mode 100644 index 000000000..8ab9c9660 --- /dev/null +++ b/test/support/push_sw.js @@ -0,0 +1,12 @@ +let port; + +self.addEventListener('push', (event) => { + const res = event.data.json(); + port.postMessage({ payload: res }); +}); + +self.addEventListener('message', (event) => { + if (event.data.type === 'INIT_PORT') { + port = event.ports[0]; + } +}); diff --git a/test/support/runPlaywrightTests.js b/test/support/runPlaywrightTests.js index 5dd1ca2b5..ce12b1ea5 100644 --- a/test/support/runPlaywrightTests.js +++ b/test/support/runPlaywrightTests.js @@ -12,7 +12,9 @@ const mochaServer = new MochaServer(/* playwrightTest: */ true); const runTests = async (browserType) => { mochaServer.listen(); const browser = await browserType.launch(); - const page = await browser.newPage(); + const context = await browser.newContext(); + await context.grantPermissions(['notifications']); + const page = await context.newPage(); await page.goto(`http://${host}:${port}`); console.log(`\nrunning tests in ${browserType.name()}`); diff --git a/test/web_server.js b/test/web_server.js index bc5c29bdb..6a83d71cc 100755 --- a/test/web_server.js +++ b/test/web_server.js @@ -1,5 +1,6 @@ var express = require('express'), cors = require('cors'); +path = require('path'); /** * Runs a simple web server that runs the mocha.html tests @@ -33,6 +34,11 @@ class MochaServer { } }); + // service workers have limited scope if not served from the base path + app.get('/push_sw.js', (req, res) => { + res.sendFile(path.join(__dirname, 'support', 'push_sw.js')); + }); + app.use('/node_modules', express.static(__dirname + '/../node_modules')); app.use('/test', express.static(__dirname)); app.use('/browser', express.static(__dirname + '/../src/web'));