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:
zt
2026-08-08 14:40:50 +09:00
committed by GitHub
co-authored by Claude Opus 5 autofix-ci[bot]
parent c10d16ec8d
commit 5f3746ead8
5 changed files with 397 additions and 1726 deletions
+2 -2
View File
@@ -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
+258 -1718
View File
File diff suppressed because it is too large Load Diff