UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

443 lines (382 loc) 11.4 kB
/* global EventTarget, CustomEvent, Event */ /** * @module permissions * This module provides an API for querying and requesting permissions. */ import { IllegalConstructorError } from '../errors.js' import Notification from '../notification.js' import Enumeration from '../enumeration.js' import hooks from '../hooks.js' import ipc from '../ipc.js' import gc from '../gc.js' import os from '../os.js' /** * The 'permissionchange' event name. * @ignore * @type {string} */ const PERMISSION_CHANGE_EVENT = 'permissionchange' /** * @typedef {{ name: string }} PermissionDescriptor */ const isAndroid = os.platform() === 'android' const isApple = os.platform() === 'darwin' || os.platform() === 'ios' const isLinux = os.platform() === 'linux' /** * Get a bound platform `navigator.permissions` function. * @ignore * @param {string} name} * @return {function} */ function getPlatformFunction (name) { if (!globalThis.navigator?.permissions?.[name]) return null const value = globalThis.navigator.permissions[name] return value.bind(globalThis.navigator.permissions) } /** * Native platform functions * @ignore */ const platform = { query: getPlatformFunction('query') } /** * An enumeration of the permission types. * - 'geolocation' * - 'notifications' * - 'push' * - 'persistent-storage' * - 'midi' * - 'storage-access' * @type {Enumeration} * @ignore */ export const types = Enumeration.from([ 'geolocation', 'notifications', 'push', 'persistent-storage', 'midi', 'storage-access', 'camera', 'microphone' ]) /** * A container that provides the state of an object and an event handler * for monitoring changes permission changes. * @ignore */ class PermissionStatus extends EventTarget { #removePermissionChangeListener = null #subscribed = true #onchange = null #signal = null #state = null #name = null /** * `PermissionStatus` class constructor. * @param {string} name * @param {string} initialState * @param {object=} [options] * @param {?AbortSignal} [options.signal = null] */ constructor (name, initialState, options = { signal: null }) { super() this.#name = name this.#state = initialState this.#signal = options?.signal ?? null this.#removePermissionChangeListener = hooks.onPermissionChange((event) => { const { detail } = event if (this.#subscribed === false) { this.#removePermissionChangeListener() this.#removePermissionChangeListener = null return } if (detail.name === name && detail.state !== this.#state) { this.#state = detail.state this.dispatchEvent(new Event('change')) } }) if (this.#signal?.aborted === true) { this.#removePermissionChangeListener() } if (typeof this.#signal?.addEventListener === 'function') { this.#signal.addEventListener('abort', () => { this.#removePermissionChangeListener() this.#removePermissionChangeListener = null this.unsubscribe() }, { once: true }) } gc.ref(this) } /** * String tag for `PermissionStatus`. * @ignore */ get [Symbol.toStringTag] () { return 'PermissionStatus' } /** * The name of this permission this status is for. * @type {string} */ get name () { return this.#name } /** * The current state of the permission status. * @type {string} */ get state () { return this.#state } /** * Level 0 event target 'change' event listener accessor * @type {function(Event)} */ get onchange () { return this.#onchange } set onchange (onchange) { if (typeof this.#onchange === 'function') { this.removeEventListener('change', this.#onchange) } if (typeof onchange === 'function') { this.#onchange = onchange this.addEventListener('change', onchange) } } /** * Non-standard method for unsubscribing to status state updates. * @ignore */ unsubscribe () { this.#subscribed = false } /** * Implements `gc.finalizer` for gc'd resource cleanup. * @return {gc.Finalizer} * @ignore */ [gc.finalizer] () { return { args: [this.#removePermissionChangeListener], handle (removePermissionChangeListener) { if (typeof removePermissionChangeListener === 'function') { removePermissionChangeListener() } } } } } /** * Query for a permission status. * @param {PermissionDescriptor} descriptor * @param {object=} [options] * @param {?AbortSignal} [options.signal = null] * @return {Promise<PermissionStatus>} */ export async function query (descriptor, options) { if (arguments.length === 0) { throw new TypeError( 'Failed to execute \'query\' on \'Permissions\': ' + '1 argument required, but only 0 present.' ) } if (!descriptor || typeof descriptor !== 'object') { throw new TypeError( 'Failed to execute \'query\' on \'Permissions\': ' + 'parameter 1 is not of type \'object\'.' ) } const { name } = descriptor if (name === undefined) { throw new TypeError( 'Failed to execute \'query\' on \'Permissions\': ' + 'Failed to read the \'name\' property from \'PermissionDescriptor\': ' + 'Required member is undefined.' ) } if (typeof name !== 'string' || name.length === 0 || !types.contains(name)) { throw new TypeError( 'Failed to execute \'query\' on \'Permissions\': ' + 'Failed to read the \'name\' property from \'PermissionDescriptor\': ' + `The provided value '${name}' is not a valid enum value of type PermissionName.` ) } if (name === 'camera' || name === 'microphone') { if (!globalThis.navigator.mediaDevices) { if (globalThis.isServiceWorkerScope) { throw new TypeError('MediaDevices are not supported in ServiceWorkerGlobalScope.') } else if (globalThis.isSharedWorkerScope) { throw new TypeError('MediaDevices are not supported in SharedWorkerGlobalScope.') } else if (globalThis.isWorkerScope) { throw new TypeError('MediaDevices are not supported in WorkerGlobalScope.') } else { throw new TypeError('MediaDevices are not supported.') } } } if (!isAndroid && !isApple) { if (isLinux) { if (name === 'notifications' || name === 'push') { return new PermissionStatus(name, Notification.permission) } } if (typeof platform.query === 'function') { return platform.query(descriptor) } } const { signal } = options || {} if (options?.signal) { delete options.signal } if ( name === 'notifications' || name === 'geolocation' || (isAndroid && (name === 'camera' || name === 'microphone')) ) { const result = await ipc.request('permissions.query', { name }, { signal }) if (result.err) { throw result.err } return new PermissionStatus(name, result.data?.state, options) } if (typeof platform.query === 'function') { return platform.query(descriptor) } throw new TypeError('Not supported') } /** * Request a permission to be granted. * @param {PermissionDescriptor} descriptor * @param {object=} [options] * @param {?AbortSignal} [options.signal = null] * @return {Promise<PermissionStatus>} */ export async function request (descriptor, options) { if (arguments.length === 0) { throw new TypeError( 'Failed to execute \'request\' on \'Permissions\': ' + '1 argument required, but only 0 present.' ) } if (!descriptor || typeof descriptor !== 'object') { throw new TypeError( 'Failed to execute \'request\' on \'Permissions\': ' + 'parameter 1 is not of type \'object\'.' ) } const { name } = descriptor if (name === undefined) { throw new TypeError( 'Failed to execute \'request\' on \'Permissions\': ' + 'Failed to read the \'name\' property from \'PermissionDescriptor\': ' + 'Required member is undefined.' ) } if (typeof name !== 'string' || name.length === 0 || !types.contains(name)) { throw new TypeError( 'Failed to execute \'request\' on \'Permissions\': ' + 'Failed to read the \'name\' property from \'PermissionDescriptor\': ' + `The provided value '${name}' is not a valid enum value of type PermissionName.` ) } if (name === 'camera' || name === 'microphone') { // will throw if `MediaDevices` are not supported const status = await query({ name }, options) if (status.state === 'granted') { return new PermissionStatus(name, 'granted', options) } if (!isAndroid) { const constraints = { video: false, audio: false } if (name === 'camera') { constraints.video = true delete constraints.audio } else if (name === 'microphone') { constraints.audio = true delete constraints.video } try { const stream = await globalThis.navigator.mediaDevices.getUserMedia(constraints) const tracks = await stream.getTracks() for (const track of tracks) { await track.stop() } return new PermissionStatus(name, 'granted', options) } catch (err) { if (err.name === 'NotAllowedError') { return new PermissionStatus(name, 'denied', options) } else { throw err } } } } if (isLinux) { if (name === 'notifications' || name === 'push') { const currentState = Notification.permission // `Notification.requestPermission` will use the native // `requestPermission` API internally, so this won't be a cycle const state = await Notification.requestPermission() if (currentState !== state) { const globalEvent = new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: { name, state } }) queueMicrotask(() => { globalThis.dispatchEvent(globalEvent) }) } return new PermissionStatus(name, state, options) } return new PermissionStatus(name, 'prompt', options) } const { signal } = options || {} if (options?.signal) { delete options.signal } const result = await ipc.request( 'permissions.request', { ...options, name }, { signal } ) if (result.err) { throw result.err } const globalEvent = new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: { name, state: result.data.state } }) queueMicrotask(() => { globalThis.dispatchEvent(globalEvent) }) return new PermissionStatus(name, result.data.state, options) } /** * An interface for querying and revoking permissions. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Permissions} */ class Permissions { /** * Returns the state of a user permission on the global scope. * @type {function(PermissionDescriptor): Promise<PermissionStatus>} */ query = query /** * Request a permission to be granted. * @type {function(PermissionDescriptor): Promise<PermissionStatus>} */ request = request /** * `Permissions` class constructor. This interface is * not constructable. * @ignore */ constructor () { throw new IllegalConstructorError() } } export default Object.assign(Object.create(Permissions.prototype), { query, request })