From 5e8bf756014058fd35e7cccfba447fff3466d803 Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:25:57 +0800 Subject: [PATCH] fix(stage-tamagotchi): permission issue (#2002) --------- Authored-by-agent: Codex --- apps/stage-tamagotchi/src/main/index.ts | 5 +- .../src/main/libs/electron/url.ts | 35 +++ .../src/main/services/electron/index.ts | 1 + .../electron/media-permissions.test.ts | 225 ++++++++++++++++++ .../services/electron/media-permissions.ts | 132 ++++++++++ .../src/main/windows/shared/window.ts | 3 +- 6 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/libs/electron/url.ts create mode 100644 apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index eafd643ce..a575cdd41 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -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() diff --git a/apps/stage-tamagotchi/src/main/libs/electron/url.ts b/apps/stage-tamagotchi/src/main/libs/electron/url.ts new file mode 100644 index 000000000..e9443ff80 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/electron/url.ts @@ -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 + } +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 7b86cc531..d62c659be 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts new file mode 100644 index 000000000..8d3349661 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.test.ts @@ -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 + +/** + * Creates official Electron request details for media permission tests. + */ +function createMediaRequestDetails(overrides: Partial = {}): MediaAccessPermissionRequest { + return { + isMainFrame: true, + requestingUrl: 'file:///app/index.html', + ...overrides, + } +} + +/** + * Creates official Electron check details for media permission tests. + */ +function createPermissionCheckDetails(overrides: Partial = {}): 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) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts new file mode 100644 index 000000000..3e12820ef --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/media-permissions.ts @@ -0,0 +1,132 @@ +import type { Session, WebContents } from 'electron' + +import { isLocalAppURL } from '../../libs/electron/url' + +type PermissionCheckHandler = Exclude[0], null> +type PermissionRequestHandler = Exclude[0], null> +type ElectronPermission = Parameters[1] | Parameters[1] +type ElectronPermissionDetails = Parameters[3] | Parameters[3] +type LocalAppWebContents = Pick + +const LOCAL_APP_PERMISSION_NAMES = new Set([ + '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, +): 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) + }) +} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.ts index e8b92058a..637467b4d 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/window.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/window.ts @@ -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 })