From 4cda1828ec4338173318807264555d5933011b3c Mon Sep 17 00:00:00 2001 From: Makito Date: Mon, 12 Jan 2026 03:30:19 +0900 Subject: [PATCH] feat(electron-screen-capture): introduce new screen capture utilities (#937) --- apps/stage-tamagotchi/electron.vite.config.ts | 1 + apps/stage-tamagotchi/package.json | 2 +- apps/stage-tamagotchi/src/main/index.ts | 4 +- .../src/main/windows/beat-sync/index.ts | 4 + .../src/main/windows/main/index.ts | 3 + .../src/main/windows/settings/index.ts | 3 + .../src/renderer/beat-sync.main.ts | 14 -- .../pages/devtools/screen-capture.vue | 227 ++++++++++++++++++ .../pages/settings/system/developer.vue | 6 + cspell.config.yaml | 3 + packages/electron-screen-capture/package.json | 50 ++++ packages/electron-screen-capture/src/index.ts | 23 ++ .../electron-screen-capture/src/main/index.ts | 218 +++++++++++++++++ .../electron-screen-capture/src/main/utils.ts | 26 ++ .../electron-screen-capture/src/renderer.ts | 49 ++++ .../electron-screen-capture/src/vue/index.ts | 1 + .../src/vue/use-electron-screen-capture.ts | 49 ++++ .../electron-screen-capture/tsconfig.json | 19 ++ .../electron-screen-capture/tsdown.config.ts | 38 +++ .../stage-layouts/src/layouts/settings.vue | 4 + packages/stage-shared/package.json | 1 + .../stage-shared/src/beat-sync/detector.ts | 43 ++-- pnpm-lock.yaml | 117 +++++++-- pnpm-workspace.yaml | 4 + 24 files changed, 851 insertions(+), 58 deletions(-) create mode 100644 apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue create mode 100644 packages/electron-screen-capture/package.json create mode 100644 packages/electron-screen-capture/src/index.ts create mode 100644 packages/electron-screen-capture/src/main/index.ts create mode 100644 packages/electron-screen-capture/src/main/utils.ts create mode 100644 packages/electron-screen-capture/src/renderer.ts create mode 100644 packages/electron-screen-capture/src/vue/index.ts create mode 100644 packages/electron-screen-capture/src/vue/use-electron-screen-capture.ts create mode 100644 packages/electron-screen-capture/tsconfig.json create mode 100644 packages/electron-screen-capture/tsdown.config.ts diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts index 220b6ead2..0a2231991 100644 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ b/apps/stage-tamagotchi/electron.vite.config.ts @@ -52,6 +52,7 @@ export default defineConfig({ '@proj-airi/stage-ui/*', '@proj-airi/drizzle-duckdb-wasm', '@proj-airi/drizzle-duckdb-wasm/*', + '@proj-airi/electron-screen-capture', // Static Assets: Models, Images, etc. 'src/renderer/public/assets/*', diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index ab1788486..28b2fb506 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -53,6 +53,7 @@ "@proj-airi/audio": "workspace:^", "@proj-airi/ccc": "workspace:^", "@proj-airi/drizzle-duckdb-wasm": "catalog:", + "@proj-airi/electron-screen-capture": "workspace:^", "@proj-airi/font-cjkfonts-allseto": "workspace:^", "@proj-airi/font-xiaolai": "workspace:^", "@proj-airi/i18n": "workspace:^", @@ -89,7 +90,6 @@ "dompurify": "^3.3.1", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", - "electron-audio-loopback": "^1.0.6", "electron-click-drag-plugin": "^2.0.2", "electron-updater": "^6.6.2", "es-toolkit": "^1.43.0", diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index d5d387edd..38669e621 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -2,8 +2,8 @@ import { env, platform } from 'node:process' import { electronApp, optimizer } from '@electron-toolkit/utils' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' +import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main' import { app, ipcMain } from 'electron' -import { initMain as initAudioLoopback } from 'electron-audio-loopback' import { noop } from 'es-toolkit' import { createLoggLogger, injeca } from 'injeca' import { isLinux } from 'std-env' @@ -67,7 +67,7 @@ if (isLinux) { app.dock?.setIcon(icon) electronApp.setAppUserModelId('ai.moeru.airi') -initAudioLoopback() +initScreenCaptureForMain() app.whenReady().then(async () => { injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig())) diff --git a/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts b/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts index 8f30de581..51468afa0 100644 --- a/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts @@ -1,6 +1,7 @@ import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' import { BrowserWindow } from 'electron' import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location' @@ -15,5 +16,8 @@ export async function setupBeatSync() { }) await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'), 'beat-sync.html')) + + initScreenCaptureForWindow(window) + return window } diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 691048b4d..43d48b5d1 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -13,6 +13,7 @@ import clickDragPlugin from 'electron-click-drag-plugin' import { is } from '@electron-toolkit/utils' import { defineInvokeHandler } from '@moeru/eventa' import { createContext } from '@moeru/eventa/adapters/electron/main' +import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' import { defu } from 'defu' import { BrowserWindow, ipcMain, shell } from 'electron' import { isLinux, isMacOS } from 'std-env' @@ -175,5 +176,7 @@ export async function setupMainWindow(params: { }) } + initScreenCaptureForWindow(window) + return window } diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 485cdd256..1a9cda8df 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -4,6 +4,7 @@ import type { WidgetsWindowManager } from '../widgets' import { join, resolve } from 'node:path' +import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main' import { BrowserWindow, shell } from 'electron' import icon from '../../../../resources/icon.png?asset' @@ -49,6 +50,8 @@ export function setupSettingsWindowReusableFunc(params: { devtoolsMarkdownStressWindow: params.devtoolsMarkdownStressWindow, }) + initScreenCaptureForWindow(window) + return window }).getWindow } diff --git a/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts b/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts index 388f8645d..f0edf7dbd 100644 --- a/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts +++ b/apps/stage-tamagotchi/src/renderer/beat-sync.main.ts @@ -11,27 +11,13 @@ import { createContext, } from '@proj-airi/stage-shared/beat-sync' -const { ipcRenderer } = window.electron - const context = createContext() const changeState = defineInvoke(context, beatSyncStateChangedInvokeEventa) const signalBeat = defineInvoke(context, beatSyncBeatSignaledInvokeEventa) -function enableLoopbackAudio() { - // electron-audio-loopback currently registers this handler internally - return ipcRenderer.invoke('enable-loopback-audio') -} - -function disableLoopbackAudio() { - // electron-audio-loopback currently registers this handler internally - return ipcRenderer.invoke('disable-loopback-audio') -} - const detector = createBeatSyncDetector({ env: StageEnvironment.Tamagotchi, - enableLoopbackAudio, - disableLoopbackAudio, }) detector.on('stateChange', state => changeState(state)) diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue new file mode 100644 index 000000000..7b841e205 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/devtools/screen-capture.vue @@ -0,0 +1,227 @@ + + + + + +meta: + layout: settings + diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue index a285a3641..1993943b3 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue @@ -65,6 +65,12 @@ const menu = computed(() => [ icon: 'i-solar:transfer-horizontal-bold-duotone', to: '/devtools/websocket-inspector', }, + { + title: 'Screen Capture', + description: 'Capture screen or window as video and/or audio streams', + icon: 'i-solar:screen-share-bold-duotone', + to: '/devtools/screen-capture', + }, ]) const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools) diff --git a/cspell.config.yaml b/cspell.config.yaml index fb739cf95..ac2125c10 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -15,6 +15,7 @@ words: - allseto - animejs - APNG + - Armbruster - astrojs - Attributify - attw @@ -34,6 +35,7 @@ words: - buildless - bumpp - byteorder + - Catap - catppuccin - cdylib - cerebras @@ -228,6 +230,7 @@ words: - prismarine - prng - pthread + - pulseaudio - quanlai - qwen - Raycaster diff --git a/packages/electron-screen-capture/package.json b/packages/electron-screen-capture/package.json new file mode 100644 index 000000000..8b1feb37b --- /dev/null +++ b/packages/electron-screen-capture/package.json @@ -0,0 +1,50 @@ +{ + "name": "@proj-airi/electron-screen-capture", + "type": "module", + "private": true, + "description": "Screen capture", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "packages/electron-screen-capture" + }, + "exports": { + ".": "./dist/index.js", + "./main": "./dist/main.mjs", + "./renderer": "./dist/renderer.js", + "./vue": "./dist/vue.js", + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "README.md", + "dist", + "package.json" + ], + "scripts": { + "dev": "pnpm run build", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "vue": ">=3" + }, + "devDependencies": { + "@electron-toolkit/preload": "catalog:", + "@guiiai/logg": "catalog:", + "@moeru/eventa": "catalog:", + "@moeru/std": "catalog:", + "async-mutex": "catalog:", + "electron": "catalog:", + "nanoid": "catalog:", + "vite": "catalog:rolldown-vite" + } +} diff --git a/packages/electron-screen-capture/src/index.ts b/packages/electron-screen-capture/src/index.ts new file mode 100644 index 000000000..c811a1fc2 --- /dev/null +++ b/packages/electron-screen-capture/src/index.ts @@ -0,0 +1,23 @@ +import type { DesktopCapturerSource, SourcesOptions } from 'electron' + +import { defineInvokeEventa } from '@moeru/eventa' + +export interface SerializableDesktopCapturerSource extends Pick { + appIcon?: Uint8Array + thumbnail?: Uint8Array +} + +export interface ScreenCaptureSetSourceRequest { + options: SourcesOptions + sourceId: string + /** + * Timeout in milliseconds to release the setSourceMutex. + * + * @default 5000 + */ + timeout?: number +} + +export const screenCaptureGetSources = defineInvokeEventa('eventa:invoke:electron:screen-capture:get-sources') +export const screenCaptureSetSourceEx = defineInvokeEventa('eventa:invoke:electron:screen-capture:set-source') +export const screenCaptureResetSource = defineInvokeEventa('eventa:invoke:electron:screen-capture:reset-source') diff --git a/packages/electron-screen-capture/src/main/index.ts b/packages/electron-screen-capture/src/main/index.ts new file mode 100644 index 000000000..cf5b4be88 --- /dev/null +++ b/packages/electron-screen-capture/src/main/index.ts @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright (c) Alec Armbruster, Licensed under MIT License +// SPDX-FileCopyrightText: Copyright (c) Moeru AI Project AIRI Team + +import type { MutexInterface } from 'async-mutex' +import type { BrowserWindow, DesktopCapturerSource, SourcesOptions } from 'electron' + +import { useLogg } from '@guiiai/logg' +import { defineInvokeHandler } from '@moeru/eventa' +import { createContext } from '@moeru/eventa/adapters/electron/main' +import { Mutex, withTimeout } from 'async-mutex' +import { app, desktopCapturer, ipcMain, session as sessionModule } from 'electron' +import { nanoid } from 'nanoid' + +import { screenCaptureGetSources, screenCaptureResetSource, screenCaptureSetSourceEx } from '..' +import { toSerializableDesktopCapturerSource } from './utils' + +export const defaultSourcesOptions: SourcesOptions = { types: ['screen'] } + +export const featureSwitchKey = 'enable-features' as const + +export enum LoopbackAudioTypes { + Loopback = 'loopback', + LoopbackWithMute = 'loopbackWithMute', +} + +enum DefaultFeatureFlags { + PulseaudioLoopbackForScreenShare = 'PulseaudioLoopbackForScreenShare', + /** + * Note(Makito): Some discussions on this flag can be found here: + * + * - {@link https://issues.chromium.org/issues/355308245} + * - {@link https://issues.chromium.org/issues/394329567} + */ + MacLoopbackAudioForScreenShare = 'MacLoopbackAudioForScreenShare', +} + +enum CoreAudioTapFeatureFlags { + MacCoreAudioTapSystemAudioLoopbackOverride = 'MacCatapSystemAudioLoopbackCapture', +} + +enum ScreenCaptureKitFeatureFlags { + MacScreenCaptureKitSystemAudioLoopbackOverride = 'MacSckSystemAudioLoopbackOverride', +} + +export function buildFeatureFlags({ + otherEnabledFeatures, + forceCoreAudioTap, +}: { + otherEnabledFeatures?: string[] + forceCoreAudioTap?: boolean +}): string { + const featureFlags = [...Object.values(DefaultFeatureFlags), ...(otherEnabledFeatures ?? [])] + + if (forceCoreAudioTap) { + featureFlags.push(CoreAudioTapFeatureFlags.MacCoreAudioTapSystemAudioLoopbackOverride) + } + else { + featureFlags.push(ScreenCaptureKitFeatureFlags.MacScreenCaptureKitSystemAudioLoopbackOverride) + } + + return featureFlags.join(',') +} + +let initMainCalled = false + +export interface InitMainOptions { + forceCoreAudioTap?: boolean + mutexAcquireTimeout?: number +} + +export interface InitWindowOptions { + loopbackWithMute?: boolean + sourcesOptions?: SourcesOptions + onAfterGetSources?: (sources: DesktopCapturerSource[]) => DesktopCapturerSource[] +} + +export interface GetLoopbackAudioMediaStreamOptions { + removeVideo?: boolean +} + +let setSourceMutex: MutexInterface +let screenCaptureSourceMutexHandle: string | undefined +let setSourceMutexTimeoutHandle: NodeJS.Timeout | undefined + +const log = useLogg('screen-capture').useGlobalConfig() + +export function initScreenCaptureForMain(options: InitMainOptions = {}): void { + const { + forceCoreAudioTap = false, + mutexAcquireTimeout = 5000, + } = options + + if (mutexAcquireTimeout <= 0 || !Number.isFinite(mutexAcquireTimeout) || Number.isNaN(mutexAcquireTimeout)) { + throw new Error('mutexAcquireTimeout must be a positive finite number') + } + + if (initMainCalled) { + log.warn('initScreenCaptureForMain should only be called once') + return + } + initMainCalled = true + setSourceMutex = withTimeout(new Mutex(), mutexAcquireTimeout) + + // Get other enabled features from the command line. + const otherEnabledFeatures = app.commandLine.getSwitchValue(featureSwitchKey)?.split(',') + + // Remove the switch if it exists. + if (app.commandLine.hasSwitch(featureSwitchKey)) { + app.commandLine.removeSwitch(featureSwitchKey) + } + + // Add the feature flags to the command line with any other user-enabled features concatenated. + const currentFeatureFlags = buildFeatureFlags({ + otherEnabledFeatures, + forceCoreAudioTap, + }) + + app.commandLine.appendSwitch(featureSwitchKey, currentFeatureFlags) +} + +function resetScreenCaptureSource() { + sessionModule.defaultSession.setDisplayMediaRequestHandler(null) + clearTimeout(setSourceMutexTimeoutHandle) + setSourceMutexTimeoutHandle = undefined + screenCaptureSourceMutexHandle = undefined +} + +const initializedWindows = new WeakSet() + +export function initScreenCaptureForWindow(window: BrowserWindow, options?: InitWindowOptions): void { + log.debug(`init for window #${window.id} (${window.title})`) + + if (!initMainCalled) { + // Throwing an error because this is unlikely to be recoverable. + throw new Error('initScreenCaptureForMain must be called before calling initScreenCaptureForWindow') + } + + if (initializedWindows.has(window)) { + log.warn('initScreenCaptureForWindow should only be called once per window') + return + } + initializedWindows.add(window) + + const { context } = createContext(ipcMain, window, { onlySameWindow: true }) + const session = sessionModule.defaultSession + + defineInvokeHandler(context, screenCaptureGetSources, async (sourcesOptions) => { + const sources = await desktopCapturer.getSources(sourcesOptions) + return sources.map(source => toSerializableDesktopCapturerSource(source)) + }) + + defineInvokeHandler(context, screenCaptureSetSourceEx, async (request, eventaOptions) => { + // FIXME: Would be better if `onlySameWindow` in `createContext` also filters out invocations here. + if (window.webContents.id !== eventaOptions?.raw.ipcMainEvent.sender.id) + return + + const { timeout } = request + if (typeof timeout === 'number' && (timeout <= 0 || !Number.isFinite(timeout) || Number.isNaN(timeout))) { + throw new Error('timeout must be a positive finite number') + } + + await setSourceMutex.acquire() + log.debug(`setSourceMutex acquired for window #${window.id} (${window.title})`) + + clearTimeout(setSourceMutexTimeoutHandle) + const handle = nanoid() + setSourceMutexTimeoutHandle = undefined + screenCaptureSourceMutexHandle = handle + + try { + session.setDisplayMediaRequestHandler(async (_request, callback) => { + const sources = await desktopCapturer.getSources(request.options) + const source = sources.find(source => source.id === request.sourceId) + if (!source) { + throw new Error(`Source with id ${request.sourceId} not found.`) + } + callback({ + video: source, + audio: options?.loopbackWithMute ? LoopbackAudioTypes.LoopbackWithMute : LoopbackAudioTypes.Loopback, + }) + }) + + setSourceMutexTimeoutHandle = setTimeout(() => { + if (screenCaptureSourceMutexHandle !== handle) + return + + resetScreenCaptureSource() + setSourceMutex.release() + log.warn( + `setSourceMutex released for window #${window.id} (${window.title}) due to timeout. ` + + 'Please make sure to invoke screenCaptureResetSource when getDisplayMedia is completed.', + ) + }, timeout ?? 5000) + + return handle + } + catch (e) { + log.error( + `screenCaptureSetSourceEx failed for window #${window.id} (${window.title}): ${e instanceof Error ? e.message : String(e)}`, + e instanceof Error ? e.stack : undefined, + ) + + resetScreenCaptureSource() + setSourceMutex.release() + throw e + } + }) + + defineInvokeHandler(context, screenCaptureResetSource, async (mutexHandle) => { + if (screenCaptureSourceMutexHandle !== mutexHandle) + return + + resetScreenCaptureSource() + setSourceMutex.release() + + log.debug(`setSourceMutex released by window #${window.id} (${window.title})`) + }) +} diff --git a/packages/electron-screen-capture/src/main/utils.ts b/packages/electron-screen-capture/src/main/utils.ts new file mode 100644 index 000000000..0b368a076 --- /dev/null +++ b/packages/electron-screen-capture/src/main/utils.ts @@ -0,0 +1,26 @@ +import type { DesktopCapturerSource } from 'electron' + +import type { SerializableDesktopCapturerSource } from '..' + +/** + * Serializes a DesktopCapturerSource to a format that can be sent over IPC. + * + * Using Uint8Array for appIcon and thumbnail here instead of transferable objects + * or SharedArrayBuffer due to Electron limitations. + * + * See: + * - {@link https://github.com/electron/electron/issues/27024} + * - {@link https://github.com/electron/electron/issues/34905} + * + * @param source - The DesktopCapturerSource to serialize + * @returns A serializable representation of the DesktopCapturerSource + */ +export function toSerializableDesktopCapturerSource(source: DesktopCapturerSource): SerializableDesktopCapturerSource { + return { + id: source.id, + name: source.name, + display_id: source.display_id, + appIcon: source.appIcon != null && !source.appIcon.isEmpty() ? new Uint8Array(source.appIcon.toPNG().buffer) : undefined, + thumbnail: source.thumbnail != null ? new Uint8Array(source.thumbnail.toJPEG(90).buffer) : undefined, + } +} diff --git a/packages/electron-screen-capture/src/renderer.ts b/packages/electron-screen-capture/src/renderer.ts new file mode 100644 index 000000000..311d3cf67 --- /dev/null +++ b/packages/electron-screen-capture/src/renderer.ts @@ -0,0 +1,49 @@ +import type { createContext } from '@moeru/eventa/adapters/electron/renderer' +import type { SourcesOptions } from 'electron' + +import type { ScreenCaptureSetSourceRequest, SerializableDesktopCapturerSource } from '.' + +import { defineInvoke } from '@moeru/eventa' + +import { screenCaptureGetSources, screenCaptureResetSource, screenCaptureSetSourceEx } from '.' + +export interface SourceOptionsWithRequest { + sourcesOptions?: SourcesOptions + request?: Omit +} + +export function setupElectronScreenCapture(context: ReturnType['context']) { + const invokeGetSources = defineInvoke(context, screenCaptureGetSources) + const setSource = defineInvoke(context, screenCaptureSetSourceEx) + const resetSource = defineInvoke(context, screenCaptureResetSource) + + async function getSources(sourcesOptions: SourcesOptions) { + return invokeGetSources(sourcesOptions) + } + + async function selectWithSource( + selectFn: (sources: SerializableDesktopCapturerSource[]) => string | Promise, + useFn: () => R | Promise, + options?: SourceOptionsWithRequest, + ): Promise { + const sources = await getSources(options?.sourcesOptions) + const sourceId = await selectFn(sources) + + let handle: string | undefined + try { + handle = await setSource({ + options: options?.sourcesOptions, + sourceId, + timeout: options?.request?.timeout, + }) + return await useFn() + } + finally { + if (handle) { + await resetSource(handle) + } + } + } + + return { getSources, setSource, selectWithSource, resetSource } +} diff --git a/packages/electron-screen-capture/src/vue/index.ts b/packages/electron-screen-capture/src/vue/index.ts new file mode 100644 index 000000000..a9fa316c2 --- /dev/null +++ b/packages/electron-screen-capture/src/vue/index.ts @@ -0,0 +1 @@ +export * from './use-electron-screen-capture' diff --git a/packages/electron-screen-capture/src/vue/use-electron-screen-capture.ts b/packages/electron-screen-capture/src/vue/use-electron-screen-capture.ts new file mode 100644 index 000000000..fb2b0a89b --- /dev/null +++ b/packages/electron-screen-capture/src/vue/use-electron-screen-capture.ts @@ -0,0 +1,49 @@ +import type { IpcRenderer } from '@electron-toolkit/preload' +import type { SourcesOptions } from 'electron' +import type { MaybeRefOrGetter } from 'vue' + +import type { ScreenCaptureSetSourceRequest, SerializableDesktopCapturerSource } from '..' + +import { defineInvoke } from '@moeru/eventa' +import { createContext } from '@moeru/eventa/adapters/electron/renderer' +import { toValue } from 'vue' + +import { screenCaptureGetSources, screenCaptureResetSource, screenCaptureSetSourceEx } from '..' + +export function useElectronScreenCapture(ipcRenderer: IpcRenderer, sourcesOptions: MaybeRefOrGetter) { + const context = createContext(ipcRenderer).context + + const invokeGetSources = defineInvoke(context, screenCaptureGetSources) + const setSource = defineInvoke(context, screenCaptureSetSourceEx) + const resetSource = defineInvoke(context, screenCaptureResetSource) + + async function getSources() { + return invokeGetSources(toValue(sourcesOptions)) + } + + async function selectWithSource( + selectFn: (sources: SerializableDesktopCapturerSource[]) => string, + useFn: () => Promise, + request?: Omit, + ): Promise { + const sources = await getSources() + const sourceId = selectFn(sources) + + let handle: string | undefined + try { + handle = await setSource({ + options: toValue(sourcesOptions), + sourceId, + timeout: request?.timeout, + }) + return await useFn() + } + finally { + if (handle) { + await resetSource(handle) + } + } + } + + return { getSources, setSource, resetSource, selectWithSource } +} diff --git a/packages/electron-screen-capture/tsconfig.json b/packages/electron-screen-capture/tsconfig.json new file mode 100644 index 000000000..6869a6239 --- /dev/null +++ b/packages/electron-screen-capture/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/electron-screen-capture/tsdown.config.ts b/packages/electron-screen-capture/tsdown.config.ts new file mode 100644 index 000000000..4eeaee369 --- /dev/null +++ b/packages/electron-screen-capture/tsdown.config.ts @@ -0,0 +1,38 @@ +import type { UserConfig } from 'tsdown' + +import { defineConfig } from 'tsdown' + +const sharedConfig: UserConfig = { + format: 'esm', + unbundle: true, + external: [ + 'electron', + 'vue', + ], + exports: true, +} + +export default defineConfig([ + { + ...sharedConfig, + platform: 'node', + entry: { + main: 'src/main/index.ts', + }, + }, + { + ...sharedConfig, + platform: 'neutral', + entry: { + index: 'src/index.ts', + }, + }, + { + ...sharedConfig, + platform: 'browser', + entry: { + vue: 'src/vue/index.ts', + renderer: 'src/renderer.ts', + }, + }, +]) diff --git a/packages/stage-layouts/src/layouts/settings.vue b/packages/stage-layouts/src/layouts/settings.vue index a7c0e0e69..d73a41704 100644 --- a/packages/stage-layouts/src/layouts/settings.vue +++ b/packages/stage-layouts/src/layouts/settings.vue @@ -126,6 +126,10 @@ const routeHeaderMetadataMap = computed(() => { subtitle: t('settings.title'), title: t('settings.pages.system.sections.section.developer.sections.section.markdown-stress.title'), }, + '/devtools/screen-capture': { + subtitle: t('settings.title'), + title: 'Screen Capture', + }, } for (const metadata of allProvidersMetadata.value) { diff --git a/packages/stage-shared/package.json b/packages/stage-shared/package.json index 2e358f4bc..114de9fff 100644 --- a/packages/stage-shared/package.json +++ b/packages/stage-shared/package.json @@ -26,6 +26,7 @@ "@electron-toolkit/preload": "^3.0.2", "@moeru/eventa": "^1.0.0-alpha.10", "@nekopaw/tempora": "catalog:", + "@proj-airi/electron-screen-capture": "workspace:^", "@types/audioworklet": "catalog:" } } diff --git a/packages/stage-shared/src/beat-sync/detector.ts b/packages/stage-shared/src/beat-sync/detector.ts index b953fb7e2..f2475f49e 100644 --- a/packages/stage-shared/src/beat-sync/detector.ts +++ b/packages/stage-shared/src/beat-sync/detector.ts @@ -7,8 +7,10 @@ import analyserWorklet from '@nekopaw/tempora/worklet?url' import { defineInvoke, defineInvokeHandler } from '@moeru/eventa' import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora' +import { setupElectronScreenCapture } from '@proj-airi/electron-screen-capture/renderer' import { isStageTamagotchi, isStageWeb, StageEnvironment } from '../environment' +import { isElectronWindow } from '../window' import { beatSyncBeatSignaledInvokeEventa, beatSyncGetInputByteFrequencyDataInvokeEventa, @@ -35,17 +37,10 @@ export interface BeatSyncDetector { readonly source: AudioNode | undefined } -export type CreateBeatSyncDetectorOptions = | - { - env: StageEnvironment.Tamagotchi - enableLoopbackAudio: () => Promise - disableLoopbackAudio: () => Promise - } - | { - env: StageEnvironment.Web - } | { - env: StageEnvironment.Capacitor - } +export type CreateBeatSyncDetectorOptions + = | { env: StageEnvironment.Tamagotchi } + | { env: StageEnvironment.Web } + | { env: StageEnvironment.Capacitor } export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): BeatSyncDetector { let context: AudioContext | undefined @@ -161,12 +156,26 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): return node } case StageEnvironment.Tamagotchi: { - await options.enableLoopbackAudio() + if (!isElectronWindow(window)) { + throw new Error(`Electron window is required for this environment: ${options.env}`) + } - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: true, - }) + // FIXME(Makito): Will refactor later + const { createContext } = await import('@moeru/eventa/adapters/electron/renderer') + const { selectWithSource } = setupElectronScreenCapture(createContext(window.electron.ipcRenderer).context) + + const stream = await selectWithSource( + (sources) => { + if (sources.length === 0) + throw new Error('No screen source available') + return sources[0].id + }, + async () => await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }), + { sourcesOptions: { types: ['screen'] } }, + ) const videoTracks = stream.getVideoTracks() @@ -178,9 +187,7 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): const node = ctx.createMediaStreamSource(stream) stopSource = () => { stream.getTracks().forEach(track => track.stop()) - options.disableLoopbackAudio() } - await options.disableLoopbackAudio() return node } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca85968b8..00142845d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@electric-sql/pglite': specifier: ^0.3.14 version: 0.3.14 + '@electron-toolkit/preload': + specifier: ^3.0.2 + version: 3.0.2 '@guiiai/logg': specifier: ^1.2.11 version: 1.2.11 @@ -33,6 +36,9 @@ catalogs: '@moeru/eslint-config': specifier: 0.1.0-beta.15 version: 0.1.0-beta.15 + '@moeru/eventa': + specifier: 1.0.0-alpha.10 + version: 1.0.0-alpha.10 '@moeru/std': specifier: 0.1.0-beta.14 version: 0.1.0-beta.14 @@ -93,6 +99,9 @@ catalogs: '@xsai/utils-chat': specifier: 0.4.0-beta.13 version: 0.4.0-beta.13 + async-mutex: + specifier: 0.5.0 + version: 0.5.0 better-auth: specifier: ^1.4.9 version: 1.4.10 @@ -102,6 +111,9 @@ catalogs: drizzle-valibot: specifier: ^0.4.2 version: 0.4.2 + electron: + specifier: ^39.2.7 + version: 39.2.7 embla-carousel-vue: specifier: ^8.6.0 version: 8.6.0 @@ -304,7 +316,7 @@ importers: version: 0.5.6 unplugin-vue: specifier: ^7.1.0 - version: 7.1.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.25(typescript@5.9.3))(yaml@2.8.2) + version: 7.1.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2) vite: specifier: ^7.3.0 version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -398,7 +410,7 @@ importers: version: 1.19.7(hono@4.11.3) better-auth: specifier: ^1.4.5 - version: 1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3)) + version: 1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3)) drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7) @@ -423,7 +435,7 @@ importers: devDependencies: '@better-auth/cli': specifier: ^1.4.5 - version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(better-call@1.1.7(zod@4.2.1))(drizzle-kit@0.31.8)(jose@6.1.3)(kysely@0.28.9)(magicast@0.5.1)(nanostores@1.1.0)(postgres@3.4.7)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3)) + version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(better-call@1.1.7(zod@4.2.1))(drizzle-kit@0.31.8)(jose@6.1.3)(kysely@0.28.9)(magicast@0.5.1)(nanostores@1.1.0)(postgres@3.4.7)(react@19.2.3)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3)) drizzle-kit: specifier: ^0.31.7 version: 0.31.8 @@ -866,6 +878,9 @@ importers: '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7)) + '@proj-airi/electron-screen-capture': + specifier: workspace:^ + version: link:../../packages/electron-screen-capture '@proj-airi/font-cjkfonts-allseto': specifier: workspace:^ version: link:../../packages/font-cjkfonts-allseto @@ -974,9 +989,6 @@ importers: drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7) - electron-audio-loopback: - specifier: ^1.0.6 - version: 1.0.6(electron@39.2.7) electron-click-drag-plugin: specifier: ^2.0.2 version: 2.0.2 @@ -1802,6 +1814,37 @@ importers: specifier: ^1.0.2 version: 1.0.2 + packages/electron-screen-capture: + dependencies: + vue: + specifier: '>=3' + version: 3.5.25(typescript@5.9.3) + devDependencies: + '@electron-toolkit/preload': + specifier: 'catalog:' + version: 3.0.2(electron@39.2.7) + '@guiiai/logg': + specifier: 'catalog:' + version: 1.2.11 + '@moeru/eventa': + specifier: 'catalog:' + version: 1.0.0-alpha.10(electron@39.2.7)(h3@2.0.1-rc.5(crossws@0.4.1(srvx@0.9.8(patch_hash=f0151386fdcbcb6f53833cf8f66926ef2eb31b71a2782d6e0960dec832ef108d)))) + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.14 + async-mutex: + specifier: 'catalog:' + version: 0.5.0 + electron: + specifier: 'catalog:' + version: 39.2.7 + nanoid: + specifier: 'catalog:' + version: 5.1.6 + vite: + specifier: catalog:rolldown-vite + version: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/font-cjkfonts-allseto: {} packages/font-departure-mono: {} @@ -2157,6 +2200,9 @@ importers: '@nekopaw/tempora': specifier: 'catalog:' version: 0.4.0-alpha.1 + '@proj-airi/electron-screen-capture': + specifier: workspace:^ + version: link:../electron-screen-capture '@types/audioworklet': specifier: 'catalog:' version: 0.0.92 @@ -8939,6 +8985,9 @@ packages: '@vue/runtime-dom@3.5.25': resolution: {integrity: sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==} + '@vue/runtime-dom@3.5.26': + resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + '@vue/server-renderer@3.5.25': resolution: {integrity: sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==} peerDependencies: @@ -10704,11 +10753,6 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-audio-loopback@1.0.6: - resolution: {integrity: sha512-QW0ogDqMpWHDAQHmQyssJ+Yh4qR3kWCP3Q4H9WuIXKwVlgkqOYGyt0v/JzbK3tBNTwfqbuHZy86kwCCajxqAdg==} - peerDependencies: - electron: '>=31.0.1' - electron-builder-squirrel-windows@26.4.0: resolution: {integrity: sha512-7dvalY38xBzWNaoOJ4sqy2aGIEpl2S1gLPkkB0MHu1Hu5xKQ82il1mKSFlXs6fLpXUso/NmyjdHGlSHDRoG8/w==} @@ -16259,6 +16303,14 @@ packages: typescript: optional: true + vue@3.5.26: + resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -17793,7 +17845,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(better-call@1.1.7(zod@4.2.1))(drizzle-kit@0.31.8)(jose@6.1.3)(kysely@0.28.9)(magicast@0.5.1)(nanostores@1.1.0)(postgres@3.4.7)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3))': + '@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(better-call@1.1.7(zod@4.2.1))(drizzle-kit@0.31.8)(jose@6.1.3)(kysely@0.28.9)(magicast@0.5.1)(nanostores@1.1.0)(postgres@3.4.7)(react@19.2.3)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 '@babel/preset-react': 7.28.5(@babel/core@7.28.5) @@ -17805,7 +17857,7 @@ snapshots: '@mrleebo/prisma-ast': 0.13.1 '@prisma/client': 5.22.0 '@types/pg': 8.16.0 - better-auth: 1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7)(react@19.2.3))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3)) + better-auth: 1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7)(react@19.2.3))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3)) better-sqlite3: 12.5.0 c12: 3.3.3(magicast@0.5.1) chalk: 5.6.2 @@ -23015,6 +23067,13 @@ snapshots: '@vue/shared': 3.5.25 csstype: 3.2.3 + '@vue/runtime-dom@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/runtime-core': 3.5.26 + '@vue/shared': 3.5.26 + csstype: 3.2.3 + '@vue/server-renderer@3.5.25(vue@3.5.25(typescript@5.9.3))': dependencies: '@vue/compiler-ssr': 3.5.25 @@ -23027,6 +23086,12 @@ snapshots: '@vue/shared': 3.5.26 vue: 3.5.25(typescript@5.9.3) + '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + vue: 3.5.26(typescript@5.9.3) + '@vue/shared@3.5.25': {} '@vue/shared@3.5.26': {} @@ -23668,7 +23733,7 @@ snapshots: best-effort-json-parser@1.2.1: {} - better-auth@1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7)(react@19.2.3))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3)): + better-auth@1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7)(react@19.2.3))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -23690,9 +23755,9 @@ snapshots: pg: 8.16.3 react: 19.2.3 vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) + vue: 3.5.26(typescript@5.9.3) - better-auth@1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3)): + better-auth@1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -23714,7 +23779,7 @@ snapshots: pg: 8.16.3 react: 19.2.3 vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) + vue: 3.5.26(typescript@5.9.3) better-auth@1.4.10(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(kysely@0.28.9)(pg@8.16.3)(postgres@3.4.7))(pg@8.16.3)(react@19.2.3)(vitest@4.0.16)(vue@3.5.25(typescript@5.9.3)): dependencies: @@ -24686,10 +24751,6 @@ snapshots: dependencies: jake: 10.9.4 - electron-audio-loopback@1.0.6(electron@39.2.7): - dependencies: - electron: 39.2.7 - electron-builder-squirrel-windows@26.4.0(dmg-builder@26.4.0): dependencies: app-builder-lib: 26.4.0(dmg-builder@26.4.0)(electron-builder-squirrel-windows@26.4.0) @@ -30953,7 +31014,7 @@ snapshots: transitivePeerDependencies: - vue - unplugin-vue@7.1.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.25(typescript@5.9.3))(yaml@2.8.2): + unplugin-vue@7.1.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2): dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -30961,7 +31022,7 @@ snapshots: obug: 2.1.1 unplugin: 2.3.11 vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) + vue: 3.5.26(typescript@5.9.3) transitivePeerDependencies: - '@types/node' - esbuild @@ -31671,6 +31732,16 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vue@3.5.26(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-sfc': 3.5.26 + '@vue/runtime-dom': 3.5.26 + '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.9.3)) + '@vue/shared': 3.5.26 + optionalDependencies: + typescript: 5.9.3 + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 83bd02e0b..ce7755234 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,10 +28,12 @@ catalog: '@capacitor/ios': ^8.0.0 '@capacitor/local-notifications': ^8.0.0 '@electric-sql/pglite': ^0.3.14 + '@electron-toolkit/preload': ^3.0.2 '@guiiai/logg': ^1.2.11 '@iconify-json/logos': ^1.2.10 '@iconify-json/tabler': ^1.2.26 '@moeru/eslint-config': 0.1.0-beta.15 + '@moeru/eventa': 1.0.0-alpha.10 '@moeru/std': 0.1.0-beta.14 '@nekopaw/tempora': 0.4.0-alpha.1 '@pinia/testing': ^1.0.3 @@ -52,9 +54,11 @@ catalog: '@xsai/stream-text': ^0.4.0-beta.13 '@xsai/tool': ^0.4.0-beta.13 '@xsai/utils-chat': 0.4.0-beta.13 + async-mutex: 0.5.0 better-auth: ^1.4.9 builder-util-runtime: ^9.3.1 drizzle-valibot: ^0.4.2 + electron: ^39.2.7 embla-carousel-vue: ^8.6.0 es-toolkit: 1.43.0 hono: 4.11.3