{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-permission",
  "title": "usePermission",
  "description": "Subscribes to navigator.permissions for one name or a whole panel of them — query only, never request — folding Firefox's rejections, Safari's gaps, unknown names and insecure origins into one honest unsupported state with a reason, plus change-event updates and a manual query().",
  "files": [
    {
      "path": "src/registry/hooks/use-permission.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * Names accepted by `navigator.permissions.query()`.\n *\n * The DOM lib's own `PermissionName` union is narrower than reality (it has no\n * `clipboard-read`, for instance) and every engine ships a *different* subset of\n * the registry, so this type is deliberately open: the literals give\n * autocomplete, the `(string & {})` arm lets a newer name through without a cast\n * or a library upgrade. An unknown name is not a type error — it is a runtime\n * `TypeError`, which this hook reports as `state: \"unsupported\"`.\n */\nexport type PermissionQueryName =\n  | \"accelerometer\"\n  | \"ambient-light-sensor\"\n  | \"background-sync\"\n  | \"camera\"\n  | \"clipboard-read\"\n  | \"clipboard-write\"\n  | \"display-capture\"\n  | \"geolocation\"\n  | \"gyroscope\"\n  | \"local-fonts\"\n  | \"magnetometer\"\n  | \"microphone\"\n  | \"midi\"\n  | \"notifications\"\n  | \"payment-handler\"\n  | \"persistent-storage\"\n  | \"push\"\n  | \"screen-wake-lock\"\n  | \"storage-access\"\n  | \"window-management\"\n  // `string & {}` keeps the literal autocomplete above while still accepting any future name.\n  | (string & {})\n\n/**\n * granted / denied / prompt — verbatim from `PermissionStatus.state`.\n * unknown     — the query for this name has not answered yet (also the state\n *               during the first client render, before the effect runs).\n * unsupported — this environment cannot answer for this name: no Permissions\n *               API at all, or the query rejected. See `error.reason`.\n */\nexport type PermissionQueryState = \"granted\" | \"denied\" | \"prompt\" | \"unsupported\" | \"unknown\"\n\n/**\n * no-api        — `navigator.permissions` is missing. The Permissions API is\n *                 secure-context only, so a plain `http://` page never has it;\n *                 Safari before 16 never shipped it at all.\n * unknown-name  — the engine rejected the *name* with a `TypeError`. Firefox\n *                 does this for `camera` / `microphone`, Safari for most names\n *                 outside its short list. It means \"this engine will not answer\",\n *                 not \"the capability is missing\".\n * query-rejected— the name was recognised but the query still failed, e.g.\n *                 Chromium's `push` without `userVisibleOnly: true`\n *                 (`NotSupportedError`).\n */\nexport type PermissionUnsupportedReason = \"no-api\" | \"unknown-name\" | \"query-rejected\"\n\nexport interface PermissionQueryError {\n  reason: PermissionUnsupportedReason\n  /** The rejection's `name` (\"TypeError\", \"NotSupportedError\", …); `\"\"` when the API itself is absent. */\n  name: string\n  /** The engine's own text — unlocalised and wildly different per browser. Branch UI copy on `reason`, never on this. */\n  message: string\n}\n\nexport interface PermissionQueryResult {\n  state: PermissionQueryState\n  /**\n   * Can this environment answer for this name? `false` once the API is missing\n   * or the query rejected. While `state` is `\"unknown\"` this is optimistic — it\n   * only means the Permissions API exists; the name-level verdict lands with the\n   * first resolution.\n   */\n  isSupported: boolean\n  /** Why the state is `\"unsupported\"`; `null` in every other state. */\n  error: PermissionQueryError | null\n  /**\n   * Re-read this permission now. Referentially stable for as long as the name\n   * stays in the argument, so it is safe in dependency arrays. Needed because\n   * `change` events are not guaranteed — several engines stay silent when a\n   * permission is *revoked* from site settings.\n   */\n  query: () => void\n}\n\ninterface PermissionEntry {\n  state: PermissionQueryState\n  error: PermissionQueryError | null\n}\n\n/** `\\0` can never appear in a permission name, so joining on it is collision-free. */\nconst SEPARATOR = \"\\u0000\"\n\nconst NO_API_ERROR: PermissionQueryError = {\n  reason: \"no-api\",\n  name: \"\",\n  message:\n    \"navigator.permissions is unavailable here. The Permissions API is secure-context only (an http:// page never gets it) and older Safari never shipped it.\",\n}\n\nconst FALLBACK_MESSAGE: Record<PermissionUnsupportedReason, string> = {\n  \"no-api\": NO_API_ERROR.message,\n  \"unknown-name\": \"This engine does not accept that permission name, so it cannot report a state for it.\",\n  \"query-rejected\": \"The browser refused to answer this permission query.\",\n}\n\nfunction detectPermissionsApi(): boolean {\n  return typeof navigator !== \"undefined\" && typeof navigator.permissions?.query === \"function\"\n}\n\nconst subscribeNoop = () => () => {}\n/** No navigator on the server: the honest snapshot is \"cannot answer\". */\nconst getServerSnapshot = () => false\n\nfunction toQueryError(error: unknown): PermissionQueryError {\n  const name =\n    typeof error === \"object\" && error !== null && \"name\" in error ? String((error as { name: unknown }).name) : \"\"\n  const message =\n    typeof error === \"object\" && error !== null && \"message\" in error\n      ? String((error as { message: unknown }).message)\n      : \"\"\n  // A rejected *name* always surfaces as TypeError (WebIDL enum conversion);\n  // anything else means the name was understood but the query still failed.\n  const reason: PermissionUnsupportedReason = name === \"TypeError\" ? \"unknown-name\" : \"query-rejected\"\n  return { reason, name, message: message || FALLBACK_MESSAGE[reason] }\n}\n\n/**\n * `query()` is spec'd to return a promise, but engines have historically thrown\n * synchronously on a bad descriptor — and `Promise.resolve(fn())` cannot catch\n * that, because the throw escapes before `Promise.resolve` ever sees a value.\n * The executor form converts a synchronous throw into a rejection, which is the\n * whole point: **no caller of this hook may ever see an unhandled rejection.**\n */\nfunction queryPermission(name: string): Promise<PermissionStatus> {\n  return new Promise<PermissionStatus>(resolve => {\n    resolve(navigator.permissions.query({ name } as unknown as PermissionDescriptor))\n  })\n}\n\nfunction sameError(a: PermissionQueryError | null, b: PermissionQueryError | null): boolean {\n  if (a === b) return true\n  if (!a || !b) return false\n  return a.reason === b.reason && a.name === b.name && a.message === b.message\n}\n\nfunction toNameList(input: PermissionQueryName | readonly PermissionQueryName[]): readonly string[] {\n  // `Array.isArray` does not narrow a `readonly T[]` union arm, hence the cast.\n  return Array.isArray(input) ? (input as readonly string[]) : [input as string]\n}\n\n/**\n * Subscribes to `navigator.permissions` — **query only, never request**.\n *\n * Asking for a permission has to be done by the capability's own API from a real\n * user gesture (`Notification.requestPermission()`, `getUserMedia()`,\n * `getCurrentPosition()`); this hook deliberately touches none of them. A\n * permission dialog is a one-shot budget, and a hook that spent it on mount\n * would be a trap. Querying, by contrast, raises no dialog at all — so it is\n * safe to run for a whole panel of permissions on page load.\n *\n * **The browser differences are the entire point.** `navigator.permissions\n * .query()` is the least interoperable API in this corner of the platform:\n * Firefox rejects `camera` / `microphone` outright instead of answering\n * \"denied\", Safari answers for only a short list of names, any unrecognised name\n * throws a `TypeError`, and on a non-secure origin the API is not exposed at\n * all. Every one of those lands on `state: \"unsupported\"` with an `error.reason`\n * explaining which — a consumer never has to catch anything, and an\n * unanswerable permission is never mistaken for a denied one.\n *\n * Capability detection runs through `useSyncExternalStore` with a `false` server\n * snapshot rather than during render, so SSR and the hydrating first paint agree\n * on `unsupported` / `isSupported: false` and React swaps in the truth right\n * after hydration.\n *\n * @example\n * const camera = usePermission(\"camera\")\n * if (camera.state === \"prompt\") // asking is still worth offering\n *\n * @example\n * const perms = usePermission([\"geolocation\", \"notifications\"])\n * perms.geolocation.state // \"granted\" | \"denied\" | \"prompt\" | …\n */\nexport function usePermission(name: PermissionQueryName): PermissionQueryResult\nexport function usePermission<N extends PermissionQueryName>(\n  names: readonly N[],\n): Record<N, PermissionQueryResult>\nexport function usePermission(\n  input: PermissionQueryName | readonly PermissionQueryName[],\n): PermissionQueryResult | Record<string, PermissionQueryResult> {\n  const isList = Array.isArray(input)\n\n  // Normalising to a string key is what makes an inline `[\"camera\", \"mic\"]`\n  // argument safe: a fresh array identity on every render would otherwise\n  // re-arm the effect on every render, re-querying and re-subscribing forever.\n  // Duplicates are dropped here too, so a name is queried exactly once.\n  const key = [...new Set(toNameList(input))].join(SEPARATOR)\n  const names = React.useMemo(() => (key === \"\" ? [] : key.split(SEPARATOR)), [key])\n\n  const apiSupported = React.useSyncExternalStore(subscribeNoop, detectPermissionsApi, getServerSnapshot)\n\n  const [entries, setEntries] = React.useState<Record<string, PermissionEntry>>({})\n  const mountedRef = React.useRef(false)\n\n  // Updater stays pure — StrictMode double-invokes it. Bailing out on an\n  // unchanged entry keeps a `change` event that re-reports the same state from\n  // re-rendering every consumer of the hook.\n  const write = React.useCallback((name: string, next: PermissionEntry) => {\n    setEntries(prev => {\n      const current = prev[name]\n      if (current && current.state === next.state && sameError(current.error, next.error)) return prev\n      return { ...prev, [name]: next }\n    })\n  }, [])\n\n  React.useEffect(() => {\n    // Set in the effect *body*: only clearing it in cleanup leaves it false for\n    // the live instance after StrictMode's mount → cleanup → mount.\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  React.useEffect(() => {\n    if (!apiSupported || names.length === 0) return\n\n    let cancelled = false\n    const attached: { status: PermissionStatus; handler: () => void }[] = []\n\n    for (const name of names) {\n      queryPermission(name).then(\n        status => {\n          // The query is async: by the time it resolves the component may be\n          // gone, or the name set may have been swapped for another one.\n          if (cancelled || !mountedRef.current) return\n          const handler = () => {\n            if (cancelled || !mountedRef.current) return\n            write(name, { state: status.state, error: null })\n          }\n          status.addEventListener(\"change\", handler)\n          attached.push({ status, handler })\n          write(name, { state: status.state, error: null })\n        },\n        error => {\n          if (cancelled || !mountedRef.current) return\n          // Firefox on camera/microphone, Safari on most names, any typo'd\n          // name: all rejections become an honest \"unsupported\" instead of an\n          // unhandled rejection in the consumer's console.\n          write(name, { state: \"unsupported\", error: toQueryError(error) })\n        },\n      )\n    }\n\n    return () => {\n      cancelled = true\n      // Every PermissionStatus that got a listener loses it here — on unmount\n      // *and* whenever the requested names change.\n      for (const { status, handler } of attached) status.removeEventListener(\"change\", handler)\n      attached.length = 0\n    }\n  }, [apiSupported, names, write])\n\n  const refresh = React.useCallback(\n    (name: string) => {\n      // Probe at call time rather than reading `apiSupported`: this runs from an\n      // event handler, where the real answer is always available.\n      if (!detectPermissionsApi()) {\n        write(name, { state: \"unsupported\", error: NO_API_ERROR })\n        return\n      }\n      queryPermission(name).then(\n        status => {\n          if (mountedRef.current) write(name, { state: status.state, error: null })\n        },\n        error => {\n          if (mountedRef.current) write(name, { state: \"unsupported\", error: toQueryError(error) })\n        },\n      )\n    },\n    [write],\n  )\n\n  // Stable per-name `query()` identities: they only change when the name set does.\n  const queries = React.useMemo(() => {\n    const map: Record<string, () => void> = {}\n    for (const name of names) map[name] = () => refresh(name)\n    return map\n  }, [names, refresh])\n\n  const results = React.useMemo(() => {\n    const map: Record<string, PermissionQueryResult> = {}\n    for (const name of names) {\n      const entry = entries[name]\n      map[name] = apiSupported\n        ? {\n            state: entry?.state ?? \"unknown\",\n            isSupported: entry?.state !== \"unsupported\",\n            error: entry?.error ?? null,\n            query: queries[name],\n          }\n        : { state: \"unsupported\", isSupported: false, error: NO_API_ERROR, query: queries[name] }\n    }\n    return map\n  }, [names, entries, apiSupported, queries])\n\n  return isList ? results : results[names[0]]\n}\n\nexport default usePermission\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}