From afa747d065cdefff8a17b806fda9858afe0e6fea Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Fri, 25 Sep 2026 18:34:36 +0200 Subject: [PATCH] Handle blocked audio and invalid VAPID keys (#11046) * Handle blocked audio and invalid VAPID keys Signed-off-by: Artyom Savchenko * Fix build issues Signed-off-by: Artyom Savchenko --------- Signed-off-by: Artyom Savchenko --- common/config/rush/pnpm-lock.yaml | 3 ++ packages/presentation/package.json | 1 + packages/presentation/src/sound.ts | 36 +++++++++++++++++-- .../presentation/src/workspaceIdentity.ts | 4 ++- packages/presentation/tsconfig.json | 5 +-- plugins/notification-resources/src/utils.ts | 28 +++++++++++++-- 6 files changed, 68 insertions(+), 9 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index eea9e06c83..95e1ba93d4 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -14824,6 +14824,9 @@ importers: '@types/jest': specifier: ^29.5.5 version: 29.5.14 + '@types/node': + specifier: ^22.18.1 + version: 22.19.0 '@types/png-chunks-extract': specifier: ^1.0.2 version: 1.0.2 diff --git a/packages/presentation/package.json b/packages/presentation/package.json index 04988fc0d0..a9077d5893 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -35,6 +35,7 @@ "jest": "^29.7.0", "ts-jest": "^29.1.1", "@types/jest": "^29.5.5", + "@types/node": "^22.18.1", "@testing-library/jest-dom": "^6.6.3", "jest-environment-jsdom": "^30.2.0", "svelte-eslint-parser": "^0.33.1", diff --git a/packages/presentation/src/sound.ts b/packages/presentation/src/sound.ts index b705b86b88..df2549cc89 100644 --- a/packages/presentation/src/sound.ts +++ b/packages/presentation/src/sound.ts @@ -4,6 +4,7 @@ import { getClient } from '.' import notification from '@hcengineering/notification' const sounds = new Map() +const resumeTimeoutMs = 1000 let context: AudioContext | undefined function getAudioContext (): AudioContext { @@ -11,6 +12,36 @@ function getAudioContext (): AudioContext { return context } +function isAudioContextRunning (context: AudioContext): boolean { + return context.state === 'running' +} + +async function resumeAudioContext (context: AudioContext): Promise { + if (context.state === 'running') return true + if (context.state === 'closed') return false + + // Calling resume before the document has received user activation can leave + // its promise pending indefinitely because of the browser autoplay policy. + if (navigator.userActivation?.hasBeenActive === false) return false + + let timeout: ReturnType | undefined + try { + const resumed = await Promise.race([ + context.resume().then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(false) + }, resumeTimeoutMs) + }) + ]) + return resumed && isAudioContextRunning(context) + } catch { + return false + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + export async function isNotificationAllowed (_class?: Ref>): Promise { if (_class === undefined) return false const client = getClient() @@ -52,9 +83,8 @@ export async function playSound (soundKey: string, loop = false): Promise<(() => try { const context = getAudioContext() - if (context.state === 'suspended') { - await context.resume() - } + if (!(await resumeAudioContext(context))) return null + const audio = context.createBufferSource() audio.buffer = sound audio.loop = loop diff --git a/packages/presentation/src/workspaceIdentity.ts b/packages/presentation/src/workspaceIdentity.ts index f0c93ef558..7dc8b6bd47 100644 --- a/packages/presentation/src/workspaceIdentity.ts +++ b/packages/presentation/src/workspaceIdentity.ts @@ -201,7 +201,9 @@ export function createWorkspaceFavicon (ownerDocument: Document = document): { let disposed = false let request: AbortController | undefined function removeDefaultLinks (): void { - for (const icon of ownerDocument.head.querySelectorAll('link[rel~="icon"]:not(#workspace-favicon)')) icon.remove() + for (const icon of Array.from(ownerDocument.head.querySelectorAll('link[rel~="icon"]:not(#workspace-favicon)'))) { + icon.remove() + } } function restore (): void { link.remove() diff --git a/packages/presentation/tsconfig.json b/packages/presentation/tsconfig.json index 14ada1dc34..23a04a8a12 100644 --- a/packages/presentation/tsconfig.json +++ b/packages/presentation/tsconfig.json @@ -4,8 +4,9 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./lib", - "declarationDir": "./types" + "declarationDir": "./types", + "types": ["node", "jest"] }, "include": ["src/**/*"], "exclude": ["node_modules", "lib", "dist", "types", "bundle"] -} \ No newline at end of file +} diff --git a/plugins/notification-resources/src/utils.ts b/plugins/notification-resources/src/utils.ts index 94bac12cc5..f72db1d2fd 100644 --- a/plugins/notification-resources/src/utils.ts +++ b/plugins/notification-resources/src/utils.ts @@ -735,7 +735,7 @@ export function pushAvailable (): boolean { return ( 'serviceWorker' in navigator && 'PushManager' in window && - isValidPushPublicKey(publicKey) && + hasValidPushPublicKeyFormat(publicKey) && 'Notification' in window && Notification.permission !== 'denied' ) @@ -748,7 +748,12 @@ export async function subscribePush (): Promise { } const client = getClient() const publicKey = getPushPublicKey() - if ('serviceWorker' in navigator && 'PushManager' in window && isValidPushPublicKey(publicKey)) { + if ('serviceWorker' in navigator && 'PushManager' in window && hasValidPushPublicKeyFormat(publicKey)) { + if (!(await isValidPushPublicKey(publicKey))) { + pushAllowed.set(false) + return false + } + try { const loc = getCurrentLocation() let registration = await navigator.serviceWorker.getRegistration(`/${loc.path[0]}/${loc.path[1]}`) @@ -808,7 +813,7 @@ function getPushPublicKey (): string | undefined { return publicKey.trim() !== '' ? publicKey : undefined } -function isValidPushPublicKey (publicKey: string | undefined): publicKey is string { +function hasValidPushPublicKeyFormat (publicKey: string | undefined): publicKey is string { if (publicKey === undefined || publicKey.trim() === '') return false try { @@ -819,6 +824,23 @@ function isValidPushPublicKey (publicKey: string | undefined): publicKey is stri } } +async function isValidPushPublicKey (publicKey: string): Promise { + if (globalThis.crypto?.subtle === undefined) return false + + try { + await globalThis.crypto.subtle.importKey( + 'raw', + urlBase64ToUint8Array(publicKey), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ) + return true + } catch { + return false + } +} + function urlBase64ToUint8Array (value: string): Uint8Array { const padding = '='.repeat((4 - (value.length % 4)) % 4) const base64 = `${value}${padding}`.replace(/-/g, '+').replace(/_/g, '/')