# Permissions & OS-native popups Chrome renders permission prompts, file pickers, and print dialogs in **browser chrome**, not in the page. They are invisible to CDP page screenshots, and `Input.dispatchMouseEvent` coordinates go to the viewport — they can't click the popup. CDP event coverage is uneven: `Page.javascriptDialogOpening` (alerts + beforeunload), `Page.fileChooserOpened` (requires `Page.enable({enableFileChooserOpenedEvent: true})`), and `Page.downloadWillBegin` do fire. **Permission popups (geolocation, notifications, camera/mic, ...) emit no CDP event at all** — which is why the harness injects an in-page wrapper to surface them. Two things to know: 1. **Pre-empt them.** `Browser.grantPermissions` + `Emulation.setGeolocationOverride` resolve the common cases without ever showing a popup. 2. **Detect them.** If one is already open, `page_info()` surfaces a `blockers` key, and `pending_blockers()` returns the full log. ## Detection `page_info()` auto-surfaces blockers. After the usual viewport keys: ```python info = page_info() if info.get("blockers"): # popup was triggered — Chrome is waiting for the user (or you via CDP) print(info["blockers"]) ``` `pending_blockers()` gives the full picture, split by source: ```python b = pending_blockers() # b["js"] — permission-gated Web API calls: geolocation, notifications, # mediaDevices, clipboard, bluetooth/usb/serial/hid, file pickers, # print, requestStorageAccess, clicks # b["cdp"] — Page.javascriptDialogOpening, Page.fileChooserOpened, # Page.downloadWillBegin (fires without JS injection) ``` Why both? Chrome emits **no** CDP event when a permission popup opens, so the daemon installs a JS wrapper on every document that pushes to `window.__bu_blockers__`. CDP-side events are captured directly via the event tap. ## Pre-grant — the preferred pattern Grant at the browser level; the popup never needs to render. Scope is the CDP session, which maps to "Allow this time" semantics (resets on daemon restart / CDP disconnect). ```python cdp("Browser.grantPermissions", origin="https://www.example.com", permissions=["geolocation"]) # For geolocation specifically, pair with an override so the site actually # receives coords — otherwise Chrome may fall back to OS Core Location, which # requires a separate macOS-level grant. cdp("Emulation.setGeolocationOverride", latitude=37.7749, longitude=-122.4194, accuracy=50) ``` Permission names: `geolocation`, `notifications`, `videoCapture`, `audioCapture`, `clipboardReadWrite`, `clipboardSanitizedWrite`, `backgroundSync`, `midi`, `midiSysex`, `periodicBackgroundSync`, `protectedMediaIdentifier`, `sensors`, `storageAccess`, `durableStorage`, `paymentHandler`, `backgroundFetch`, `nfc`, `idleDetection`, `displayCapture`, `captureHandle`, `wakeLockScreen`, `wakeLockSystem`, `windowManagement`, `localFonts`, `topLevelStorageAccess`. ```python cdp("Browser.resetPermissions") # back to 'prompt' for all origins ``` `Browser.setPermission` is the finer-grained alternative — sets one permission at a time with explicit `setting: 'granted' | 'denied' | 'prompt'`, useful for toggling mid-session or explicitly denying: ```python cdp("Browser.setPermission", permission={"name": "geolocation"}, setting="denied", origin="https://www.example.com") ``` ## Dismissing a popup that already opened `Browser.grantPermissions` updates the permission *state* but does **not** close an already-rendered popup. Easiest path: reload the page after granting. ```python cdp("Browser.grantPermissions", origin=..., permissions=["geolocation"]) cdp("Emulation.setGeolocationOverride", latitude=..., longitude=..., accuracy=50) cdp("Page.reload") wait_for_load() # next navigator.geolocation.getCurrentPosition call returns the overridden coords, no popup. ``` ## Proactive state check `navigator.permissions.query({name: 'geolocation'}).state` returns `'prompt' | 'granted' | 'denied'` without triggering anything. If it's `'prompt'` and the site is about to ask, a popup **will** appear — pre-grant before calling the API. ## Rules that held up in practice - **Native popups are outside the viewport.** `screenshot()` can't see them; use `screencapture -x` on macOS if you need visual proof. - **Pre-grant beats reactive handling.** `Browser.grantPermissions` before the trigger means no popup, no detection overhead, no race. - **`Emulation.setGeolocationOverride` is required** when Chrome lacks OS-level Core Location permission — otherwise granted-but-unresolvable requests time out. - **The wrapper script is installed on every new document** via `Page.addScriptToEvaluateOnNewDocument`. Don't rely on it for iframes attached via a different session (install there too if needed). - **Callback-based APIs (`getCurrentPosition`) don't resolve promises** the wrapper can hook. A blocker entry means "the site called this" — not "the popup is still open". Use timestamps + state inspection to judge. - **File pickers from `` clicks** are caught by a capture-phase click listener, not by wrapping. Prefer `upload_file(selector, path)` (CDP `DOM.setFileInputFiles`) to avoid opening the native chooser at all. - **`Page.setInterceptFileChooserDialog` suppresses the native chooser.** The harness does not enable interception by default — turning it on breaks any normal file-input flow, so only enable per-task if you plan to respond to `Page.fileChooserOpened` yourself.