refactor(airi-plugin-web-extension): use eventa for ipc

This commit is contained in:
Neko Ayaka
2026-01-18 03:51:40 +08:00
parent 4cc9d8aa36
commit e796ee59ad
11 changed files with 201 additions and 94 deletions
@@ -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<typeof createRuntimeEventaContext>['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<ExtensionSettings>) {
@@ -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)
}
})
@@ -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() {
@@ -17,7 +17,7 @@ body {
margin: 0;
display: flex;
place-items: center;
min-width: 600px;
min-width: 420px;
min-height: 100vh;
}
@@ -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:",
@@ -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)
@@ -1,32 +1,22 @@
import type { BackgroundToPopupMessage, ExtensionSettings, ExtensionStatus, PopupToBackgroundMessage } from '../shared/types'
import type { ExtensionStatus } from '../shared/types'
export async function requestStatus(): Promise<ExtensionStatus> {
return await browser.runtime.sendMessage({ type: 'popup:get-status' } satisfies PopupToBackgroundMessage)
}
import { defineInvoke } from '@moeru/eventa'
export async function updateSettings(partial: Partial<ExtensionSettings>): Promise<ExtensionStatus> {
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<ExtensionStatus> {
return await browser.runtime.sendMessage({ type: 'popup:toggle-enabled', payload: enabled } satisfies PopupToBackgroundMessage)
}
const { context } = createRuntimeEventaContext()
export async function requestVisionFrame(): Promise<ExtensionStatus> {
return await browser.runtime.sendMessage({ type: 'popup:request-vision-frame' } satisfies PopupToBackgroundMessage)
}
export async function clearError(): Promise<ExtensionStatus> {
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()
}
@@ -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<string, Map<EventListenerOrEventListenerObject, RuntimeEventListener>>()
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()
},
}
}
@@ -0,0 +1,11 @@
import type { ExtensionSettings, ExtensionStatus } from './types'
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
export const popupGetStatus = defineInvokeEventa<ExtensionStatus>('eventa:invoke:web-extension:popup:get-status')
export const popupUpdateSettings = defineInvokeEventa<ExtensionStatus, Partial<ExtensionSettings>>('eventa:invoke:web-extension:popup:update-settings')
export const popupToggleEnabled = defineInvokeEventa<ExtensionStatus, boolean>('eventa:invoke:web-extension:popup:toggle-enabled')
export const popupRequestVisionFrame = defineInvokeEventa<ExtensionStatus>('eventa:invoke:web-extension:popup:request-vision-frame')
export const popupClearError = defineInvokeEventa<ExtensionStatus>('eventa:invoke:web-extension:popup:clear-error')
export const backgroundStatusChanged = defineEventa<ExtensionStatus>('eventa:event:web-extension:background:status')
@@ -74,15 +74,5 @@ export interface ExtensionStatus {
lastVisionFrameAt?: number
}
export type PopupToBackgroundMessage
= | { type: 'popup:get-status' }
| { type: 'popup:update-settings', payload: Partial<ExtensionSettings> }
| { 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' }
@@ -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',
+15 -7
View File
@@ -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