mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-12 02:13:37 +00:00
fix(stage-tamagotchi): permission issue (#2002)
--------- Authored-by-agent: Codex <codex@openai.com>
This commit is contained in:
parent
62a01d83b9
commit
5e8bf75601
6 changed files with 399 additions and 2 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
35
apps/stage-tamagotchi/src/main/libs/electron/url.ts
Normal file
35
apps/stage-tamagotchi/src/main/libs/electron/url.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue