diff --git a/plugins/airi-plugin-web-extension/entrypoints/background.ts b/plugins/airi-plugin-web-extension/entrypoints/background.ts index 27e5e4f9d..2245cdac9 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/background.ts +++ b/plugins/airi-plugin-web-extension/entrypoints/background.ts @@ -2,9 +2,10 @@ import type { BackgroundToContentMessage, ContentToBackgroundMessage, ExtensionSettings, - PopupToBackgroundMessage, } from '../src/shared/types' +import { defineInvokeHandler } from '@moeru/eventa' + import { createClientState, ensureClient, @@ -15,6 +16,15 @@ import { } from '../src/background/client' import { loadSettings, saveSettings } from '../src/background/storage' import { DEFAULT_SETTINGS, STORAGE_KEY } from '../src/shared/constants' +import { + backgroundStatusChanged, + popupClearError, + popupGetStatus, + popupRequestVisionFrame, + popupToggleEnabled, + popupUpdateSettings, +} from '../src/shared/eventa' +import { createRuntimeEventaContext } from '../src/shared/eventa-runtime' import { detectSiteFromUrl } from '../src/shared/sites' const state = createClientState() @@ -23,6 +33,7 @@ let settings: ExtensionSettings = { ...DEFAULT_SETTINGS } let lastVideoNotifyKey = '' let lastStatusSentAt = 0 let connectionKey = '' +let eventaContext: ReturnType['context'] | undefined async function refreshClient() { const nextKey = `${settings.enabled}:${settings.wsUrl}:${settings.token}` @@ -54,7 +65,7 @@ function emitStatus() { return lastStatusSentAt = now - void browser.runtime.sendMessage({ type: 'background:status', payload: toStatus(state, settings) }).catch(() => {}) + eventaContext?.emit(backgroundStatusChanged, toStatus(state, settings)) } async function updateSettings(partial: Partial) { @@ -106,45 +117,47 @@ function handleContentMessage(message: ContentToBackgroundMessage) { } } -async function handlePopupMessage(message: PopupToBackgroundMessage) { - switch (message.type) { - case 'popup:get-status': - return toStatus(state, settings) - case 'popup:update-settings': - await updateSettings(message.payload) - return toStatus(state, settings) - case 'popup:toggle-enabled': - await updateSettings({ enabled: message.payload }) - return toStatus(state, settings) - case 'popup:request-vision-frame': { - const message: BackgroundToContentMessage = { type: 'background:request-vision-frame' } - const tabs = await browser.tabs.query({ active: true, currentWindow: true }) - const tab = tabs[0] - if (tab?.id != null) { - await browser.tabs.sendMessage(tab.id, message).catch(() => {}) - } - return toStatus(state, settings) - } - case 'popup:clear-error': - state.lastError = undefined - emitStatus() - return toStatus(state, settings) - } -} - export default defineBackground(() => { + const { context } = createRuntimeEventaContext() + eventaContext = context + + defineInvokeHandler(context, popupGetStatus, () => toStatus(state, settings)) + defineInvokeHandler(context, popupUpdateSettings, async (partial) => { + await updateSettings(partial) + return toStatus(state, settings) + }) + + defineInvokeHandler(context, popupToggleEnabled, async (enabled) => { + await updateSettings({ enabled }) + return toStatus(state, settings) + }) + + defineInvokeHandler(context, popupRequestVisionFrame, async () => { + const message: BackgroundToContentMessage = { type: 'background:request-vision-frame' } + const tabs = await browser.tabs.query({ active: true, currentWindow: true }) + const tab = tabs[0] + if (tab?.id != null) { + await browser.tabs.sendMessage(tab.id, message).catch(() => {}) + } + + return toStatus(state, settings) + }) + + defineInvokeHandler(context, popupClearError, () => { + state.lastError = undefined + emitStatus() + return toStatus(state, settings) + }) + void init() - browser.runtime.onMessage.addListener((message: ContentToBackgroundMessage | PopupToBackgroundMessage) => { - if (message && typeof message === 'object' && 'type' in message) { - if (message.type.startsWith('content:')) { - handleContentMessage(message as ContentToBackgroundMessage) - return - } - - if (message.type.startsWith('popup:')) { - return handlePopupMessage(message as PopupToBackgroundMessage) - } + browser.runtime.onMessage.addListener((message: unknown) => { + if (!message || typeof message !== 'object') + return + if ('__eventa' in message) + return + if ('type' in message && typeof message.type === 'string' && message.type.startsWith('content:')) { + handleContentMessage(message as ContentToBackgroundMessage) } }) diff --git a/plugins/airi-plugin-web-extension/entrypoints/content.ts b/plugins/airi-plugin-web-extension/entrypoints/content.ts index 9f7eb1a7e..1c5d84239 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/content.ts +++ b/plugins/airi-plugin-web-extension/entrypoints/content.ts @@ -2,10 +2,7 @@ import { startContentObserver } from '../src/content' export default defineContentScript({ matches: [ - '*://*.youtube.com/*', - '*://*.youtu.be/*', - '*://*.bilibili.com/*', - '*://*.b23.tv/*', + '*://*/*', ], runAt: 'document_idle', main() { diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/style.css b/plugins/airi-plugin-web-extension/entrypoints/popup/style.css index 4fe918751..2482b95ac 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/popup/style.css +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/style.css @@ -17,7 +17,7 @@ body { margin: 0; display: flex; place-items: center; - min-width: 600px; + min-width: 420px; min-height: 100vh; } diff --git a/plugins/airi-plugin-web-extension/package.json b/plugins/airi-plugin-web-extension/package.json index f31f75f61..2b4f0f0c6 100644 --- a/plugins/airi-plugin-web-extension/package.json +++ b/plugins/airi-plugin-web-extension/package.json @@ -14,15 +14,14 @@ "compile": "vue-tsc --noEmit", "postinstall": "wxt prepare" }, - "dependencies": { - "@vueuse/core": "catalog:" - }, "devDependencies": { "@iconify-json/solar": "^1.2.5", "@iconify-json/svg-spinners": "^1.2.4", + "@moeru/eventa": "^1.0.0-alpha.10", "@proj-airi/server-sdk": "workspace:^", "@proj-airi/ui": "workspace:^", "@unocss/reset": "^66.5.11", + "@vueuse/core": "catalog:", "@wxt-dev/module-vue": "^1.0.3", "nanoid": "^5.1.6", "vue": "catalog:", diff --git a/plugins/airi-plugin-web-extension/src/content/index.ts b/plugins/airi-plugin-web-extension/src/content/index.ts index 80eedae59..21ad86b20 100644 --- a/plugins/airi-plugin-web-extension/src/content/index.ts +++ b/plugins/airi-plugin-web-extension/src/content/index.ts @@ -235,6 +235,8 @@ function observeVideo(site: VideoSite) { safeSend({ type: 'content:page', payload: buildPageContext(site) }) } + const onPlayback = () => sendVideo(true) + const attach = () => { const found = document.querySelector('video') as HTMLVideoElement | null if (!found || found === video) @@ -271,8 +273,6 @@ function observeVideo(site: VideoSite) { sendVideo(false) }, TITLE_POLL_INTERVAL) - const onPlayback = () => sendVideo(true) - const cleanup = () => { window.clearInterval(interval) window.clearInterval(progressInterval) diff --git a/plugins/airi-plugin-web-extension/src/popup/bridge.ts b/plugins/airi-plugin-web-extension/src/popup/bridge.ts index 9a281192c..c7b382648 100644 --- a/plugins/airi-plugin-web-extension/src/popup/bridge.ts +++ b/plugins/airi-plugin-web-extension/src/popup/bridge.ts @@ -1,32 +1,22 @@ -import type { BackgroundToPopupMessage, ExtensionSettings, ExtensionStatus, PopupToBackgroundMessage } from '../shared/types' +import type { ExtensionStatus } from '../shared/types' -export async function requestStatus(): Promise { - return await browser.runtime.sendMessage({ type: 'popup:get-status' } satisfies PopupToBackgroundMessage) -} +import { defineInvoke } from '@moeru/eventa' -export async function updateSettings(partial: Partial): Promise { - return await browser.runtime.sendMessage({ type: 'popup:update-settings', payload: partial } satisfies PopupToBackgroundMessage) -} +import { backgroundStatusChanged, popupClearError, popupGetStatus, popupRequestVisionFrame, popupToggleEnabled, popupUpdateSettings } from '../shared/eventa' +import { createRuntimeEventaContext } from '../shared/eventa-runtime' -export async function toggleEnabled(enabled: boolean): Promise { - return await browser.runtime.sendMessage({ type: 'popup:toggle-enabled', payload: enabled } satisfies PopupToBackgroundMessage) -} +const { context } = createRuntimeEventaContext() -export async function requestVisionFrame(): Promise { - return await browser.runtime.sendMessage({ type: 'popup:request-vision-frame' } satisfies PopupToBackgroundMessage) -} - -export async function clearError(): Promise { - return await browser.runtime.sendMessage({ type: 'popup:clear-error' } satisfies PopupToBackgroundMessage) -} +export const requestStatus = defineInvoke(context, popupGetStatus) +export const updateSettings = defineInvoke(context, popupUpdateSettings) +export const toggleEnabled = defineInvoke(context, popupToggleEnabled) +export const requestVisionFrame = defineInvoke(context, popupRequestVisionFrame) +export const clearError = defineInvoke(context, popupClearError) export function onBackgroundStatus(callback: (status: ExtensionStatus) => void) { - const listener = (message: BackgroundToPopupMessage) => { - if (message?.type === 'background:status') - callback(message.payload) - } + const off = context.on(backgroundStatusChanged, (event) => { + callback(event.body!) + }) - browser.runtime.onMessage.addListener(listener) - - return () => browser.runtime.onMessage.removeListener(listener) + return () => off() } diff --git a/plugins/airi-plugin-web-extension/src/shared/eventa-runtime.ts b/plugins/airi-plugin-web-extension/src/shared/eventa-runtime.ts new file mode 100644 index 000000000..91d7db43d --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/shared/eventa-runtime.ts @@ -0,0 +1,102 @@ +import { createContext } from '@moeru/eventa/adapters/event-target' +import { nanoid } from 'nanoid' + +const EVENTA_MESSAGE_EVENT = 'eventa:message' +const EVENTA_RUNTIME_CHANNEL = 'airi:web-extension:eventa' + +const runtimeInstanceId = nanoid() + +interface EventaRuntimeMessage { + __eventa: true + channel: string + sourceId: string + type?: string + detail?: unknown +} + +type RuntimeEventListener = (event: Event) => void + +class RuntimeEventTarget implements EventTarget { + private listeners = new Map>() + + constructor(private readonly send: (message: EventaRuntimeMessage) => void) {} + + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null) { + if (!listener) + return + + if (!this.listeners.has(type)) + this.listeners.set(type, new Map()) + + const handler: RuntimeEventListener = typeof listener === 'function' + ? listener + : event => listener.handleEvent(event) + + this.listeners.get(type)?.set(listener, handler) + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) { + if (!listener) + return + + this.listeners.get(type)?.delete(listener) + } + + dispatchEvent(event: Event) { + const detail = 'detail' in event ? (event as CustomEvent).detail : undefined + this.send({ + __eventa: true, + channel: EVENTA_RUNTIME_CHANNEL, + sourceId: runtimeInstanceId, + type: event.type, + detail, + }) + + return true + } + + emit(type: string, detail?: unknown) { + const event = { type, detail } as CustomEvent + for (const listener of this.listeners.get(type)?.values() ?? []) + listener(event) + } +} + +export function createRuntimeEventaContext() { + const eventTarget = new RuntimeEventTarget((message) => { + void browser.runtime.sendMessage(message).catch(() => {}) + }) + + const { context, dispose } = createContext(eventTarget, { + messageEventName: EVENTA_MESSAGE_EVENT, + errorEventName: false, + }) + + const runtimeListener = (message: unknown) => { + if (!message || typeof message !== 'object') + return + + const runtimeMessage = message as EventaRuntimeMessage + + if (!('__eventa' in runtimeMessage) || !runtimeMessage.__eventa) + return + + if (runtimeMessage.channel !== EVENTA_RUNTIME_CHANNEL) + return + + if (runtimeMessage.sourceId === runtimeInstanceId) + return + + eventTarget.emit(runtimeMessage.type ?? EVENTA_MESSAGE_EVENT, runtimeMessage.detail) + } + + browser.runtime.onMessage.addListener(runtimeListener) + + return { + context, + dispose: () => { + browser.runtime.onMessage.removeListener(runtimeListener) + dispose() + }, + } +} diff --git a/plugins/airi-plugin-web-extension/src/shared/eventa.ts b/plugins/airi-plugin-web-extension/src/shared/eventa.ts new file mode 100644 index 000000000..88dcd5e72 --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/shared/eventa.ts @@ -0,0 +1,11 @@ +import type { ExtensionSettings, ExtensionStatus } from './types' + +import { defineEventa, defineInvokeEventa } from '@moeru/eventa' + +export const popupGetStatus = defineInvokeEventa('eventa:invoke:web-extension:popup:get-status') +export const popupUpdateSettings = defineInvokeEventa>('eventa:invoke:web-extension:popup:update-settings') +export const popupToggleEnabled = defineInvokeEventa('eventa:invoke:web-extension:popup:toggle-enabled') +export const popupRequestVisionFrame = defineInvokeEventa('eventa:invoke:web-extension:popup:request-vision-frame') +export const popupClearError = defineInvokeEventa('eventa:invoke:web-extension:popup:clear-error') + +export const backgroundStatusChanged = defineEventa('eventa:event:web-extension:background:status') diff --git a/plugins/airi-plugin-web-extension/src/shared/types.ts b/plugins/airi-plugin-web-extension/src/shared/types.ts index 9ae0282aa..56ac91663 100644 --- a/plugins/airi-plugin-web-extension/src/shared/types.ts +++ b/plugins/airi-plugin-web-extension/src/shared/types.ts @@ -74,15 +74,5 @@ export interface ExtensionStatus { lastVisionFrameAt?: number } -export type PopupToBackgroundMessage - = | { type: 'popup:get-status' } - | { type: 'popup:update-settings', payload: Partial } - | { type: 'popup:toggle-enabled', payload: boolean } - | { type: 'popup:request-vision-frame' } - | { type: 'popup:clear-error' } - -export type BackgroundToPopupMessage - = | { type: 'background:status', payload: ExtensionStatus } - export type BackgroundToContentMessage = | { type: 'background:request-vision-frame' } diff --git a/plugins/airi-plugin-web-extension/wxt.config.ts b/plugins/airi-plugin-web-extension/wxt.config.ts index 603e5c4d7..b65d55703 100644 --- a/plugins/airi-plugin-web-extension/wxt.config.ts +++ b/plugins/airi-plugin-web-extension/wxt.config.ts @@ -13,11 +13,8 @@ export default defineConfig({ name: 'AIRI Web Extension', description: 'Capture web context (videos, pages, subtitles) for Project AIRI.', permissions: ['storage', 'tabs'], - host_permissions: [ - '*://*.youtube.com/*', - '*://*.youtu.be/*', - '*://*.bilibili.com/*', - '*://*.b23.tv/*', + optional_host_permissions: [ + '*://*/*', ], action: { default_title: 'AIRI Web Extension', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 673501f2d..5f892cd14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3006,10 +3006,6 @@ importers: version: 1.5.1 plugins/airi-plugin-web-extension: - dependencies: - '@vueuse/core': - specifier: 'catalog:' - version: 14.1.0(vue@3.5.26(typescript@5.9.3)) devDependencies: '@iconify-json/solar': specifier: ^1.2.5 @@ -3017,6 +3013,9 @@ importers: '@iconify-json/svg-spinners': specifier: ^1.2.4 version: 1.2.4 + '@moeru/eventa': + specifier: ^1.0.0-alpha.10 + 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)))) '@proj-airi/server-sdk': specifier: workspace:^ version: link:../../packages/server-sdk @@ -3026,9 +3025,12 @@ importers: '@unocss/reset': specifier: ^66.5.11 version: 66.5.11 + '@vueuse/core': + specifier: 'catalog:' + version: 14.1.0(vue@3.5.26(typescript@5.9.3)) '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@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))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.3(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.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) nanoid: specifier: ^5.1.6 version: 5.1.6 @@ -22249,6 +22251,12 @@ snapshots: vite: 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) vue: 3.5.26(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.3(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.26(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.53 + 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.26(typescript@5.9.3) + '@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@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))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@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))(vitest@4.0.16) @@ -22948,9 +22956,9 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@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))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@wxt-dev/module-vue@1.0.3(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.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitejs/plugin-vue': 6.0.3(vite@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))(vue@3.5.26(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(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.26(typescript@5.9.3)) wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - vite