diff --git a/src/common/lib/types/devicedetails.ts b/src/common/lib/types/devicedetails.ts index 10566d3da..818e028bb 100644 --- a/src/common/lib/types/devicedetails.ts +++ b/src/common/lib/types/devicedetails.ts @@ -22,10 +22,15 @@ export enum DevicePlatform { type DevicePushState = 'ACTIVE' | 'FAILING' | 'FAILED'; +interface WebPushEncryptionKey { + p256dh: string; + auth: string; +} + interface WebPushRecipient { transportType: 'web'; targetUrl: string; - encryptionKey: string; + encryptionKey: WebPushEncryptionKey; } interface PushChannelRecipient { diff --git a/src/platform/web/config.ts b/src/platform/web/config.ts index 06a09e847..1529e3241 100644 --- a/src/platform/web/config.ts +++ b/src/platform/web/config.ts @@ -1,5 +1,7 @@ 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'; // Workaround for salesforce lightning locker compat const globalObject = Utils.getGlobalObject(); @@ -79,6 +81,11 @@ const Config: IPlatformConfig = { return byteArray.buffer; }, isWebworker: isWebWorkerContext(), + push: { + platform: DevicePlatform.Browser, + formFactor: DeviceFormFactor.Desktop, + storage: webstorage, + }, }; export default Config; diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts new file mode 100644 index 000000000..39a1d392c --- /dev/null +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -0,0 +1,89 @@ +import { ActivationStateMachine } from 'plugins/push/pushactivation'; + +function toBase64Url(arrayBuffer: ArrayBuffer) { + const buffer = new Uint8Array(arrayBuffer.slice(0, arrayBuffer.byteLength)); + return btoa(String.fromCharCode.apply(null, Array.from(buffer))); +} + +function urlBase64ToBase64(base64String: string) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + return base64; +} + +function base64ToUint8Array(base64String: string) { + const rawData = window.atob(base64String); + 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 { ErrorInfo, Defaults } = machine.client; + + 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.client.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.client.options, { format: 'text' }); + const appServerKey = ( + await machine.client.rest.Resource.get(machine.client, '/push/publicVapidKey', headers, {}, null, true) + ).body as string; + + if (!worker.active) { + await navigator.serviceWorker.ready; + } + + const subscription = await worker.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: base64ToUint8Array(urlBase64ToBase64(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 device = machine.client.device; + device.push.recipient = { + transportType: 'web', + targetUrl: btoa(endpoint), + encryptionKey: { + p256dh: toBase64Url(p256dh), + auth: toBase64Url(auth), + }, + }; + device.persist(); + + machine.handleEvent(new GotPushDeviceDetails()); + } catch (err) { + machine.handleEvent( + new GettingPushDeviceDetailsFailed(new ErrorInfo('Failed to register service worker', 50000, 500, err as Error)), + ); + } +} diff --git a/src/plugins/push/index.ts b/src/plugins/push/index.ts index dd2d052d8..fe28c45b6 100644 --- a/src/plugins/push/index.ts +++ b/src/plugins/push/index.ts @@ -1,4 +1,5 @@ import PushChannel from './pushchannel'; +import { getW3CPushDeviceDetails } from './getW3CDeviceDetails'; import { ActivationStateMachine, CalledActivate, CalledDeactivate, localDeviceFactory } from './pushactivation'; export { @@ -7,6 +8,7 @@ export { CalledActivate, CalledDeactivate, PushChannel, + getW3CPushDeviceDetails, }; export default { @@ -15,4 +17,5 @@ export default { CalledActivate, CalledDeactivate, PushChannel, + getW3CPushDeviceDetails, }; diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index ab3064a18..1b73a5f35 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -135,7 +135,7 @@ export class ActivationStateMachine { updateFailedCallback?: ErrCallback; // Used for testing - pushManager?: PushManager; + _pushManager?: PushManager; // exported for testing GettingPushDeviceDetailsFailed = GettingPushDeviceDetailsFailed; @@ -481,18 +481,16 @@ class NotActivated extends ActivationState { if (device.push.recipient) { machine.pendingEvents.push(new GotPushDeviceDetails()); + } else if (machine.pushConfig.getPushDeviceDetails) { + machine.pushConfig.getPushDeviceDetails?.(machine); + } else if (machine.pushConfig.platform === DevicePlatform.Browser) { + getW3CPushDeviceDetails(machine); } else { - if (machine.pushConfig.getPushDeviceDetails) { - machine.pushConfig.getPushDeviceDetails?.(machine); - } else if (machine.pushConfig.platform === DevicePlatform.Browser) { - getW3CPushDeviceDetails(machine); - } else { - machine.handleEvent( - new GettingPushDeviceDetailsFailed( - new machine.client.ErrorInfo('No available implementation to get push device details', 50000, 500), - ), - ); - } + machine.handleEvent( + new GettingPushDeviceDetailsFailed( + new machine.client.ErrorInfo('No available implementation to get push device details', 50000, 500), + ), + ); } return new WaitingForPushDeviceDetails(); diff --git a/test/browser/push.test.js b/test/browser/push.test.js new file mode 100644 index 000000000..618b450fa --- /dev/null +++ b/test/browser/push.test.js @@ -0,0 +1,95 @@ +'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(); + + /** + * These tests don't work in CI for various reasons (below) but should work when running locally via `npm run test:webserver`, provided + * that you have enabled notification permissions for the origin serving the test ui. + * + * chromium, firefox - don't support push notifications in incognito + * webkit - doesn't have a way to launch programatically with notification permissions granted + */ + if (Notification.permission === 'granted') { + describe('browser/push', function () { + this.timeout(60 * 1000); + + before(function (done) { + helper.setupApp(function () { + rest = helper.AblyRest({ + pushServiceWorkerUrl: swUrl, + plugins: { Push: PushPlugin }, + }); + done(); + }); + }); + + 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(); + }); + + /* + * RSH2a + */ + it('push_activation_succeeds', async function () { + await rest.push.activate(); + expect(rest.device.deviceIdentityToken).to.be.ok; + }); + + // no spec item + 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..95fa4e536 100644 --- a/test/support/browser_file_list.js +++ b/test/support/browser_file_list.js @@ -61,6 +61,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..07f580ae3 100644 --- a/test/support/runPlaywrightTests.js +++ b/test/support/runPlaywrightTests.js @@ -12,7 +12,8 @@ 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(); + 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..9d10abcb3 100755 --- a/test/web_server.js +++ b/test/web_server.js @@ -1,5 +1,6 @@ var express = require('express'), - cors = require('cors'); + 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'));