From 4f85ce1f1e0fab8a1ec4d2382025ddc3644f6878 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 17 Feb 2021 17:52:11 +0000 Subject: [PATCH 01/27] First pass at github actions --- .github/workflows/run-tests.yaml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/run-tests.yaml diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 00000000..ac17a6bf --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,27 @@ +name: run-tests +on: + push: + branches: + - '*' + pull_request: + branches: + - master +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14' + - uses: nanasess/setup-chromedriver@master + - name: "Install deps" + run: npm install + - name: "Lint" + run: npm run lint + - name: "Run unit tests" + run: npm run test:unit + - name: "Run end-to-end tests" + run: | + npm run build:cdn + npm run test:e2e From 278ed93b6c0dcfd3dab6b346e32f09e9c0b02e31 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 17 Feb 2021 18:07:21 +0000 Subject: [PATCH 02/27] start chromedriver and Xvfb --- .github/workflows/run-tests.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index ac17a6bf..85fad611 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -23,5 +23,7 @@ jobs: run: npm run test:unit - name: "Run end-to-end tests" run: | - npm run build:cdn - npm run test:e2e + export DISPLAY=:99 + chromedriver --url-base=/wd/hub & + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + npm run build:cdn && npm run test:e2e From 8a8a71e643f62f6545183f7a415ef9b8f38aedbc Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 17 Feb 2021 18:17:01 +0000 Subject: [PATCH 03/27] Remove .travis.yml --- .travis.yml | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 73017934..00000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ -language: node_js -node_js: - - "10" -sudo: required -env: DISPLAY=':99.0' -dist: bionic - -jobs: - include: - - name: "Linting" - script: npm run lint - - - name: "Typescript type checks" - script: npm run test:ts - - - name: "Unit tests" - script: npm run test:unit - - - name: "End to end tests" - script: - - npm run build:cdn - - npm run test:e2e - before_script: - - CHROME_VERSION=`google-chrome --version | cut -d ' ' -f 3 | rev | cut -d '.' -f2- | rev` - - echo $CHROME_VERSION - - LATEST_CHROMEDRIVER_VERSION=`curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROME_VERSION"` - - echo $LATEST_CHROMEDRIVER_VERSION - - curl "https://chromedriver.storage.googleapis.com/${LATEST_CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" -O - - unzip chromedriver_linux64.zip - - sudo mv chromedriver /usr/local/bin - - sudo service xvfb start - addons: - chrome: stable - apt: - packages: - - curl - - unzip From 57a8e37264292968feadcaf0f7a01c2ff60f8c94 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 13 Jan 2021 12:25:40 +0000 Subject: [PATCH 04/27] Split library into web and safari clients --- src/push-notifications.js | 535 +------------------------------------- src/safari-client.js | 422 ++++++++++++++++++++++++++++++ src/web-push-client.js | 529 +++++++++++++++++++++++++++++++++++++ 3 files changed, 959 insertions(+), 527 deletions(-) create mode 100644 src/safari-client.js create mode 100644 src/web-push-client.js diff --git a/src/push-notifications.js b/src/push-notifications.js index 4dc45585..e5e6c2fd 100644 --- a/src/push-notifications.js +++ b/src/push-notifications.js @@ -1,15 +1,8 @@ -import doRequest from './do-request'; +import { WebPushClient } from './web-push-client'; +import { SafariClient } from './safari-client'; import TokenProvider from './token-provider'; -import DeviceStateStore from './device-state-store'; -import { version as sdkVersion } from '../package.json'; -const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); -const MAX_INTEREST_LENGTH = 164; -const MAX_INTERESTS_NUM = 5000; - -const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; - -export const RegistrationState = Object.freeze({ +const RegistrationState = Object.freeze({ PERMISSION_PROMPT_REQUIRED: 'PERMISSION_PROMPT_REQUIRED', PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS: 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', @@ -18,523 +11,11 @@ export const RegistrationState = Object.freeze({ PERMISSION_DENIED: 'PERMISSION_DENIED', }); -export class Client { - constructor(config) { - if (!config) { - throw new Error('Config object required'); - } - const { - instanceId, - endpointOverride = null, - serviceWorkerRegistration = null, - } = config; - - if (instanceId === undefined) { - throw new Error('Instance ID is required'); - } - if (typeof instanceId !== 'string') { - throw new Error('Instance ID must be a string'); - } - if (instanceId.length === 0) { - throw new Error('Instance ID cannot be empty'); - } - - if (!('indexedDB' in window)) { - throw new Error( - 'Pusher Beams does not support this browser version (IndexedDB not supported)' - ); - } - - if (!window.isSecureContext) { - throw new Error( - 'Pusher Beams relies on Service Workers, which only work in secure contexts. Check that your page is being served from localhost/over HTTPS' - ); - } - - if (!('serviceWorker' in navigator)) { - throw new Error( - 'Pusher Beams does not support this browser version (Service Workers not supported)' - ); - } - - if (!('PushManager' in window)) { - throw new Error( - 'Pusher Beams does not support this browser version (Web Push not supported)' - ); - } - - if (serviceWorkerRegistration) { - const serviceWorkerScope = serviceWorkerRegistration.scope; - const currentURL = window.location.href; - const scopeMatchesCurrentPage = currentURL.startsWith(serviceWorkerScope); - if (!scopeMatchesCurrentPage) { - throw new Error( - `Could not initialize Pusher web push: current page not in serviceWorkerRegistration scope (${serviceWorkerScope})` - ); - } - } - - this.instanceId = instanceId; - this._deviceId = null; - this._token = null; - this._userId = null; - this._serviceWorkerRegistration = serviceWorkerRegistration; - this._deviceStateStore = new DeviceStateStore(instanceId); - this._endpoint = endpointOverride; // Internal only - - this._ready = this._init(); - } - - async _init() { - if (this._deviceId !== null) { - return; - } - - await this._deviceStateStore.connect(); - - if (this._serviceWorkerRegistration) { - // If we have been given a service worker, wait for it to be ready - await window.navigator.serviceWorker.ready; - } else { - // Otherwise register our own one - this._serviceWorkerRegistration = await getServiceWorkerRegistration(); - } - - await this._detectSubscriptionChange(); - - this._deviceId = await this._deviceStateStore.getDeviceId(); - this._token = await this._deviceStateStore.getToken(); - this._userId = await this._deviceStateStore.getUserId(); - } - - // Ensure SDK is loaded and is consistent - async _resolveSDKState() { - await this._ready; - await this._detectSubscriptionChange(); - } - - async _detectSubscriptionChange() { - const storedToken = await this._deviceStateStore.getToken(); - const actualToken = await getWebPushToken(this._serviceWorkerRegistration); - - const pushTokenHasChanged = storedToken !== actualToken; - - if (pushTokenHasChanged) { - // The web push subscription has changed out from underneath us. - // This can happen when the user disables the web push permission - // (potentially also renabling it, thereby changing the token) - // - // This means the SDK has effectively been stopped, so we should update - // the SDK state to reflect that. - await this._deviceStateStore.clear(); - this._deviceId = null; - this._token = null; - this._userId = null; - } - } - - async getDeviceId() { - await this._resolveSDKState(); - return this._ready.then(() => this._deviceId); - } - - async getToken() { - await this._resolveSDKState(); - return this._ready.then(() => this._token); - } - - async getUserId() { - await this._resolveSDKState(); - return this._ready.then(() => this._userId); - } - - get _baseURL() { - if (this._endpoint !== null) { - return this._endpoint; - } - return `https://${this.instanceId}.pushnotifications.pusher.com`; - } - - _throwIfNotStarted(message) { - if (!this._deviceId) { - throw new Error( - `${message}. SDK not registered with Beams. Did you call .start?` - ); - } - } - - async start() { - await this._resolveSDKState(); - - if (!isSupportedBrowser()) { - return this; - } - - if (this._deviceId !== null) { - return this; - } - - const { vapidPublicKey: publicKey } = await this._getPublicKey(); - - // register with pushManager, get endpoint etc - const token = await this._getPushToken(publicKey); - - // get device id from errol - const deviceId = await this._registerDevice(token); - - await this._deviceStateStore.setToken(token); - await this._deviceStateStore.setDeviceId(deviceId); - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent( - window.navigator.userAgent - ); - - this._token = token; - this._deviceId = deviceId; - return this; - } - - async getRegistrationState() { - await this._resolveSDKState(); - - if (Notification.permission === 'denied') { - return RegistrationState.PERMISSION_DENIED; - } - - if (Notification.permission === 'granted' && this._deviceId !== null) { - return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; - } - - if (Notification.permission === 'granted' && this._deviceId === null) { - return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; - } - - return RegistrationState.PERMISSION_PROMPT_REQUIRED; - } - - async addDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not add Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'POST', - path, - }; - await doRequest(options); - } - - async removeDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not remove Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'DELETE', - path, - }; - await doRequest(options); - } - - async getDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not get Device Interests'); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'GET', - path, - }; - return (await doRequest(options))['interests'] || []; - } - - async setDeviceInterests(interests) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not set Device Interests'); - - if (interests === undefined || interests === null) { - throw new Error('interests argument is required'); - } - if (!Array.isArray(interests)) { - throw new Error('interests argument must be an array'); - } - if (interests.length > MAX_INTERESTS_NUM) { - throw new Error( - `Number of interests (${ - interests.length - }) exceeds maximum of ${MAX_INTERESTS_NUM}` - ); - } - for (let interest of interests) { - validateInterestName(interest); - } - - const uniqueInterests = Array.from(new Set(interests)); - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'PUT', - path, - body: { - interests: uniqueInterests, - }, - }; - await doRequest(options); - } - - async clearDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not clear Device Interests'); - - await this.setDeviceInterests([]); - } - - async setUserId(userId, tokenProvider) { - await this._resolveSDKState(); - - if (!isSupportedBrowser()) { - return; - } - - if (this._deviceId === null) { - const error = new Error('.start must be called before .setUserId'); - return Promise.reject(error); - } - if (typeof userId !== 'string') { - throw new Error(`User ID must be a string (was ${userId})`); - } - if (userId === '') { - throw new Error('User ID cannot be the empty string'); - } - if (this._userId !== null && this._userId !== userId) { - throw new Error('Changing the `userId` is not allowed.'); - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/user`; - - const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); - const options = { - method: 'PUT', - path, - headers: { - Authorization: `Bearer ${beamsAuthToken}`, - }, - }; - await doRequest(options); - - this._userId = userId; - return this._deviceStateStore.setUserId(userId); - } - - async stop() { - await this._resolveSDKState(); - - if (!isSupportedBrowser()) { - return; - } - - if (this._deviceId === null) { - return; - } - - await this._deleteDevice(); - await this._deviceStateStore.clear(); - this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. - - this._deviceId = null; - this._token = null; - this._userId = null; - } - - async clearAllState() { - if (!isSupportedBrowser()) { - return; - } - - await this.stop(); - await this.start(); - } - - async _getPublicKey() { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/web-vapid-public-key`; - - const options = { method: 'GET', path }; - return doRequest(options); - } - - async _getPushToken(publicKey) { - try { - // The browser might already have a push subscription to different key. - // Lets clear it out first. - await this._clearPushToken(); - const sub = await this._serviceWorkerRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUInt8Array(publicKey), - }); - return btoa(JSON.stringify(sub)); - } catch (e) { - return Promise.reject(e); - } - } - - async _clearPushToken() { - return navigator.serviceWorker.ready - .then(reg => reg.pushManager.getSubscription()) - .then(sub => { - if (sub) sub.unsubscribe(); - }); - } - - async _registerDevice(token) { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web`; - - const device = { - token, - metadata: { - sdkVersion, - }, - }; - - const options = { method: 'POST', path, body: device }; - const response = await doRequest(options); - return response.id; - } - - async _deleteDevice() { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${encodeURIComponent(this._deviceId)}`; - - const options = { method: 'DELETE', path }; - await doRequest(options); - } - - /** - * Submit SDK version and browser details (via the user agent) to Pusher Beams. - */ - async _updateDeviceMetadata() { - const userAgent = window.navigator.userAgent; - const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); - const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); - - if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { - // Nothing to do - return; - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/metadata`; - - const metadata = { - sdkVersion, - }; - - const options = { method: 'PUT', path, body: metadata }; - await doRequest(options); - - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent(userAgent); - } -} - -const validateInterestName = interest => { - if (interest === undefined || interest === null) { - throw new Error('Interest name is required'); - } - if (typeof interest !== 'string') { - throw new Error(`Interest ${interest} is not a string`); - } - if (!INTERESTS_REGEX.test(interest)) { - throw new Error( - `interest "${interest}" contains a forbidden character. ` + - 'Allowed characters are: ASCII upper/lower-case letters, ' + - 'numbers or one of _-=@,.;' - ); - } - if (interest.length > MAX_INTEREST_LENGTH) { - throw new Error( - `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` - ); - } -}; - -async function getServiceWorkerRegistration() { - // Check that service worker file exists - const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); - if (swStatusCode !== 200) { - throw new Error( - 'Cannot start SDK, service worker missing: No file found at /service-worker.js' - ); - } - - window.navigator.serviceWorker.register(SERVICE_WORKER_URL, { - // explicitly opting out of `importScripts` caching just in case our - // customers decides to host and serve the imported scripts and - // accidentally set `Cache-Control` to something other than `max-age=0` - updateViaCache: 'none', - }); - return window.navigator.serviceWorker.ready; -} - -function getWebPushToken(swReg) { - return swReg.pushManager - .getSubscription() - .then(sub => (!sub ? null : encodeSubscription(sub))); -} - -function encodeSubscription(sub) { - return btoa(JSON.stringify(sub)); -} - -function urlBase64ToUInt8Array(base64String) { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); -} - -/** - * Modified from https://stackoverflow.com/questions/4565112 - */ -function isSupportedBrowser() { - const winNav = window.navigator; - const vendorName = winNav.vendor; - - const isChromium = - window.chrome !== null && typeof window.chrome !== 'undefined'; - const isOpera = winNav.userAgent.indexOf('OPR') > -1; - const isEdge = winNav.userAgent.indexOf('Edg') > -1; - const isFirefox = winNav.userAgent.indexOf('Firefox') > -1; - - const isChrome = - isChromium && vendorName === 'Google Inc.' && !isEdge && !isOpera; - - const isSupported = isChrome || isOpera || isFirefox || isEdge; - - if (!isSupported) { - console.warn( - 'Pusher Web Push Notifications supports Chrome, Firefox, Edge and Opera.' - ); +function Client(config) { + if ('safari' in window) { + return new SafariClient(config); } - return isSupported; + return new WebPushClient(config); } -export { TokenProvider }; +export { Client, RegistrationState, TokenProvider }; diff --git a/src/safari-client.js b/src/safari-client.js new file mode 100644 index 00000000..9e8c7f54 --- /dev/null +++ b/src/safari-client.js @@ -0,0 +1,422 @@ +import doRequest from './do-request'; +import DeviceStateStore from './device-state-store'; +import { version as sdkVersion } from '../package.json'; + +const __url = 'https://localhost:8080'; +const __pushId = 'web.io.lees.safari-push'; + +export class SafariClient { + constructor(config) { + if (!config) { + throw new Error('Config object required'); + } + const { instanceId, endpointOverride = null } = config; + + if (instanceId === undefined) { + throw new Error('Instance ID is required'); + } + if (typeof instanceId !== 'string') { + throw new Error('Instance ID must be a string'); + } + if (instanceId.length === 0) { + throw new Error('Instance ID cannot be empty'); + } + + if (!('indexedDB' in window)) { + throw new Error( + 'Pusher Beams does not support this browser version (IndexedDB not supported)' + ); + } + + if (!isSupportedVersion()) { + throw new Error( + 'Pusher Beams does not support this safari version (Safari Push Noifications not supported)' + ); + } + + this.instanceId = instanceId; + this._deviceId = null; + this._token = null; + this._userId = null; + this._deviceStateStore = new DeviceStateStore(instanceId); + this._endpoint = endpointOverride; // Internal only + + this._ready = this._init(); + } + + async _init() { + if (this._deviceId !== null) { + return; + } + + await this._deviceStateStore.connect(); + + await this._detectSubscriptionChange(); + + this._deviceId = await this._deviceStateStore.getDeviceId(); + this._token = await this._deviceStateStore.getToken(); + this._userId = await this._deviceStateStore.getUserId(); + } + + async _detectSubscriptionChange() { + const storedToken = await this._deviceStateStore.getToken(); + const actualToken = getDeviceToken(); + + const tokenHasChanged = storedToken !== actualToken; + if (tokenHasChanged) { + // The device token has changed. This is should only really happen when + // users restore from an iCloud backup + await this._deviceStateStore.clear(); + this._deviceId = null; + this._token = null; + this._userId = null; + } + } + + _requestPermission() { + return new Promise((resolve) => { + window.safari.pushNotification.requestPermission( + __url, + __pushId, + { userID: 'abcdef' }, + resolve + ); + }); + } + + async start() { + if (this._deviceId !== null) { + return this; + } + + let { deviceToken, permission } = getPermission(__pushId); + + if (permission === 'default') { + console.debug('permission is default, requesting permission'); + let { deviceToken, permission } = await this._requestPermission(__pushId); + if (permission == 'granted') { + const deviceId = await this._registerDevice(deviceToken); + await this._deviceStateStore.setToken(deviceToken); + await this._deviceStateStore.setDeviceId(deviceId); + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent( + window.navigator.userAgent + ); + this._token = deviceToken; + this._deviceId = deviceId; + } + } + } + + async _registerDevice(deviceToken) { + // const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + // this.instanceId + // )}/devices/web`; + + // const device = { + // token, + // metadata: { + // sdkVersion, + // }, + // }; + + // const options = { method: 'POST', path, body: device }; + // const response = await doRequest(options); + // return response.id; + return new Promise((resolve) => { + console.debug( + 'I should be sending the device token to errol now, but that is not implemented yet' + ); + resolve('not--a--real--device--id'); + }); + } + + async getRegistrationState() { + await this._resolveSDKState(); + + const { permission } = getPermission(__pushId); + + if (permission === 'denied') { + return RegistrationState.PERMISSION_DENIED; + } + + if (permission === 'granted' && this._deviceId !== null) { + return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; + } + + if (permission === 'granted' && this._deviceId === null) { + return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; + } + + return RegistrationState.PERMISSION_PROMPT_REQUIRED; + } + + async clearAllState() { + throw new Error('Not implemented'); + // if (!isSupportedBrowser()) { + // return; + // } + + // await this.stop(); + // await this.start(); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // Everything below can be moved to the base client + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + async getDeviceId() { + await this._resolveSDKState(); + return this._ready.then(() => this.deviceId); + } + async getToken() { + await this._resolveSDKState(); + return this._ready.then(() => this._token); + } + async getUserId() { + await this._resolveSDKState(); + return this._ready.then(() => this.userId); + } + + get _baseURL() { + if (this._endpoint !== null) { + return this._endpoint; + } + return `https://${this.instanceId}.pushnotifications.pusher.com`; + } + + _throwIfNotStarted(message) { + if (!this._deviceId) { + throw new Error( + `${message}. SDK not registered with Beams. Did you call .start?` + ); + } + } + + async _resolveSDKState() { + await this._ready; + await this._detectSubscriptionChange(); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // copied and pasted from the other client + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // + async addDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not add Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'POST', + path, + }; + await doRequest(options); + } + + async removeDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not remove Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'DELETE', + path, + }; + await doRequest(options); + } + + async getDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not get Device Interests'); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests`; + const options = { + method: 'GET', + path, + }; + return (await doRequest(options))['interests'] || []; + } + + async setDeviceInterests(interests) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not set Device Interests'); + + if (interests === undefined || interests === null) { + throw new Error('interests argument is required'); + } + if (!Array.isArray(interests)) { + throw new Error('interests argument must be an array'); + } + if (interests.length > MAX_INTERESTS_NUM) { + throw new Error( + `Number of interests (${ + interests.length + }) exceeds maximum of ${MAX_INTERESTS_NUM}` + ); + } + for (let interest of interests) { + validateInterestName(interest); + } + + const uniqueInterests = Array.from(new Set(interests)); + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests`; + const options = { + method: 'PUT', + path, + body: { + interests: uniqueInterests, + }, + }; + await doRequest(options); + } + + async clearDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not clear Device Interests'); + + await this.setDeviceInterests([]); + } + + async setUserId(userId, tokenProvider) { + await this._resolveSDKState(); + + if (!isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + const error = new Error('.start must be called before .setUserId'); + return Promise.reject(error); + } + if (typeof userId !== 'string') { + throw new Error(`User ID must be a string (was ${userId})`); + } + if (userId === '') { + throw new Error('User ID cannot be the empty string'); + } + if (this._userId !== null && this._userId !== userId) { + throw new Error('Changing the `userId` is not allowed.'); + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/user`; + + const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); + const options = { + method: 'PUT', + path, + headers: { + Authorization: `Bearer ${beamsAuthToken}`, + }, + }; + await doRequest(options); + + this._userId = userId; + return this._deviceStateStore.setUserId(userId); + } + + async stop() { + await this._resolveSDKState(); + + if (!isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + return; + } + + await this._deleteDevice(); + await this._deviceStateStore.clear(); + this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. + + this._deviceId = null; + this._token = null; + this._userId = null; + } + + async _deleteDevice() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${encodeURIComponent(this._deviceId)}`; + + const options = { method: 'DELETE', path }; + await doRequest(options); + } + + // TODO is this ever used? + /** + * Submit SDK version and browser details (via the user agent) to Pusher Beams. + */ + async _updateDeviceMetadata() { + const userAgent = window.navigator.userAgent; + const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); + const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); + + if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { + // Nothing to do + return; + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/metadata`; + + const metadata = { + sdkVersion, + }; + + const options = { method: 'PUT', path, body: metadata }; + await doRequest(options); + + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent(userAgent); + } +} + +function isSupportedVersion() { + return 'safari' in window && 'pushNotification' in window.safari; +} + +// TODO should be in base client +const validateInterestName = (interest) => { + if (interest === undefined || interest === null) { + throw new Error('Interest name is required'); + } + if (typeof interest !== 'string') { + throw new Error(`Interest ${interest} is not a string`); + } + if (!INTERESTS_REGEX.test(interest)) { + throw new Error( + `interest "${interest}" contains a forbidden character. ` + + 'Allowed characters are: ASCII upper/lower-case letters, ' + + 'numbers or one of _-=@,.;' + ); + } + if (interest.length > MAX_INTEREST_LENGTH) { + throw new Error( + `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` + ); + } +}; + +function getPermission(pushId) { + return window.safari.pushNotification.permission(pushId); +} +function getDeviceToken() { + const { deviceToken } = window.safari.pushNotification.permission(__pushId); + return deviceToken; +} diff --git a/src/web-push-client.js b/src/web-push-client.js new file mode 100644 index 00000000..3d46f881 --- /dev/null +++ b/src/web-push-client.js @@ -0,0 +1,529 @@ +import doRequest from './do-request'; +import DeviceStateStore from './device-state-store'; +import { version as sdkVersion } from '../package.json'; + +const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); +const MAX_INTEREST_LENGTH = 164; +const MAX_INTERESTS_NUM = 5000; + +const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; + +export class WebPushClient { + constructor(config) { + if (!config) { + throw new Error('Config object required'); + } + const { + instanceId, + endpointOverride = null, + serviceWorkerRegistration = null, + } = config; + + if (instanceId === undefined) { + throw new Error('Instance ID is required'); + } + if (typeof instanceId !== 'string') { + throw new Error('Instance ID must be a string'); + } + if (instanceId.length === 0) { + throw new Error('Instance ID cannot be empty'); + } + + if (!('indexedDB' in window)) { + throw new Error( + 'Pusher Beams does not support this browser version (IndexedDB not supported)' + ); + } + + if (!window.isSecureContext) { + throw new Error( + 'Pusher Beams relies on Service Workers, which only work in secure contexts. Check that your page is being served from localhost/over HTTPS' + ); + } + + if (!('serviceWorker' in navigator)) { + throw new Error( + 'Pusher Beams does not support this browser version (Service Workers not supported)' + ); + } + + if (!('PushManager' in window)) { + throw new Error( + 'Pusher Beams does not support this browser version (Web Push not supported)' + ); + } + + if (serviceWorkerRegistration) { + const serviceWorkerScope = serviceWorkerRegistration.scope; + const currentURL = window.location.href; + const scopeMatchesCurrentPage = currentURL.startsWith(serviceWorkerScope); + if (!scopeMatchesCurrentPage) { + throw new Error( + `Could not initialize Pusher web push: current page not in serviceWorkerRegistration scope (${serviceWorkerScope})` + ); + } + } + + this.instanceId = instanceId; + this._deviceId = null; + this._token = null; + this._userId = null; + this._serviceWorkerRegistration = serviceWorkerRegistration; + this._deviceStateStore = new DeviceStateStore(instanceId); + this._endpoint = endpointOverride; // Internal only + + this._ready = this._init(); + } + + async _init() { + if (this._deviceId !== null) { + return; + } + + await this._deviceStateStore.connect(); + + if (this._serviceWorkerRegistration) { + // If we have been given a service worker, wait for it to be ready + await window.navigator.serviceWorker.ready; + } else { + // Otherwise register our own one + this._serviceWorkerRegistration = await getServiceWorkerRegistration(); + } + + await this._detectSubscriptionChange(); + + this._deviceId = await this._deviceStateStore.getDeviceId(); + this._token = await this._deviceStateStore.getToken(); + this._userId = await this._deviceStateStore.getUserId(); + } + + // Ensure SDK is loaded and is consistent + async _resolveSDKState() { + await this._ready; + await this._detectSubscriptionChange(); + } + + async _detectSubscriptionChange() { + const storedToken = await this._deviceStateStore.getToken(); + const actualToken = await getWebPushToken(this._serviceWorkerRegistration); + + const pushTokenHasChanged = storedToken !== actualToken; + + if (pushTokenHasChanged) { + // The web push subscription has changed out from underneath us. + // This can happen when the user disables the web push permission + // (potentially also renabling it, thereby changing the token) + // + // This means the SDK has effectively been stopped, so we should update + // the SDK state to reflect that. + await this._deviceStateStore.clear(); + this._deviceId = null; + this._token = null; + this._userId = null; + } + } + + async getDeviceId() { + await this._resolveSDKState(); + return this._ready.then(() => this._deviceId); + } + + async getToken() { + await this._resolveSDKState(); + return this._ready.then(() => this._token); + } + + async getUserId() { + await this._resolveSDKState(); + return this._ready.then(() => this._userId); + } + + get _baseURL() { + if (this._endpoint !== null) { + return this._endpoint; + } + return `https://${this.instanceId}.pushnotifications.pusher.com`; + } + + _throwIfNotStarted(message) { + if (!this._deviceId) { + throw new Error( + `${message}. SDK not registered with Beams. Did you call .start?` + ); + } + } + + async start() { + await this._resolveSDKState(); + + if (!isSupportedBrowser()) { + return this; + } + + if (this._deviceId !== null) { + return this; + } + + const { vapidPublicKey: publicKey } = await this._getPublicKey(); + + // register with pushManager, get endpoint etc + const token = await this._getPushToken(publicKey); + + // get device id from errol + const deviceId = await this._registerDevice(token); + + await this._deviceStateStore.setToken(token); + await this._deviceStateStore.setDeviceId(deviceId); + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent( + window.navigator.userAgent + ); + + this._token = token; + this._deviceId = deviceId; + return this; + } + + async getRegistrationState() { + await this._resolveSDKState(); + + if (Notification.permission === 'denied') { + return RegistrationState.PERMISSION_DENIED; + } + + if (Notification.permission === 'granted' && this._deviceId !== null) { + return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; + } + + if (Notification.permission === 'granted' && this._deviceId === null) { + return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; + } + + return RegistrationState.PERMISSION_PROMPT_REQUIRED; + } + + async addDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not add Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'POST', + path, + }; + await doRequest(options); + } + + async removeDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not remove Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'DELETE', + path, + }; + await doRequest(options); + } + + async getDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not get Device Interests'); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests`; + const options = { + method: 'GET', + path, + }; + return (await doRequest(options))['interests'] || []; + } + + async setDeviceInterests(interests) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not set Device Interests'); + + if (interests === undefined || interests === null) { + throw new Error('interests argument is required'); + } + if (!Array.isArray(interests)) { + throw new Error('interests argument must be an array'); + } + if (interests.length > MAX_INTERESTS_NUM) { + throw new Error( + `Number of interests (${ + interests.length + }) exceeds maximum of ${MAX_INTERESTS_NUM}` + ); + } + for (let interest of interests) { + validateInterestName(interest); + } + + const uniqueInterests = Array.from(new Set(interests)); + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/interests`; + const options = { + method: 'PUT', + path, + body: { + interests: uniqueInterests, + }, + }; + await doRequest(options); + } + + async clearDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not clear Device Interests'); + + await this.setDeviceInterests([]); + } + + async setUserId(userId, tokenProvider) { + await this._resolveSDKState(); + + if (!isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + const error = new Error('.start must be called before .setUserId'); + return Promise.reject(error); + } + if (typeof userId !== 'string') { + throw new Error(`User ID must be a string (was ${userId})`); + } + if (userId === '') { + throw new Error('User ID cannot be the empty string'); + } + if (this._userId !== null && this._userId !== userId) { + throw new Error('Changing the `userId` is not allowed.'); + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/user`; + + const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); + const options = { + method: 'PUT', + path, + headers: { + Authorization: `Bearer ${beamsAuthToken}`, + }, + }; + await doRequest(options); + + this._userId = userId; + return this._deviceStateStore.setUserId(userId); + } + + async stop() { + await this._resolveSDKState(); + + if (!isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + return; + } + + await this._deleteDevice(); + await this._deviceStateStore.clear(); + this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. + + this._deviceId = null; + this._token = null; + this._userId = null; + } + + async clearAllState() { + if (!isSupportedBrowser()) { + return; + } + + await this.stop(); + await this.start(); + } + + async _getPublicKey() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/web-vapid-public-key`; + + const options = { method: 'GET', path }; + return doRequest(options); + } + + async _getPushToken(publicKey) { + try { + // The browser might already have a push subscription to different key. + // Lets clear it out first. + await this._clearPushToken(); + const sub = await this._serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUInt8Array(publicKey), + }); + return btoa(JSON.stringify(sub)); + } catch (e) { + return Promise.reject(e); + } + } + + async _clearPushToken() { + return navigator.serviceWorker.ready + .then((reg) => reg.pushManager.getSubscription()) + .then((sub) => { + if (sub) sub.unsubscribe(); + }); + } + + async _registerDevice(token) { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web`; + + const device = { + token, + metadata: { + sdkVersion, + }, + }; + + const options = { method: 'POST', path, body: device }; + const response = await doRequest(options); + return response.id; + } + + async _deleteDevice() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${encodeURIComponent(this._deviceId)}`; + + const options = { method: 'DELETE', path }; + await doRequest(options); + } + + /** + * Submit SDK version and browser details (via the user agent) to Pusher Beams. + */ + async _updateDeviceMetadata() { + const userAgent = window.navigator.userAgent; + const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); + const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); + + if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { + // Nothing to do + return; + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/metadata`; + + const metadata = { + sdkVersion, + }; + + const options = { method: 'PUT', path, body: metadata }; + await doRequest(options); + + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent(userAgent); + } +} + +// TODO this should be in the base client +const validateInterestName = (interest) => { + if (interest === undefined || interest === null) { + throw new Error('Interest name is required'); + } + if (typeof interest !== 'string') { + throw new Error(`Interest ${interest} is not a string`); + } + if (!INTERESTS_REGEX.test(interest)) { + throw new Error( + `interest "${interest}" contains a forbidden character. ` + + 'Allowed characters are: ASCII upper/lower-case letters, ' + + 'numbers or one of _-=@,.;' + ); + } + if (interest.length > MAX_INTEREST_LENGTH) { + throw new Error( + `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` + ); + } +}; + +async function getServiceWorkerRegistration() { + // Check that service worker file exists + const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); + if (swStatusCode !== 200) { + throw new Error( + 'Cannot start SDK, service worker missing: No file found at /service-worker.js' + ); + } + + window.navigator.serviceWorker.register(SERVICE_WORKER_URL, { + // explicitly opting out of `importScripts` caching just in case our + // customers decides to host and serve the imported scripts and + // accidentally set `Cache-Control` to something other than `max-age=0` + updateViaCache: 'none', + }); + return window.navigator.serviceWorker.ready; +} + +function getWebPushToken(swReg) { + return swReg.pushManager + .getSubscription() + .then((sub) => (!sub ? null : encodeSubscription(sub))); +} + +function encodeSubscription(sub) { + return btoa(JSON.stringify(sub)); +} + +function urlBase64ToUInt8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); +} + +/** + * Modified from https://stackoverflow.com/questions/4565112 + */ +function isSupportedBrowser() { + const winNav = window.navigator; + const vendorName = winNav.vendor; + + const isChromium = + window.chrome !== null && typeof window.chrome !== 'undefined'; + const isOpera = winNav.userAgent.indexOf('OPR') > -1; + const isEdge = winNav.userAgent.indexOf('Edg') > -1; + const isFirefox = winNav.userAgent.indexOf('Firefox') > -1; + + const isChrome = + isChromium && vendorName === 'Google Inc.' && !isEdge && !isOpera; + + const isSupported = isChrome || isOpera || isFirefox || isEdge; + + if (!isSupported) { + console.warn( + 'Pusher Web Push Notifications supports Chrome, Firefox, Edge and Opera.' + ); + } + return isSupported; +} From 41795245d2285c7ad2553e2e2b8d8b526bd78950 Mon Sep 17 00:00:00 2001 From: James Lees Date: Tue, 19 Jan 2021 17:06:08 +0000 Subject: [PATCH 05/27] Split common methods into base client --- src/base-client.js | 202 ++++++++++++++++++++++++++++++++++++++ src/push-notifications.js | 10 +- src/safari-client.js | 202 ++------------------------------------ src/web-push-client.js | 194 ++---------------------------------- 4 files changed, 219 insertions(+), 389 deletions(-) create mode 100644 src/base-client.js diff --git a/src/base-client.js b/src/base-client.js new file mode 100644 index 00000000..0f7ed114 --- /dev/null +++ b/src/base-client.js @@ -0,0 +1,202 @@ +import doRequest from './do-request'; + +const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); +const MAX_INTEREST_LENGTH = 164; +const MAX_INTERESTS_NUM = 5000; + +export const RegistrationState = Object.freeze({ + PERMISSION_PROMPT_REQUIRED: 'PERMISSION_PROMPT_REQUIRED', + PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS: + 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', + PERMISSION_GRANTED_REGISTERED_WITH_BEAMS: + 'PERMISSION_GRANTED_REGISTERED_WITH_BEAMS', + PERMISSION_DENIED: 'PERMISSION_DENIED', +}); + +export default class BaseClient { + constructor(config) {} + + async getDeviceId() { + await this._resolveSDKState(); + return this._ready.then(() => this._deviceId); + } + async getToken() { + await this._resolveSDKState(); + return this._ready.then(() => this._token); + } + async getUserId() { + await this._resolveSDKState(); + return this._ready.then(() => this._userId); + } + + get _baseURL() { + if (this._endpoint !== null) { + return this._endpoint; + } + return `https://${this.instanceId}.pushnotifications.pusher.com`; + } + + _throwIfNotStarted(message) { + if (!this._deviceId) { + throw new Error( + `${message}. SDK not registered with Beams. Did you call .start?` + ); + } + } + + async _resolveSDKState() { + await this._ready; + await this._detectSubscriptionChange(); + } + + async addDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not add Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${ + this._deviceId + }/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'POST', + path, + }; + await doRequest(options); + } + + async removeDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not remove Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${ + this._deviceId + }/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'DELETE', + path, + }; + await doRequest(options); + } + + async getDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not get Device Interests'); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/interests`; + const options = { + method: 'GET', + path, + }; + return (await doRequest(options))['interests'] || []; + } + + async setDeviceInterests(interests) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not set Device Interests'); + + if (interests === undefined || interests === null) { + throw new Error('interests argument is required'); + } + if (!Array.isArray(interests)) { + throw new Error('interests argument must be an array'); + } + if (interests.length > MAX_INTERESTS_NUM) { + throw new Error( + `Number of interests (${ + interests.length + }) exceeds maximum of ${MAX_INTERESTS_NUM}` + ); + } + for (let interest of interests) { + validateInterestName(interest); + } + + const uniqueInterests = Array.from(new Set(interests)); + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/interests`; + const options = { + method: 'PUT', + path, + body: { + interests: uniqueInterests, + }, + }; + await doRequest(options); + } + + async clearDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not clear Device Interests'); + + await this.setDeviceInterests([]); + } + + async _deleteDevice() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${encodeURIComponent(this._deviceId)}`; + + const options = { method: 'DELETE', path }; + await doRequest(options); + } + + // TODO is this ever used? + /** + * Submit SDK version and browser details (via the user agent) to Pusher Beams. + */ + async _updateDeviceMetadata() { + const userAgent = window.navigator.userAgent; + const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); + const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); + + if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { + // Nothing to do + return; + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/metadata`; + + const metadata = { + sdkVersion, + }; + + const options = { method: 'PUT', path, body: metadata }; + await doRequest(options); + + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent(userAgent); + } +} + +function validateInterestName(interest) { + if (interest === undefined || interest === null) { + throw new Error('Interest name is required'); + } + if (typeof interest !== 'string') { + throw new Error(`Interest ${interest} is not a string`); + } + if (!INTERESTS_REGEX.test(interest)) { + throw new Error( + `interest "${interest}" contains a forbidden character. ` + + 'Allowed characters are: ASCII upper/lower-case letters, ' + + 'numbers or one of _-=@,.;' + ); + } + if (interest.length > MAX_INTEREST_LENGTH) { + throw new Error( + `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` + ); + } +} diff --git a/src/push-notifications.js b/src/push-notifications.js index e5e6c2fd..06eb1e0d 100644 --- a/src/push-notifications.js +++ b/src/push-notifications.js @@ -1,15 +1,7 @@ import { WebPushClient } from './web-push-client'; import { SafariClient } from './safari-client'; import TokenProvider from './token-provider'; - -const RegistrationState = Object.freeze({ - PERMISSION_PROMPT_REQUIRED: 'PERMISSION_PROMPT_REQUIRED', - PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS: - 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', - PERMISSION_GRANTED_REGISTERED_WITH_BEAMS: - 'PERMISSION_GRANTED_REGISTERED_WITH_BEAMS', - PERMISSION_DENIED: 'PERMISSION_DENIED', -}); +import { RegistrationState } from './base-client'; function Client(config) { if ('safari' in window) { diff --git a/src/safari-client.js b/src/safari-client.js index 9e8c7f54..29fd648d 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -1,12 +1,16 @@ import doRequest from './do-request'; +import BaseClient from './base-client'; import DeviceStateStore from './device-state-store'; import { version as sdkVersion } from '../package.json'; +import { RegistrationState } from './base-client'; const __url = 'https://localhost:8080'; const __pushId = 'web.io.lees.safari-push'; -export class SafariClient { +export class SafariClient extends BaseClient { constructor(config) { + // TODO can this validation be moved into the base client + super(config); if (!config) { throw new Error('Config object required'); } @@ -40,6 +44,7 @@ export class SafariClient { this._userId = null; this._deviceStateStore = new DeviceStateStore(instanceId); this._endpoint = endpointOverride; // Internal only + this._platform = 'safari'; this._ready = this._init(); } @@ -161,140 +166,13 @@ export class SafariClient { // await this.start(); } - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // Everything below can be moved to the base client - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - async getDeviceId() { - await this._resolveSDKState(); - return this._ready.then(() => this.deviceId); - } - async getToken() { - await this._resolveSDKState(); - return this._ready.then(() => this._token); - } - async getUserId() { - await this._resolveSDKState(); - return this._ready.then(() => this.userId); - } - - get _baseURL() { - if (this._endpoint !== null) { - return this._endpoint; - } - return `https://${this.instanceId}.pushnotifications.pusher.com`; - } - - _throwIfNotStarted(message) { - if (!this._deviceId) { - throw new Error( - `${message}. SDK not registered with Beams. Did you call .start?` - ); - } - } - - async _resolveSDKState() { - await this._ready; - await this._detectSubscriptionChange(); - } - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // copied and pasted from the other client - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // - async addDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not add Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'POST', - path, - }; - await doRequest(options); - } - - async removeDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not remove Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'DELETE', - path, - }; - await doRequest(options); - } - - async getDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not get Device Interests'); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'GET', - path, - }; - return (await doRequest(options))['interests'] || []; - } - - async setDeviceInterests(interests) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not set Device Interests'); - - if (interests === undefined || interests === null) { - throw new Error('interests argument is required'); - } - if (!Array.isArray(interests)) { - throw new Error('interests argument must be an array'); - } - if (interests.length > MAX_INTERESTS_NUM) { - throw new Error( - `Number of interests (${ - interests.length - }) exceeds maximum of ${MAX_INTERESTS_NUM}` - ); - } - for (let interest of interests) { - validateInterestName(interest); - } - - const uniqueInterests = Array.from(new Set(interests)); - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'PUT', - path, - body: { - interests: uniqueInterests, - }, - }; - await doRequest(options); - } - - async clearDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not clear Device Interests'); - - await this.setDeviceInterests([]); - } - + // TODO these _should_ be movable, just need to resolve the isSupported thing async setUserId(userId, tokenProvider) { await this._resolveSDKState(); - if (!isSupportedBrowser()) { - return; - } + // if (!isSupportedBrowser()) { + // return; + // } if (this._deviceId === null) { const error = new Error('.start must be called before .setUserId'); @@ -347,72 +225,12 @@ export class SafariClient { this._token = null; this._userId = null; } - - async _deleteDevice() { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${encodeURIComponent(this._deviceId)}`; - - const options = { method: 'DELETE', path }; - await doRequest(options); - } - - // TODO is this ever used? - /** - * Submit SDK version and browser details (via the user agent) to Pusher Beams. - */ - async _updateDeviceMetadata() { - const userAgent = window.navigator.userAgent; - const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); - const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); - - if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { - // Nothing to do - return; - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/metadata`; - - const metadata = { - sdkVersion, - }; - - const options = { method: 'PUT', path, body: metadata }; - await doRequest(options); - - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent(userAgent); - } } function isSupportedVersion() { return 'safari' in window && 'pushNotification' in window.safari; } -// TODO should be in base client -const validateInterestName = (interest) => { - if (interest === undefined || interest === null) { - throw new Error('Interest name is required'); - } - if (typeof interest !== 'string') { - throw new Error(`Interest ${interest} is not a string`); - } - if (!INTERESTS_REGEX.test(interest)) { - throw new Error( - `interest "${interest}" contains a forbidden character. ` + - 'Allowed characters are: ASCII upper/lower-case letters, ' + - 'numbers or one of _-=@,.;' - ); - } - if (interest.length > MAX_INTEREST_LENGTH) { - throw new Error( - `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` - ); - } -}; - function getPermission(pushId) { return window.safari.pushNotification.permission(pushId); } diff --git a/src/web-push-client.js b/src/web-push-client.js index 3d46f881..e6cc255f 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -1,15 +1,15 @@ import doRequest from './do-request'; +import BaseClient from './base-client'; import DeviceStateStore from './device-state-store'; import { version as sdkVersion } from '../package.json'; - -const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); -const MAX_INTEREST_LENGTH = 164; -const MAX_INTERESTS_NUM = 5000; +import { RegistrationState } from './base-client'; const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; -export class WebPushClient { +export class WebPushClient extends BaseClient { constructor(config) { + // TODO can this validation be moved into the base client + super(config); if (!config) { throw new Error('Config object required'); } @@ -71,6 +71,7 @@ export class WebPushClient { this._serviceWorkerRegistration = serviceWorkerRegistration; this._deviceStateStore = new DeviceStateStore(instanceId); this._endpoint = endpointOverride; // Internal only + this._platform = 'web'; this._ready = this._init(); } @@ -97,12 +98,6 @@ export class WebPushClient { this._userId = await this._deviceStateStore.getUserId(); } - // Ensure SDK is loaded and is consistent - async _resolveSDKState() { - await this._ready; - await this._detectSubscriptionChange(); - } - async _detectSubscriptionChange() { const storedToken = await this._deviceStateStore.getToken(); const actualToken = await getWebPushToken(this._serviceWorkerRegistration); @@ -123,36 +118,6 @@ export class WebPushClient { } } - async getDeviceId() { - await this._resolveSDKState(); - return this._ready.then(() => this._deviceId); - } - - async getToken() { - await this._resolveSDKState(); - return this._ready.then(() => this._token); - } - - async getUserId() { - await this._resolveSDKState(); - return this._ready.then(() => this._userId); - } - - get _baseURL() { - if (this._endpoint !== null) { - return this._endpoint; - } - return `https://${this.instanceId}.pushnotifications.pusher.com`; - } - - _throwIfNotStarted(message) { - if (!this._deviceId) { - throw new Error( - `${message}. SDK not registered with Beams. Did you call .start?` - ); - } - } - async start() { await this._resolveSDKState(); @@ -202,94 +167,6 @@ export class WebPushClient { return RegistrationState.PERMISSION_PROMPT_REQUIRED; } - async addDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not add Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'POST', - path, - }; - await doRequest(options); - } - - async removeDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not remove Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'DELETE', - path, - }; - await doRequest(options); - } - - async getDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not get Device Interests'); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'GET', - path, - }; - return (await doRequest(options))['interests'] || []; - } - - async setDeviceInterests(interests) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not set Device Interests'); - - if (interests === undefined || interests === null) { - throw new Error('interests argument is required'); - } - if (!Array.isArray(interests)) { - throw new Error('interests argument must be an array'); - } - if (interests.length > MAX_INTERESTS_NUM) { - throw new Error( - `Number of interests (${ - interests.length - }) exceeds maximum of ${MAX_INTERESTS_NUM}` - ); - } - for (let interest of interests) { - validateInterestName(interest); - } - - const uniqueInterests = Array.from(new Set(interests)); - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'PUT', - path, - body: { - interests: uniqueInterests, - }, - }; - await doRequest(options); - } - - async clearDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not clear Device Interests'); - - await this.setDeviceInterests([]); - } - async setUserId(userId, tokenProvider) { await this._resolveSDKState(); @@ -406,67 +283,8 @@ export class WebPushClient { const response = await doRequest(options); return response.id; } - - async _deleteDevice() { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${encodeURIComponent(this._deviceId)}`; - - const options = { method: 'DELETE', path }; - await doRequest(options); - } - - /** - * Submit SDK version and browser details (via the user agent) to Pusher Beams. - */ - async _updateDeviceMetadata() { - const userAgent = window.navigator.userAgent; - const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); - const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); - - if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { - // Nothing to do - return; - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/metadata`; - - const metadata = { - sdkVersion, - }; - - const options = { method: 'PUT', path, body: metadata }; - await doRequest(options); - - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent(userAgent); - } } -// TODO this should be in the base client -const validateInterestName = (interest) => { - if (interest === undefined || interest === null) { - throw new Error('Interest name is required'); - } - if (typeof interest !== 'string') { - throw new Error(`Interest ${interest} is not a string`); - } - if (!INTERESTS_REGEX.test(interest)) { - throw new Error( - `interest "${interest}" contains a forbidden character. ` + - 'Allowed characters are: ASCII upper/lower-case letters, ' + - 'numbers or one of _-=@,.;' - ); - } - if (interest.length > MAX_INTEREST_LENGTH) { - throw new Error( - `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` - ); - } -}; - async function getServiceWorkerRegistration() { // Check that service worker file exists const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); From 6f7aaaca8aeb4681687998b1f2a5530f1a7243df Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 22 Jan 2021 16:56:51 +0000 Subject: [PATCH 06/27] Move register device method to base client --- src/base-client.js | 18 ++++++++++++++++++ src/safari-client.js | 23 ----------------------- src/web-push-client.js | 18 ------------------ 3 files changed, 18 insertions(+), 41 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index 0f7ed114..4255d811 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -178,6 +178,24 @@ export default class BaseClient { await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); await this._deviceStateStore.setLastSeenUserAgent(userAgent); } + + async _registerDevice(deviceToken) { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}`; + + const device = { + token, + metadata: { + sdkVersion, + }, + }; + + const options = { method: 'POST', path, body: device }; + const response = await doRequest(options); + return response.id; + } + } function validateInterestName(interest) { diff --git a/src/safari-client.js b/src/safari-client.js index 29fd648d..a7f7b129 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -113,29 +113,6 @@ export class SafariClient extends BaseClient { } } - async _registerDevice(deviceToken) { - // const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - // this.instanceId - // )}/devices/web`; - - // const device = { - // token, - // metadata: { - // sdkVersion, - // }, - // }; - - // const options = { method: 'POST', path, body: device }; - // const response = await doRequest(options); - // return response.id; - return new Promise((resolve) => { - console.debug( - 'I should be sending the device token to errol now, but that is not implemented yet' - ); - resolve('not--a--real--device--id'); - }); - } - async getRegistrationState() { await this._resolveSDKState(); diff --git a/src/web-push-client.js b/src/web-push-client.js index e6cc255f..49cf6e1a 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -267,24 +267,6 @@ export class WebPushClient extends BaseClient { }); } - async _registerDevice(token) { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web`; - - const device = { - token, - metadata: { - sdkVersion, - }, - }; - - const options = { method: 'POST', path, body: device }; - const response = await doRequest(options); - return response.id; - } -} - async function getServiceWorkerRegistration() { // Check that service worker file exists const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); From 1df7da990963cbc0ca0e8faab39e7d59452e3110 Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 22 Jan 2021 16:57:12 +0000 Subject: [PATCH 07/27] add some TODOs --- src/safari-client.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/safari-client.js b/src/safari-client.js index a7f7b129..59d19693 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -134,6 +134,8 @@ export class SafariClient extends BaseClient { } async clearAllState() { + // TODO we can only call start() in a user gesture so this may not work in + // safari, can't we clear the state another way throw new Error('Not implemented'); // if (!isSupportedBrowser()) { // return; @@ -143,7 +145,10 @@ export class SafariClient extends BaseClient { // await this.start(); } - // TODO these _should_ be movable, just need to resolve the isSupported thing + // TODO these seem similar enough to go in the base client but + // isSupportedBrowser is going to be different for safari/web-push. It's not + // clear to me at the moment why we need to check whether the browser is + // supported here anyway async setUserId(userId, tokenProvider) { await this._resolveSDKState(); From f32dce68d5eb001be8bfe7c418feb3d3a4306aa8 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 17 Feb 2021 13:51:02 +0000 Subject: [PATCH 08/27] Add missing brace --- src/web-push-client.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web-push-client.js b/src/web-push-client.js index 49cf6e1a..a412e80c 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -266,6 +266,7 @@ export class WebPushClient extends BaseClient { if (sub) sub.unsubscribe(); }); } +} async function getServiceWorkerRegistration() { // Check that service worker file exists From 280ae7413c4bcbe719f137de5e2a704f86e8430b Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 17 Feb 2021 17:22:21 +0000 Subject: [PATCH 09/27] token -> deviceToken --- src/base-client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/base-client.js b/src/base-client.js index 4255d811..e073ae05 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -185,7 +185,7 @@ export default class BaseClient { )}/devices/${this._platform}`; const device = { - token, + deviceToken, metadata: { sdkVersion, }, From ab70466fcffaf9a9a1579a6809a5196aa900deaf Mon Sep 17 00:00:00 2001 From: James Lees Date: Thu, 18 Feb 2021 11:37:12 +0000 Subject: [PATCH 10/27] fix linter errors --- src/base-client.js | 4 ++-- src/safari-client.js | 8 ++++++-- src/web-push-client.js | 8 ++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index e073ae05..6a1a1118 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -1,4 +1,5 @@ import doRequest from './do-request'; +import { version as sdkVersion } from '../package.json'; const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); const MAX_INTEREST_LENGTH = 164; @@ -14,7 +15,7 @@ export const RegistrationState = Object.freeze({ }); export default class BaseClient { - constructor(config) {} + constructor(_) {} async getDeviceId() { await this._resolveSDKState(); @@ -195,7 +196,6 @@ export default class BaseClient { const response = await doRequest(options); return response.id; } - } function validateInterestName(interest) { diff --git a/src/safari-client.js b/src/safari-client.js index 59d19693..a5fb146d 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -79,7 +79,7 @@ export class SafariClient extends BaseClient { } _requestPermission() { - return new Promise((resolve) => { + return new Promise(resolve => { window.safari.pushNotification.requestPermission( __url, __pushId, @@ -94,7 +94,7 @@ export class SafariClient extends BaseClient { return this; } - let { deviceToken, permission } = getPermission(__pushId); + let { permission } = getPermission(__pushId); if (permission === 'default') { console.debug('permission is default, requesting permission'); @@ -209,6 +209,10 @@ export class SafariClient extends BaseClient { } } +function isSupportedBrowser() { + return isSupportedVersion(); +} + function isSupportedVersion() { return 'safari' in window && 'pushNotification' in window.safari; } diff --git a/src/web-push-client.js b/src/web-push-client.js index a412e80c..90bf27a5 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -261,8 +261,8 @@ export class WebPushClient extends BaseClient { async _clearPushToken() { return navigator.serviceWorker.ready - .then((reg) => reg.pushManager.getSubscription()) - .then((sub) => { + .then(reg => reg.pushManager.getSubscription()) + .then(sub => { if (sub) sub.unsubscribe(); }); } @@ -289,7 +289,7 @@ async function getServiceWorkerRegistration() { function getWebPushToken(swReg) { return swReg.pushManager .getSubscription() - .then((sub) => (!sub ? null : encodeSubscription(sub))); + .then(sub => (!sub ? null : encodeSubscription(sub))); } function encodeSubscription(sub) { @@ -300,7 +300,7 @@ function urlBase64ToUInt8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); - return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); + return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); } /** From d672b111941a938c91a0af4546a33b70815e53c9 Mon Sep 17 00:00:00 2001 From: James Lees Date: Thu, 18 Feb 2021 12:15:44 +0000 Subject: [PATCH 11/27] fix accidental renaming token->deviceToken --- src/base-client.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index 6a1a1118..604042af 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -180,13 +180,13 @@ export default class BaseClient { await this._deviceStateStore.setLastSeenUserAgent(userAgent); } - async _registerDevice(deviceToken) { + async _registerDevice(token) { const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( this.instanceId )}/devices/${this._platform}`; const device = { - deviceToken, + token, metadata: { sdkVersion, }, From fdb1cb5de13e17ddb04255d97ecc797d675a020c Mon Sep 17 00:00:00 2001 From: James Lees Date: Thu, 18 Feb 2021 18:12:07 +0000 Subject: [PATCH 12/27] Split register device methods Safari devices will include the web push ID --- src/base-client.js | 10 +--------- src/safari-client.js | 15 ++++++++++++++- src/web-push-client.js | 9 +++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index 604042af..136adc6b 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -180,18 +180,10 @@ export default class BaseClient { await this._deviceStateStore.setLastSeenUserAgent(userAgent); } - async _registerDevice(token) { + async _registerDevice(device) { const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( this.instanceId )}/devices/${this._platform}`; - - const device = { - token, - metadata: { - sdkVersion, - }, - }; - const options = { method: 'POST', path, body: device }; const response = await doRequest(options); return response.id; diff --git a/src/safari-client.js b/src/safari-client.js index a5fb146d..2d6648d7 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -100,7 +100,10 @@ export class SafariClient extends BaseClient { console.debug('permission is default, requesting permission'); let { deviceToken, permission } = await this._requestPermission(__pushId); if (permission == 'granted') { - const deviceId = await this._registerDevice(deviceToken); + const deviceId = await this._registerDevice( + deviceToken, + this._websitePushId + ); await this._deviceStateStore.setToken(deviceToken); await this._deviceStateStore.setDeviceId(deviceId); await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); @@ -207,6 +210,16 @@ export class SafariClient extends BaseClient { this._token = null; this._userId = null; } + + async _registerDevice(token, websitePushId) { + return await super._registerDevice({ + token, + websitePushId, + metadata: { + sdkVersion, + }, + }); + } } function isSupportedBrowser() { diff --git a/src/web-push-client.js b/src/web-push-client.js index 90bf27a5..afb20f86 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -266,6 +266,15 @@ export class WebPushClient extends BaseClient { if (sub) sub.unsubscribe(); }); } + + async _registerDevice(token) { + return await super._registerDevice({ + token, + metadata: { + sdkVersion, + }, + }); + } } async function getServiceWorkerRegistration() { From 61ee849cd4e9ec8056777c5fcdb613a4c893fdd8 Mon Sep 17 00:00:00 2001 From: James Lees Date: Thu, 18 Feb 2021 18:16:43 +0000 Subject: [PATCH 13/27] Add dummy request for websitePushID --- src/safari-client.js | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 2d6648d7..83c68c6f 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -50,6 +50,10 @@ export class SafariClient extends BaseClient { } async _init() { + // Temporary until the website push id endpoint is up and running + this._websitePushId = __pushId; + this._serviceUrl = __url; + if (this._deviceId !== null) { return; } @@ -58,14 +62,16 @@ export class SafariClient extends BaseClient { await this._detectSubscriptionChange(); - this._deviceId = await this._deviceStateStore.getDeviceId(); + this._deviceId = await this._deviceStateStore.getDeviceId( + this._websitePushId + ); this._token = await this._deviceStateStore.getToken(); this._userId = await this._deviceStateStore.getUserId(); } async _detectSubscriptionChange() { const storedToken = await this._deviceStateStore.getToken(); - const actualToken = getDeviceToken(); + const actualToken = getDeviceToken(this._websitePushId); const tokenHasChanged = storedToken !== actualToken; if (tokenHasChanged) { @@ -81,8 +87,8 @@ export class SafariClient extends BaseClient { _requestPermission() { return new Promise(resolve => { window.safari.pushNotification.requestPermission( - __url, - __pushId, + this._serviceUrl, + this._websitePushId, { userID: 'abcdef' }, resolve ); @@ -90,15 +96,19 @@ export class SafariClient extends BaseClient { } async start() { + await this.ready; + if (this._deviceId !== null) { return this; } - let { permission } = getPermission(__pushId); + let { permission } = getPermission(this._websitePushId); if (permission === 'default') { console.debug('permission is default, requesting permission'); - let { deviceToken, permission } = await this._requestPermission(__pushId); + let { deviceToken, permission } = await this._requestPermission( + this._websitePushId + ); if (permission == 'granted') { const deviceId = await this._registerDevice( deviceToken, @@ -119,7 +129,7 @@ export class SafariClient extends BaseClient { async getRegistrationState() { await this._resolveSDKState(); - const { permission } = getPermission(__pushId); + const { permission } = getPermission(this._websitePushId); if (permission === 'denied') { return RegistrationState.PERMISSION_DENIED; @@ -233,7 +243,9 @@ function isSupportedVersion() { function getPermission(pushId) { return window.safari.pushNotification.permission(pushId); } -function getDeviceToken() { - const { deviceToken } = window.safari.pushNotification.permission(__pushId); +function getDeviceToken(websitePushId) { + const { deviceToken } = window.safari.pushNotification.permission( + websitePushId + ); return deviceToken; } From d5b7483f48de0d86070affb63732b9257ffe630e Mon Sep 17 00:00:00 2001 From: James Lees Date: Thu, 18 Feb 2021 18:24:44 +0000 Subject: [PATCH 14/27] dummy method for requesting websitePushId --- src/safari-client.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/safari-client.js b/src/safari-client.js index 83c68c6f..3ff7a877 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -51,7 +51,7 @@ export class SafariClient extends BaseClient { async _init() { // Temporary until the website push id endpoint is up and running - this._websitePushId = __pushId; + this._websitePushId = await this._fetchWebsitePushId(); this._serviceUrl = __url; if (this._deviceId !== null) { @@ -230,6 +230,13 @@ export class SafariClient extends BaseClient { }, }); } + + _fetchWebsitePushId() { + return new Promise(resolve => { + // TODO temporary + resolve(__pushId); + }); + } } function isSupportedBrowser() { From 2bf668b8a4d52eef3674863a3002eb6f9ed4a5ec Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 19 Feb 2021 14:11:19 +0000 Subject: [PATCH 15/27] Move shared config into base client --- src/base-client.js | 38 ++++++++++++++++++++++++++++++- src/safari-client.js | 51 +++++++----------------------------------- src/web-push-client.js | 40 ++++----------------------------- 3 files changed, 49 insertions(+), 80 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index 136adc6b..b0203bf8 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -1,5 +1,6 @@ import doRequest from './do-request'; import { version as sdkVersion } from '../package.json'; +import DeviceStateStore from './device-state-store'; const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); const MAX_INTEREST_LENGTH = 164; @@ -15,7 +16,41 @@ export const RegistrationState = Object.freeze({ }); export default class BaseClient { - constructor(_) {} + constructor(config, platform) { + if (this.constructor === BaseClient) { + throw new Error( + 'BaseClient is abstract and should not be directly constructed.' + ); + } + + if (!config) { + throw new Error('Config object required'); + } + + const { instanceId, endpointOverride = null } = config; + if (instanceId === undefined) { + throw new Error('Instance ID is required'); + } + if (typeof instanceId !== 'string') { + throw new Error('Instance ID must be a string'); + } + if (instanceId.length === 0) { + throw new Error('Instance ID cannot be empty'); + } + + if (!('indexedDB' in window)) { + throw new Error( + 'Pusher Beams does not support this browser version (IndexedDB not supported)' + ); + } + this.instanceId = instanceId; + this._deviceId = null; + this._token = null; + this._userId = null; + this._deviceStateStore = new DeviceStateStore(instanceId); + this._endpoint = endpointOverride; // Internal only + this._platform = platform; + } async getDeviceId() { await this._resolveSDKState(); @@ -25,6 +60,7 @@ export default class BaseClient { await this._resolveSDKState(); return this._ready.then(() => this._token); } + async getUserId() { await this._resolveSDKState(); return this._ready.then(() => this._userId); diff --git a/src/safari-client.js b/src/safari-client.js index 3ff7a877..924da4b6 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -1,56 +1,26 @@ import doRequest from './do-request'; import BaseClient from './base-client'; -import DeviceStateStore from './device-state-store'; import { version as sdkVersion } from '../package.json'; import { RegistrationState } from './base-client'; const __url = 'https://localhost:8080'; const __pushId = 'web.io.lees.safari-push'; +const platform = 'safari'; + export class SafariClient extends BaseClient { constructor(config) { - // TODO can this validation be moved into the base client - super(config); - if (!config) { - throw new Error('Config object required'); - } - const { instanceId, endpointOverride = null } = config; - - if (instanceId === undefined) { - throw new Error('Instance ID is required'); - } - if (typeof instanceId !== 'string') { - throw new Error('Instance ID must be a string'); - } - if (instanceId.length === 0) { - throw new Error('Instance ID cannot be empty'); - } - - if (!('indexedDB' in window)) { - throw new Error( - 'Pusher Beams does not support this browser version (IndexedDB not supported)' - ); - } - - if (!isSupportedVersion()) { + super(config, platform); + if (!isSupportedBrowser()) { throw new Error( - 'Pusher Beams does not support this safari version (Safari Push Noifications not supported)' + 'Pusher Beams does not support this Safari version (Safari Push Notifications not supported)' ); } - this.instanceId = instanceId; - this._deviceId = null; - this._token = null; - this._userId = null; - this._deviceStateStore = new DeviceStateStore(instanceId); - this._endpoint = endpointOverride; // Internal only - this._platform = 'safari'; - this._ready = this._init(); } async _init() { - // Temporary until the website push id endpoint is up and running this._websitePushId = await this._fetchWebsitePushId(); this._serviceUrl = __url; @@ -59,7 +29,6 @@ export class SafariClient extends BaseClient { } await this._deviceStateStore.connect(); - await this._detectSubscriptionChange(); this._deviceId = await this._deviceStateStore.getDeviceId( @@ -165,9 +134,9 @@ export class SafariClient extends BaseClient { async setUserId(userId, tokenProvider) { await this._resolveSDKState(); - // if (!isSupportedBrowser()) { - // return; - // } + if (!isSupportedBrowser()) { + return; + } if (this._deviceId === null) { const error = new Error('.start must be called before .setUserId'); @@ -240,10 +209,6 @@ export class SafariClient extends BaseClient { } function isSupportedBrowser() { - return isSupportedVersion(); -} - -function isSupportedVersion() { return 'safari' in window && 'pushNotification' in window.safari; } diff --git a/src/web-push-client.js b/src/web-push-client.js index afb20f86..75fcd577 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -1,39 +1,14 @@ import doRequest from './do-request'; import BaseClient from './base-client'; -import DeviceStateStore from './device-state-store'; import { version as sdkVersion } from '../package.json'; import { RegistrationState } from './base-client'; const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; +const platform = 'web'; export class WebPushClient extends BaseClient { constructor(config) { - // TODO can this validation be moved into the base client - super(config); - if (!config) { - throw new Error('Config object required'); - } - const { - instanceId, - endpointOverride = null, - serviceWorkerRegistration = null, - } = config; - - if (instanceId === undefined) { - throw new Error('Instance ID is required'); - } - if (typeof instanceId !== 'string') { - throw new Error('Instance ID must be a string'); - } - if (instanceId.length === 0) { - throw new Error('Instance ID cannot be empty'); - } - - if (!('indexedDB' in window)) { - throw new Error( - 'Pusher Beams does not support this browser version (IndexedDB not supported)' - ); - } + super(config, platform); if (!window.isSecureContext) { throw new Error( @@ -53,6 +28,8 @@ export class WebPushClient extends BaseClient { ); } + const { serviceWorkerRegistration = null } = config; + if (serviceWorkerRegistration) { const serviceWorkerScope = serviceWorkerRegistration.scope; const currentURL = window.location.href; @@ -63,16 +40,7 @@ export class WebPushClient extends BaseClient { ); } } - - this.instanceId = instanceId; - this._deviceId = null; - this._token = null; - this._userId = null; this._serviceWorkerRegistration = serviceWorkerRegistration; - this._deviceStateStore = new DeviceStateStore(instanceId); - this._endpoint = endpointOverride; // Internal only - this._platform = 'web'; - this._ready = this._init(); } From 3721fecd35310d028e395fdf340131af3f7fb8e3 Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 19 Feb 2021 15:01:12 +0000 Subject: [PATCH 16/27] Move isSupportedBrowser to subclass --- src/safari-client.js | 14 +++++----- src/web-push-client.js | 59 +++++++++++++++++++++--------------------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 924da4b6..0d929ffe 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -11,7 +11,7 @@ const platform = 'safari'; export class SafariClient extends BaseClient { constructor(config) { super(config, platform); - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { throw new Error( 'Pusher Beams does not support this Safari version (Safari Push Notifications not supported)' ); @@ -119,7 +119,7 @@ export class SafariClient extends BaseClient { // TODO we can only call start() in a user gesture so this may not work in // safari, can't we clear the state another way throw new Error('Not implemented'); - // if (!isSupportedBrowser()) { + // if (!this._isSupportedBrowser()) { // return; // } @@ -134,7 +134,7 @@ export class SafariClient extends BaseClient { async setUserId(userId, tokenProvider) { await this._resolveSDKState(); - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { return; } @@ -173,7 +173,7 @@ export class SafariClient extends BaseClient { async stop() { await this._resolveSDKState(); - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { return; } @@ -206,11 +206,11 @@ export class SafariClient extends BaseClient { resolve(__pushId); }); } + _isSupportedBrowser() { + return 'safari' in window && 'pushNotification' in window.safari; + } } -function isSupportedBrowser() { - return 'safari' in window && 'pushNotification' in window.safari; -} function getPermission(pushId) { return window.safari.pushNotification.permission(pushId); diff --git a/src/web-push-client.js b/src/web-push-client.js index 75fcd577..2da96ac4 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -89,7 +89,7 @@ export class WebPushClient extends BaseClient { async start() { await this._resolveSDKState(); - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { return this; } @@ -138,7 +138,7 @@ export class WebPushClient extends BaseClient { async setUserId(userId, tokenProvider) { await this._resolveSDKState(); - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { return; } @@ -177,7 +177,7 @@ export class WebPushClient extends BaseClient { async stop() { await this._resolveSDKState(); - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { return; } @@ -195,7 +195,7 @@ export class WebPushClient extends BaseClient { } async clearAllState() { - if (!isSupportedBrowser()) { + if (!this._isSupportedBrowser()) { return; } @@ -243,6 +243,32 @@ export class WebPushClient extends BaseClient { }, }); } + + /** + * Modified from https://stackoverflow.com/questions/4565112 + */ + _isSupportedBrowser() { + const winNav = window.navigator; + const vendorName = winNav.vendor; + + const isChromium = + window.chrome !== null && typeof window.chrome !== 'undefined'; + const isOpera = winNav.userAgent.indexOf('OPR') > -1; + const isEdge = winNav.userAgent.indexOf('Edg') > -1; + const isFirefox = winNav.userAgent.indexOf('Firefox') > -1; + + const isChrome = + isChromium && vendorName === 'Google Inc.' && !isEdge && !isOpera; + + const isSupported = isChrome || isOpera || isFirefox || isEdge; + + if (!isSupported) { + console.warn( + 'Pusher Web Push Notifications supports Chrome, Firefox, Edge and Opera.' + ); + } + return isSupported; + } } async function getServiceWorkerRegistration() { @@ -280,28 +306,3 @@ function urlBase64ToUInt8Array(base64String) { return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); } -/** - * Modified from https://stackoverflow.com/questions/4565112 - */ -function isSupportedBrowser() { - const winNav = window.navigator; - const vendorName = winNav.vendor; - - const isChromium = - window.chrome !== null && typeof window.chrome !== 'undefined'; - const isOpera = winNav.userAgent.indexOf('OPR') > -1; - const isEdge = winNav.userAgent.indexOf('Edg') > -1; - const isFirefox = winNav.userAgent.indexOf('Firefox') > -1; - - const isChrome = - isChromium && vendorName === 'Google Inc.' && !isEdge && !isOpera; - - const isSupported = isChrome || isOpera || isFirefox || isEdge; - - if (!isSupported) { - console.warn( - 'Pusher Web Push Notifications supports Chrome, Firefox, Edge and Opera.' - ); - } - return isSupported; -} From 9a9a4b71f6e8b2c6af6b33c53e94f010ab1f5bbe Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 19 Feb 2021 15:05:43 +0000 Subject: [PATCH 17/27] Move setUserId to base class --- src/base-client.js | 39 ++++++++++++++++++++++++++++++++++++++ src/safari-client.js | 43 ------------------------------------------ src/web-push-client.js | 39 -------------------------------------- 3 files changed, 39 insertions(+), 82 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index b0203bf8..961cd2db 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -224,6 +224,45 @@ export default class BaseClient { const response = await doRequest(options); return response.id; } + + async setUserId(userId, tokenProvider) { + await this._resolveSDKState(); + + if (!this._isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + const error = new Error('.start must be called before .setUserId'); + return Promise.reject(error); + } + if (typeof userId !== 'string') { + throw new Error(`User ID must be a string (was ${userId})`); + } + if (userId === '') { + throw new Error('User ID cannot be the empty string'); + } + if (this._userId !== null && this._userId !== userId) { + throw new Error('Changing the `userId` is not allowed.'); + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/web/${this._deviceId}/user`; + + const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); + const options = { + method: 'PUT', + path, + headers: { + Authorization: `Bearer ${beamsAuthToken}`, + }, + }; + await doRequest(options); + + this._userId = userId; + return this._deviceStateStore.setUserId(userId); + } } function validateInterestName(interest) { diff --git a/src/safari-client.js b/src/safari-client.js index 0d929ffe..8934257f 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -127,49 +127,6 @@ export class SafariClient extends BaseClient { // await this.start(); } - // TODO these seem similar enough to go in the base client but - // isSupportedBrowser is going to be different for safari/web-push. It's not - // clear to me at the moment why we need to check whether the browser is - // supported here anyway - async setUserId(userId, tokenProvider) { - await this._resolveSDKState(); - - if (!this._isSupportedBrowser()) { - return; - } - - if (this._deviceId === null) { - const error = new Error('.start must be called before .setUserId'); - return Promise.reject(error); - } - if (typeof userId !== 'string') { - throw new Error(`User ID must be a string (was ${userId})`); - } - if (userId === '') { - throw new Error('User ID cannot be the empty string'); - } - if (this._userId !== null && this._userId !== userId) { - throw new Error('Changing the `userId` is not allowed.'); - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/user`; - - const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); - const options = { - method: 'PUT', - path, - headers: { - Authorization: `Bearer ${beamsAuthToken}`, - }, - }; - await doRequest(options); - - this._userId = userId; - return this._deviceStateStore.setUserId(userId); - } - async stop() { await this._resolveSDKState(); diff --git a/src/web-push-client.js b/src/web-push-client.js index 2da96ac4..7e4ca35c 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -135,45 +135,6 @@ export class WebPushClient extends BaseClient { return RegistrationState.PERMISSION_PROMPT_REQUIRED; } - async setUserId(userId, tokenProvider) { - await this._resolveSDKState(); - - if (!this._isSupportedBrowser()) { - return; - } - - if (this._deviceId === null) { - const error = new Error('.start must be called before .setUserId'); - return Promise.reject(error); - } - if (typeof userId !== 'string') { - throw new Error(`User ID must be a string (was ${userId})`); - } - if (userId === '') { - throw new Error('User ID cannot be the empty string'); - } - if (this._userId !== null && this._userId !== userId) { - throw new Error('Changing the `userId` is not allowed.'); - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/user`; - - const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); - const options = { - method: 'PUT', - path, - headers: { - Authorization: `Bearer ${beamsAuthToken}`, - }, - }; - await doRequest(options); - - this._userId = userId; - return this._deviceStateStore.setUserId(userId); - } - async stop() { await this._resolveSDKState(); From a0cc09e9065fc788a23c91bbc8c160b12e753733 Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 19 Feb 2021 15:21:13 +0000 Subject: [PATCH 18/27] Add not implemented methods on abstract class Also npm run format --- src/base-client.js | 20 ++++++++++++++++++++ src/safari-client.js | 2 -- src/web-push-client.js | 1 - 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index 961cd2db..e5fdd570 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -263,6 +263,26 @@ export default class BaseClient { this._userId = userId; return this._deviceStateStore.setUserId(userId); } + + async start() { + throwNotImplementedError('start'); + } + async getRegistrationState() { + throwNotImplementedError('getRegistrationState'); + } + async stop() { + throwNotImplementedError('stop'); + } + async clearAllState() { + throwNotImplementedError('clearAllState'); + } +} + +function throwNotImplementedError(method) { + throw new Error( + `${method} not implemented on abstract BaseClient.` + + 'Instantiate either WebPushClient or SafariClient' + ); } function validateInterestName(interest) { diff --git a/src/safari-client.js b/src/safari-client.js index 8934257f..61c872f8 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -1,4 +1,3 @@ -import doRequest from './do-request'; import BaseClient from './base-client'; import { version as sdkVersion } from '../package.json'; import { RegistrationState } from './base-client'; @@ -168,7 +167,6 @@ export class SafariClient extends BaseClient { } } - function getPermission(pushId) { return window.safari.pushNotification.permission(pushId); } diff --git a/src/web-push-client.js b/src/web-push-client.js index 7e4ca35c..94a81f78 100644 --- a/src/web-push-client.js +++ b/src/web-push-client.js @@ -266,4 +266,3 @@ function urlBase64ToUInt8Array(base64String) { const rawData = window.atob(base64); return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); } - From f29633d4e84a6ada837bb25e759bb33f48b0c5f4 Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 19 Feb 2021 15:26:57 +0000 Subject: [PATCH 19/27] Add comment about base client --- src/base-client.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/base-client.js b/src/base-client.js index e5fdd570..9848f167 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -15,6 +15,19 @@ export const RegistrationState = Object.freeze({ PERMISSION_DENIED: 'PERMISSION_DENIED', }); +/* BaseClient is an abstract client containing functionality shared between + * safari and web push clients. Platform specific classes should extend this + * class. This method expects sub classes to implement the following public + * methods: + * async start() + * async getRegistrationState() { + * async stop() { + * async clearAllState() { + * + * It also assumes that the following private methods are implemented: + * async _init() + * async _detectSubscriptionChange() + */ export default class BaseClient { constructor(config, platform) { if (this.constructor === BaseClient) { From 8a6989074d51e5779ea010d3d2c4cce50a6e78ed Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 24 Feb 2021 14:08:48 +0000 Subject: [PATCH 20/27] Remove pointless getPermission function --- src/safari-client.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 61c872f8..335d263e 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -39,7 +39,7 @@ export class SafariClient extends BaseClient { async _detectSubscriptionChange() { const storedToken = await this._deviceStateStore.getToken(); - const actualToken = getDeviceToken(this._websitePushId); + const { deviceToken: actualToken } = getPermission(this._websitePushId); const tokenHasChanged = storedToken !== actualToken; if (tokenHasChanged) { @@ -167,12 +167,6 @@ export class SafariClient extends BaseClient { } } -function getPermission(pushId) { - return window.safari.pushNotification.permission(pushId); -} -function getDeviceToken(websitePushId) { - const { deviceToken } = window.safari.pushNotification.permission( - websitePushId - ); - return deviceToken; +function getPermission(websitePushId) { + return window.safari.pushNotification.permission(websitePushId); } From 8477e520559e210dc3daaee2416475e9af899769 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 24 Feb 2021 14:09:41 +0000 Subject: [PATCH 21/27] Handle state updating on init The state management is a little bit different to on web-push because the device token should (almost) never change and we can't freely check the stored token before requesting permission (because of how strict the safari is at enforcing permission request from a user gesture). This means the start behaves a little differently: - if the permission is default, immediately request permission - register the device - update the internal state --- src/safari-client.js | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 335d263e..10bc0775 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -43,8 +43,9 @@ export class SafariClient extends BaseClient { const tokenHasChanged = storedToken !== actualToken; if (tokenHasChanged) { - // The device token has changed. This is should only really happen when - // users restore from an iCloud backup + // The device token has changed. This could be because the user has + // rescinded permission, or because the user has restored from a backup. + // Either way we should clear out the old state await this._deviceStateStore.clear(); this._deviceId = null; this._token = null; @@ -53,6 +54,12 @@ export class SafariClient extends BaseClient { } _requestPermission() { + // Check to see whether we've already asked for permission, if we have we + // can't ask again + let { deviceToken, permission } = getPermission(this._websitePushId); + if (permission !== 'default') { + return Promise.resolve({ deviceToken, permission }); + } return new Promise(resolve => { window.safari.pushNotification.requestPermission( this._serviceUrl, @@ -70,27 +77,20 @@ export class SafariClient extends BaseClient { return this; } - let { permission } = getPermission(this._websitePushId); - - if (permission === 'default') { - console.debug('permission is default, requesting permission'); - let { deviceToken, permission } = await this._requestPermission( + let { deviceToken, permission } = await this._requestPermission(); + if (permission == 'granted') { + const deviceId = await this._registerDevice( + deviceToken, this._websitePushId ); - if (permission == 'granted') { - const deviceId = await this._registerDevice( - deviceToken, - this._websitePushId - ); - await this._deviceStateStore.setToken(deviceToken); - await this._deviceStateStore.setDeviceId(deviceId); - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent( - window.navigator.userAgent - ); - this._token = deviceToken; - this._deviceId = deviceId; - } + await this._deviceStateStore.setToken(deviceToken); + await this._deviceStateStore.setDeviceId(deviceId); + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent( + window.navigator.userAgent + ); + this._token = deviceToken; + this._deviceId = deviceId; } } From 8a864e6cdb28cba430bc4e52f5be592e5d9efac6 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 24 Feb 2021 14:14:27 +0000 Subject: [PATCH 22/27] Implement a clearAllState method --- src/safari-client.js | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 10bc0775..166c4aa9 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -115,15 +115,16 @@ export class SafariClient extends BaseClient { } async clearAllState() { - // TODO we can only call start() in a user gesture so this may not work in - // safari, can't we clear the state another way - throw new Error('Not implemented'); - // if (!this._isSupportedBrowser()) { - // return; - // } - - // await this.stop(); - // await this.start(); + if (!this._isSupportedBrowser()) { + return; + } + + await this._deleteDevice(); + await this._deviceStateStore.clear(); + + this._deviceId = null; + this._token = null; + this._userId = null; } async stop() { @@ -136,14 +137,7 @@ export class SafariClient extends BaseClient { if (this._deviceId === null) { return; } - - await this._deleteDevice(); - await this._deviceStateStore.clear(); - this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. - - this._deviceId = null; - this._token = null; - this._userId = null; + await this.clearAllState(); } async _registerDevice(token, websitePushId) { From bea8c1211d97cde3aac0ed886070231a74487454 Mon Sep 17 00:00:00 2001 From: James Lees Date: Fri, 26 Feb 2021 13:46:46 +0000 Subject: [PATCH 23/27] Fetch website push ID from beams --- src/safari-client.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 166c4aa9..d3026c74 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -1,10 +1,9 @@ +import doRequest from './do-request'; import BaseClient from './base-client'; import { version as sdkVersion } from '../package.json'; import { RegistrationState } from './base-client'; const __url = 'https://localhost:8080'; -const __pushId = 'web.io.lees.safari-push'; - const platform = 'safari'; export class SafariClient extends BaseClient { @@ -20,7 +19,8 @@ export class SafariClient extends BaseClient { } async _init() { - this._websitePushId = await this._fetchWebsitePushId(); + let { websitePushId } = await this._fetchWebsitePushId(); + this._websitePushId = websitePushId; this._serviceUrl = __url; if (this._deviceId !== null) { @@ -64,7 +64,7 @@ export class SafariClient extends BaseClient { window.safari.pushNotification.requestPermission( this._serviceUrl, this._websitePushId, - { userID: 'abcdef' }, + {}, resolve ); }); @@ -151,11 +151,14 @@ export class SafariClient extends BaseClient { } _fetchWebsitePushId() { - return new Promise(resolve => { - // TODO temporary - resolve(__pushId); - }); + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/safari-website-push-id`; + + const options = { method: 'GET', path }; + return doRequest(options); } + _isSupportedBrowser() { return 'safari' in window && 'pushNotification' in window.safari; } From 5cccdcc1448136bf8f2fb51376edbbe4d5105cc0 Mon Sep 17 00:00:00 2001 From: James Lees Date: Wed, 3 Mar 2021 19:31:22 +0000 Subject: [PATCH 24/27] Replace getPermission with getCurrentPermission get is close to a synonym of request, hopefully getCurrentPermission is clearer --- src/safari-client.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index 166c4aa9..1fba966b 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -39,7 +39,9 @@ export class SafariClient extends BaseClient { async _detectSubscriptionChange() { const storedToken = await this._deviceStateStore.getToken(); - const { deviceToken: actualToken } = getPermission(this._websitePushId); + const { deviceToken: actualToken } = getCurrentPermission( + this._websitePushId + ); const tokenHasChanged = storedToken !== actualToken; if (tokenHasChanged) { @@ -56,7 +58,7 @@ export class SafariClient extends BaseClient { _requestPermission() { // Check to see whether we've already asked for permission, if we have we // can't ask again - let { deviceToken, permission } = getPermission(this._websitePushId); + let { deviceToken, permission } = getCurrentPermission(this._websitePushId); if (permission !== 'default') { return Promise.resolve({ deviceToken, permission }); } @@ -97,7 +99,7 @@ export class SafariClient extends BaseClient { async getRegistrationState() { await this._resolveSDKState(); - const { permission } = getPermission(this._websitePushId); + const { permission } = getCurrentPermission(this._websitePushId); if (permission === 'denied') { return RegistrationState.PERMISSION_DENIED; @@ -161,6 +163,6 @@ export class SafariClient extends BaseClient { } } -function getPermission(websitePushId) { +function getCurrentPermission(websitePushId) { return window.safari.pushNotification.permission(websitePushId); } From 993766ccf07a9dbbbc8cecc97cf929a42223ed26 Mon Sep 17 00:00:00 2001 From: James Lees Date: Thu, 4 Mar 2021 17:04:52 +0000 Subject: [PATCH 25/27] Use real URL instead of hardcoded localhost --- src/safari-client.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/safari-client.js b/src/safari-client.js index ac56ba9b..e056c039 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -3,7 +3,6 @@ import BaseClient from './base-client'; import { version as sdkVersion } from '../package.json'; import { RegistrationState } from './base-client'; -const __url = 'https://localhost:8080'; const platform = 'safari'; export class SafariClient extends BaseClient { @@ -21,7 +20,9 @@ export class SafariClient extends BaseClient { async _init() { let { websitePushId } = await this._fetchWebsitePushId(); this._websitePushId = websitePushId; - this._serviceUrl = __url; + this._serviceUrl = `${ + this._baseURL + }/safari_api/v1/instances/${encodeURIComponent(this.instanceId)}`; if (this._deviceId !== null) { return; From 261977056eea835cc9654344c4f39e99d6f44b9c Mon Sep 17 00:00:00 2001 From: James Lees Date: Mon, 22 Mar 2021 18:41:00 +0000 Subject: [PATCH 26/27] Bug fixes after testing - always return self from start method - start method should fail if permission request throws - use this._platform when setting user id --- src/base-client.js | 2 +- src/safari-client.js | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/base-client.js b/src/base-client.js index 9848f167..bd77bee8 100644 --- a/src/base-client.js +++ b/src/base-client.js @@ -261,7 +261,7 @@ export default class BaseClient { const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( this.instanceId - )}/devices/web/${this._deviceId}/user`; + )}/devices/${this._platform}/${this._deviceId}/user`; const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); const options = { diff --git a/src/safari-client.js b/src/safari-client.js index e056c039..74635ad9 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -63,13 +63,17 @@ export class SafariClient extends BaseClient { if (permission !== 'default') { return Promise.resolve({ deviceToken, permission }); } - return new Promise(resolve => { - window.safari.pushNotification.requestPermission( - this._serviceUrl, - this._websitePushId, - {}, - resolve - ); + return new Promise((resolve, reject) => { + try { + window.safari.pushNotification.requestPermission( + this._serviceUrl, + this._websitePushId, + {}, + resolve + ); + } catch (e) { + reject(e); + } }); } @@ -95,6 +99,7 @@ export class SafariClient extends BaseClient { this._token = deviceToken; this._deviceId = deviceId; } + return this; } async getRegistrationState() { From bf99229060ba9deca42483e94b93ebbc1f56ff7b Mon Sep 17 00:00:00 2001 From: James Lees Date: Tue, 23 Mar 2021 11:11:17 +0000 Subject: [PATCH 27/27] Throw error if permission denied --- src/safari-client.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/safari-client.js b/src/safari-client.js index 74635ad9..a7bc54cb 100644 --- a/src/safari-client.js +++ b/src/safari-client.js @@ -98,6 +98,8 @@ export class SafariClient extends BaseClient { ); this._token = deviceToken; this._deviceId = deviceId; + } else if (permission === 'denied') { + throw new Error('Registration failed - permission denied'); } return this; }