fix(stage-tamagotchi): permission issue (#2002)

---------

Authored-by-agent: Codex <codex@openai.com>
This commit is contained in:
Lovehsigure_520 2026-07-11 00:25:57 +08:00 committed by GitHub
parent 62a01d83b9
commit 5e8bf75601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 399 additions and 2 deletions

View file

@ -13,7 +13,7 @@ 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 { app, ipcMain } from 'electron'
import { app, ipcMain, session } from 'electron'
import { noop } from 'es-toolkit'
import { createLoggLogger, injeca, lifecycle } from 'injeca'
import { isLinux } from 'std-env'
@ -37,6 +37,7 @@ import { setupExtensionHost } from './services/airi/plugins'
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
import { setupAutoUpdater } from './services/electron/auto-updater'
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
import { setupMediaPermissionHandlers } from './services/electron/media-permissions'
import { setupTray } from './tray'
import { setupAboutWindowReusable } from './windows/about'
import { setupBeatSync } from './windows/beat-sync'
@ -114,6 +115,8 @@ app.whenReady().then(async () => {
return
}
setupMediaPermissionHandlers(session.defaultSession)
// Initialize file logger and register the hook
fileLogger = await setupFileLogger()

View file

@ -0,0 +1,35 @@
import { env } from 'node:process'
/**
* Checks whether a URL belongs to an AIRI-owned local renderer page.
*
* Use when:
* - Electron main-process policies need to distinguish AIRI pages from remote content
* - Packaged and development renderer URLs must share the same trust decision
*
* Expects:
* - Packaged pages use file URLs
* - Development pages share the exact origin configured by Electron Vite
*
* Returns:
* - Whether the URL uses the packaged file scheme or the configured renderer origin
*/
export function isLocalAppURL(rawURL: string | undefined): boolean {
if (!rawURL)
return false
try {
const url = new URL(rawURL)
if (url.protocol === 'file:')
return true
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !env.ELECTRON_RENDERER_URL)
return false
const rendererURL = new URL(env.ELECTRON_RENDERER_URL)
return url.origin === rendererURL.origin
}
catch {
return false
}
}

View file

@ -3,4 +3,5 @@ export * from './auto-updater'
export * from './global-shortcut'
export * from './powerMonitor'
export * from './screen'
export * from './system-preferences'
export * from './window'

View file

@ -0,0 +1,225 @@
import type { MediaAccessPermissionRequest, PermissionCheckHandlerHandlerDetails, WebContents } from 'electron'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { shouldGrantAudioCapturePermission, shouldGrantElectronPermission } from './media-permissions'
const localWebContents = {
getURL: () => 'file:///app/index.html',
} satisfies Pick<WebContents, 'getURL'>
/**
* Creates official Electron request details for media permission tests.
*/
function createMediaRequestDetails(overrides: Partial<MediaAccessPermissionRequest> = {}): MediaAccessPermissionRequest {
return {
isMainFrame: true,
requestingUrl: 'file:///app/index.html',
...overrides,
}
}
/**
* Creates official Electron check details for media permission tests.
*/
function createPermissionCheckDetails(overrides: Partial<PermissionCheckHandlerHandlerDetails> = {}): PermissionCheckHandlerHandlerDetails {
return {
isMainFrame: true,
...overrides,
}
}
/**
* @example
* shouldGrantElectronPermission(localWebContents, 'media', origin, details)
*/
describe('media permissions', () => {
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173')
})
afterEach(() => {
vi.unstubAllEnvs()
})
/** @example Local packaged pages may request audio-only media. */
it('grants local audio media permission requests', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['audio'] }),
)).toBe(true)
})
/** @example Camera-only requests remain denied. */
it('rejects video-only media permission requests', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['video'] }),
)).toBe(false)
})
/** @example Combined microphone and camera requests remain denied. */
it('rejects media permission requests that include video', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['audio', 'video'] }),
)).toBe(false)
})
/** @example A generic media request without a declared audio type is not inferred as safe. */
it('does not treat missing media details as audio', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails(),
)).toBe(false)
})
/** @example Electron permission checks report audio through mediaType. */
it('grants local audio permission checks', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'file:///app/index.html',
createPermissionCheckDetails({ mediaType: 'audio' }),
)).toBe(true)
})
/** @example A remote top-level origin cannot request the microphone. */
it('rejects audio requests from non-local origins', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'https://example.com',
createPermissionCheckDetails({ mediaType: 'audio' }),
)).toBe(false)
})
/** @example A remote requesting frame is rejected even inside a local BrowserWindow. */
it('rejects remote frame requests even when the host window is local', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['audio'], requestingUrl: 'https://example.com/frame.html' }),
)).toBe(false)
})
/** @example A local child frame embedded by a remote page is not AIRI-owned. */
it('rejects local frames embedded by a remote origin', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://localhost:5173',
createPermissionCheckDetails({
embeddingOrigin: 'https://example.com',
mediaType: 'audio',
securityOrigin: 'http://localhost:5173',
}),
)).toBe(false)
})
/** @example All explicit requester identities are accepted when they remain local. */
it('grants audio requests with explicit local requester URLs', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://localhost:5173',
createPermissionCheckDetails({
mediaType: 'audio',
requestingUrl: 'http://localhost:5173',
securityOrigin: 'http://localhost:5173',
}),
)).toBe(true)
})
/** @example Extension assets served from AIRI's loopback server remain untrusted. */
it('rejects plugin asset frames served from a loopback origin', () => {
// ROOT CAUSE:
//
// Treating every loopback HTTP origin as AIRI-owned also trusts extension UI frames.
// Those frames use the same loopback transport but do not share the renderer origin.
// We fixed this by matching HTTP origins against ELECTRON_RENDERER_URL exactly.
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://127.0.0.1:48123',
createPermissionCheckDetails({
mediaType: 'audio',
requestingUrl: 'http://127.0.0.1:48123/_airi/extensions/example/sessions/session/ui/index.html',
securityOrigin: 'http://127.0.0.1:48123',
}),
)).toBe(false)
})
/** @example A plugin development server cannot inherit AIRI renderer permissions. */
it('rejects plugin frames served from another localhost port', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://localhost:4173',
createPermissionCheckDetails({
mediaType: 'audio',
requestingUrl: 'http://localhost:4173/index.html',
securityOrigin: 'http://localhost:4173',
}),
)).toBe(false)
})
/** @example Chromium's opaque origin does not override an explicit packaged file URL. */
it('ignores opaque file origins when packaged local pages request audio', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
'null',
createMediaRequestDetails({ mediaTypes: ['audio'] }),
)).toBe(true)
})
/** @example Local AIRI pages retain screen-capture access. */
it('grants display capture requests from local app pages', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'display-capture',
undefined,
createMediaRequestDetails(),
)).toBe(true)
})
/** @example Remote frames cannot invoke screen capture through the global session handler. */
it('rejects display capture requests from remote pages', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'display-capture',
undefined,
createMediaRequestDetails({ requestingUrl: 'https://example.com/capture.html' }),
)).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(
localWebContents,
'clipboard-sanitized-write',
'file:///app/index.html',
createPermissionCheckDetails(),
)).toBe(true)
})
/** @example Unreviewed permission categories are denied by default. */
it('rejects unrelated permissions instead of granting all local requests', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'notifications',
'file:///app/index.html',
createPermissionCheckDetails(),
)).toBe(false)
})
})

View file

@ -0,0 +1,132 @@
import type { Session, WebContents } from 'electron'
import { isLocalAppURL } from '../../libs/electron/url'
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1]
type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
type LocalAppWebContents = Pick<WebContents, 'getURL'>
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
'display-capture',
'clipboard-sanitized-write',
])
/**
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
*/
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
return !!rawURL && rawURL !== 'null'
}
/**
* Checks whether Electron described an audio-only media permission operation.
*/
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
if (permission !== 'media' || !details)
return false
if ('mediaTypes' in details && details.mediaTypes?.length) {
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
}
return 'mediaType' in details && details.mediaType === 'audio'
}
/**
* Checks whether every requester identity supplied by Electron is local to AIRI.
*/
function shouldGrantLocalAppPermission(
webContents: LocalAppWebContents | null,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
const requesterURLs = [
requestingOrigin,
details?.requestingUrl,
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
].filter(isUsableRequesterURL)
if (requesterURLs.length)
return requesterURLs.every(isLocalAppURL)
return isLocalAppURL(webContents?.getURL())
}
/**
* Decides whether an Electron media operation is an AIRI-owned audio-only request.
*
* Use when:
* - Chromium asks the default session to check or request microphone access
* - A caller needs the same local-frame policy outside the session callbacks
*
* Expects:
* - Permission details come from Electron's official request or check handler contracts
* - Packaged pages use file URLs and development pages use loopback HTTP URLs
*
* Returns:
* - Whether the operation is audio-only and every supplied requester identity is local
*/
export function shouldGrantAudioCapturePermission(
webContents: LocalAppWebContents | null,
permission: ElectronPermission,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
return isAudioMediaPermission(permission, details)
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
}
/**
* Applies AIRI's allowlist to an Electron session permission operation.
*
* Use when:
* - Wiring both Electron permission check and request handlers
* - Preserving reviewed local display-capture and clipboard behavior
*
* Expects:
* - Unknown or unreviewed permission categories must remain denied
* - All explicit frame, security, and embedding origins must identify local AIRI pages
*
* Returns:
* - Whether the requested permission is both allowlisted and locally owned
*/
export function shouldGrantElectronPermission(
webContents: LocalAppWebContents | null,
permission: ElectronPermission,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
if (permission === 'media')
return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details)
return LOCAL_APP_PERMISSION_NAMES.has(permission)
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
}
/**
* Registers the paired Electron session handlers required for complete permission policy.
*
* Use when:
* - Initializing Electron's default session after app readiness
*
* Expects:
* - The session is the one used by AIRI renderer windows
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
*
* Returns:
* - Nothing; both handlers are installed on the supplied session
*/
export function setupMediaPermissionHandlers(
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
): void {
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(shouldGrantElectronPermission(webContents, permission, undefined, details))
})
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details)
})
}

View file

@ -10,7 +10,7 @@ import { isMacOS } from 'std-env'
import { createServerChannelService } from '../../services/airi/channel-server'
import { createI18nService } from '../../services/airi/i18n'
import { createAppService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron'
import { createAppService, createPowerMonitorService, createScreenService, createSystemPreferencesService, createWindowService } from '../../services/electron'
export function toggleWindowShow(window?: BrowserWindow | null): void {
if (!window) {
@ -100,6 +100,7 @@ export async function setupBaseWindowElectronInvokes(params: {
createWindowService({ context: params.context, window: params.window })
createAppService({ context: params.context, window: params.window })
createPowerMonitorService({ context: params.context, window: params.window })
createSystemPreferencesService({ context: params.context, window: params.window })
await createI18nService({ context: params.context, window: params.window, i18n: params.i18n })