@marianmeres/micperms
v2.0.0
Published
[](https://www.npmjs.com/package/@marianmeres/micperms) [](https://jsr.io/@marianmeres/micperms) [, checks and requests permission, tracks state reactively, and supports native bridge for opening app settings.
Does not own MediaStreams — when getUserMedia is called to probe permission,
all tracks are stopped immediately. Your app handles its own stream acquisition once
permission is granted.
Installation
# Deno / JSR
deno add jsr:@marianmeres/micperms
# npm
npm install @marianmeres/micpermsUsage
import { createMicPerms } from "@marianmeres/micperms";
const mic = createMicPerms();
// Reactive subscription (Svelte $store compatible)
mic.subscribe((state) => {
console.log(state.status); // "unknown" | "prompt" | "granted" | "denied"
console.log(state.platform); // "browser" | "pwa" | "ios-webview" | "android-webview"
console.log(state.observedDenied); // true once denial has ever been observed
console.log(state.error?.code); // typed MicPermsErrorCode union, or undefined
});
// Check current permission (via Permissions API)
await mic.check();
// Request permission (via getUserMedia, tracks released immediately)
await mic.request();
// Smart recheck: query first, fall back to getUserMedia if ambiguous
await mic.recheck();
// Open native app settings (iOS/Android WebView only)
mic.openSettings();
// Reset internal state (clears sticky-denial, error, status -> "unknown")
mic.reset();
// Cleanup (detaches listeners; also makes check/request log a warning)
mic.destroy();Configuration
const mic = createMicPerms({
platform: "ios-webview", // override auto-detection
iosBridgeHandler: "openAppSettings", // iOS bridge handler name
androidBridgeObject: "Android", // Android bridge object on window
androidBridgeMethod: "openAppSettings",
appResumedEvent: "app-resumed", // event fired by native layer on return
adapter: myCustomAdapter, // injectable for testing
logger: console, // default: noop
});Semantics
getUserMedia()is the only ground truth.check()wrapsnavigator.permissions.query({ name: "microphone" }), which is not authoritative in mobile WebViews (see below).request()wrapsgetUserMedia(), which always reflects reality.- Sticky denial. Once
request()(oronPermissionChange) has observed"denied", that state is cached internally —state.observedDeniedbecomestrueand silentcheck()calls will not downgradestatusto"prompt"/"unknown". Cleared on any observed"granted", byopenSettings()(user is on their way to change the OS setting), or by the explicitreset()method. - Passive triggers never prompt. Internal listeners for
visibilitychange,pageshow(withevent.persisted === true— bfcache restores), andapp-resumedonly callcheck()(silent). They never invokegetUserMedia(), which would produce an unexpected OS prompt. recheck()is an opt-in escalation. It callscheck()and, if the result is ambiguous ("prompt"/"unknown"), escalates torequest(). Only your code can trigger it — call it in response to a user gesture, not on resume.- Concurrent
check()/request()calls coalesce. Re-entrant calls while another is in flight return the same in-flight promise; the underlying adapter is invoked once per concurrent batch, and all callers observe an identical resolved value. - Device/origin errors are typed. When
getUserMediarejects withNotFoundError,SecurityError, orNotReadableError, the rejection is classified intostate.error.code(seeMicPermsErrorCode) andstate.statusis preserved. UIs should checkerrorbefore acting onstatus.
Why the Permissions API is not trusted in WebViews
navigator.permissions.query({ name: "microphone" }) is not reliable in
mobile WebViews. Concretely:
- iOS WKWebView: the Permissions API is not implemented. The adapter's
queryPermission()returnsnullandcheck()preserves the prior status. - Android WebView: the Permissions API is present but reports
"prompt"even after the user has OS-denied microphone access (and in some Chromium versions, also when the embedder has already granted at theWebChromeClient.onPermissionRequest()layer). This is not a library bug — it is a consequence of the W3C Permissions API spec permitting a UA to return"prompt"when it cannot determine a persistent origin-scoped decision, combined with the fact that Android's microphone permission lives at the app layer, not the web-origin layer the Permissions API knows about. The JS runtime literally does not have the information, so the API returns its spec-permitted fallback. See ongoing Chromium discussions (search the Chromium issue tracker for "permissions.query microphone webview"). - Desktop Chrome / Firefox / Safari: the Permissions API is reliable;
check()alone is sufficient to populate UI.
This is why sticky-denial exists: once getUserMedia() has produced a
NotAllowedError on Android WebView, that observation outranks any
subsequent "prompt" from the Permissions API. Before this was enforced
(fixed in 1.1.1), the combination of a lying Permissions API and an
auto-recheck() on visibilitychange caused an infinite
denied → prompt → denied → prompt → … loop in Android WebView.
API
See API.md for complete API documentation.
