Handle blocked audio and invalid VAPID keys (#11046)

* Handle blocked audio and invalid VAPID keys

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Fix build issues

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artyom Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-09-25 18:34:36 +02:00
committed by GitHub
parent c44df0ef7e
commit afa747d065
6 changed files with 68 additions and 9 deletions
+3
View File
@@ -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
+1
View File
@@ -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",
+33 -3
View File
@@ -4,6 +4,7 @@ import { getClient } from '.'
import notification from '@hcengineering/notification'
const sounds = new Map<Asset, AudioBuffer>()
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<boolean> {
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<typeof setTimeout> | undefined
try {
const resumed = await Promise.race([
context.resume().then(() => true),
new Promise<boolean>((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<Class<Doc>>): Promise<boolean> {
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
@@ -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()
+3 -2
View File
@@ -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"]
}
}
+25 -3
View File
@@ -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<boolean> {
}
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<boolean> {
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<ArrayBuffer> {
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const base64 = `${value}${padding}`.replace(/-/g, '+').replace(/_/g, '/')