fix(stage-tamagotchi): grant screen capture through the media permission (#2178)
## Description Screen capture in Stage Tamagotchi is denied before it ever reaches the desktop picker: `navigator.mediaDevices.getDisplayMedia()` resolves with `NotAllowedError: Permission denied`, so the vision screen-capture panel can list sources but never start a stream. The cause is in `shouldGrantElectronPermission`. Electron reports **screen capture as the `media` permission**, not as `display-capture`, and it only appends `audio`/`video` to `details.mediaTypes` for *device* capture — so a `getDisplayMedia()` request arrives as `media` with an **empty** `mediaTypes` list ([`web_contents_permission_helper.cc#L249-L274`](https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274)). The handler took an early return for every `media` operation and required audio-only details, so display capture was rejected before the allowlisted `display-capture` entry could be consulted: ```ts if (permission === 'media') return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details) return LOCAL_APP_PERMISSION_NAMES.has(permission) && shouldGrantLocalAppPermission(...) ``` The fix resolves a `media` operation that declares no device media type back to `display-capture`, so the existing allowlist and local-frame checks decide the outcome — which is what `LOCAL_APP_PERMISSION_NAMES` already intended: ```ts const allowlistPermission = isDisplayCaptureMediaPermission(permission, details) ? 'display-capture' : permission return LOCAL_APP_PERMISSION_NAMES.has(allowlistPermission) && shouldGrantLocalAppPermission(webContents, requestingOrigin, details) ``` Camera and microphone operations always report their device media type (`['video']`, `['audio']`, `mediaType: 'audio'`), so they never take this path and stay exactly as strict as before. Remote frames are still rejected, because the local-frame check is unchanged and still applies to display capture. Three regression tests are added: screen capture from a local page is granted, screen capture from a remote page is rejected, and a camera request is still denied now that it shares the `media` permission. ## Linked Issues Closes #2177 ## Additional Context - **Regression range.** This was introduced by #2002 (`5e8bf75`, 2026-07-10), which added the permission allowlist. Nothing on the failing path is platform-specific — the `media` vs `display-capture` mismatch is in Electron's browser process — so although the issue was reported on Windows, screen capture has been broken on macOS and Linux since that commit too. Worth noting for anyone triaging similar reports. - **Detection signal.** The predicate keys on `mediaTypes.length === 0` rather than on the absence of the field, so a `media` operation with *no* `mediaTypes` at all (e.g. permission *checks*, which send `mediaType: 'unknown'` instead) is not silently promoted to display capture. That keeps the change to exactly the shape Electron documents for `getDisplayMedia()` requests. - **Deliberately out of scope.** #2104 (camera snapshot denied) is a policy decision — whether the camera should join the allowlist — not this bug, and #2132 (`systemPreferences.getMediaAccessStatus` undefined on Linux) is unrelated. Happy to follow up on either if you'd like them addressed. - **Second layer still applies.** `setDisplayMediaRequestHandler` in `packages/electron-screen-capture` is only installed inside the `setSource` mutex window, so a grant here still requires the renderer to have selected a source first. This change does not widen that. - **Verification.** `media-permissions.test.ts` goes 16/16 → 19/19; with only the tests applied, the new local-screen-capture case fails as expected. Type checking and the repo ESLint config both pass clean on the two touched files. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
autofix-ci[bot]
parent
c10d16ec8d
commit
5f3746ead8
@@ -12,7 +12,7 @@ import messages from '@proj-airi/i18n/locales'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
||||
import { hasSelectedScreenCaptureSource, initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
||||
import { app, ipcMain, session } from 'electron'
|
||||
import { noop } from 'es-toolkit'
|
||||
import { createLoggLogger, injeca, lifecycle } from 'injeca'
|
||||
@@ -116,7 +116,7 @@ app.whenReady().then(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
setupMediaPermissionHandlers(session.defaultSession)
|
||||
setupMediaPermissionHandlers(session.defaultSession, hasSelectedScreenCaptureSource)
|
||||
|
||||
// Initialize file logger and register the hook
|
||||
fileLogger = await setupFileLogger()
|
||||
|
||||
@@ -203,6 +203,86 @@ describe('media permissions', () => {
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2177
|
||||
it('grants screen capture requests reported as media from local app pages (Issue #2177)', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// `navigator.mediaDevices.getDisplayMedia()` reaches `setPermissionRequestHandler` as the `media`
|
||||
// permission, and Electron only appends `audio` or `video` to `mediaTypes` for device capture, so a
|
||||
// desktop capture request arrives with an empty `mediaTypes` list.
|
||||
//
|
||||
// `shouldGrantElectronPermission` returned early for every `media` operation and demanded audio-only
|
||||
// details, so screen capture was denied before the allowlisted `display-capture` entry was reached:
|
||||
//
|
||||
// if (permission === 'media')
|
||||
// return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details)
|
||||
//
|
||||
// We fixed this by resolving a `media` operation without device media types to `display-capture`, so
|
||||
// the existing allowlist and local-frame checks decide the outcome.
|
||||
expect(shouldGrantElectronPermission(
|
||||
localWebContents,
|
||||
'media',
|
||||
undefined,
|
||||
createMediaRequestDetails({ mediaTypes: [], securityOrigin: 'file:///app/index.html' }),
|
||||
() => true,
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects screen capture requests reported as media from remote pages', () => {
|
||||
expect(shouldGrantElectronPermission(
|
||||
localWebContents,
|
||||
'media',
|
||||
undefined,
|
||||
createMediaRequestDetails({
|
||||
mediaTypes: [],
|
||||
requestingUrl: 'https://example.com/capture.html',
|
||||
securityOrigin: 'https://example.com',
|
||||
}),
|
||||
() => true,
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2178#discussion_r3681573150
|
||||
it('rejects desktop capture that no renderer asked for', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Electron reports the legacy `chromeMediaSource: 'desktop'` constraint with the same empty
|
||||
// `mediaTypes` list as `getDisplayMedia()`, but serves it from `HandleUserMediaRequest` instead of
|
||||
// `setDisplayMediaRequestHandler`. Granting on empty `mediaTypes` alone therefore also handed a local
|
||||
// page the full desktop through `getUserMedia()`, skipping AIRI's own source selection:
|
||||
//
|
||||
// const allowlistPermission = isDisplayCaptureMediaPermission(permission, details) ? 'display-capture' : permission
|
||||
//
|
||||
// We fixed this by additionally requiring an authorized capture source, which only AIRI's selected
|
||||
// source flow installs.
|
||||
expect(shouldGrantElectronPermission(
|
||||
localWebContents,
|
||||
'media',
|
||||
undefined,
|
||||
createMediaRequestDetails({ mediaTypes: [], securityOrigin: 'file:///app/index.html' }),
|
||||
() => false,
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('denies desktop capture when no authorization callback is supplied', () => {
|
||||
expect(shouldGrantElectronPermission(
|
||||
localWebContents,
|
||||
'media',
|
||||
undefined,
|
||||
createMediaRequestDetails({ mediaTypes: [], securityOrigin: 'file:///app/index.html' }),
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps camera requests denied now that screen capture shares the media permission', () => {
|
||||
expect(shouldGrantElectronPermission(
|
||||
localWebContents,
|
||||
'media',
|
||||
undefined,
|
||||
createMediaRequestDetails({ mediaTypes: ['video'], securityOrigin: 'file:///app/index.html' }),
|
||||
() => true,
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
/** @example Local AIRI pages retain sanitized clipboard writes used by chat copy actions. */
|
||||
it('grants sanitized clipboard writes from local app pages', () => {
|
||||
expect(shouldGrantElectronPermission(
|
||||
|
||||
@@ -34,6 +34,22 @@ function isAudioMediaPermission(permission: ElectronPermission, details?: Electr
|
||||
return 'mediaType' in details && details.mediaType === 'audio'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether Electron described a desktop capture operation of any kind.
|
||||
*
|
||||
* Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to
|
||||
* `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type
|
||||
* at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like
|
||||
* this, so the permission details alone cannot tell them apart.
|
||||
* See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}.
|
||||
*/
|
||||
function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
||||
if (permission !== 'media' || !details)
|
||||
return false
|
||||
|
||||
return 'mediaTypes' in details && details.mediaTypes?.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether every requester identity supplied by Electron is local to AIRI.
|
||||
*/
|
||||
@@ -89,20 +105,37 @@ export function shouldGrantAudioCapturePermission(
|
||||
* Expects:
|
||||
* - Unknown or unreviewed permission categories must remain denied
|
||||
* - All explicit frame, security, and embedding origins must identify local AIRI pages
|
||||
* - Electron reports desktop capture through the `media` permission instead of `display-capture`
|
||||
* - Desktop capture is only ever requested by AIRI's own selected-source flow
|
||||
*
|
||||
* Returns:
|
||||
* - Whether the requested permission is both allowlisted and locally owned
|
||||
* - Whether the requested permission is allowlisted, locally owned, and authorized for desktop capture
|
||||
*/
|
||||
export function shouldGrantElectronPermission(
|
||||
webContents: LocalAppWebContents | null,
|
||||
permission: ElectronPermission,
|
||||
requestingOrigin?: string,
|
||||
details?: ElectronPermissionDetails,
|
||||
isDesktopCaptureAuthorized: () => boolean = () => false,
|
||||
): boolean {
|
||||
if (permission === 'media')
|
||||
return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details)
|
||||
if (shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details))
|
||||
return true
|
||||
|
||||
return LOCAL_APP_PERMISSION_NAMES.has(permission)
|
||||
// Desktop capture arrives as a `media` operation, so it has to be resolved back to the reviewed
|
||||
// `display-capture` entry before the allowlist is consulted. Camera and microphone operations keep
|
||||
// reporting their device media type and therefore never reach the allowlist through this path.
|
||||
const isDesktopCapture = isDesktopCaptureMediaPermission(permission, details)
|
||||
|
||||
// Electron cannot distinguish `getDisplayMedia()` from the legacy `chromeMediaSource: 'desktop'`
|
||||
// constraint here, and only the former is routed through `setDisplayMediaRequestHandler`. Requiring an
|
||||
// authorized source keeps the legacy path from capturing the full desktop behind AIRI's picker, and
|
||||
// costs the supported path nothing: without that handler Electron answers `NOT_SUPPORTED` regardless.
|
||||
if (isDesktopCapture && !isDesktopCaptureAuthorized())
|
||||
return false
|
||||
|
||||
const allowlistPermission = isDesktopCapture ? 'display-capture' : permission
|
||||
|
||||
return LOCAL_APP_PERMISSION_NAMES.has(allowlistPermission)
|
||||
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
|
||||
}
|
||||
|
||||
@@ -115,18 +148,20 @@ export function shouldGrantElectronPermission(
|
||||
* Expects:
|
||||
* - The session is the one used by AIRI renderer windows
|
||||
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
|
||||
* - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing; both handlers are installed on the supplied session
|
||||
*/
|
||||
export function setupMediaPermissionHandlers(
|
||||
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
||||
isDesktopCaptureAuthorized: () => boolean,
|
||||
): void {
|
||||
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
callback(shouldGrantElectronPermission(webContents, permission, undefined, details))
|
||||
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
|
||||
})
|
||||
|
||||
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details)
|
||||
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +144,22 @@ function resetScreenCaptureSource() {
|
||||
screenCaptureSourceMutexHandle = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a renderer-selected capture source is currently being served.
|
||||
*
|
||||
* Use when:
|
||||
* - A permission handler has to tell AIRI's own display capture flow apart from an unsolicited request
|
||||
*
|
||||
* Expects:
|
||||
* - `initScreenCaptureForWindow` installed the invoke handlers that own the selection mutex
|
||||
*
|
||||
* Returns:
|
||||
* - Whether `setDisplayMediaRequestHandler` is currently bound to a source picked by a renderer
|
||||
*/
|
||||
export function hasSelectedScreenCaptureSource(): boolean {
|
||||
return screenCaptureSourceMutexHandle !== undefined
|
||||
}
|
||||
|
||||
const initializedWindows = new WeakSet<BrowserWindow>()
|
||||
|
||||
// NOTICE: use this to guard to prevent handling destroyed window
|
||||
|
||||
Generated
+258
-1718
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user